Skip to content

Conversation

@lindesvard
Copy link
Contributor

@lindesvard lindesvard commented Jan 21, 2026

Summary by CodeRabbit

  • New Features

    • Profile settings tabs: edit name and manage email notification preferences
    • Per-category unsubscribe flow and unsubscribe link handling
    • Automated onboarding email sequence (multi-step emails) with templates
  • Improvements

    • Streamlined public-facing page/card layout for shared links and password entry
    • Emails now include notification preference/unsubscribe handling and updated footer links

✏️ Tip: You can customize this high-level summary in your review settings.

@vercel
Copy link

vercel bot commented Jan 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
openpanel-public Error Error Jan 21, 2026 6:51pm

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 21, 2026

📝 Walkthrough

Walkthrough

Adds 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

Cohort / File(s) Summary
Public Page UI
apps/start/src/components/public-page-card.tsx, apps/start/src/components/auth/share-enter-password.tsx
Add PublicPageCard component and wrap the share password entry form into it; dynamic title/description based on shareType.
Profile Tabs & Preferences UI
apps/start/src/routes/_app.$organizationId.profile._tabs.tsx, apps/start/src/routes/_app.$organizationId.profile._tabs.index.tsx, apps/start/src/routes/_app.$organizationId.profile._tabs.email-preferences.tsx
New profile tabs route with profile edit and email preferences form using TRPC + react-hook-form.
Unsubscribe Route
apps/start/src/routes/unsubscribe.tsx
New public /unsubscribe route validating search params and invoking TRPC unsubscribe mutation; renders confirmation and success states.
Route Registry
apps/start/src/routeTree.gen.ts
Generated routing declarations updated to include unsubscribe and profile/_tabs routes and their children.
Email Templates & Components
packages/email/src/emails/*onboarding*.tsx, packages/email/src/components/{button,list,footer,layout}.tsx, packages/email/onboarding-emails.md
Add six onboarding email templates plus email helper components; Footer/Layout now accept optional unsubscribeUrl; onboarding emails include Zod schemas.
Email Core & Unsubscribe Utility
packages/email/src/index.tsx, packages/email/src/unsubscribe.ts, packages/email/src/emails/index.tsx
Refactor sendEmail to use templateKey, schema validation, skip unsubscribed recipients, inject List-Unsubscribe headers; add HMAC token generation/verification and unsubscribe URL builder; register onboarding templates.
TRPC Email Router
packages/trpc/src/routers/email.ts, packages/trpc/src/root.ts
New emailRouter with unsubscribe (public), getPreferences (protected), updatePreferences (protected); wired into appRouter.
Worker Cron & Jobs
apps/worker/src/jobs/cron.onboarding.ts, apps/worker/src/jobs/cron.ts, apps/worker/src/boot-cron.ts, apps/worker/src/boot-workers.ts, apps/worker/package.json
Add onboarding cron job and job handler; register onboarding job in cron boot; refine worker shutdown logging; add small dependency entries.
Database Schema & Migrations
packages/db/prisma/schema.prisma, packages/db/prisma/migrations/*onboarding*.sql, packages/db/prisma/migrations/*unsubscribe*.sql
Add Organization.onboarding field and EmailUnsubscribe model with unique (email, category) index; migrations to add/alter columns and create table.
Constants, Queue & Payments
packages/constants/index.ts, packages/queue/src/queues.ts, packages/payments/src/prices.ts
Add emailCategories and EmailCategory type; extend CronQueuePayload with onboarding; remove deprecated trial job helper; add getRecommendedPlan helper.
Email package deps & exports
packages/email/package.json, packages/email/src/index.tsx
Add @openpanel/db dependency; expose TemplateKey, EmailData types and unsubscribe export; adjust sendEmail signature (to narrowed to string, templateKey usage).
Minor Fixes / Typos
apps/start/src/hooks/use-cookie-store.tsx, apps/start/src/modals/create-invite.tsx, packages/trpc/src/routers/auth.ts
Small comment/typo fixes and minor whitespace change in auth router.

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)
Loading
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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • PR #269: Overlaps changes to apps/start/src/components/auth/share-enter-password.tsx (similar UI refactor).
  • PR #214: Related edits to apps/worker/src/boot-workers.ts concerning shutdown/exit logging behavior.

Poem

🐰 I stitched five letters, soft and bright,

nudged inboxes with gentle light.
Tokens clever, links so neat,
Preferences kept and unsubscribes sweet.
Hop on—onboarding's done just right!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Feature/onboarding emails' directly corresponds to the main changeset, which implements a comprehensive onboarding email system including new email templates, database schema for email preferences, a cron job for sending scheduled emails, TRPC endpoints for managing preferences, and frontend routes for handling unsubscribes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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: Forward unsubscribeUrl to Layout so the footer link renders.

unsubscribeUrl is 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 include unsubscribeUrl and adjust component props type.

The zEmailResetPassword schema is missing the unsubscribeUrl field that the component accepts. While the current code doesn't pass unsubscribeUrl to 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: the sendEmail function injects unsubscribeUrl into the data variable after Zod validation (line 64), meaning the component never receives it. Add unsubscribeUrl to 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 of formatEventsCount.

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.

formatEventsCount uses 'en-gb' locale (line 4) while formattedPrice uses '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, and useSuspenseQuery. 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 unsubscribeUrl is 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) include unsubscribeUrl directly 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>

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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: unsubscribeUrl is accepted but not passed to Layout.

The prop is destructured but never used. Compare with email-reset-password.tsx which 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 Link elements have empty key="" 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.data used in rendering (same reference), directly mutating the parsed schema output is not ideal. Consider creating an explicit renderData object 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: itsit's
  • Line 22: what's matterswhat matters
  • Line 27: how many clicks your hero CTAhow many clicks on your hero CTA
  • Lines 31-33: Start small and incrementally add more events as you go is usually the best approachStarting 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 known emailCategories keys.

The category input accepts any string, allowing invalid categories to be stored in the database. Restrict to known values using z.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 with UNAUTHORIZED code instead of generic Error.

The protectedProcedure should already handle authentication, but if this check is needed, throwing a generic Error returns INTERNAL_SERVER_ERROR to 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 with UNAUTHORIZED code.

🔧 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 in updatePreferences input.

Similar to the unsubscribe procedure, the categories record should validate keys against known emailCategories.

🔧 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 of formatEventsCount.

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 the email field for query performance.

If you query unsubscribes by email alone (e.g., to check all categories a user has unsubscribed from), an index on email would improve lookup performance. The composite unique constraint 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 validator schema isn't being used for form validation. Consider connecting it via @hookform/resolvers/zod to 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 useMutation hook already provides isPending, isSuccess, and error properties. 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 the Img component from @react-email/components.

Using the native <img> tag works, but the Img component from @react-email/components provides 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 $transaction or 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);

@lindesvard lindesvard merged commit e645c09 into main Jan 22, 2026
7 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants