-
Notifications
You must be signed in to change notification settings - Fork 296
Feature/onboarding emails #277
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughAdds a full onboarding email system: new email templates, unsubscribe handling, TRPC email router, DB migrations for onboarding and unsubscribes, worker cron job to schedule onboarding emails, profile email-preferences UI, and small public-page UI refactor. Changes
Sequence Diagram(s)sequenceDiagram
participant Worker as Worker (Cron)
participant DB as Database
participant TRPC as TRPC/Email
participant Resend as Email Service
Worker->>DB: Fetch orgs in onboarding state + creators
DB-->>Worker: Organization list
loop per organization
Worker->>Worker: Compute daysSinceCreation
Worker->>Worker: Select next onboarding template
Worker->>TRPC: sendEmail(templateKey, data)
TRPC->>DB: Query email_unsubscribes for recipient/category
DB-->>TRPC: unsubscribe record (exists/none)
alt not unsubscribed
TRPC->>Resend: Send email payload (headers + content)
Resend-->>TRPC: Success
TRPC-->>Worker: Sent confirmation
Worker->>DB: Update Organization.onboarding to lastSentTemplate
else unsubscribed
TRPC-->>Worker: Skipped (null)
end
end
Worker->>Worker: Return summary (sent/skipped/completed)
sequenceDiagram
participant User as Authenticated User
participant UI as Preferences Page
participant TRPC as TRPC Router
participant DB as Database
User->>UI: Open email preferences
UI->>TRPC: getPreferences()
TRPC->>DB: SELECT email_unsubscribes WHERE email = user.email
DB-->>TRPC: unsubscribe rows
TRPC-->>UI: categories map (true= subscribed)
UI->>UI: User toggles categories & saves
UI->>TRPC: updatePreferences({ categories })
loop per category
alt subscribe (true)
TRPC->>DB: DELETE FROM email_unsubscribes WHERE email,category
else unsubscribe (false)
TRPC->>DB: UPSERT email_unsubscribes (email,category)
end
DB-->>TRPC: OK
end
TRPC-->>UI: { success: true }
UI->>User: Show success toast
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 16
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
packages/email/src/emails/trial-ending-soon.tsx (1)
13-24: ForwardunsubscribeUrltoLayoutso the footer link renders.
unsubscribeUrlis accepted but never used, so the notification preferences link won’t appear in this email. Pass it through (or drop the prop if it’s not supported).🐛 Proposed fix
- <Layout> + <Layout unsubscribeUrl={unsubscribeUrl}>packages/email/src/emails/email-reset-password.tsx (1)
6-17: Update the Zod schema to includeunsubscribeUrland adjust component props type.The
zEmailResetPasswordschema is missing theunsubscribeUrlfield that the component accepts. While the current code doesn't passunsubscribeUrlto this email (no category defined), the schema should reflect the actual component interface. Additionally, similar emails with categories have the same schema gap, which masks a deeper issue: thesendEmailfunction injectsunsubscribeUrlinto the data variable after Zod validation (line 64), meaning the component never receives it. AddunsubscribeUrlto the schema so components can receive it correctly once the injection logic is fixed:export const zEmailResetPassword = z.object({ url: z.string(), + unsubscribeUrl: z.string().url().optional(), }); export type Props = z.infer<typeof zEmailResetPassword>;Then update the component to accept Props directly instead of the intersection:
-export function EmailResetPassword({ - url = 'https://openpanel.dev', - unsubscribeUrl, -}: Props & { unsubscribeUrl?: string }) { +export function EmailResetPassword({ + url = 'https://openpanel.dev', + unsubscribeUrl, +}: Props) {
🤖 Fix all issues with AI agents
In
`@apps/start/src/routes/_app`.$organizationId.profile._tabs.email-preferences.tsx:
- Around line 38-103: The form is defaulting UI checkboxes with
`checked={field.value ?? true}` which leaves RHF state undefined and can omit
untouched categories on submit; instead seed `defaultValues.categories` with
explicit boolean values for every key in `emailCategories` (using
`preferencesQuery.data` when present, falling back to true/false) when calling
useForm<IForm> and when calling reset(...) after the mutation, and change the
Controller render to bind the Switch `checked` directly to `field.value` (no
nullish fallback) so the React Hook Form state always contains explicit
booleans; update places using `useForm`, `reset`, and the Controller render to
apply this consistent defaulting.
In `@apps/worker/src/jobs/cron.onboarding.ts`:
- Around line 93-99: The template name passed into the email(...) call is
misspelled as 'onboarding-featue-request' which will break template dispatch and
onboarding state matching; update the template string inside the email(...)
invocation to the correct 'onboarding-feature-request' (keep the rest of the
object, including data: (ctx) => ({ firstName: getters.firstName(ctx) }),
unchanged) so the onboarding flow matches the expected template key.
In
`@packages/db/prisma/migrations/20260121071611_add_unsubscribe_email/migration.sql`:
- Around line 1-12: The unique constraint is currently case-sensitive on
email_unsubscribes.email, so differently-cased addresses bypass the unsubscribe;
modify the migration to enforce case-insensitive uniqueness by either (a)
converting the email column to CITEXT (ensure CREATE EXTENSION IF NOT EXISTS
citext is run and set the "email" column to type CITEXT) and keep the unique
index "email_unsubscribes_email_category_key", or (b) replace the unique index
with a functional unique index on lower(email) and category (e.g., drop the
existing "email_unsubscribes_email_category_key" and create a unique index on
lower(email), category) so that email comparisons are normalized to lowercase at
the DB level. Ensure the chosen approach is applied in the migration that
creates the table and index.
In `@packages/email/onboarding-emails.md`:
- Line 102: Update the typo in the sentence "Your project will recieve events
for the next 30 days, if you haven't upgraded by then we'll remove your
workspace and projects." by replacing "recieve" with "receive" so the line reads
"Your project will receive events for the next 30 days, if you haven't upgraded
by then we'll remove your workspace and projects."; locate the exact string in
packages/email/onboarding-emails.md and make the single-word correction.
In `@packages/email/src/components/button.tsx`:
- Around line 1-7: The Button component uses React types (React.ReactNode and
React.CSSProperties) but React is not imported, causing "Cannot find namespace
'React'"; fix by adding an import for React (e.g., import * as React from
'react' or import React from 'react') at the top of the file so the Button props
types resolve correctly—update the file that defines function Button to include
that React import.
In `@packages/email/src/emails/index.tsx`:
- Around line 61-66: The template key in the email templates map is misspelled
as 'onboarding-featue-request'; rename this key to 'onboarding-feature-request'
where the entry with Component: OnboardingFeatureRequest and schema:
zOnboardingFeatureRequest is defined so the generated TemplateKey and runtime
lookups use the correct name, then run typechecks/build to ensure no other
references need updating.
In `@packages/email/src/emails/onboarding-trial-ended.tsx`:
- Around line 26-28: The Layout component in the OnboardingTrialEnded email is
not receiving the unsubscribeUrl prop, so the footer link won't render; update
the JSX where Layout is used (the Layout element in the return of the
OnboardingTrialEnded component) to forward the unsubscribeUrl prop (pass
unsubscribeUrl={unsubscribeUrl}) so the Layout component receives and can render
the footer link.
In `@packages/email/src/emails/onboarding-trial-ending.tsx`:
- Around line 48-49: In the onboarding-trial-ending email component update the
typo "recieve" to "receive" in the sentence currently reading "Your project will
recieve events for the next 30 days…" within the onboarding-trial-ending.tsx
content; locate the string in the component/template and correct the spelling so
it reads "Your project will receive events for the next 30 days, if you haven't
upgraded by then we'll remove your workspace and projects."
- Around line 28-30: The Layout component is not receiving the unsubscribeUrl
prop, so the footer unsubscribe link never renders; in
onboarding-trial-ending.tsx pass the unsubscribeUrl prop through to the Layout
element (the JSX <Layout> wrapper) so Layout (and its footer) can use it—locate
the Layout usage in the return of the onboarding-trial-ending component and add
unsubscribeUrl={unsubscribeUrl} to that JSX element.
In `@packages/email/src/emails/onboarding-welcome.tsx`:
- Around line 32-45: The List contains two Link children with empty keys causing
non-unique React keys; update the key props on the Link elements used inside the
List (e.g., in the onboarding-welcome component) to stable, unique identifiers
such as "install-tracking-script" and "start-tracking-events" (or similarly
descriptive strings) so each List item has a distinct key for proper
reconciliation.
In `@packages/email/src/emails/onboarding-what-to-track.tsx`:
- Around line 20-33: Fix several typos in the copy inside
onboarding-what-to-track.tsx: in the first <Text> node change "its" to "it's"
and "what's matters" to "what matters"; in the <List> items change "Conversions
(how many clicks your hero CTA)" to clearer copy such as "Conversions (how many
clicks your hero CTA receives)" and change "What did the user do after clicking
the CTA" to present-tense phrasing like "What the user does after clicking the
CTA"; finally update the trailing <Text> sentence to read smoothly, e.g., "Start
small and incrementally add more events as you go." so the sentence grammar and
tense are consistent.
In `@packages/email/src/index.tsx`:
- Around line 61-75: You mutate data to add unsubscribeUrl but still render with
props.data so the template never receives it; create a merged renderData object
(e.g., const renderData = {...props.data, unsubscribeUrl}) and use renderData
when calling template.Component and template.subject inside the
resend.emails.send call (replace props.data with renderData) so the footer can
access unsubscribeUrl; also pass renderData to any other places where you
currently use props.data for rendering.
In `@packages/email/src/unsubscribe.ts`:
- Around line 14-21: The direct equality check in verifyUnsubscribeToken is
vulnerable to timing attacks; replace the token comparison with a timing-safe
comparison (use crypto.timingSafeEqual) by converting both token and the
expectedToken from generateUnsubscribeToken into Buffers (e.g., Buffer.from(...,
'utf8' or 'hex' depending on encoding) and if lengths differ, perform a
constant-time fallback like comparing against a zero-filled buffer of the same
length to avoid early returns; update verifyUnsubscribeToken to import/require
crypto, build the two buffers, handle length mismatch safely, and return the
result of timingSafeEqual instead of using ===.
- Around line 3-7: Replace the current SECRET fallback with an EFFECTIVE_SECRET
that reads process.env.UNSUBSCRIBE_SECRET || process.env.COOKIE_SECRET ||
process.env.SECRET and if that result is falsy and process.env.NODE_ENV ===
'production' throw an error (fail loudly); allow a non-production fallback only
for dev/test. Then update generateUnsubscribeToken to use EFFECTIVE_SECRET
instead of SECRET so tokens never use the hardcoded default in production.
In `@packages/trpc/src/routers/email.ts`:
- Around line 10-14: The email category input is currently free-form; update the
input schema for the procedures that accept category strings (notably
unsubscribe and updatePreferences) to validate against the known set of keys by
replacing z.string() for category with z.enum(Object.keys(emailCategories)) (or
an equivalent static z.enum of the permitted category names) so only valid
emailCategories keys are accepted and invalid categories cannot be stored.
- Around line 19-22: Replace generic Error throws with TRPCError instances:
where verifyUnsubscribeToken(email, category, token) currently throws new
Error('Invalid unsubscribe link'), throw new TRPCError({ code: 'BAD_REQUEST',
message: 'Invalid unsubscribe link' }); similarly, for all session validation
failures (the checks that inspect session / ctx.session or result of getSession
at the top-level around lines where session is validated), replace new
Error(...) with new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' })
so invalid/missing sessions return UNAUTHORIZED; update imports to include
TRPCError from '@trpc/server' if not already present and apply to the checks
that reference session and verifyUnsubscribeToken.
🧹 Nitpick comments (7)
packages/payments/src/prices.ts (2)
3-7: Duplicate implementation offormatEventsCount.This function is identical to the one already exported in
packages/payments/scripts/create-products.ts. Consider either importing from there or extracting to a shared utility module to avoid duplication.
68-71: Inconsistent locale usage between formatters.
formatEventsCountuses'en-gb'locale (line 4) whileformattedPriceuses'en-US'(line 68). This may be intentional since the currency is USD, but consider aligning locales for consistency or documenting the reasoning.packages/email/onboarding-emails.md (1)
7-90: Use Markdown headings instead of bold lines.Lines 7/27/48/70/88 are styled as bold text; markdownlint flags this. Consider
##headings to satisfy linting and improve structure.♻️ Proposed heading updates
-**Email 1 - Welcome (Day 0)** +## Email 1 - Welcome (Day 0) -**Email 2 - What to track (Day 2)** +## Email 2 - What to track (Day 2) -**Email 3 - Dashboards (Day 6)** +## Email 3 - Dashboards (Day 6) -**Email 4 - Replace the stack (Day 14)** +## Email 4 - Replace the stack (Day 14) -**Email 5 - Trial ending (Day 26)** +## Email 5 - Trial ending (Day 26)apps/start/src/components/public-page-card.tsx (1)
23-30: Consider a semantic heading for the title.Using an
<h1>improves document structure and accessibility for public pages.♻️ Suggested tweak
- <div className="text-xl font-semibold">{title}</div> + <h1 className="text-xl font-semibold">{title}</h1>apps/start/src/routes/_app.$organizationId.profile._tabs.tsx (1)
4-5: Remove unused imports.Several imports are not used in this file:
ProfileAvatar,SerieIcon,useTRPC,getProfileName, anduseSuspenseQuery. These appear to be leftover from development or copy-paste.♻️ Suggested cleanup
import FullPageLoadingState from '@/components/full-page-loading-state'; import { PageContainer } from '@/components/page-container'; import { PageHeader } from '@/components/page-header'; -import { ProfileAvatar } from '@/components/profiles/profile-avatar'; -import { SerieIcon } from '@/components/report-chart/common/serie-icon'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { usePageTabs } from '@/hooks/use-page-tabs'; -import { useTRPC } from '@/integrations/trpc/react'; -import { getProfileName } from '@/utils/getters'; -import { useSuspenseQuery } from '@tanstack/react-query'; import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';Also applies to: 8-10
packages/email/src/emails/email-invite.tsx (1)
6-17: Consider aligning the type pattern with other email templates.The
unsubscribeUrlis added via type intersection (Props & { unsubscribeUrl?: string }) rather than including it in the Zod schema. Other templates in this PR (e.g.,onboarding-welcome.tsx) includeunsubscribeUrldirectly in their Zod schemas. This inconsistency could cause confusion during maintenance.♻️ Option to align with other templates
export const zEmailInvite = z.object({ url: z.string(), organizationName: z.string(), + unsubscribeUrl: z.string().optional(), }); export type Props = z.infer<typeof zEmailInvite>; export default EmailInvite; export function EmailInvite({ organizationName = 'Acme Co', url = 'https://openpanel.dev', unsubscribeUrl, -}: Props & { unsubscribeUrl?: string }) { +}: Props) {packages/email/src/emails/onboarding-dashboards.tsx (1)
24-46: Use the UTM URL in a CTA (or remove the builder).
Right now the UTM-enriched URL is built but never surfaced to readers. Consider linking to the dashboard or dropping the URL builder.💡 Example CTA using the tracked URL
- <Text>Takes maybe 10 minutes to set up. Worth it.</Text> + <Text> + Takes maybe 10 minutes to set up.{' '} + <Link href={newUrl.toString()}>Open your dashboard</Link>. + </Text>
apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx
Show resolved
Hide resolved
packages/db/prisma/migrations/20260121071611_add_unsubscribe_email/migration.sql
Show resolved
Hide resolved
8d49132 to
f9b1ec5
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/email/src/emails/trial-ending-soon.tsx (1)
16-23:unsubscribeUrlis accepted but not passed toLayout.The prop is destructured but never used. Compare with
email-reset-password.tsxwhich correctly passes it to<Layout unsubscribeUrl={unsubscribeUrl}>. Without this, users receiving this email won't see the unsubscribe link.🐛 Proposed fix
return ( - <Layout> + <Layout unsubscribeUrl={unsubscribeUrl}> <Text>Your trial period is ending soon for {organizationName}!</Text>
🤖 Fix all issues with AI agents
In `@apps/start/src/routes/_app`.$organizationId.profile._tabs.index.tsx:
- Around line 72-81: The two InputWithLabel instances are passing redundant
defaultValue props that conflict with the form-level defaultValues configured in
useForm; remove the defaultValue attributes from the First name and Last name
InputWithLabel components so the values come solely from the react-hook-form
defaults (the inputs already use {...register('firstName')} and
{...register('lastName')}); if you need to programmatically change values later,
use the useForm methods (setValue/reset) rather than per-input defaultValue.
In `@apps/worker/src/jobs/cron.onboarding.ts`:
- Around line 234-248: The sendEmail call may return null on
validation/unsubscribe/missing API key/Resend failures, but the code
unconditionally updates onboarding and increments emailsSent; change the flow in
the try block to capture the result of await sendEmail(...) (call to sendEmail)
and only run db.organization.update({ data: { onboarding: nextEmail.template }
}) and emailsSent++ when the sendEmail result indicates success (non-null/true);
if sendEmail returns falsy, skip the DB update and increment, and optionally log
or continue to next item so failed sends can be retried.
In `@packages/email/onboarding-emails.md`:
- Around line 7-88: Replace the bolded heading lines (e.g., "**Email 1 - Welcome
(Day 0)**", "**Email 2 - What to track (Day 2)**", "**Email 3 - Dashboards (Day
6)**", "**Email 4 - Replace the stack (Day 14)**", "**Email 5 - Trial ending
(Day 26)**") with proper Markdown headings using one or more leading #
characters (for example "## Email 1 - Welcome (Day 0)"), remove the surrounding
** markers, and keep the existing paragraph content unchanged so the file passes
MD036.
In `@packages/email/src/emails/onboarding-dashboards.tsx`:
- Around line 19-23: The code constructs a UTM-tagged URL (newUrl) from
dashboardUrl but never uses it in the rendered email, so add a visible
link/button that uses newUrl.toString() as the href; locate the UTM construction
in onboarding-dashboards.tsx and replace or add the anchor/button element (e.g.,
the CTA that currently points to dashboardUrl or is missing) to reference
newUrl.toString(), ensuring the link opens the dashboard with utm_source=email,
utm_medium=email, and utm_campaign=onboarding-dashboards.
In `@packages/email/src/emails/onboarding-welcome.tsx`:
- Around line 7-17: The schema zOnboardingWelcome declares dashboardUrl but the
OnboardingWelcome component (function OnboardingWelcome) and Props never use it;
either remove dashboardUrl from zOnboardingWelcome and Props if unused, or add
dashboardUrl to the component signature and use it in the email body (e.g.,
render a link/button to dashboardUrl). Update zOnboardingWelcome and the
inferred Props consistently and ensure the component destructuring includes
dashboardUrl when you insert the link.
♻️ Duplicate comments (8)
packages/email/src/unsubscribe.ts (1)
3-7: Hardcoded fallback secret still poses a security risk in production.The previous review comment about this issue has not been addressed. If none of the environment variables are set, the hardcoded default will be used, making unsubscribe tokens predictable.
packages/email/src/emails/onboarding-welcome.tsx (1)
34-45: Use unique keys for the List items.Both
Linkelements have emptykey=""props, which violates React best practices for list reconciliation.✅ Suggested fix
<Link - key="" + key="install-tracking" href={'https://openpanel.dev/docs/get-started/install-openpanel'} > Install tracking script </Link>, <Link - key="" + key="track-events" href={'https://openpanel.dev/docs/get-started/track-events'} > Start tracking your events </Link>,packages/email/src/index.tsx (1)
61-75: Mutating parsed data is a code smell.While the mutation on line 64 does affect
props.dataused in rendering (same reference), directly mutating the parsed schema output is not ideal. Consider creating an explicitrenderDataobject for clarity and immutability.♻️ Suggested refactor
- const headers: Record<string, string> = {}; + const headers: Record<string, string> = {}; + let renderData: Record<string, unknown> = props.data; + if ('category' in template && template.category) { const unsubscribeUrl = getUnsubscribeUrl(to, template.category); - (props.data as any).unsubscribeUrl = unsubscribeUrl; + renderData = { ...props.data, unsubscribeUrl }; headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`; headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'; } try { const res = await resend.emails.send({ from: FROM, to, - subject: template.subject(props.data as any), - react: <template.Component {...(props.data as any)} />, + subject: template.subject(renderData as any), + react: <template.Component {...(renderData as any)} />, headers: Object.keys(headers).length > 0 ? headers : undefined, });packages/email/src/emails/onboarding-what-to-track.tsx (1)
20-34: Fix typos and grammatical errors in user-facing copy.Several issues in the email copy:
- Line 21:
its→it's- Line 22:
what's matters→what matters- Line 27:
how many clicks your hero CTA→how many clicks on your hero CTA- Lines 31-33:
Start small and incrementally add more events as you go is usually the best approach→Starting small and incrementally adding more events as you go is usually the best approach✍️ Suggested fix
<Text> - Tracking can be overwhelming at first, and that's why its important to - focus on what's matters. For most products, that's something like: + Tracking can be overwhelming at first, and that's why it's important to + focus on what matters. For most products, that's something like: </Text> <List items={[ 'Find good funnels to track (onboarding or checkout)', - 'Conversions (how many clicks your hero CTA)', + 'Conversions (how many clicks on your hero CTA)', 'What did the user do after clicking the CTA', ]} /> <Text> - Start small and incrementally add more events as you go is usually the - best approach. + Starting small and incrementally adding more events as you go is usually + the best approach. </Text>packages/trpc/src/routers/email.ts (4)
11-15: Validate category against knownemailCategorieskeys.The
categoryinput accepts any string, allowing invalid categories to be stored in the database. Restrict to known values usingz.enum().🔧 Suggested fix
+const categorySchema = z.enum( + Object.keys(emailCategories) as [string, ...string[]], +); + export const emailRouter = createTRPCRouter({ unsubscribe: publicProcedure .input( z.object({ email: z.string().email(), - category: z.string(), + category: categorySchema, token: z.string(), }), )
43-46: Use TRPCError withUNAUTHORIZEDcode instead of generic Error.The
protectedProcedureshould already handle authentication, but if this check is needed, throwing a genericErrorreturnsINTERNAL_SERVER_ERRORto clients. Use a TRPC error with appropriate status code.🔧 Suggested fix
+import { TRPCError } from '@trpc/server'; + getPreferences: protectedProcedure.query(async ({ ctx }) => { if (!ctx.session.userId || !ctx.session.user?.email) { - throw new Error('User not authenticated'); + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'User not authenticated', + }); }
71-80: Same issue: Use TRPCError withUNAUTHORIZEDcode.🔧 Suggested fix
if (!ctx.session.userId || !ctx.session.user?.email) { - throw new Error('User not authenticated'); + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'User not authenticated', + }); }
71-75: Validate category keys inupdatePreferencesinput.Similar to the
unsubscribeprocedure, the categories record should validate keys against knownemailCategories.🔧 Suggested fix
updatePreferences: protectedProcedure .input( z.object({ - categories: z.record(z.string(), z.boolean()), + categories: z.record(categorySchema, z.boolean()), }), )
🧹 Nitpick comments (6)
packages/payments/src/prices.ts (1)
3-7: Duplicate implementation offormatEventsCount.This function is identical to the one in
packages/payments/scripts/create-products.ts(lines 6-10). Consider exporting this function from a shared location and importing it where needed to avoid duplication and potential drift.packages/db/prisma/schema.prisma (1)
615-623: Consider adding an index on theIf you query unsubscribes by email alone (e.g., to check all categories a user has unsubscribed from), an index on
(email, category)doesn't efficiently support email-only queries.💡 Optional improvement
model EmailUnsubscribe { id String `@id` `@default`(dbgenerated("gen_random_uuid()")) `@db.Uuid` email String category String createdAt DateTime `@default`(now()) @@unique([email, category]) + @@index([email]) @@map("email_unsubscribes") }apps/start/src/routes/_app.$organizationId.profile._tabs.index.tsx (1)
16-19: The Zod validator is defined but not connected to the form.The
validatorschema isn't being used for form validation. Consider connecting it via@hookform/resolvers/zodto enforce runtime validation, or remove it if validation isn't needed.♻️ Option to connect the validator
+import { zodResolver } from '@hookform/resolvers/zod'; const { register, handleSubmit, formState, reset } = useForm<IForm>({ + resolver: zodResolver(validator), defaultValues: { firstName: user?.firstName ?? '', lastName: user?.lastName ?? '', }, });apps/start/src/routes/unsubscribe.tsx (1)
27-42: Consider using mutation state directly instead of redundant local state.The
useMutationhook already providesisPending,isSuccess, anderrorproperties. The manual state management duplicates this functionality.♻️ Proposed simplification
function RouteComponent() { const search = useSearch({ from: '/unsubscribe' }); const { email, category, token } = search; const trpc = useTRPC(); - const [isUnsubscribing, setIsUnsubscribing] = useState(false); - const [isSuccess, setIsSuccess] = useState(false); - const [error, setError] = useState<string | null>(null); - const unsubscribeMutation = useMutation( - trpc.email.unsubscribe.mutationOptions({ - onSuccess: () => { - setIsSuccess(true); - setIsUnsubscribing(false); - }, - onError: (err) => { - setError(err.message || 'Failed to unsubscribe'); - setIsUnsubscribing(false); - }, - }), - ); + const unsubscribeMutation = useMutation( + trpc.email.unsubscribe.mutationOptions(), + ); const handleUnsubscribe = () => { - setIsUnsubscribing(true); - setError(null); unsubscribeMutation.mutate({ email, category, token }); }; const categoryName = emailCategories[category as keyof typeof emailCategories] || category; - if (isSuccess) { + if (unsubscribeMutation.isSuccess) { return ( // ... ); } return ( <PublicPageCard ...> <div className="col gap-3"> - {error && ( + {unsubscribeMutation.error && ( <div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md text-sm"> - {error} + {unsubscribeMutation.error.message || 'Failed to unsubscribe'} </div> )} - <Button onClick={handleUnsubscribe} disabled={isUnsubscribing}> - {isUnsubscribing ? 'Unsubscribing...' : 'Confirm Unsubscribe'} + <Button onClick={handleUnsubscribe} disabled={unsubscribeMutation.isPending}> + {unsubscribeMutation.isPending ? 'Unsubscribing...' : 'Confirm Unsubscribe'} </Button> // ... </div> </PublicPageCard> ); }packages/email/src/emails/onboarding-dashboards.tsx (1)
52-62: Consider using theImgcomponent from@react-email/components.Using the native
<img>tag works, but theImgcomponent from@react-email/componentsprovides better email client compatibility and consistent styling with other templates in this codebase.packages/trpc/src/routers/email.ts (1)
85-110: Consider batching database operations.The loop performs individual database calls for each category. For better performance with many categories, consider using
$transactionor batching the operations.♻️ Suggested optimization
+ const operations = []; for (const [category, subscribed] of Object.entries(input.categories)) { if (subscribed) { - await db.emailUnsubscribe.deleteMany({ + operations.push(db.emailUnsubscribe.deleteMany({ where: { email, category, }, - }); + })); } else { - await db.emailUnsubscribe.upsert({ + operations.push(db.emailUnsubscribe.upsert({ where: { email_category: { email, category, }, }, create: { email, category, }, update: {}, - }); + })); } } + await db.$transaction(operations);
Summary by CodeRabbit
New Features
Improvements
✏️ Tip: You can customize this high-level summary in your review settings.