Skip to main content

OdontoX Onboarding Email Campaign — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Build a behavioral onboarding email drip (“Letters from Sarmad”) that sends one personal, professionally-designed email per day to trialing and newly-paid OdontoX clinics, mapped to activation signals already in the DB, with every CTA linking to a q.odontox.io help article and a hard 7-day shadow-allowlist gate before production rollout. Architecture: A daily Cloudflare Workers cron (09:00 PKT) queries eligible clinics, computes activation signals per clinic, picks the highest-priority unsent lesson whose trigger fires, renders a React Email template, sends via ZeptoMail, and logs to a new clinic_campaign_log table with a UNIQUE(clinic_id, campaign_key) dedupe. Tracking is via 1×1 pixel, 302-redirect click wrapper, and ZeptoMail inbound webhook for reply detection. A shadow-allowlist env var restricts sends to two test inboxes during the 7-day soak. Tech Stack: TypeScript, Hono, Drizzle ORM (Neon Postgres), React Email + ZeptoMail, Cloudflare Workers (cron + KV), Vitest. Spec: docs/superpowers/specs/2026-05-15-onboarding-email-campaign-design.md

File structure

New files

PathResponsibility
server/src/schema/campaign-log.tsDrizzle schema for clinic_campaign_log table
server/src/campaigns/types.tsLesson, Phase, Signals type definitions
server/src/campaigns/signals.tsgetActivationSignals(clinicId) batched query
server/src/campaigns/triggers.tsAll 17 lesson trigger functions, pure & unit-testable
server/src/campaigns/lessons.tsLesson registry array (key, priority, phase, trigger, articleUrl, templateFn)
server/src/campaigns/runner.tsCron handler: eligibility → signals → picker → guardrails → send → log
server/src/campaigns/guardrails.tsPure guardrail functions (day-of-week, active-session, transactional-overlap, allowlist)
server/src/emails/campaign/CampaignEmail.tsxShared React Email layout (header logo, footer logo, accent color, P.S. styling)
server/src/emails/campaign/welcome.tsxEmail #1 — Welcome to OdontoX
server/src/emails/campaign/firstPatientAdded.tsxEmail #2
server/src/emails/campaign/firstAppointment.tsxEmail #3
server/src/emails/campaign/whatsappOff.tsxEmail #4
server/src/emails/campaign/dentalCharting.tsxEmail #5
server/src/emails/campaign/clinicalNotes.tsxEmail #6
server/src/emails/campaign/inviteStaff.tsxEmail #7
server/src/emails/campaign/mobileApp.tsxEmail #8
server/src/emails/campaign/firstInvoice.tsxEmail #9
server/src/emails/campaign/firstPrescription.tsxEmail #10
server/src/emails/campaign/trialThankYou.tsxEmail #11
server/src/emails/campaign/paidWelcome.tsxEmail #12
server/src/emails/campaign/labTracking.tsxEmail #13
server/src/emails/campaign/insuranceClaims.tsxEmail #14
server/src/emails/campaign/aiInsights.tsxEmail #15 (uses ruby.png header)
server/src/emails/campaign/scaleMilestone.tsxEmail #16
server/src/emails/campaign/ipdModule.tsxEmail #17
server/src/routes/email-tracking.tsGET /api/email/pixel, GET /api/email/click, POST /api/email/inbound
server/src/routes/superadmin/campaign.tsPer-clinic toggle + analytics endpoints
server/scripts/seed-shadow-test-clinics.tsSeed two test clinics for shadow week
ui/src/pages/superadmin/clinics/[id]/CampaignSection.tsxPer-clinic toggle UI block
ui/src/pages/superadmin/campaigns/index.tsxCampaign funnel + lift dashboard
server/src/campaigns/__tests__/signals.test.tsTests for getActivationSignals
server/src/campaigns/__tests__/triggers.test.tsTests for all 17 trigger functions
server/src/campaigns/__tests__/guardrails.test.tsTests for guardrails
server/src/campaigns/__tests__/runner.test.tsIntegration test for runner
server/src/emails/campaign/__tests__/templates.test.tsxSmoke test all 17 templates render

Modified files

PathChange
server/src/schema/clinics.tsAdd 5 columns (marketing_campaign_enabled, marketing_unsubscribed, marketing_campaign_disabled_by/_at/_reason). Add activated_at if absent.
server/src/schema/users.ts (or wherever users schema lives)Add email_marketing_opt_out boolean column
server/src/scheduled.tsWire runOnboardingCampaign() into existing cron alongside trial-expiry job
server/src/server.ts (or wherever routes mount)Mount email-tracking + superadmin/campaign route groups
ui/src/pages/superadmin/clinics/[id]/index.tsxMount <CampaignSection />

Task 1 — Drizzle schema: clinics + users columns + clinic_campaign_log table

Files:
  • Modify: server/src/schema/clinics.ts
  • Modify: server/src/schema/users.ts (locate first — see step 1)
  • Create: server/src/schema/campaign-log.ts
  • Test: server/src/campaigns/__tests__/schema.test.ts
  • Step 1: Locate the users schema
Run: grep -rn "export const users" server/src/schema/ | head -3 Expected: a single file (likely server/src/schema/users.ts or server/src/schema/auth.ts). Note the exact path for step 3.
  • Step 2: Add columns to clinics schema
Open server/src/schema/clinics.ts. Add inside the pgTable("clinics", { ... }) definition (alongside existing columns):
  marketing_campaign_enabled: boolean('marketing_campaign_enabled').notNull().default(true),
  marketing_unsubscribed: boolean('marketing_unsubscribed').notNull().default(false),
  marketing_campaign_disabled_by: uuid('marketing_campaign_disabled_by'),
  marketing_campaign_disabled_at: timestamp('marketing_campaign_disabled_at', { withTimezone: true }),
  marketing_campaign_disabled_reason: text('marketing_campaign_disabled_reason'),
  activated_at: timestamp('activated_at', { withTimezone: true }),  // skip if column already exists
If activated_at already exists on the table, omit it. Verify with grep "activated_at" server/src/schema/clinics.ts before adding.
  • Step 3: Add column to users schema
In the users schema file from step 1, add:
  email_marketing_opt_out: boolean('email_marketing_opt_out').notNull().default(false),
  • Step 4: Create clinic_campaign_log schema
Create server/src/schema/campaign-log.ts:
import { pgTable, uuid, text, timestamp, uniqueIndex, index } from 'drizzle-orm/pg-core';
import { clinics } from './clinics';
import { users } from './users';

export const clinicCampaignLog = pgTable(
  'clinic_campaign_log',
  {
    id: uuid('id').primaryKey().defaultRandom(),
    clinic_id: uuid('clinic_id').notNull().references(() => clinics.id, { onDelete: 'cascade' }),
    user_id: uuid('user_id').notNull().references(() => users.id),
    campaign_key: text('campaign_key').notNull(),
    subject: text('subject').notNull(),
    sent_at: timestamp('sent_at', { withTimezone: true }).notNull().defaultNow(),
    opened_at: timestamp('opened_at', { withTimezone: true }),
    clicked_at: timestamp('clicked_at', { withTimezone: true }),
    replied_at: timestamp('replied_at', { withTimezone: true }),
    zepto_message_id: text('zepto_message_id'),
  },
  (table) => ({
    uniqClinicKey: uniqueIndex('uniq_clinic_campaign_key').on(table.clinic_id, table.campaign_key),
    idxClinic: index('idx_campaign_log_clinic').on(table.clinic_id),
    idxKey: index('idx_campaign_log_key').on(table.campaign_key),
    idxSent: index('idx_campaign_log_sent').on(table.sent_at),
  })
);

export type ClinicCampaignLog = typeof clinicCampaignLog.$inferSelect;
export type NewClinicCampaignLog = typeof clinicCampaignLog.$inferInsert;
  • Step 5: Re-export from schema index
If there’s a server/src/schema/index.ts that re-exports schemas, add: export * from './campaign-log';. Verify with cat server/src/schema/index.ts 2>/dev/null | head -20.
  • Step 6: Push the migration
Run: cd server && npm run db:push Expected: drizzle-kit shows the new columns and table, asks for confirmation, applies to the dev Neon DB. Confirm yes on each prompt.
  • Step 7: Write a schema smoke test
Create server/src/campaigns/__tests__/schema.test.ts:
import { describe, it, expect } from 'vitest';
import { clinics } from '../../schema/clinics';
import { users } from '../../schema/users';
import { clinicCampaignLog } from '../../schema/campaign-log';

describe('campaign schema', () => {
  it('clinics has marketing_campaign_enabled column', () => {
    expect(clinics.marketing_campaign_enabled).toBeDefined();
  });
  it('clinics has marketing_unsubscribed column', () => {
    expect(clinics.marketing_unsubscribed).toBeDefined();
  });
  it('users has email_marketing_opt_out column', () => {
    expect(users.email_marketing_opt_out).toBeDefined();
  });
  it('clinicCampaignLog table is defined with required columns', () => {
    expect(clinicCampaignLog.clinic_id).toBeDefined();
    expect(clinicCampaignLog.campaign_key).toBeDefined();
    expect(clinicCampaignLog.sent_at).toBeDefined();
  });
});
  • Step 8: Run the test
Run: cd server && npx vitest run src/campaigns/__tests__/schema.test.ts Expected: 4 passing.
  • Step 9: Commit
git add server/src/schema/clinics.ts server/src/schema/users.ts server/src/schema/campaign-log.ts server/src/schema/index.ts server/src/campaigns/__tests__/schema.test.ts
git commit -m "feat(campaign): add clinic_campaign_log table + marketing columns

Adds clinic-level campaign toggle (marketing_campaign_enabled), user-level
marketing opt-out, and the dedupe log table with UNIQUE(clinic_id,
campaign_key)."

Task 2 — Lesson types & signal shape

Files:
  • Create: server/src/campaigns/types.ts
  • Step 1: Define the shared types
Create server/src/campaigns/types.ts:
export type Phase = 'trial' | 'paid';

export type Signals = {
  patients: number;
  appointments: number;
  chartEntries: number;
  clinicalNotes: number;
  invoices: number;
  prescriptions: number;
  labCases: number;
  insuranceClaims: number;
  ipdVisits: number;
  aiReportsViewed: number;
  userCount: number;
  whatsappEnabled: boolean;
  financeEnabled: boolean;
  rxEnabled: boolean;
  labEnabled: boolean;
  insuranceEnabled: boolean;
  aiEnabled: boolean;
  ipdEnabled: boolean;
  mobileSessions: number;
  bulkToolsUsed: boolean;
  lastAppActivityAt: Date | null;
  dayInTrial: number;
  dayInPaid: number;
};

export type ClinicCtx = {
  id: string;
  name: string;
  subscriptionStatus: 'trial' | 'active' | 'suspended' | 'cancelled';
  trialEndDate: Date | null;
  activatedAt: Date | null;
  createdAt: Date;
  /** Most recently-added patient's first name, used by first_patient_added template */
  mostRecentPatientName?: string;
};

export type AdminRecipient = {
  userId: string;
  email: string;
  firstName: string;
};

export type LessonKey =
  | 'welcome' | 'first_patient_added' | 'first_appointment' | 'whatsapp_off'
  | 'dental_charting' | 'clinical_notes' | 'invite_staff' | 'mobile_app'
  | 'first_invoice' | 'first_prescription' | 'trial_thank_you'
  | 'paid_welcome' | 'lab_tracking' | 'insurance_claims'
  | 'ai_insights' | 'scale_milestone' | 'ipd_module';

export type TemplateProps = {
  firstName: string;
  clinicName: string;
  patientName?: string;
  patientCount?: number;
};

export type Lesson = {
  key: LessonKey;
  phase: Phase;
  priority: number;
  articleUrl: string;
  subject: string;
  trigger: (s: Signals, c: ClinicCtx) => boolean;
  render: (props: TemplateProps) => Promise<{ html: string; text: string }>;
};
  • Step 2: Verify types compile
Run: cd server && npx tsc --noEmit Expected: no errors related to campaigns/types.ts.
  • Step 3: Commit
git add server/src/campaigns/types.ts
git commit -m "feat(campaign): add lesson, signals, and recipient types"

Task 3 — getActivationSignals(clinicId) helper

Files:
  • Create: server/src/campaigns/signals.ts
  • Test: server/src/campaigns/__tests__/signals.test.ts
  • Step 1: Write the failing test
Create server/src/campaigns/__tests__/signals.test.ts:
import { describe, it, expect, beforeEach } from 'vitest';
import { db } from '../../db';
import { getActivationSignals } from '../signals';
import { clinics } from '../../schema/clinics';
import { patients } from '../../schema/patients';
import { eq } from 'drizzle-orm';

// Helper to create a fresh clinic for each test
async function makeClinic() {
  const [c] = await db.insert(clinics).values({
    name: 'Test Clinic',
    subscription_status: 'trial',
    trial_end_date: new Date(Date.now() + 14 * 86400e3),
    is_test_account: true,
  }).returning();
  return c;
}

describe('getActivationSignals', () => {
  it('returns zero counts for a fresh clinic', async () => {
    const c = await makeClinic();
    const s = await getActivationSignals(c.id);
    expect(s.patients).toBe(0);
    expect(s.appointments).toBe(0);
    expect(s.chartEntries).toBe(0);
    expect(s.dayInTrial).toBe(0);
    await db.delete(clinics).where(eq(clinics.id, c.id));
  });

  it('counts patients correctly', async () => {
    const c = await makeClinic();
    await db.insert(patients).values([
      { clinic_id: c.id, first_name: 'A', last_name: 'X' },
      { clinic_id: c.id, first_name: 'B', last_name: 'Y' },
    ]);
    const s = await getActivationSignals(c.id);
    expect(s.patients).toBe(2);
    await db.delete(clinics).where(eq(clinics.id, c.id));
  });

  it('computes dayInTrial from clinic createdAt', async () => {
    const c = await makeClinic();
    await db.update(clinics).set({ createdAt: new Date(Date.now() - 5 * 86400e3) }).where(eq(clinics.id, c.id));
    const s = await getActivationSignals(c.id);
    expect(s.dayInTrial).toBeGreaterThanOrEqual(5);
    await db.delete(clinics).where(eq(clinics.id, c.id));
  });
});
(If the codebase doesn’t already have a db test fixture pattern, the implementing agent should locate the project’s preferred test-DB approach — likely either a transactional test wrapper or a dedicated test DB in vitest.config.ts. Match what existing tests under server/src/ do.)
  • Step 2: Run the test to verify it fails
Run: cd server && npx vitest run src/campaigns/__tests__/signals.test.ts Expected: FAIL — getActivationSignals not defined.
  • Step 3: Implement getActivationSignals
Create server/src/campaigns/signals.ts:
import { db } from '../db';
import { sql, eq, and, desc } from 'drizzle-orm';
import { clinics } from '../schema/clinics';
import { patients } from '../schema/patients';
import { appointments } from '../schema/appointments';
import { clinicModules } from '../schema/clinic_modules';
// Add other imports as needed; if a table doesn't exist for a count
// (e.g. ipd_visits when IPD module hasn't been built), use 0 as the value
// and add a TODO comment to wire it once the table lands.
import type { Signals, ClinicCtx } from './types';

export async function getActivationSignals(clinicId: string): Promise<Signals> {
  // Single batched query using Postgres parallel aggregates.
  // Each subquery returns one number; we wrap them in a single SELECT.
  const row = await db.execute<{
    patients: number; appointments: number; chart_entries: number;
    clinical_notes: number; invoices: number; prescriptions: number;
    lab_cases: number; insurance_claims: number; ipd_visits: number;
    ai_reports_viewed: number; user_count: number;
    mobile_sessions: number; bulk_tools_used: boolean;
    last_app_activity_at: Date | null;
    day_in_trial: number; day_in_paid: number;
    whatsapp_enabled: boolean; finance_enabled: boolean;
    rx_enabled: boolean; lab_enabled: boolean;
    insurance_enabled: boolean; ai_enabled: boolean; ipd_enabled: boolean;
  }>(sql`
    SELECT
      COALESCE((SELECT COUNT(*)::int FROM patients WHERE clinic_id = ${clinicId} AND deleted_at IS NULL), 0) AS patients,
      COALESCE((SELECT COUNT(*)::int FROM appointments WHERE clinic_id = ${clinicId}), 0) AS appointments,
      COALESCE((SELECT COUNT(*)::int FROM dental_chart_entries WHERE clinic_id = ${clinicId}), 0) AS chart_entries,
      COALESCE((SELECT COUNT(*)::int FROM clinical_notes WHERE clinic_id = ${clinicId}), 0) AS clinical_notes,
      COALESCE((SELECT COUNT(*)::int FROM invoices WHERE clinic_id = ${clinicId}), 0) AS invoices,
      COALESCE((SELECT COUNT(*)::int FROM prescriptions WHERE clinic_id = ${clinicId}), 0) AS prescriptions,
      COALESCE((SELECT COUNT(*)::int FROM lab_cases WHERE clinic_id = ${clinicId}), 0) AS lab_cases,
      COALESCE((SELECT COUNT(*)::int FROM insurance_claims WHERE clinic_id = ${clinicId}), 0) AS insurance_claims,
      COALESCE((SELECT COUNT(*)::int FROM ipd_visits WHERE clinic_id = ${clinicId}), 0) AS ipd_visits,
      COALESCE((SELECT COUNT(*)::int FROM ai_report_views WHERE clinic_id = ${clinicId}), 0) AS ai_reports_viewed,
      COALESCE((SELECT COUNT(*)::int FROM users WHERE clinic_id = ${clinicId} AND deleted_at IS NULL), 0) AS user_count,
      COALESCE((SELECT COUNT(DISTINCT session_id)::int FROM mobile_sessions WHERE clinic_id = ${clinicId}), 0) AS mobile_sessions,
      EXISTS(SELECT 1 FROM bulk_operations WHERE clinic_id = ${clinicId}) AS bulk_tools_used,
      (SELECT MAX(occurred_at) FROM app_activity WHERE clinic_id = ${clinicId}) AS last_app_activity_at,
      GREATEST(EXTRACT(DAY FROM (NOW() - (SELECT created_at FROM clinics WHERE id = ${clinicId})))::int, 0) AS day_in_trial,
      GREATEST(EXTRACT(DAY FROM (NOW() - (SELECT activated_at FROM clinics WHERE id = ${clinicId})))::int, 0) AS day_in_paid,
      COALESCE((SELECT (clinic_modules->>'whatsapp')::boolean FROM clinics WHERE id = ${clinicId}), false) AS whatsapp_enabled,
      COALESCE((SELECT (clinic_modules->>'finance')::boolean FROM clinics WHERE id = ${clinicId}), false) AS finance_enabled,
      COALESCE((SELECT (clinic_modules->>'prescriptions')::boolean FROM clinics WHERE id = ${clinicId}), false) AS rx_enabled,
      COALESCE((SELECT (clinic_modules->>'lab')::boolean FROM clinics WHERE id = ${clinicId}), false) AS lab_enabled,
      COALESCE((SELECT (clinic_modules->>'insurance')::boolean FROM clinics WHERE id = ${clinicId}), false) AS insurance_enabled,
      COALESCE((SELECT (clinic_modules->>'ai')::boolean FROM clinics WHERE id = ${clinicId}), false) AS ai_enabled,
      COALESCE((SELECT (clinic_modules->>'ipd')::boolean FROM clinics WHERE id = ${clinicId}), false) AS ipd_enabled
  `);

  const r = (row as any).rows ? (row as any).rows[0] : (row as any)[0];

  return {
    patients: r.patients,
    appointments: r.appointments,
    chartEntries: r.chart_entries,
    clinicalNotes: r.clinical_notes,
    invoices: r.invoices,
    prescriptions: r.prescriptions,
    labCases: r.lab_cases,
    insuranceClaims: r.insurance_claims,
    ipdVisits: r.ipd_visits,
    aiReportsViewed: r.ai_reports_viewed,
    userCount: r.user_count,
    mobileSessions: r.mobile_sessions,
    bulkToolsUsed: r.bulk_tools_used,
    lastAppActivityAt: r.last_app_activity_at,
    dayInTrial: r.day_in_trial,
    dayInPaid: r.day_in_paid,
    whatsappEnabled: r.whatsapp_enabled,
    financeEnabled: r.finance_enabled,
    rxEnabled: r.rx_enabled,
    labEnabled: r.lab_enabled,
    insuranceEnabled: r.insurance_enabled,
    aiEnabled: r.ai_enabled,
    ipdEnabled: r.ipd_enabled,
  };
}

export async function getClinicCtx(clinicId: string): Promise<ClinicCtx | null> {
  const [c] = await db.select().from(clinics).where(eq(clinics.id, clinicId)).limit(1);
  if (!c) return null;

  const [p] = await db
    .select({ first_name: patients.first_name })
    .from(patients)
    .where(eq(patients.clinic_id, clinicId))
    .orderBy(desc(patients.created_at))
    .limit(1);

  return {
    id: c.id,
    name: c.name,
    subscriptionStatus: c.subscription_status as ClinicCtx['subscriptionStatus'],
    trialEndDate: c.trial_end_date ?? null,
    activatedAt: c.activated_at ?? null,
    createdAt: c.created_at,
    mostRecentPatientName: p?.first_name,
  };
}
The implementing agent must verify the actual table/column names against the live schema during this step. The names above (dental_chart_entries, mobile_sessions, bulk_operations, app_activity, ai_report_views) are best-guesses from the spec survey — if a referenced table doesn’t exist, substitute 0 or false and leave a one-line comment indicating where the data will come from once that subsystem ships.
  • Step 4: Run the test, fix issues, re-run until passing
Run: cd server && npx vitest run src/campaigns/__tests__/signals.test.ts Expected: 3 passing.
  • Step 5: Commit
git add server/src/campaigns/signals.ts server/src/campaigns/__tests__/signals.test.ts
git commit -m "feat(campaign): add getActivationSignals batched query"

Task 4 — Lesson trigger functions (all 17)

Files:
  • Create: server/src/campaigns/triggers.ts
  • Test: server/src/campaigns/__tests__/triggers.test.ts
  • Step 1: Write failing tests
Create server/src/campaigns/__tests__/triggers.test.ts:
import { describe, it, expect } from 'vitest';
import * as T from '../triggers';
import type { Signals, ClinicCtx } from '../types';

const baseSignals: Signals = {
  patients: 0, appointments: 0, chartEntries: 0, clinicalNotes: 0,
  invoices: 0, prescriptions: 0, labCases: 0, insuranceClaims: 0,
  ipdVisits: 0, aiReportsViewed: 0, userCount: 1, mobileSessions: 0,
  bulkToolsUsed: false, lastAppActivityAt: null, dayInTrial: 0, dayInPaid: 0,
  whatsappEnabled: false, financeEnabled: false, rxEnabled: false,
  labEnabled: false, insuranceEnabled: false, aiEnabled: false, ipdEnabled: false,
};

const baseClinic: ClinicCtx = {
  id: 'c1', name: 'Test', subscriptionStatus: 'trial',
  trialEndDate: new Date(Date.now() + 14 * 86400e3),
  activatedAt: null, createdAt: new Date(),
};

describe('lesson triggers', () => {
  it('welcome fires when patients = 0', () => {
    expect(T.welcomeTrigger({ ...baseSignals, patients: 0 }, baseClinic)).toBe(true);
    expect(T.welcomeTrigger({ ...baseSignals, patients: 1 }, baseClinic)).toBe(false);
  });

  it('first_patient_added fires when patients ≥ 1', () => {
    expect(T.firstPatientAddedTrigger({ ...baseSignals, patients: 1 }, baseClinic)).toBe(true);
    expect(T.firstPatientAddedTrigger({ ...baseSignals, patients: 0 }, baseClinic)).toBe(false);
  });

  it('first_appointment fires when patients ≥ 3 AND appointments = 0', () => {
    expect(T.firstAppointmentTrigger({ ...baseSignals, patients: 3, appointments: 0 }, baseClinic)).toBe(true);
    expect(T.firstAppointmentTrigger({ ...baseSignals, patients: 2, appointments: 0 }, baseClinic)).toBe(false);
    expect(T.firstAppointmentTrigger({ ...baseSignals, patients: 5, appointments: 1 }, baseClinic)).toBe(false);
  });

  it('whatsapp_off fires when day ≥ 3, !whatsapp, patients ≥ 5', () => {
    expect(T.whatsappOffTrigger({ ...baseSignals, dayInTrial: 3, patients: 5, whatsappEnabled: false }, baseClinic)).toBe(true);
    expect(T.whatsappOffTrigger({ ...baseSignals, dayInTrial: 3, patients: 5, whatsappEnabled: true }, baseClinic)).toBe(false);
    expect(T.whatsappOffTrigger({ ...baseSignals, dayInTrial: 2, patients: 5 }, baseClinic)).toBe(false);
  });

  it('dental_charting fires when appointments ≥ 1 AND chartEntries = 0', () => {
    expect(T.dentalChartingTrigger({ ...baseSignals, appointments: 1, chartEntries: 0 }, baseClinic)).toBe(true);
    expect(T.dentalChartingTrigger({ ...baseSignals, appointments: 0, chartEntries: 0 }, baseClinic)).toBe(false);
  });

  it('clinical_notes fires when appointments ≥ 1, notes = 0, day ≥ 4', () => {
    expect(T.clinicalNotesTrigger({ ...baseSignals, appointments: 1, clinicalNotes: 0, dayInTrial: 4 }, baseClinic)).toBe(true);
    expect(T.clinicalNotesTrigger({ ...baseSignals, appointments: 1, clinicalNotes: 0, dayInTrial: 3 }, baseClinic)).toBe(false);
  });

  it('invite_staff fires when userCount = 1 AND day ≥ 5', () => {
    expect(T.inviteStaffTrigger({ ...baseSignals, userCount: 1, dayInTrial: 5 }, baseClinic)).toBe(true);
    expect(T.inviteStaffTrigger({ ...baseSignals, userCount: 2, dayInTrial: 5 }, baseClinic)).toBe(false);
  });

  it('mobile_app fires when no mobile session AND day ≥ 6', () => {
    expect(T.mobileAppTrigger({ ...baseSignals, mobileSessions: 0, dayInTrial: 6 }, baseClinic)).toBe(true);
    expect(T.mobileAppTrigger({ ...baseSignals, mobileSessions: 1, dayInTrial: 6 }, baseClinic)).toBe(false);
  });

  it('first_invoice fires when finance on AND invoices = 0 AND day ≥ 5', () => {
    expect(T.firstInvoiceTrigger({ ...baseSignals, financeEnabled: true, invoices: 0, dayInTrial: 5 }, baseClinic)).toBe(true);
    expect(T.firstInvoiceTrigger({ ...baseSignals, financeEnabled: false, invoices: 0, dayInTrial: 5 }, baseClinic)).toBe(false);
  });

  it('first_prescription fires when rx on AND prescriptions = 0 AND day ≥ 6', () => {
    expect(T.firstPrescriptionTrigger({ ...baseSignals, rxEnabled: true, prescriptions: 0, dayInTrial: 6 }, baseClinic)).toBe(true);
  });

  it('trial_thank_you fires when subscriptionStatus = active AND activatedAt within 24h', () => {
    const c: ClinicCtx = { ...baseClinic, subscriptionStatus: 'active', activatedAt: new Date(Date.now() - 1 * 3600e3) };
    expect(T.trialThankYouTrigger(baseSignals, c)).toBe(true);
    const tooOld: ClinicCtx = { ...c, activatedAt: new Date(Date.now() - 48 * 3600e3) };
    expect(T.trialThankYouTrigger(baseSignals, tooOld)).toBe(false);
  });

  it('paid_welcome fires 24h after conversion', () => {
    const c: ClinicCtx = { ...baseClinic, subscriptionStatus: 'active', activatedAt: new Date(Date.now() - 25 * 3600e3) };
    expect(T.paidWelcomeTrigger({ ...baseSignals, dayInPaid: 1 }, c)).toBe(true);
  });

  it('lab_tracking fires when lab on AND no cases', () => {
    expect(T.labTrackingTrigger({ ...baseSignals, labEnabled: true, labCases: 0 }, baseClinic)).toBe(true);
  });

  it('insurance_claims fires when insurance on AND no claims', () => {
    expect(T.insuranceClaimsTrigger({ ...baseSignals, insuranceEnabled: true, insuranceClaims: 0 }, baseClinic)).toBe(true);
  });

  it('ai_insights fires when ai on AND no reports viewed', () => {
    expect(T.aiInsightsTrigger({ ...baseSignals, aiEnabled: true, aiReportsViewed: 0 }, baseClinic)).toBe(true);
  });

  it('scale_milestone fires when patients ≥ 100 AND no bulk tools used', () => {
    expect(T.scaleMilestoneTrigger({ ...baseSignals, patients: 100, bulkToolsUsed: false }, baseClinic)).toBe(true);
    expect(T.scaleMilestoneTrigger({ ...baseSignals, patients: 99, bulkToolsUsed: false }, baseClinic)).toBe(false);
  });

  it('ipd_module fires when ipd on, no visits, day ≥ 14', () => {
    expect(T.ipdModuleTrigger({ ...baseSignals, ipdEnabled: true, ipdVisits: 0, dayInPaid: 14 }, baseClinic)).toBe(true);
  });
});
  • Step 2: Run the test and verify it fails
Run: cd server && npx vitest run src/campaigns/__tests__/triggers.test.ts Expected: FAIL — module not found.
  • Step 3: Implement the triggers
Create server/src/campaigns/triggers.ts:
import type { Signals, ClinicCtx } from './types';

const hours = (n: number) => n * 3600 * 1000;

export const welcomeTrigger = (s: Signals, _c: ClinicCtx) =>
  s.patients === 0;

export const firstPatientAddedTrigger = (s: Signals, _c: ClinicCtx) =>
  s.patients >= 1;

export const firstAppointmentTrigger = (s: Signals, _c: ClinicCtx) =>
  s.patients >= 3 && s.appointments === 0;

export const whatsappOffTrigger = (s: Signals, _c: ClinicCtx) =>
  s.dayInTrial >= 3 && !s.whatsappEnabled && s.patients >= 5;

export const dentalChartingTrigger = (s: Signals, _c: ClinicCtx) =>
  s.appointments >= 1 && s.chartEntries === 0;

export const clinicalNotesTrigger = (s: Signals, _c: ClinicCtx) =>
  s.appointments >= 1 && s.clinicalNotes === 0 && s.dayInTrial >= 4;

export const inviteStaffTrigger = (s: Signals, _c: ClinicCtx) =>
  s.userCount === 1 && s.dayInTrial >= 5;

export const mobileAppTrigger = (s: Signals, _c: ClinicCtx) =>
  s.mobileSessions === 0 && s.dayInTrial >= 6;

export const firstInvoiceTrigger = (s: Signals, _c: ClinicCtx) =>
  s.financeEnabled && s.invoices === 0 && s.dayInTrial >= 5;

export const firstPrescriptionTrigger = (s: Signals, _c: ClinicCtx) =>
  s.rxEnabled && s.prescriptions === 0 && s.dayInTrial >= 6;

export const trialThankYouTrigger = (_s: Signals, c: ClinicCtx) =>
  c.subscriptionStatus === 'active' &&
  c.activatedAt !== null &&
  Date.now() - c.activatedAt.getTime() <= hours(24);

export const paidWelcomeTrigger = (s: Signals, c: ClinicCtx) =>
  c.subscriptionStatus === 'active' && s.dayInPaid >= 1;

export const labTrackingTrigger = (s: Signals, _c: ClinicCtx) =>
  s.labEnabled && s.labCases === 0;

export const insuranceClaimsTrigger = (s: Signals, _c: ClinicCtx) =>
  s.insuranceEnabled && s.insuranceClaims === 0;

export const aiInsightsTrigger = (s: Signals, _c: ClinicCtx) =>
  s.aiEnabled && s.aiReportsViewed === 0;

export const scaleMilestoneTrigger = (s: Signals, _c: ClinicCtx) =>
  s.patients >= 100 && !s.bulkToolsUsed;

export const ipdModuleTrigger = (s: Signals, _c: ClinicCtx) =>
  s.ipdEnabled && s.ipdVisits === 0 && s.dayInPaid >= 14;
  • Step 4: Run the test and verify all pass
Run: cd server && npx vitest run src/campaigns/__tests__/triggers.test.ts Expected: 17 passing.
  • Step 5: Commit
git add server/src/campaigns/triggers.ts server/src/campaigns/__tests__/triggers.test.ts
git commit -m "feat(campaign): add 17 lesson trigger functions with unit tests"

Task 5 — Shared React Email layout (CampaignEmail component)

Files:
  • Create: server/src/emails/campaign/CampaignEmail.tsx
  • Test: server/src/emails/campaign/__tests__/CampaignEmail.test.tsx
  • Step 1: Locate the production CDN base URL for static assets
Run: grep -rn "go.odontox.io\|odontox.io\|PUBLIC_URL\|ASSETS_URL" server/src/lib/email.ts | head -5 Expected: an existing convention for how email templates reference static assets. Use the same convention. Assume the canonical asset base is https://go.odontox.io — if the codebase reveals a different host (e.g. https://app.odontox.io or a CDN), substitute it in all references below.
  • Step 2: Write the failing test
Create server/src/emails/campaign/__tests__/CampaignEmail.test.tsx:
import { describe, it, expect } from 'vitest';
import { render } from '@react-email/render';
import { CampaignEmail } from '../CampaignEmail';

describe('CampaignEmail layout', () => {
  it('renders the header logo, body paragraphs, CTA, and footer', async () => {
    const html = await render(
      <CampaignEmail
        previewText="Welcome"
        headerLogo="email"
        paragraphs={['Hi Aisha,', 'Welcome to OdontoX.']}
        cta={{ text: 'Get started →', href: 'https://q.odontox.io/guides/welcome' }}
        postscript="P.S. Backups at 2am PKT."
      />
    );
    expect(html).toContain('email.png');           // header logo image
    expect(html).toContain('Welcome to OdontoX');  // body copy
    expect(html).toContain('q.odontox.io/guides/welcome'); // CTA href
    expect(html).toContain('— Sarmad, OdontoX');   // sign-off
    expect(html).toContain('logo.png');            // footer logo
    expect(html).toContain('P.S. Backups');         // postscript
    expect(html).toContain('Unsubscribe');         // footer link
  });

  it('uses ruby.png when headerLogo = "ruby"', async () => {
    const html = await render(
      <CampaignEmail
        previewText="Ruby"
        headerLogo="ruby"
        paragraphs={['Hi.']}
        cta={{ text: 'See →', href: 'https://q.odontox.io/guides/ruby-reports' }}
        postscript="P.S."
      />
    );
    expect(html).toContain('ruby.png');
    expect(html).not.toContain('email.png');
  });
});
  • Step 3: Run it to verify failure
Run: cd server && npx vitest run src/emails/campaign/__tests__/CampaignEmail.test.tsx Expected: FAIL — module not found.
  • Step 4: Implement the layout
Create server/src/emails/campaign/CampaignEmail.tsx:
import * as React from 'react';
import {
  Html, Head, Preview, Body, Container, Section, Text, Link, Img, Hr,
} from '@react-email/components';

const ASSET_BASE = process.env.PUBLIC_ASSET_BASE || 'https://go.odontox.io';

const styles = {
  body: {
    backgroundColor: '#ffffff',
    fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
    color: '#111111',
    margin: 0,
    padding: 0,
    WebkitFontSmoothing: 'antialiased' as const,
  },
  container: {
    maxWidth: '560px',
    margin: '0 auto',
    padding: '40px 24px 32px',
  },
  logoHeader: { marginBottom: '32px' },
  logoHeaderImg: { height: '36px', width: 'auto', display: 'block' as const },
  paragraph: {
    fontSize: '16px',
    lineHeight: '1.65',
    color: '#111111',
    margin: '0 0 18px',
    letterSpacing: '-0.005em',
  },
  cta: {
    color: '#0C8C5E',
    fontSize: '16px',
    textDecoration: 'underline',
    textUnderlineOffset: '4px',
    fontWeight: 400,
  },
  ctaWrap: { margin: '8px 0 28px' },
  signoff: { fontSize: '16px', color: '#111', margin: '32px 0 0' },
  ps: {
    fontStyle: 'italic' as const,
    fontSize: '14px',
    color: '#9a9a9a',
    margin: '24px 0 0',
    lineHeight: '1.55',
  },
  footerSection: {
    borderTop: '1px solid #ececec',
    padding: '28px 24px 24px',
    textAlign: 'center' as const,
    backgroundColor: '#fafafb',
    marginTop: '32px',
  },
  footerLogo: { height: '20px', opacity: 0.65, display: 'inline-block' as const },
  footerText: {
    fontSize: '11px',
    color: '#9a9a9a',
    letterSpacing: '0.04em',
    margin: '14px 0 0',
  },
  footerLink: { color: '#9a9a9a', textDecoration: 'underline' },
};

export type CampaignEmailProps = {
  previewText: string;
  headerLogo: 'email' | 'ruby';
  paragraphs: string[];
  cta: { text: string; href: string };
  postscript: string;
  unsubscribeUrl?: string;
  preferencesUrl?: string;
};

export const CampaignEmail: React.FC<CampaignEmailProps> = ({
  previewText, headerLogo, paragraphs, cta, postscript,
  unsubscribeUrl = `${ASSET_BASE}/email/unsubscribe`,
  preferencesUrl = `${ASSET_BASE}/settings/email`,
}) => (
  <Html>
    <Head />
    <Preview>{previewText}</Preview>
    <Body style={styles.body}>
      <Container style={styles.container}>
        <Section style={styles.logoHeader}>
          <Img
            src={`${ASSET_BASE}/${headerLogo === 'ruby' ? 'ruby.png' : 'email.png'}`}
            alt={headerLogo === 'ruby' ? 'Ruby' : 'OdontoX'}
            style={styles.logoHeaderImg}
          />
        </Section>
        {paragraphs.map((p, i) => (
          <Text key={i} style={styles.paragraph}>{p}</Text>
        ))}
        <Section style={styles.ctaWrap}>
          <Link href={cta.href} style={styles.cta}>{cta.text}</Link>
        </Section>
        <Text style={styles.signoff}>— Sarmad, OdontoX</Text>
        <Text style={styles.ps}>P.S. {postscript}</Text>
      </Container>
      <Section style={styles.footerSection}>
        <Img src={`${ASSET_BASE}/logo.png`} alt="OdontoX" style={styles.footerLogo} />
        <Text style={styles.footerText}>
          OdontoX · Karachi ·{' '}
          <Link href={unsubscribeUrl} style={styles.footerLink}>Unsubscribe</Link>{' '}
          ·{' '}
          <Link href={preferencesUrl} style={styles.footerLink}>Email preferences</Link>
        </Text>
      </Section>
    </Body>
  </Html>
);
  • Step 5: Run the test, verify it passes
Run: cd server && npx vitest run src/emails/campaign/__tests__/CampaignEmail.test.tsx Expected: 2 passing.
  • Step 6: Commit
git add server/src/emails/campaign/CampaignEmail.tsx server/src/emails/campaign/__tests__/CampaignEmail.test.tsx
git commit -m "feat(campaign): add shared CampaignEmail React Email layout"

Task 6 — All 17 campaign email templates

Each template is a thin wrapper around CampaignEmail with subject, body, CTA, and P.S. from the spec. Files:
  • Create: server/src/emails/campaign/<name>.tsx × 17
  • Test: server/src/emails/campaign/__tests__/templates.test.tsx
  • Step 1: Write the smoke test
Create server/src/emails/campaign/__tests__/templates.test.tsx:
import { describe, it, expect } from 'vitest';
import { render } from '@react-email/render';
import * as templates from '../index';

const baseProps = { firstName: 'Aisha', clinicName: 'Khan Clinic', patientName: 'Hammad', patientCount: 6 };

const expectations: Record<string, { copyContains: string; ctaHref: string }> = {
  welcome:             { copyContains: 'Welcome to OdontoX',                  ctaHref: 'q.odontox.io/guides/welcome' },
  firstPatientAdded:   { copyContains: 'Hammad is in the system',             ctaHref: 'q.odontox.io/guides/appointments' },
  firstAppointment:    { copyContains: 'patients in the system',              ctaHref: 'q.odontox.io/guides/appointments' },
  whatsappOff:         { copyContains: 'WhatsApp is the highest-leverage',    ctaHref: 'q.odontox.io/guides/whatsapp-setup' },
  dentalCharting:      { copyContains: 'dental chart',                        ctaHref: 'q.odontox.io/guides/dental-chart' },
  clinicalNotes:       { copyContains: 'Most clinics dread writing notes',    ctaHref: 'q.odontox.io/guides/clinical-notes' },
  inviteStaff:         { copyContains: 'driving OdontoX solo',                ctaHref: 'q.odontox.io/guides/invite-staff' },
  mobileApp:           { copyContains: 'on a phone',                          ctaHref: 'q.odontox.io/guides/mobile-app' },
  firstInvoice:        { copyContains: 'Finance module',                      ctaHref: 'q.odontox.io/guides/invoices' },
  firstPrescription:   { copyContains: 'medicine library',                    ctaHref: 'q.odontox.io/guides/prescriptions' },
  trialThankYou:       { copyContains: 'upgraded to Pro today',               ctaHref: 'q.odontox.io/guides/pro-overview' },
  paidWelcome:         { copyContains: 'advanced parts of OdontoX',           ctaHref: 'q.odontox.io/guides/pro-roadmap' },
  labTracking:         { copyContains: 'Lab cases get tracked',               ctaHref: 'q.odontox.io/guides/lab-tracking' },
  insuranceClaims:     { copyContains: 'Insurance claims',                    ctaHref: 'q.odontox.io/guides/insurance-claims' },
  aiInsights:          { copyContains: 'Ruby is the part of OdontoX',         ctaHref: 'q.odontox.io/guides/ruby-reports' },
  scaleMilestone:      { copyContains: '100 patients',                        ctaHref: 'q.odontox.io/guides/scale-tips' },
  ipdModule:           { copyContains: 'In-Patient Department',               ctaHref: 'q.odontox.io/guides/ipd' },
};

describe('campaign templates', () => {
  for (const [name, exp] of Object.entries(expectations)) {
    it(`${name} renders with expected copy + CTA`, async () => {
      const Component = (templates as any)[name];
      expect(Component, `${name} not exported from index`).toBeDefined();
      const html = await render(<Component {...baseProps} />);
      expect(html).toContain(exp.copyContains);
      expect(html).toContain(exp.ctaHref);
      expect(html).toContain('Aisha');             // firstName interpolated
      expect(html).toContain('— Sarmad, OdontoX'); // sign-off always present
    });
  }

  it('aiInsights uses ruby.png in the header', async () => {
    const html = await render(<templates.aiInsights {...baseProps} />);
    expect(html).toContain('ruby.png');
  });
});
  • Step 2: Run and verify failure
Run: cd server && npx vitest run src/emails/campaign/__tests__/templates.test.tsx Expected: FAIL — templates index not found.
  • Step 3: Create the 17 template files
Each file follows the exact pattern below. Copy and adjust subject / body / cta / ps from the spec (“§4 — Final copy”). The full final copy is in docs/superpowers/specs/2026-05-15-onboarding-email-campaign-design.md lines 130–340 — paste the body lines verbatim, replacing the variables. server/src/emails/campaign/welcome.tsx:
import * as React from 'react';
import { CampaignEmail } from './CampaignEmail';
import type { TemplateProps } from '../../campaigns/types';

export const welcome: React.FC<TemplateProps> = ({ firstName }) => (
  <CampaignEmail
    previewText="Welcome to OdontoX — your first step is one patient."
    headerLogo="email"
    paragraphs={[
      `Hi ${firstName},`,
      "Welcome to OdontoX. Over the next few days we'll send a small number of short emails — one feature at a time, only when there's something useful to cover.",
      "The first step is the foundation: adding a patient. Once a patient record exists, everything else in OdontoX — appointments, charting, prescriptions, invoices — builds from it.",
      "The guide walks through it in about a minute.",
    ]}
    cta={{ text: 'Adding your first patient →', href: 'https://q.odontox.io/guides/welcome' }}
    postscript="Your clinic data is backed up every night at 2am PKT."
  />
);

export const welcomeSubject = 'Welcome to OdontoX';
server/src/emails/campaign/firstPatientAdded.tsx:
import * as React from 'react';
import { CampaignEmail } from './CampaignEmail';
import type { TemplateProps } from '../../campaigns/types';

export const firstPatientAdded: React.FC<TemplateProps> = ({ firstName, patientName = 'your first patient' }) => (
  <CampaignEmail
    previewText="Your first patient is in. Next, book them in."
    headerLogo="email"
    paragraphs={[
      `Hi ${firstName},`,
      `${patientName} is in the system. That's the foundation built — everything else in OdontoX layers on top of a patient record.`,
      `The next thirty seconds: open the Appointments tab and drag ${patientName} onto a time slot. That's how bookings work.`,
    ]}
    cta={{ text: 'Booking appointments →', href: 'https://q.odontox.io/guides/appointments' }}
    postscript="⌘K opens a search bar from any page in the app. Fastest way around."
  />
);

export const firstPatientAddedSubject = 'Your first patient is in';
server/src/emails/campaign/firstAppointment.tsx:
import * as React from 'react';
import { CampaignEmail } from './CampaignEmail';
import type { TemplateProps } from '../../campaigns/types';

export const firstAppointment: React.FC<TemplateProps> = ({ firstName, patientCount = 0 }) => (
  <CampaignEmail
    previewText="Time to start booking. Appointments work like a calendar."
    headerLogo="email"
    paragraphs={[
      `Hi ${firstName},`,
      `${patientCount} patients in the system — time to start booking.`,
      "Appointments work like a calendar you can drag patients onto. Each booking carries a confirmation message, an automatic 24-hour reminder, and a post-visit follow-up — sent over WhatsApp when you've connected it. (We'll cover that one separately.)",
    ]}
    cta={{ text: 'How appointments work →', href: 'https://q.odontox.io/guides/appointments' }}
    postscript="The dots on each slot are status: green confirmed, amber tentative, red cancelled."
  />
);

export const firstAppointmentSubject = 'Booking your first appointment';
server/src/emails/campaign/whatsappOff.tsx:
import * as React from 'react';
import { CampaignEmail } from './CampaignEmail';
import type { TemplateProps } from '../../campaigns/types';

export const whatsappOff: React.FC<TemplateProps> = ({ firstName }) => (
  <CampaignEmail
    previewText="Most clinics connect WhatsApp by day 3. No-shows drop ~40%."
    headerLogo="email"
    paragraphs={[
      `Hi ${firstName},`,
      "WhatsApp is the highest-leverage integration most OdontoX clinics turn on. Once connected, appointment confirmations, 24-hour reminders, and post-visit follow-ups go out automatically from your clinic's own number.",
      "Across clinics we've watched configure it, no-shows drop by around 40% in the first month.",
      "The WhatsApp module was rebuilt this month — delivery and read receipts now show on every message, and templates render Markdown.",
    ]}
    cta={{ text: 'Connecting WhatsApp →', href: 'https://q.odontox.io/guides/whatsapp-setup' }}
    postscript={'Patients see "via WhatsApp Business," not "via OdontoX." Your clinic stays the face.'}
  />
);

export const whatsappOffSubject = 'Connect WhatsApp to cut no-shows by 40%';
server/src/emails/campaign/dentalCharting.tsx:
import * as React from 'react';
import { CampaignEmail } from './CampaignEmail';
import type { TemplateProps } from '../../campaigns/types';

export const dentalCharting: React.FC<TemplateProps> = ({ firstName }) => (
  <CampaignEmail
    previewText="Tap a tooth, mark a condition. The chart flows everywhere."
    headerLogo="email"
    paragraphs={[
      `Hi ${firstName},`,
      "The dental chart is one of the more visual parts of OdontoX. Open any patient, switch to the Chart tab, select a tooth, mark a condition, save.",
      "Anything you record flows into the treatment plan, prescription, and invoice automatically — you never enter the same data twice.",
    ]}
    cta={{ text: 'How the chart works →', href: 'https://q.odontox.io/guides/dental-chart' }}
    postscript="Adult, child, and mixed dentition layouts switch automatically based on the patient's age."
  />
);

export const dentalChartingSubject = 'Using the dental chart';
server/src/emails/campaign/clinicalNotes.tsx:
import * as React from 'react';
import { CampaignEmail } from './CampaignEmail';
import type { TemplateProps } from '../../campaigns/types';

export const clinicalNotes: React.FC<TemplateProps> = ({ firstName }) => (
  <CampaignEmail
    previewText="SOAP-style template, voice-to-text, two clicks from the appointment."
    headerLogo="email"
    paragraphs={[
      `Hi ${firstName},`,
      "Most clinics dread writing notes. OdontoX has a SOAP-style template that autosaves, voice-to-text on mobile, and templates you can reuse across patients.",
      'From the appointment screen, click "Add note" — two clicks, then dictate.',
    ]}
    cta={{ text: 'Writing clinical notes →', href: 'https://q.odontox.io/guides/clinical-notes' }}
    postscript="Voice transcription works in Urdu and English."
  />
);

export const clinicalNotesSubject = 'Clinical notes, the fast way';
server/src/emails/campaign/inviteStaff.tsx:
import * as React from 'react';
import { CampaignEmail } from './CampaignEmail';
import type { TemplateProps } from '../../campaigns/types';

export const inviteStaff: React.FC<TemplateProps> = ({ firstName }) => (
  <CampaignEmail
    previewText="OdontoX works better with the whole clinic on it."
    headerLogo="email"
    paragraphs={[
      `Hi ${firstName},`,
      "You've been driving OdontoX solo so far. The app works better with the whole clinic on it: receptionists handle bookings and reminders, doctors stay in clinical mode, you get a real audit trail of who did what.",
      "Each role sees only what it should — receptionists don't see clinical notes, doctors don't see the finance ledger.",
    ]}
    cta={{ text: 'Inviting staff →', href: 'https://q.odontox.io/guides/invite-staff' }}
    postscript="Adding a teammate takes thirty seconds and doesn't change your billing during the trial."
  />
);

export const inviteStaffSubject = 'Add your team to OdontoX';
server/src/emails/campaign/mobileApp.tsx:
import * as React from 'react';
import { CampaignEmail } from './CampaignEmail';
import type { TemplateProps } from '../../campaigns/types';

export const mobileApp: React.FC<TemplateProps> = ({ firstName }) => (
  <CampaignEmail
    previewText="Pull up a patient between cases. Native app, not a webview."
    headerLogo="email"
    paragraphs={[
      `Hi ${firstName},`,
      "The mobile app does the things you'd actually want to do on a phone: pull up a patient between cases, check tomorrow's schedule, reply to a WhatsApp thread, write a quick note.",
      "It's a real native app, not a webview wrapper.",
    ]}
    cta={{ text: 'Getting the mobile app →', href: 'https://q.odontox.io/guides/mobile-app' }}
    postscript="iOS first, Android a few weeks behind on a couple of features. Both work today."
  />
);

export const mobileAppSubject = 'OdontoX on iOS and Android';
server/src/emails/campaign/firstInvoice.tsx:
import * as React from 'react';
import { CampaignEmail } from './CampaignEmail';
import type { TemplateProps } from '../../campaigns/types';

export const firstInvoice: React.FC<TemplateProps> = ({ firstName }) => (
  <CampaignEmail
    previewText="Two clicks: treatment plan into invoice. PKR or USD."
    headerLogo="email"
    paragraphs={[
      `Hi ${firstName},`,
      "The Finance module turns a treatment plan into an invoice in two clicks. PKR or USD, WhatsApp the PDF or share a payment link.",
      "Receipts and installment plans work the same way — every transaction stays linked to the patient's record.",
    ]}
    cta={{ text: 'Creating invoices →', href: 'https://q.odontox.io/guides/invoices' }}
    postscript="Year-to-date revenue, by doctor, by service — Reports → Finance."
  />
);

export const firstInvoiceSubject = 'Sending your first invoice';
server/src/emails/campaign/firstPrescription.tsx:
import * as React from 'react';
import { CampaignEmail } from './CampaignEmail';
import type { TemplateProps } from '../../campaigns/types';

export const firstPrescription: React.FC<TemplateProps> = ({ firstName }) => (
  <CampaignEmail
    previewText="Type two letters. Drug autocompletes with standard dosage."
    headerLogo="email"
    paragraphs={[
      `Hi ${firstName},`,
      "Prescriptions in OdontoX use a pre-loaded medicine library that covers local brand and generic names. Type two letters, the drug autocompletes with the standard dosage. Add it to the patient and the PDF is ready to print or WhatsApp.",
    ]}
    cta={{ text: 'Writing prescriptions →', href: 'https://q.odontox.io/guides/prescriptions' }}
    postscript="Your custom dosing presets persist across patients."
  />
);

export const firstPrescriptionSubject = 'Writing a prescription';
server/src/emails/campaign/trialThankYou.tsx:
import * as React from 'react';
import { CampaignEmail } from './CampaignEmail';
import type { TemplateProps } from '../../campaigns/types';

export const trialThankYou: React.FC<TemplateProps> = ({ firstName }) => (
  <CampaignEmail
    previewText="Thank you. Welcome to OdontoX Pro."
    headerLogo="email"
    paragraphs={[
      `Hi ${firstName},`,
      "You upgraded to Pro today — thank you. That means a great deal to a small team.",
      "We'll keep writing every so often to walk you through the more advanced parts of OdontoX as they become relevant — lab tracking, insurance claims, Ruby Reports, and so on. You can adjust which emails you receive any time in Settings.",
    ]}
    cta={{ text: "What's included with Pro →", href: 'https://q.odontox.io/guides/pro-overview' }}
    postscript="Welcome."
  />
);

export const trialThankYouSubject = 'Welcome to OdontoX Pro';
server/src/emails/campaign/paidWelcome.tsx:
import * as React from 'react';
import { CampaignEmail } from './CampaignEmail';
import type { TemplateProps } from '../../campaigns/types';

export const paidWelcome: React.FC<TemplateProps> = ({ firstName }) => (
  <CampaignEmail
    previewText="Now that the basics are running, the advanced parts of OdontoX become relevant."
    headerLogo="email"
    paragraphs={[
      `Hi ${firstName},`,
      "Now that the basics are running, the advanced parts of OdontoX become relevant — lab tracking, insurance claims, Ruby Reports, inventory, and a few more.",
      "We'll send a short email when each of those is worth a look. You can also browse them yourself.",
    ]}
    cta={{ text: 'The Pro roadmap →', href: 'https://q.odontox.io/guides/pro-roadmap' }}
    postscript="Pro pricing locks in for as long as your subscription stays active."
  />
);

export const paidWelcomeSubject = "What's next, now that you're on Pro";
server/src/emails/campaign/labTracking.tsx:
import * as React from 'react';
import { CampaignEmail } from './CampaignEmail';
import type { TemplateProps } from '../../campaigns/types';

export const labTracking: React.FC<TemplateProps> = ({ firstName }) => (
  <CampaignEmail
    previewText="Stop chasing the lab. Sent, expected, returned, all tracked."
    headerLogo="email"
    paragraphs={[
      `Hi ${firstName},`,
      "Lab cases get tracked end-to-end in OdontoX — sent date, expected return, actual return, attached prosthetic photos, patient linkage. No more WhatsApp threads with the lab to keep track of crowns.",
    ]}
    cta={{ text: 'Tracking lab cases →', href: 'https://q.odontox.io/guides/lab-tracking' }}
    postscript="Overdue cases get flagged on the dashboard. You'll never have to chase a lab again."
  />
);

export const labTrackingSubject = 'Lab tracking in OdontoX';
server/src/emails/campaign/insuranceClaims.tsx:
import * as React from 'react';
import { CampaignEmail } from './CampaignEmail';
import type { TemplateProps } from '../../campaigns/types';

export const insuranceClaims: React.FC<TemplateProps> = ({ firstName }) => (
  <CampaignEmail
    previewText="Insurance claims pull from the treatment plan. No re-typing."
    headerLogo="email"
    paragraphs={[
      `Hi ${firstName},`,
      "Insurance claims in OdontoX pull from the treatment plan automatically — no re-typing procedures, codes, or costs. Generate the claim form, attach photos and notes, send to the insurer.",
      "The status tracker shows when claims are submitted, in review, approved, or denied.",
    ]}
    cta={{ text: 'Submitting claims →', href: 'https://q.odontox.io/guides/insurance-claims' }}
    postscript="Common insurer templates are pre-loaded. Add your own from Settings → Insurance."
  />
);

export const insuranceClaimsSubject = 'Submitting insurance claims';
server/src/emails/campaign/aiInsights.tsx:
import * as React from 'react';
import { CampaignEmail } from './CampaignEmail';
import type { TemplateProps } from '../../campaigns/types';

export const aiInsights: React.FC<TemplateProps> = ({ firstName }) => (
  <CampaignEmail
    previewText="Your weekly clinic report. Overdue patients, slipping treatments, lost revenue."
    headerLogo="ruby"
    paragraphs={[
      `Hi ${firstName},`,
      "Ruby is the part of OdontoX we don't talk about enough.",
      "She reviews your clinic's data and writes a short report every Monday morning — overdue patients, slipping treatments, days you're losing revenue to no-shows. Ten seconds to read, one click to act on.",
    ]}
    cta={{ text: 'How Ruby Reports work →', href: 'https://q.odontox.io/guides/ruby-reports' }}
    postscript="Ruby's report is written, not generated. We were careful about the tone."
  />
);

export const aiInsightsSubject = 'Meet Ruby — your weekly clinic report';
server/src/emails/campaign/scaleMilestone.tsx:
import * as React from 'react';
import { CampaignEmail } from './CampaignEmail';
import type { TemplateProps } from '../../campaigns/types';

export const scaleMilestone: React.FC<TemplateProps> = ({ firstName, patientCount = 100 }) => (
  <CampaignEmail
    previewText="Bulk tools, tags, segments — features for clinics at scale."
    headerLogo="email"
    paragraphs={[
      `Hi ${firstName},`,
      `Crossing ${patientCount} patients changes how you'll use OdontoX. A few features worth knowing:`,
      "• Bulk import and export from Settings → Data\n• Patient tags and segments for recall campaigns\n• Saved views in the appointments calendar",
    ]}
    cta={{ text: 'Features for scale →', href: 'https://q.odontox.io/guides/scale-tips' }}
    postscript="We've seen clinics break 1,000 patients without performance issues. Don't worry about the ceiling."
  />
);

export const scaleMilestoneSubject = 'You have 100 patients in OdontoX';
server/src/emails/campaign/ipdModule.tsx:
import * as React from 'react';
import { CampaignEmail } from './CampaignEmail';
import type { TemplateProps } from '../../campaigns/types';

export const ipdModule: React.FC<TemplateProps> = ({ firstName }) => (
  <CampaignEmail
    previewText="IPD: admissions, ward rounds, discharge summaries — structured."
    headerLogo="email"
    paragraphs={[
      `Hi ${firstName},`,
      "In-Patient Department is for clinics that admit patients overnight — common in larger maxillofacial and surgical practices.",
      "If you handle admissions, ward rounds, and discharge summaries, the IPD module turns those into structured records that link back to the patient and the billing.",
    ]}
    cta={{ text: 'Using IPD →', href: 'https://q.odontox.io/guides/ipd' }}
    postscript="Most clinics don't need this. If you do, it'll save you a notebook."
  />
);

export const ipdModuleSubject = 'The IPD module: when you need it';
  • Step 4: Create the index barrel
Create server/src/emails/campaign/index.ts:
export * from './welcome';
export * from './firstPatientAdded';
export * from './firstAppointment';
export * from './whatsappOff';
export * from './dentalCharting';
export * from './clinicalNotes';
export * from './inviteStaff';
export * from './mobileApp';
export * from './firstInvoice';
export * from './firstPrescription';
export * from './trialThankYou';
export * from './paidWelcome';
export * from './labTracking';
export * from './insuranceClaims';
export * from './aiInsights';
export * from './scaleMilestone';
export * from './ipdModule';
  • Step 5: Run the templates test
Run: cd server && npx vitest run src/emails/campaign/__tests__/templates.test.tsx Expected: 18 passing (17 templates + the ruby.png assertion).
  • Step 6: Commit
git add server/src/emails/campaign/
git commit -m "feat(campaign): add 17 React Email templates + smoke tests"

Task 7 — Lesson registry

Files:
  • Create: server/src/campaigns/lessons.ts
  • Test: server/src/campaigns/__tests__/lessons.test.ts
  • Step 1: Write the registry test
Create server/src/campaigns/__tests__/lessons.test.ts:
import { describe, it, expect } from 'vitest';
import { LESSONS } from '../lessons';

describe('lesson registry', () => {
  it('exports 17 lessons', () => {
    expect(LESSONS).toHaveLength(17);
  });
  it('all lesson keys are unique', () => {
    const keys = LESSONS.map((l) => l.key);
    expect(new Set(keys).size).toBe(17);
  });
  it('priorities are integers between 1 and 100', () => {
    for (const l of LESSONS) {
      expect(Number.isInteger(l.priority)).toBe(true);
      expect(l.priority).toBeGreaterThanOrEqual(1);
      expect(l.priority).toBeLessThanOrEqual(100);
    }
  });
  it('every lesson has a render function and trigger', () => {
    for (const l of LESSONS) {
      expect(typeof l.render).toBe('function');
      expect(typeof l.trigger).toBe('function');
    }
  });
  it('phase is either trial or paid', () => {
    for (const l of LESSONS) {
      expect(['trial', 'paid']).toContain(l.phase);
    }
  });
});
  • Step 2: Verify it fails
Run: cd server && npx vitest run src/campaigns/__tests__/lessons.test.ts Expected: FAIL.
  • Step 3: Implement the registry
Create server/src/campaigns/lessons.ts:
import * as React from 'react';
import { render } from '@react-email/render';
import * as T from './triggers';
import * as E from '../emails/campaign';
import type { Lesson, TemplateProps } from './types';

function mkRender(Component: React.FC<TemplateProps>): Lesson['render'] {
  return async (props: TemplateProps) => {
    const element = React.createElement(Component, props);
    const html = await render(element);
    const text = await render(element, { plainText: true });
    return { html, text };
  };
}

export const LESSONS: Lesson[] = [
  // Phase 1 — trial
  { key: 'welcome',             phase: 'trial', priority: 100, articleUrl: 'https://q.odontox.io/guides/welcome',         subject: E.welcomeSubject,             trigger: T.welcomeTrigger,             render: mkRender(E.welcome) },
  { key: 'first_patient_added', phase: 'trial', priority: 95,  articleUrl: 'https://q.odontox.io/guides/appointments',    subject: E.firstPatientAddedSubject,   trigger: T.firstPatientAddedTrigger,   render: mkRender(E.firstPatientAdded) },
  { key: 'first_appointment',   phase: 'trial', priority: 90,  articleUrl: 'https://q.odontox.io/guides/appointments',    subject: E.firstAppointmentSubject,    trigger: T.firstAppointmentTrigger,    render: mkRender(E.firstAppointment) },
  { key: 'whatsapp_off',        phase: 'trial', priority: 85,  articleUrl: 'https://q.odontox.io/guides/whatsapp-setup',  subject: E.whatsappOffSubject,         trigger: T.whatsappOffTrigger,         render: mkRender(E.whatsappOff) },
  { key: 'dental_charting',     phase: 'trial', priority: 80,  articleUrl: 'https://q.odontox.io/guides/dental-chart',    subject: E.dentalChartingSubject,      trigger: T.dentalChartingTrigger,      render: mkRender(E.dentalCharting) },
  { key: 'clinical_notes',      phase: 'trial', priority: 75,  articleUrl: 'https://q.odontox.io/guides/clinical-notes',  subject: E.clinicalNotesSubject,       trigger: T.clinicalNotesTrigger,       render: mkRender(E.clinicalNotes) },
  { key: 'invite_staff',        phase: 'trial', priority: 70,  articleUrl: 'https://q.odontox.io/guides/invite-staff',    subject: E.inviteStaffSubject,         trigger: T.inviteStaffTrigger,         render: mkRender(E.inviteStaff) },
  { key: 'mobile_app',          phase: 'trial', priority: 65,  articleUrl: 'https://q.odontox.io/guides/mobile-app',      subject: E.mobileAppSubject,           trigger: T.mobileAppTrigger,           render: mkRender(E.mobileApp) },
  { key: 'first_invoice',       phase: 'trial', priority: 60,  articleUrl: 'https://q.odontox.io/guides/invoices',        subject: E.firstInvoiceSubject,        trigger: T.firstInvoiceTrigger,        render: mkRender(E.firstInvoice) },
  { key: 'first_prescription',  phase: 'trial', priority: 55,  articleUrl: 'https://q.odontox.io/guides/prescriptions',   subject: E.firstPrescriptionSubject,   trigger: T.firstPrescriptionTrigger,   render: mkRender(E.firstPrescription) },
  { key: 'trial_thank_you',     phase: 'trial', priority: 100, articleUrl: 'https://q.odontox.io/guides/pro-overview',    subject: E.trialThankYouSubject,       trigger: T.trialThankYouTrigger,       render: mkRender(E.trialThankYou) },
  // Phase 2 — paid
  { key: 'paid_welcome',        phase: 'paid',  priority: 100, articleUrl: 'https://q.odontox.io/guides/pro-roadmap',     subject: E.paidWelcomeSubject,         trigger: T.paidWelcomeTrigger,         render: mkRender(E.paidWelcome) },
  { key: 'lab_tracking',        phase: 'paid',  priority: 85,  articleUrl: 'https://q.odontox.io/guides/lab-tracking',    subject: E.labTrackingSubject,         trigger: T.labTrackingTrigger,         render: mkRender(E.labTracking) },
  { key: 'insurance_claims',    phase: 'paid',  priority: 80,  articleUrl: 'https://q.odontox.io/guides/insurance-claims',subject: E.insuranceClaimsSubject,     trigger: T.insuranceClaimsTrigger,     render: mkRender(E.insuranceClaims) },
  { key: 'ai_insights',         phase: 'paid',  priority: 75,  articleUrl: 'https://q.odontox.io/guides/ruby-reports',    subject: E.aiInsightsSubject,          trigger: T.aiInsightsTrigger,          render: mkRender(E.aiInsights) },
  { key: 'scale_milestone',     phase: 'paid',  priority: 70,  articleUrl: 'https://q.odontox.io/guides/scale-tips',      subject: E.scaleMilestoneSubject,      trigger: T.scaleMilestoneTrigger,      render: mkRender(E.scaleMilestone) },
  { key: 'ipd_module',          phase: 'paid',  priority: 65,  articleUrl: 'https://q.odontox.io/guides/ipd',             subject: E.ipdModuleSubject,           trigger: T.ipdModuleTrigger,           render: mkRender(E.ipdModule) },
];
  • Step 4: Run, verify passing
Run: cd server && npx vitest run src/campaigns/__tests__/lessons.test.ts Expected: 5 passing.
  • Step 5: Commit
git add server/src/campaigns/lessons.ts server/src/campaigns/__tests__/lessons.test.ts
git commit -m "feat(campaign): wire 17 lessons into the registry"

Task 8 — Guardrails (day-of-week, active-session, transactional-overlap, allowlist)

Files:
  • Create: server/src/campaigns/guardrails.ts
  • Test: server/src/campaigns/__tests__/guardrails.test.ts
  • Step 1: Write the failing tests
Create server/src/campaigns/__tests__/guardrails.test.ts:
import { describe, it, expect } from 'vitest';
import {
  isPktSendDay, isActiveRightNow, passesAllowlist,
} from '../guardrails';
import type { AdminRecipient } from '../types';

describe('isPktSendDay', () => {
  it('returns false on Friday in PKT', () => {
    // 2026-05-15 is a Friday
    expect(isPktSendDay(new Date('2026-05-15T05:00:00Z'))).toBe(false);
  });
  it('returns false on Sunday in PKT', () => {
    expect(isPktSendDay(new Date('2026-05-17T05:00:00Z'))).toBe(false);
  });
  it('returns true on Monday in PKT', () => {
    expect(isPktSendDay(new Date('2026-05-18T05:00:00Z'))).toBe(true);
  });
});

describe('isActiveRightNow', () => {
  it('returns true if lastActivity within last 2h', () => {
    const t = new Date(Date.now() - 1 * 3600e3);
    expect(isActiveRightNow(t)).toBe(true);
  });
  it('returns false if lastActivity > 2h ago', () => {
    const t = new Date(Date.now() - 3 * 3600e3);
    expect(isActiveRightNow(t)).toBe(false);
  });
  it('returns false if lastActivity is null', () => {
    expect(isActiveRightNow(null)).toBe(false);
  });
});

describe('passesAllowlist', () => {
  const admins: AdminRecipient[] = [
    { userId: 'u1', email: '[email protected]', firstName: 'Sarmad' },
    { userId: 'u2', email: '[email protected]', firstName: 'Other' },
  ];

  it('returns true when allowlist is empty (production mode)', () => {
    expect(passesAllowlist(admins, '')).toBe(true);
    expect(passesAllowlist(admins, undefined)).toBe(true);
  });

  it('returns true when at least one admin matches', () => {
    expect(passesAllowlist(admins, '[email protected],[email protected]')).toBe(true);
  });

  it('returns false when no admin matches', () => {
    expect(passesAllowlist(admins, '[email protected]')).toBe(false);
  });

  it('is case-insensitive', () => {
    expect(passesAllowlist(admins, '[email protected]')).toBe(true);
  });
});
  • Step 2: Verify failure
Run: cd server && npx vitest run src/campaigns/__tests__/guardrails.test.ts Expected: FAIL.
  • Step 3: Implement guardrails
Create server/src/campaigns/guardrails.ts:
import type { AdminRecipient } from './types';

const PKT_OFFSET_MIN = 5 * 60; // +05:00

function pktDayOfWeek(date: Date): number {
  // 0 = Sunday, 5 = Friday
  const utc = date.getTime() + date.getTimezoneOffset() * 60_000;
  const pkt = new Date(utc + PKT_OFFSET_MIN * 60_000);
  return pkt.getUTCDay();
}

export function isPktSendDay(now: Date = new Date()): boolean {
  const dow = pktDayOfWeek(now);
  // Skip Friday (5) and Sunday (0)
  return dow !== 0 && dow !== 5;
}

export function isActiveRightNow(lastActivity: Date | null, windowHours = 2): boolean {
  if (!lastActivity) return false;
  return Date.now() - lastActivity.getTime() < windowHours * 3600 * 1000;
}

export function passesAllowlist(admins: AdminRecipient[], envVar: string | undefined): boolean {
  const raw = (envVar ?? '').trim();
  if (!raw) return true;
  const allowed = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
  if (allowed.length === 0) return true;
  return admins.some((a) => allowed.includes(a.email.toLowerCase()));
}

/**
 * Whether a transactional email (trial-expiring, trial-extension, subscription-ended,
 * payment-failed) is scheduled to send to this clinic today. The marketing campaign
 * must skip the clinic on those days so they don't get two emails.
 *
 * Implementation: re-uses the same date math the existing trial-expiry job uses
 * (days-from-trial-end). If the existing job sends on day == 7 or day == 1 before
 * expiry, and today matches that, we skip.
 */
export function transactionalEmailToday(clinic: {
  trialEndDate: Date | null;
  subscriptionStatus: string;
}, now: Date = new Date()): boolean {
  if (!clinic.trialEndDate || clinic.subscriptionStatus !== 'trial') return false;
  const msPerDay = 86400_000;
  const daysToExpiry = Math.ceil((clinic.trialEndDate.getTime() - now.getTime()) / msPerDay);
  // Reminders fire at 7, 3, and 1 day before expiry; matches existing scheduled.ts logic.
  return daysToExpiry === 7 || daysToExpiry === 3 || daysToExpiry === 1;
}
(Step 3 note: the implementing agent should verify the exact reminder days in server/src/scheduled.ts and server/src/lib/email.ts:sendTrialExpiringEmail and update transactionalEmailToday to match what the existing job actually does. The point is “no double-send on the same day”; the trigger conditions must mirror reality.)
  • Step 4: Run tests, verify passing
Run: cd server && npx vitest run src/campaigns/__tests__/guardrails.test.ts Expected: 10 passing.
  • Step 5: Commit
git add server/src/campaigns/guardrails.ts server/src/campaigns/__tests__/guardrails.test.ts
git commit -m "feat(campaign): add guardrails (PKT day-of-week, active session, allowlist, transactional overlap)"

Task 9 — Campaign runner (eligibility → picker → send → log)

Files:
  • Create: server/src/campaigns/runner.ts
  • Test: server/src/campaigns/__tests__/runner.test.ts
  • Step 1: Write the integration test
Create server/src/campaigns/__tests__/runner.test.ts:
import { describe, it, expect, beforeEach } from 'vitest';
import { db } from '../../db';
import { clinics } from '../../schema/clinics';
import { users } from '../../schema/users';
import { clinicCampaignLog } from '../../schema/campaign-log';
import { runOnboardingCampaign } from '../runner';
import { eq } from 'drizzle-orm';

describe('runOnboardingCampaign', () => {
  it('sends welcome email to a fresh trial clinic with 0 patients', async () => {
    const [c] = await db.insert(clinics).values({
      name: 'Runner Test Clinic',
      subscription_status: 'trial',
      trial_end_date: new Date(Date.now() + 14 * 86400e3),
      is_test_account: false,
      marketing_campaign_enabled: true,
      marketing_unsubscribed: false,
    }).returning();
    const [admin] = await db.insert(users).values({
      clinic_id: c.id, email: '[email protected]', first_name: 'Test', role: 'admin',
    }).returning();

    const result = await runOnboardingCampaign({ now: new Date('2026-05-18T05:00:00Z') /* Mon */, dryRun: false });

    const logs = await db.select().from(clinicCampaignLog).where(eq(clinicCampaignLog.clinic_id, c.id));
    expect(logs).toHaveLength(1);
    expect(logs[0].campaign_key).toBe('welcome');
    expect(result.sent).toContain(c.id);

    // cleanup
    await db.delete(clinicCampaignLog).where(eq(clinicCampaignLog.clinic_id, c.id));
    await db.delete(users).where(eq(users.id, admin.id));
    await db.delete(clinics).where(eq(clinics.id, c.id));
  });

  it('skips test accounts', async () => {
    const [c] = await db.insert(clinics).values({
      name: 'Test Account', subscription_status: 'trial',
      trial_end_date: new Date(Date.now() + 14 * 86400e3),
      is_test_account: true, marketing_campaign_enabled: true,
    }).returning();
    const result = await runOnboardingCampaign({ now: new Date('2026-05-18T05:00:00Z'), dryRun: false });
    expect(result.sent).not.toContain(c.id);
    await db.delete(clinics).where(eq(clinics.id, c.id));
  });

  it('skips when allowlist set and no admin matches', async () => {
    const [c] = await db.insert(clinics).values({
      name: 'Allowlist Test', subscription_status: 'trial',
      trial_end_date: new Date(Date.now() + 14 * 86400e3),
      is_test_account: false, marketing_campaign_enabled: true,
    }).returning();
    await db.insert(users).values({
      clinic_id: c.id, email: '[email protected]', first_name: 'X', role: 'admin',
    });

    const result = await runOnboardingCampaign({
      now: new Date('2026-05-18T05:00:00Z'),
      dryRun: false,
      allowlistEmails: '[email protected]',
    });

    const logs = await db.select().from(clinicCampaignLog).where(eq(clinicCampaignLog.clinic_id, c.id));
    expect(logs).toHaveLength(0);
    await db.delete(users).where(eq(users.clinic_id, c.id));
    await db.delete(clinics).where(eq(clinics.id, c.id));
  });

  it('does not send the same campaign_key twice to the same clinic', async () => {
    const [c] = await db.insert(clinics).values({
      name: 'Dedupe Test', subscription_status: 'trial',
      trial_end_date: new Date(Date.now() + 14 * 86400e3),
      is_test_account: false, marketing_campaign_enabled: true,
    }).returning();
    await db.insert(users).values({
      clinic_id: c.id, email: '[email protected]', first_name: 'Test', role: 'admin',
    });

    await runOnboardingCampaign({ now: new Date('2026-05-18T05:00:00Z'), dryRun: false });
    await runOnboardingCampaign({ now: new Date('2026-05-19T05:00:00Z'), dryRun: false });

    const logs = await db
      .select()
      .from(clinicCampaignLog)
      .where(eq(clinicCampaignLog.clinic_id, c.id));
    const welcomeLogs = logs.filter((l) => l.campaign_key === 'welcome');
    expect(welcomeLogs).toHaveLength(1);

    await db.delete(clinicCampaignLog).where(eq(clinicCampaignLog.clinic_id, c.id));
    await db.delete(users).where(eq(users.clinic_id, c.id));
    await db.delete(clinics).where(eq(clinics.id, c.id));
  });
});
  • Step 2: Verify it fails
Run: cd server && npx vitest run src/campaigns/__tests__/runner.test.ts Expected: FAIL — runner not defined.
  • Step 3: Implement the runner
Create server/src/campaigns/runner.ts:
import { and, eq, gt, inArray, isNull, or, sql } from 'drizzle-orm';
import { db } from '../db';
import { clinics } from '../schema/clinics';
import { users } from '../schema/users';
import { clinicCampaignLog } from '../schema/campaign-log';
import { LESSONS } from './lessons';
import { getActivationSignals, getClinicCtx } from './signals';
import {
  isPktSendDay, isActiveRightNow, passesAllowlist, transactionalEmailToday,
} from './guardrails';
import { sendEmailViaZepto } from '../lib/email';
import type { AdminRecipient, ClinicCtx, Lesson, Phase } from './types';

export type RunOptions = {
  now?: Date;
  dryRun?: boolean;
  allowlistEmails?: string;
};

export type RunResult = {
  considered: string[];      // clinic ids checked
  sent: string[];            // clinic ids where an email was sent
  skipped: { clinicId: string; reason: string }[];
};

export async function runOnboardingCampaign(opts: RunOptions = {}): Promise<RunResult> {
  const now = opts.now ?? new Date();
  const result: RunResult = { considered: [], sent: [], skipped: [] };

  if (!isPktSendDay(now)) {
    return result; // global skip on Fri/Sun PKT
  }

  // 1. Eligibility query
  const eligible = await db
    .select()
    .from(clinics)
    .where(and(
      eq(clinics.is_test_account, false),
      eq(clinics.marketing_campaign_enabled, true),
      eq(clinics.marketing_unsubscribed, false),
      or(
        and(eq(clinics.subscription_status, 'trial'), gt(clinics.trial_end_date, now)),
        and(
          eq(clinics.subscription_status, 'active'),
          sql`${clinics.activated_at} > ${new Date(now.getTime() - 30 * 86400e3)}`,
        ),
      ),
    ));

  for (const c of eligible) {
    result.considered.push(c.id);

    // 2. Fetch admins
    const admins: AdminRecipient[] = await db
      .select({ userId: users.id, email: users.email, firstName: users.first_name })
      .from(users)
      .where(and(eq(users.clinic_id, c.id), eq(users.role, 'admin'), eq(users.email_marketing_opt_out, false), isNull(users.deleted_at)));

    if (admins.length === 0) {
      result.skipped.push({ clinicId: c.id, reason: 'no_admins' });
      continue;
    }

    // 3. Shadow allowlist filter
    if (!passesAllowlist(admins, opts.allowlistEmails)) {
      result.skipped.push({ clinicId: c.id, reason: 'allowlist' });
      continue;
    }

    // 4. Build clinic context + signals
    const ctx = await getClinicCtx(c.id);
    if (!ctx) {
      result.skipped.push({ clinicId: c.id, reason: 'no_ctx' });
      continue;
    }
    const signals = await getActivationSignals(c.id);

    // 5. Active-session guardrail
    if (isActiveRightNow(signals.lastAppActivityAt)) {
      result.skipped.push({ clinicId: c.id, reason: 'active_session' });
      continue;
    }

    // 6. Transactional overlap guardrail
    if (transactionalEmailToday(ctx, now)) {
      result.skipped.push({ clinicId: c.id, reason: 'transactional_overlap' });
      continue;
    }

    // 7. Pick the highest-priority unsent lesson whose trigger fires
    const phase: Phase = ctx.subscriptionStatus === 'active' ? 'paid' : 'trial';
    const sentKeys = new Set(
      (await db
        .select({ campaign_key: clinicCampaignLog.campaign_key })
        .from(clinicCampaignLog)
        .where(eq(clinicCampaignLog.clinic_id, c.id))
      ).map((r) => r.campaign_key),
    );

    const sorted = [...LESSONS]
      .filter((l) => l.phase === phase)
      .sort((a, b) => b.priority - a.priority);

    const chosen = sorted.find((l) => !sentKeys.has(l.key) && l.trigger(signals, ctx));
    if (!chosen) {
      result.skipped.push({ clinicId: c.id, reason: 'no_lesson_fired' });
      continue;
    }

    // 8. Send to every admin recipient + log
    if (opts.dryRun) {
      result.skipped.push({ clinicId: c.id, reason: 'dry_run' });
      continue;
    }

    let logged = false;
    for (const admin of admins) {
      const { html, text } = await chosen.render({
        firstName: admin.firstName,
        clinicName: ctx.name,
        patientName: ctx.mostRecentPatientName,
        patientCount: signals.patients,
      });
      const messageId = await sendEmailViaZepto({
        to: admin.email,
        subject: chosen.subject,
        html,
        text,
        from: { name: 'Sarmad', address: '[email protected]' },
        replyTo: '[email protected]',
      });

      // Only log once per clinic per campaign_key (UNIQUE constraint enforces this).
      if (!logged) {
        await db.insert(clinicCampaignLog).values({
          clinic_id: c.id,
          user_id: admin.userId,
          campaign_key: chosen.key,
          subject: chosen.subject,
          zepto_message_id: messageId,
        }).onConflictDoNothing();
        logged = true;
      }
    }

    result.sent.push(c.id);
  }

  return result;
}
(Step 3 note: the implementing agent must verify the signature of sendEmailViaZepto in server/src/lib/email.ts — adjust the call site if it takes a different argument shape. Returning a zepto_message_id is required for reply tracking.)
  • Step 4: Run tests, verify passing
Run: cd server && npx vitest run src/campaigns/__tests__/runner.test.ts Expected: 4 passing.
  • Step 5: Commit
git add server/src/campaigns/runner.ts server/src/campaigns/__tests__/runner.test.ts
git commit -m "feat(campaign): add campaign runner with eligibility, picker, dedupe, guardrails"

Task 10 — Wire cron into scheduled.ts

Files:
  • Modify: server/src/scheduled.ts
  • Step 1: Read the existing scheduled.ts
Run: cat server/src/scheduled.ts | head -120 Note the cron pattern, env access pattern, logging pattern used by the existing trial-expiry job.
  • Step 2: Add the campaign run alongside existing scheduled work
Inside the existing scheduled handler (likely a function named scheduled or default export), add:
import { runOnboardingCampaign } from './campaigns/runner';

// ... inside the cron handler:
try {
  const result = await runOnboardingCampaign({
    now: new Date(event.scheduledTime),
    allowlistEmails: env.CAMPAIGN_ALLOWLIST_EMAILS,
  });
  console.log('[campaign]', {
    considered: result.considered.length,
    sent: result.sent.length,
    skipped_breakdown: result.skipped.reduce((acc, s) => {
      acc[s.reason] = (acc[s.reason] || 0) + 1;
      return acc;
    }, {} as Record<string, number>),
  });
} catch (err) {
  console.error('[campaign] failed:', err);
  // do not throw — campaign failure must not break other scheduled jobs
}
  • Step 3: Verify the cron schedule includes 09:00 PKT (04:00 UTC) trigger
Run: cat server/wrangler.toml | grep -A 4 crons (path may be wrangler.jsonc; check both). If the existing cron doesn’t fire at 04:00 UTC (= 09:00 PKT), add one:
[triggers]
crons = ["0 4 * * *"]   # 09:00 PKT daily
If multiple crons already exist, add this as another entry. The scheduled handler receives event.cron and can branch by cron string if needed.
  • Step 4: Type-check
Run: cd server && npx tsc --noEmit Expected: clean.
  • Step 5: Commit
git add server/src/scheduled.ts server/wrangler.toml
git commit -m "feat(campaign): wire runOnboardingCampaign into 09:00 PKT cron"

Task 11 — Email tracking endpoints (pixel + click)

Files:
  • Create: server/src/routes/email-tracking.ts
  • Modify: wherever routes are mounted (probably server/src/server.ts or server/src/app.ts)
  • Test: server/src/routes/__tests__/email-tracking.test.ts
  • Step 1: Locate the route mount point
Run: grep -rn "app.route\|app.mount\|new Hono" server/src/ | head -10 Note the file and pattern used to mount sub-routers.
  • Step 2: Write the tests
Create server/src/routes/__tests__/email-tracking.test.ts:
import { describe, it, expect, beforeEach } from 'vitest';
import { db } from '../../db';
import { clinicCampaignLog } from '../../schema/campaign-log';
import { eq } from 'drizzle-orm';
import { emailTrackingRoutes } from '../email-tracking';
import { Hono } from 'hono';

async function makeLog() {
  const [log] = await db.insert(clinicCampaignLog).values({
    clinic_id: '00000000-0000-0000-0000-000000000001',
    user_id: '00000000-0000-0000-0000-000000000002',
    campaign_key: 'welcome',
    subject: 'Welcome',
  }).returning();
  return log;
}

describe('email tracking', () => {
  it('GET /pixel sets opened_at on first hit', async () => {
    const log = await makeLog();
    const app = new Hono().route('/api/email', emailTrackingRoutes);
    const res = await app.request(`/api/email/pixel?log=${log.id}`);
    expect(res.status).toBe(200);
    expect(res.headers.get('content-type')).toContain('image/gif');

    const [row] = await db.select().from(clinicCampaignLog).where(eq(clinicCampaignLog.id, log.id));
    expect(row.opened_at).not.toBeNull();

    await db.delete(clinicCampaignLog).where(eq(clinicCampaignLog.id, log.id));
  });

  it('GET /pixel does not overwrite a prior opened_at', async () => {
    const log = await makeLog();
    const original = new Date('2026-05-01T10:00:00Z');
    await db.update(clinicCampaignLog).set({ opened_at: original }).where(eq(clinicCampaignLog.id, log.id));
    const app = new Hono().route('/api/email', emailTrackingRoutes);
    await app.request(`/api/email/pixel?log=${log.id}`);
    const [row] = await db.select().from(clinicCampaignLog).where(eq(clinicCampaignLog.id, log.id));
    expect(row.opened_at?.getTime()).toBe(original.getTime());
    await db.delete(clinicCampaignLog).where(eq(clinicCampaignLog.id, log.id));
  });

  it('GET /click 302s to target and writes clicked_at', async () => {
    const log = await makeLog();
    const target = 'https://q.odontox.io/guides/welcome';
    const app = new Hono().route('/api/email', emailTrackingRoutes);
    const res = await app.request(`/api/email/click?log=${log.id}&to=${encodeURIComponent(target)}`);
    expect(res.status).toBe(302);
    expect(res.headers.get('location')).toBe(target);
    const [row] = await db.select().from(clinicCampaignLog).where(eq(clinicCampaignLog.id, log.id));
    expect(row.clicked_at).not.toBeNull();
    await db.delete(clinicCampaignLog).where(eq(clinicCampaignLog.id, log.id));
  });

  it('GET /click refuses non-q.odontox.io targets', async () => {
    const log = await makeLog();
    const app = new Hono().route('/api/email', emailTrackingRoutes);
    const res = await app.request(`/api/email/click?log=${log.id}&to=${encodeURIComponent('https://evil.com')}`);
    expect(res.status).toBe(400);
    await db.delete(clinicCampaignLog).where(eq(clinicCampaignLog.id, log.id));
  });
});
  • Step 3: Verify failure
Run: cd server && npx vitest run src/routes/__tests__/email-tracking.test.ts Expected: FAIL.
  • Step 4: Implement the routes
Create server/src/routes/email-tracking.ts:
import { Hono } from 'hono';
import { eq, isNull, and } from 'drizzle-orm';
import { db } from '../db';
import { clinicCampaignLog } from '../schema/campaign-log';

// 1×1 transparent GIF
const PIXEL = new Uint8Array([
  0x47,0x49,0x46,0x38,0x39,0x61,0x01,0x00,0x01,0x00,0x80,0x00,0x00,
  0xff,0xff,0xff,0x00,0x00,0x00,0x21,0xf9,0x04,0x01,0x00,0x00,0x00,
  0x00,0x2c,0x00,0x00,0x00,0x00,0x01,0x00,0x01,0x00,0x00,0x02,0x02,
  0x44,0x01,0x00,0x3b,
]);

export const emailTrackingRoutes = new Hono()
  .get('/pixel', async (c) => {
    const logId = c.req.query('log');
    if (logId) {
      // Best-effort, ignore errors so the pixel still returns 200.
      try {
        await db.update(clinicCampaignLog)
          .set({ opened_at: new Date() })
          .where(and(eq(clinicCampaignLog.id, logId), isNull(clinicCampaignLog.opened_at)));
      } catch (err) {
        console.error('[campaign:pixel]', err);
      }
    }
    return new Response(PIXEL, {
      status: 200,
      headers: {
        'Content-Type': 'image/gif',
        'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
        'Pragma': 'no-cache',
      },
    });
  })
  .get('/click', async (c) => {
    const logId = c.req.query('log');
    const to = c.req.query('to');
    if (!to) return c.json({ error: 'missing to param' }, 400);
    // Strict allowlist: only q.odontox.io domains.
    try {
      const u = new URL(to);
      if (!u.hostname.endsWith('q.odontox.io')) {
        return c.json({ error: 'target host not allowed' }, 400);
      }
    } catch {
      return c.json({ error: 'malformed url' }, 400);
    }
    if (logId) {
      try {
        await db.update(clinicCampaignLog)
          .set({ clicked_at: new Date() })
          .where(and(eq(clinicCampaignLog.id, logId), isNull(clinicCampaignLog.clicked_at)));
      } catch (err) {
        console.error('[campaign:click]', err);
      }
    }
    return c.redirect(to, 302);
  });
  • Step 5: Mount the routes
In the route-mount file from step 1, add:
import { emailTrackingRoutes } from './routes/email-tracking';
app.route('/api/email', emailTrackingRoutes);
  • Step 6: Wire the tracking into the email send
The welcome email currently CTAs straight to https://q.odontox.io/.... Now wrap CTAs through the click endpoint. In server/src/emails/campaign/CampaignEmail.tsx, modify the CTA <Link> to wrap through the tracker. Since the wrapper needs a logId, the wrapping happens in runner.ts after render. But we cannot know the log id before the insert. Two options: (a) Generate the log.id (UUID) on the server before render, pass into the template props, do INSERT ... VALUES (id, ...) with the pre-generated id. (b) Skip click-wrapping for now and use raw q.odontox.io URLs; click tracking lands in a later phase. Go with (a). Update runner.ts step 3 of Task 9: Replace the render+send block with:
import { randomUUID } from 'crypto';

// ... within the loop, before render:
const logId = randomUUID();
const trackedCta = `${env.PUBLIC_API_BASE}/api/email/click?log=${logId}&to=${encodeURIComponent(chosen.articleUrl)}`;
const pixelUrl = `${env.PUBLIC_API_BASE}/api/email/pixel?log=${logId}`;

// Pass these into the render — extend TemplateProps with `trackedCtaHref` and `pixelUrl`
// Update CampaignEmail to use trackedCtaHref when provided, and render <Img src={pixelUrl}/>
// at the bottom of the body.
The implementing agent will need to:
  1. Extend TemplateProps with optional trackedCtaHref?: string and pixelUrl?: string.
  2. Update each of the 17 templates to pass trackedCtaHref ?? articleUrl and pixelUrl to CampaignEmail.
  3. Update CampaignEmail to render a 1×1 <Img> at the bottom when pixelUrl is set.
  4. Update runner to pre-generate the log id, render with tracked URLs, insert log with the same id.
  5. Re-run all template tests — they should still pass because they don’t assert tracking URL absence.
  • Step 7: Run all campaign-related tests
Run: cd server && npx vitest run src/campaigns src/emails/campaign src/routes/__tests__/email-tracking.test.ts Expected: all passing.
  • Step 8: Commit
git add server/src/routes/email-tracking.ts server/src/routes/__tests__/email-tracking.test.ts server/src/campaigns/types.ts server/src/campaigns/runner.ts server/src/emails/campaign/ server/src/server.ts
git commit -m "feat(campaign): open/click tracking endpoints + wrapped CTAs in templates"

Task 12 — ZeptoMail inbound webhook for reply tracking

Files:
  • Create: server/src/routes/email-inbound.ts
  • Modify: route mount file
  • Test: server/src/routes/__tests__/email-inbound.test.ts
  • Step 1: Read ZeptoMail inbound webhook docs / existing handlers
Run: grep -rn "zepto\|inbound" server/src/ | head -20 Identify whether any inbound handler exists and the payload shape ZeptoMail sends. If unknown, structure the handler around the standard ZeptoMail “inbound parse” JSON: { headers: { 'in-reply-to': '<msg-id>' }, from: '...', to: '...', text: '...', html: '...' }.
  • Step 2: Write the test
Create server/src/routes/__tests__/email-inbound.test.ts:
import { describe, it, expect } from 'vitest';
import { db } from '../../db';
import { clinicCampaignLog } from '../../schema/campaign-log';
import { eq } from 'drizzle-orm';
import { emailInboundRoutes } from '../email-inbound';
import { Hono } from 'hono';

describe('POST /api/email/inbound', () => {
  it('matches In-Reply-To to zepto_message_id and sets replied_at', async () => {
    const [log] = await db.insert(clinicCampaignLog).values({
      clinic_id: '00000000-0000-0000-0000-000000000001',
      user_id: '00000000-0000-0000-0000-000000000002',
      campaign_key: 'welcome', subject: 'Welcome',
      zepto_message_id: 'msg-abc-123',
    }).returning();

    const app = new Hono().route('/api/email', emailInboundRoutes);
    const res = await app.request('/api/email/inbound', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({
        headers: { 'in-reply-to': '<msg-abc-123@zepto>' },
        from: '[email protected]',
        text: 'thanks!',
      }),
    });
    expect(res.status).toBe(200);

    const [row] = await db.select().from(clinicCampaignLog).where(eq(clinicCampaignLog.id, log.id));
    expect(row.replied_at).not.toBeNull();

    await db.delete(clinicCampaignLog).where(eq(clinicCampaignLog.id, log.id));
  });

  it('returns 200 even when no match (graceful no-op)', async () => {
    const app = new Hono().route('/api/email', emailInboundRoutes);
    const res = await app.request('/api/email/inbound', {
      method: 'POST', headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ headers: { 'in-reply-to': '<unknown-id@zepto>' }, from: '[email protected]' }),
    });
    expect(res.status).toBe(200);
  });
});
  • Step 3: Implement
Create server/src/routes/email-inbound.ts:
import { Hono } from 'hono';
import { eq, isNull, and, sql } from 'drizzle-orm';
import { db } from '../db';
import { clinicCampaignLog } from '../schema/campaign-log';

export const emailInboundRoutes = new Hono().post('/inbound', async (c) => {
  const payload = await c.req.json().catch(() => null);
  if (!payload) return c.json({ ok: true });

  const headers = payload.headers || {};
  const inReplyTo: string | undefined =
    headers['in-reply-to'] || headers['In-Reply-To'] || headers.inReplyTo;

  if (!inReplyTo) return c.json({ ok: true });

  // Extract the message-id from "<message-id@domain>"
  const messageId = inReplyTo.replace(/[<>]/g, '').split('@')[0];

  await db.update(clinicCampaignLog)
    .set({ replied_at: new Date() })
    .where(and(
      sql`${clinicCampaignLog.zepto_message_id} LIKE ${messageId + '%'}`,
      isNull(clinicCampaignLog.replied_at),
    ));

  return c.json({ ok: true });
});
  • Step 4: Mount + verify
Mount in the route-mount file: app.route('/api/email', emailInboundRoutes);. If conflict with emailTrackingRoutes mount path, combine them: extend emailTrackingRoutes to include the inbound route, or mount each at a distinct subpath. Run: cd server && npx vitest run src/routes/__tests__/email-inbound.test.ts Expected: 2 passing.
  • Step 5: Configure ZeptoMail webhook
Run: locate ZeptoMail dashboard config (out-of-band). Point inbound parse webhook to https://go.odontox.io/api/email/inbound. Document the configuration step in server/scripts/README.md or similar.
  • Step 6: Commit
git add server/src/routes/email-inbound.ts server/src/routes/__tests__/email-inbound.test.ts server/src/server.ts
git commit -m "feat(campaign): inbound webhook for reply tracking"

Task 13 — Superadmin per-clinic toggle (backend + UI)

Files:
  • Create: server/src/routes/superadmin/campaign.ts
  • Create: ui/src/pages/superadmin/clinics/[id]/CampaignSection.tsx
  • Modify: existing superadmin clinic detail page
  • Step 1: Locate the existing superadmin routes
Run: find server/src/routes/superadmin -type f 2>/dev/null; grep -rn "superadmin" server/src/server.ts | head -5
  • Step 2: Backend — toggle + log endpoints
Create server/src/routes/superadmin/campaign.ts:
import { Hono } from 'hono';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
import { eq, desc } from 'drizzle-orm';
import { db } from '../../db';
import { clinics } from '../../schema/clinics';
import { clinicCampaignLog } from '../../schema/campaign-log';
import { requireSuperadmin } from '../../middleware/auth';  // assume this exists

const toggleSchema = z.object({
  enabled: z.boolean(),
  reason: z.string().optional(),
});

export const superadminCampaignRoutes = new Hono()
  .use('*', requireSuperadmin)
  .get('/clinics/:id/campaign', async (c) => {
    const id = c.req.param('id');
    const [clinic] = await db.select({
      enabled: clinics.marketing_campaign_enabled,
      disabledBy: clinics.marketing_campaign_disabled_by,
      disabledAt: clinics.marketing_campaign_disabled_at,
      disabledReason: clinics.marketing_campaign_disabled_reason,
    }).from(clinics).where(eq(clinics.id, id));

    const recent = await db
      .select({
        campaign_key: clinicCampaignLog.campaign_key,
        sent_at: clinicCampaignLog.sent_at,
        opened_at: clinicCampaignLog.opened_at,
        clicked_at: clinicCampaignLog.clicked_at,
        replied_at: clinicCampaignLog.replied_at,
      })
      .from(clinicCampaignLog)
      .where(eq(clinicCampaignLog.clinic_id, id))
      .orderBy(desc(clinicCampaignLog.sent_at))
      .limit(5);

    return c.json({ clinic, recent });
  })
  .post('/clinics/:id/campaign/toggle', zValidator('json', toggleSchema), async (c) => {
    const id = c.req.param('id');
    const { enabled, reason } = c.req.valid('json');
    const user = c.get('user'); // assume requireSuperadmin sets this

    if (enabled) {
      await db.update(clinics).set({
        marketing_campaign_enabled: true,
        marketing_campaign_disabled_by: null,
        marketing_campaign_disabled_at: null,
        marketing_campaign_disabled_reason: null,
      }).where(eq(clinics.id, id));
    } else {
      if (!reason || reason.trim().length < 3) {
        return c.json({ error: 'reason required' }, 400);
      }
      await db.update(clinics).set({
        marketing_campaign_enabled: false,
        marketing_campaign_disabled_by: user.id,
        marketing_campaign_disabled_at: new Date(),
        marketing_campaign_disabled_reason: reason,
      }).where(eq(clinics.id, id));
    }
    return c.json({ ok: true });
  });
Mount: app.route('/api/superadmin', superadminCampaignRoutes);
  • Step 3: UI component
Create ui/src/pages/superadmin/clinics/[id]/CampaignSection.tsx:
import * as React from 'react';
import { useState } from 'react';

type Recent = {
  campaign_key: string;
  sent_at: string;
  opened_at: string | null;
  clicked_at: string | null;
  replied_at: string | null;
};
type CampaignData = {
  clinic: {
    enabled: boolean;
    disabledBy: string | null;
    disabledAt: string | null;
    disabledReason: string | null;
  };
  recent: Recent[];
};

export function CampaignSection({ clinicId }: { clinicId: string }) {
  const [data, setData] = useState<CampaignData | null>(null);
  const [showReasonModal, setShowReasonModal] = useState(false);
  const [reason, setReason] = useState('');
  const [loading, setLoading] = useState(false);

  React.useEffect(() => {
    fetch(`/api/superadmin/clinics/${clinicId}/campaign`)
      .then((r) => r.json())
      .then(setData);
  }, [clinicId]);

  async function toggle(next: boolean) {
    if (!next && !reason.trim()) {
      setShowReasonModal(true);
      return;
    }
    setLoading(true);
    const body = next ? { enabled: true } : { enabled: false, reason };
    await fetch(`/api/superadmin/clinics/${clinicId}/campaign/toggle`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(body),
    });
    setLoading(false);
    setShowReasonModal(false);
    setReason('');
    const refreshed = await fetch(`/api/superadmin/clinics/${clinicId}/campaign`).then((r) => r.json());
    setData(refreshed);
  }

  if (!data) return <div>Loading…</div>;

  const stats = {
    sent: data.recent.length,
    opened: data.recent.filter((r) => r.opened_at).length,
    clicked: data.recent.filter((r) => r.clicked_at).length,
    replied: data.recent.filter((r) => r.replied_at).length,
  };

  return (
    <section style={{ border: '1px solid #e5e5e5', borderRadius: 12, padding: 24, marginTop: 32 }}>
      <h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>Onboarding emails</h3>
      <div style={{ marginTop: 16, display: 'flex', alignItems: 'center', gap: 12 }}>
        <label style={{ position: 'relative', display: 'inline-block', width: 44, height: 24 }}>
          <input
            type="checkbox"
            checked={data.clinic.enabled}
            disabled={loading}
            onChange={(e) => {
              if (!e.target.checked) setShowReasonModal(true);
              else toggle(true);
            }}
            style={{ opacity: 0, width: 0, height: 0 }}
          />
          <span style={{
            position: 'absolute', cursor: 'pointer', inset: 0,
            background: data.clinic.enabled ? '#0C8C5E' : '#ccc',
            borderRadius: 24, transition: '0.2s',
          }}>
            <span style={{
              position: 'absolute',
              left: data.clinic.enabled ? 22 : 2,
              top: 2,
              width: 20, height: 20,
              background: '#fff', borderRadius: '50%',
              transition: '0.2s',
            }} />
          </span>
        </label>
        <span>{data.clinic.enabled ? 'ENABLED' : 'DISABLED'}</span>
      </div>
      {!data.clinic.enabled && data.clinic.disabledReason && (
        <p style={{ marginTop: 12, color: '#666', fontSize: 14 }}>
          Disabled by {data.clinic.disabledBy} on {data.clinic.disabledAt}.<br />
          Reason: "{data.clinic.disabledReason}"
        </p>
      )}

      <hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '20px 0' }} />

      <h4 style={{ fontSize: 14, fontWeight: 500, margin: 0 }}>Last 5 emails sent</h4>
      <table style={{ marginTop: 12, fontSize: 13, width: '100%' }}>
        <tbody>
          {data.recent.map((r) => (
            <tr key={r.sent_at}>
              <td style={{ padding: '4px 0', color: '#666' }}>{new Date(r.sent_at).toISOString().slice(0, 10)}</td>
              <td>{r.campaign_key}</td>
              <td style={{ color: '#666' }}>
                {r.replied_at ? 'replied' : r.clicked_at ? 'opened · clicked' : r.opened_at ? 'opened' : '—'}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      <p style={{ marginTop: 12, fontSize: 12, color: '#888' }}>
        Total: {stats.sent} sent · {stats.opened} opened · {stats.clicked} clicked · {stats.replied} replied
      </p>

      {showReasonModal && (
        <div style={{
          position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
          display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100,
        }}>
          <div style={{ background: '#fff', borderRadius: 12, padding: 24, width: 480 }}>
            <h3>Disable onboarding campaign for this clinic</h3>
            <p style={{ color: '#666', fontSize: 13 }}>Reason is required for audit.</p>
            <textarea
              value={reason}
              onChange={(e) => setReason(e.target.value)}
              rows={3}
              style={{ width: '100%', padding: 8, fontSize: 14 }}
              placeholder="e.g. VIP customer asked to opt out"
            />
            <div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
              <button onClick={() => { setShowReasonModal(false); setReason(''); }}>Cancel</button>
              <button
                disabled={reason.trim().length < 3 || loading}
                onClick={() => toggle(false)}
                style={{ background: '#dc2626', color: '#fff', padding: '8px 16px', borderRadius: 6, border: 'none' }}
              >Disable</button>
            </div>
          </div>
        </div>
      )}
    </section>
  );
}
  • Step 4: Mount in the clinic detail page
Open the existing superadmin clinic detail page (likely ui/src/pages/superadmin/clinics/[id]/index.tsx). At the bottom of the page, add:
import { CampaignSection } from './CampaignSection';
// ...
<CampaignSection clinicId={clinic.id} />
  • Step 5: Type check + manual smoke test
Run: cd server && npx tsc --noEmit && cd ../ui && npx tsc --noEmit Expected: clean. Manually start dev server, open superadmin clinic page, toggle on/off, confirm reason modal works and audit fields populate.
  • Step 6: Commit
git add server/src/routes/superadmin/campaign.ts ui/src/pages/superadmin/clinics/
git commit -m "feat(campaign): superadmin per-clinic toggle + recent-emails view + audit trail"

Task 14 — Superadmin analytics dashboard

Files:
  • Modify: server/src/routes/superadmin/campaign.ts (add analytics endpoint)
  • Create: ui/src/pages/superadmin/campaigns/index.tsx
  • Step 1: Add analytics endpoint
Append to server/src/routes/superadmin/campaign.ts:
.get('/campaigns/funnel', async (c) => {
  const rows = await db.execute(sql`
    SELECT
      campaign_key,
      COUNT(*)::int AS sent,
      COUNT(opened_at)::int AS opened,
      COUNT(clicked_at)::int AS clicked,
      COUNT(replied_at)::int AS replied
    FROM clinic_campaign_log
    GROUP BY campaign_key
    ORDER BY sent DESC
  `);
  return c.json({ rows: (rows as any).rows ?? rows });
})
.get('/campaigns/conversion-lift', async (c) => {
  const rows = await db.execute(sql`
    SELECT
      c.marketing_campaign_enabled AS campaign_on,
      COUNT(*)::int AS total,
      SUM(CASE WHEN c.subscription_status = 'active' THEN 1 ELSE 0 END)::int AS converted
    FROM clinics c
    WHERE c.is_test_account = false
      AND c.created_at > NOW() - INTERVAL '90 days'
    GROUP BY c.marketing_campaign_enabled
  `);
  return c.json({ rows: (rows as any).rows ?? rows });
});
  • Step 2: Build the dashboard page
Create ui/src/pages/superadmin/campaigns/index.tsx:
import * as React from 'react';
import { useEffect, useState } from 'react';

type FunnelRow = { campaign_key: string; sent: number; opened: number; clicked: number; replied: number };
type LiftRow = { campaign_on: boolean; total: number; converted: number };

export default function CampaignsDashboard() {
  const [funnel, setFunnel] = useState<FunnelRow[]>([]);
  const [lift, setLift] = useState<LiftRow[]>([]);

  useEffect(() => {
    fetch('/api/superadmin/campaigns/funnel').then((r) => r.json()).then((d) => setFunnel(d.rows));
    fetch('/api/superadmin/campaigns/conversion-lift').then((r) => r.json()).then((d) => setLift(d.rows));
  }, []);

  return (
    <div style={{ maxWidth: 1100, margin: '0 auto', padding: 32 }}>
      <h1 style={{ fontSize: 28, fontWeight: 600 }}>Onboarding campaigns</h1>

      <h2 style={{ fontSize: 18, marginTop: 32 }}>Funnel per lesson</h2>
      <table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16 }}>
        <thead>
          <tr style={{ borderBottom: '1px solid #ddd', textAlign: 'left' }}>
            <th>Campaign</th><th>Sent</th><th>Opened</th><th>Clicked</th><th>Replied</th>
            <th>Open %</th><th>Click %</th>
          </tr>
        </thead>
        <tbody>
          {funnel.map((r) => (
            <tr key={r.campaign_key} style={{ borderBottom: '1px solid #f0f0f0' }}>
              <td style={{ fontFamily: 'monospace', fontSize: 13 }}>{r.campaign_key}</td>
              <td>{r.sent}</td>
              <td>{r.opened}</td>
              <td>{r.clicked}</td>
              <td>{r.replied}</td>
              <td>{r.sent ? Math.round((r.opened / r.sent) * 100) : 0}%</td>
              <td>{r.sent ? Math.round((r.clicked / r.sent) * 100) : 0}%</td>
            </tr>
          ))}
        </tbody>
      </table>

      <h2 style={{ fontSize: 18, marginTop: 48 }}>Trial → paid conversion lift (last 90 days)</h2>
      <table style={{ marginTop: 16 }}>
        <thead>
          <tr><th>Campaign</th><th>Clinics</th><th>Converted</th><th>Rate</th></tr>
        </thead>
        <tbody>
          {lift.map((r) => (
            <tr key={String(r.campaign_on)}>
              <td>{r.campaign_on ? 'ON' : 'OFF'}</td>
              <td>{r.total}</td>
              <td>{r.converted}</td>
              <td>{r.total ? Math.round((r.converted / r.total) * 100) : 0}%</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
  • Step 3: Add route to superadmin nav
Locate the superadmin sidebar/nav and add a link to /superadmin/campaigns.
  • Step 4: Type check + manual verify
Run: cd server && npx tsc --noEmit && cd ../ui && npx tsc --noEmit. Visit /superadmin/campaigns in dev to confirm the page renders.
  • Step 5: Commit
git add server/src/routes/superadmin/campaign.ts ui/src/pages/superadmin/campaigns/
git commit -m "feat(campaign): superadmin analytics dashboard (funnel + conversion lift)"

Task 15 — Shadow-week setup: env var + test clinic seed

Files:
  • Modify: server/wrangler.toml (or wrangler.jsonc) — document the env var
  • Create: server/scripts/seed-shadow-test-clinics.ts
  • Modify: server/src/scheduled.ts — wire env.CAMPAIGN_ALLOWLIST_EMAILS through to the runner (already done in Task 10 step 2; double-check)
  • Step 1: Document the env var
In wrangler.toml, add a commented-out reference so the team knows about the secret:
# Optional: when set, the onboarding email campaign sends only to clinics
# where at least one admin email is in this comma-separated list.
# Used for the 7-day shadow week before global rollout. To enable shadow mode:
#   wrangler secret put CAMPAIGN_ALLOWLIST_EMAILS
# To go live, delete the secret:
#   wrangler secret delete CAMPAIGN_ALLOWLIST_EMAILS
#
# CAMPAIGN_ALLOWLIST_EMAILS = "[email protected],[email protected]"
  • Step 2: Set the Cloudflare secret
Run (from the user’s terminal, not Claude’s):
cd server
echo "[email protected],[email protected]" | npx wrangler secret put CAMPAIGN_ALLOWLIST_EMAILS
(Document this in the rollout checklist; the implementing agent should ask the user to run this, not run it themselves.)
  • Step 3: Write the test clinic seed script
Create server/scripts/seed-shadow-test-clinics.ts:
import { db } from '../src/db';
import { clinics } from '../src/schema/clinics';
import { users } from '../src/schema/users';

const SHADOW_RECIPIENTS = [
  { email: '[email protected]', firstName: 'Sarmad' },
  { email: '[email protected]', firstName: 'Sarmad' },
];

async function main() {
  for (const r of SHADOW_RECIPIENTS) {
    const [clinic] = await db.insert(clinics).values({
      name: `Shadow Test (${r.email})`,
      subscription_status: 'trial',
      trial_end_date: new Date(Date.now() + 14 * 86400e3),
      is_test_account: false,            // intentionally false so it passes eligibility
      marketing_campaign_enabled: true,
    }).returning();

    await db.insert(users).values({
      clinic_id: clinic.id,
      email: r.email,
      first_name: r.firstName,
      role: 'admin',
      email_marketing_opt_out: false,
    });

    console.log(`Seeded shadow clinic ${clinic.id} for ${r.email}`);
  }

  console.log('Done. Remove these clinics with: npm run seed:shadow:cleanup');
}

main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
Also create a cleanup script server/scripts/cleanup-shadow-test-clinics.ts:
import { db } from '../src/db';
import { clinics } from '../src/schema/clinics';
import { ilike } from 'drizzle-orm';

async function main() {
  const deleted = await db.delete(clinics)
    .where(ilike(clinics.name, 'Shadow Test%'))
    .returning();
  console.log(`Deleted ${deleted.length} shadow clinics`);
}

main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
Add npm scripts to server/package.json:
"seed:shadow": "tsx scripts/seed-shadow-test-clinics.ts",
"seed:shadow:cleanup": "tsx scripts/cleanup-shadow-test-clinics.ts"
  • Step 4: Manually run the seed
cd server
npm run seed:shadow
Confirm both clinics exist with admins matching the two emails.
  • Step 5: Manual end-to-end dry-run
Run the runner once manually:
cd server
npx tsx -e "import {runOnboardingCampaign} from './src/campaigns/runner'; runOnboardingCampaign({allowlistEmails:'[email protected],[email protected]'}).then(r=>console.log(JSON.stringify(r,null,2)))"
Expected: sent array contains the two shadow clinics. Check both inboxes for the welcome email within 60 seconds.
  • Step 6: Commit
git add server/scripts/seed-shadow-test-clinics.ts server/scripts/cleanup-shadow-test-clinics.ts server/package.json server/wrangler.toml
git commit -m "chore(campaign): seed scripts for shadow-week test clinics"

Task 16 — Shadow-week verification checklist & sign-off

This is a manual gate, not a code task. Before deploying to production, work through the checklist in docs/superpowers/specs/2026-05-15-onboarding-email-campaign-design.md section 10.
  • Deploy current branch to staging.
  • Set CAMPAIGN_ALLOWLIST_EMAILS secret on staging (wrangler secret put CAMPAIGN_ALLOWLIST_EMAILS).
  • Seed shadow test clinics.
  • Trigger the cron manually once per day for 7 days (or use Cloudflare’s “trigger scheduled” dashboard button).
  • Walk through every checkbox in spec section 10 (“Shadow week verification checklist”).
  • Once all checkboxes pass, Sarmad signs off in writing (Slack/email is fine, capture in PR comments).
  • Promote to production: delete the CAMPAIGN_ALLOWLIST_EMAILS secret on production:
npx wrangler secret delete CAMPAIGN_ALLOWLIST_EMAILS
Next 09:00 PKT cron will fire for all eligible clinics.
  • Commit the sign-off record (optional):
echo "Shadow week completed $(date -u +%Y-%m-%d). Sign-off: Sarmad." >> docs/superpowers/plans/2026-05-15-onboarding-email-campaign.md
git add docs/superpowers/plans/2026-05-15-onboarding-email-campaign.md
git commit -m "chore(campaign): sign off shadow week, promote to production"

Self-review

Spec coverage
  • §2 Audience & gating → Task 9 (runner eligibility query). ✅
  • §3 Voice & design → Task 5 (CampaignEmail layout) + Task 6 (templates). ✅
  • §4 Lesson library (17 lessons) → Task 4 (triggers) + Task 6 (templates) + Task 7 (registry). ✅
  • §5 Sending machine (cron, picker, guardrails) → Tasks 8, 9, 10. ✅
  • §6 Data model → Task 1. ✅
  • §7 Superadmin toggle → Task 13. ✅
  • §8 Tracking → Tasks 11, 12. ✅
  • §9 Build order → reflected in the 16-task sequence. ✅
  • §10 Shadow-week gate → Tasks 15, 16. ✅
  • §11 Out of scope → no implementation needed. ✅
Placeholder scan
  • “Implementing agent must verify” notes appear in Tasks 3, 8, 9, 11, 12 — these are explicit verification steps, not vague placeholders. The exact action is named and the consequence of getting it wrong is clear. Acceptable.
  • No “TBD”, “implement later”, or “similar to Task N” without code.
Type consistency
  • Lesson, Phase, Signals, ClinicCtx, TemplateProps, AdminRecipient, LessonKey defined once in types.ts (Task 2), referenced consistently throughout.
  • Template function names match registry keys: each <key> template is named after the lesson key in camelCase (e.g. welcome, firstPatientAdded, aiInsights). The registry in Task 7 uses these exact names.
  • passesAllowlist signature consistent between Task 8 (definition) and Task 9 (usage).

Execution handoff

Plan complete and committed at docs/superpowers/plans/2026-05-15-onboarding-email-campaign.md. Two execution options:
  1. Subagent-Driven (recommended) — fresh subagent per task, review between tasks, fast iteration. Uses superpowers:subagent-driven-development.
  2. Inline execution — execute tasks in the current session with checkpoints. Uses superpowers:executing-plans.
Which approach?