Case study · A production system, live today
A client-assessment and practice-automation platform I designed, built, and operate for Breakthrough Couples, a couples-coaching practice — from the React front-end through serverless pipelines to the Postgres row-level-security model. Everything below is captured from the live production system (demo data only).
i How the system fits together
A visitor can arrive three ways — the coach's WordPress site, a shared link in iMessage, or directly. Whatever the door, the same architecture serves them:
The coach never files a ticket to change content: question wording, his own added questions, and availability all live in Postgres and take effect instantly.
1 The instrument
Twenty-four relationship domains, three questions each. Every domain feeds three pillars — Connection, Relationship Health, Repair Capacity — each out of 120, totalling 360. Band thresholds, five pattern archetypes, and safety triggers were ported from a reference engine and verified against 28 fixture scenarios before launch.
Scoring is a pure function — same answers in, same result out, on the client and at the API alike. The subtle part is customCells: the coach can add his own questions (next chapter), and each area's contribution becomes the average of its base question and any added ones. With no additions the math collapses to the original engine, byte-for-byte — which is what keeps every historical result comparable.
// customCells maps a scored item index (0–71) to the answers of Harrison's own // questions that follow it. Each area's contribution is the AVERAGE of its // question and any added questions — so one question stays exactly that answer // (the 72-item instrument and every past result are unchanged), while an added // question simply blends into its area. The scale stays 120 / 120 / 120 = 360 // and the bands never move. export function scoreAssessment(answers: number[], customCells?: Record<number, number[]>): ScoredAssessment { if (!isCompleteAnswers(answers)) { throw new Error('scoreAssessment() requires 72 answers, each an integer 1–5') } const cell = (i: number): number => { const extra = customCells?.[i]?.filter((v) => Number.isInteger(v) && v >= 1 && v <= 5) if (!extra || extra.length === 0) return answers[i] let sum = answers[i] for (const v of extra) sum += v return sum / (1 + extra.length) } let connection = 0 let health = 0 let repair = 0 const domainScores: number[] = [] for (let d = 0; d < 24; d++) { const q1 = cell(3 * d) const q2 = cell(3 * d + 1) const q3 = cell(3 * d + 2) connection += q1 health += q2 repair += q3 domainScores.push(Math.round(q1 + q2 + q3)) } // Round the pillars so the familiar whole-number scale holds; with no added // questions every cell is an integer answer, so this is a no-op and output // is byte-identical to the original sum-based engine. connection = Math.round(connection) health = Math.round(health) repair = Math.round(repair) const total = connection + health + repair // … bands, lowest-five ranking, hidden-pillar and safety logic follow }
2 The privacy architecture
The practice needed something unusual: an assessment for couples who know the coach personally — where he must not be able to see their results, even though he owns the database. Saying "we won't look" wasn't enough; the system has to make looking impossible.
Privacy is enforced at four independent layers, so no single bug can leak a result:
private = true rows from every admin read, keyed to the authenticated JWT.Proven, not assumed: I emulated the coach's own authenticated session directly against production SQL — zero private rows visible — and ran a full live private walkthrough to confirm no answer events were written.
-- assessment_attempts: admins never see private attempts. DROP POLICY IF EXISTS "Only admins can view assessment attempts" ON public.assessment_attempts; CREATE POLICY "Only admins can view assessment attempts" ON public.assessment_attempts FOR SELECT USING ( private = false AND EXISTS ( SELECT 1 FROM public.admin_users WHERE admin_users.email = (auth.jwt() ->> 'email') AND admin_users.is_active = true ) ); -- assessment_answer_events: admins never see events tied to a private attempt. -- (Private attempts don't write events anymore; this covers any that predate -- the change and any future gap.) DROP POLICY IF EXISTS "Only admins can view assessment answers" ON public.assessment_answer_events; CREATE POLICY "Only admins can view assessment answers" ON public.assessment_answer_events FOR SELECT USING ( NOT EXISTS ( SELECT 1 FROM public.assessment_attempts a WHERE a.attempt_key = assessment_answer_events.attempt_key AND a.private = true ) AND EXISTS ( SELECT 1 FROM public.admin_users WHERE admin_users.email = (auth.jwt() ->> 'email') AND admin_users.is_active = true ) );
3 The coach's controls
The coach rewords questions and adds entirely new ones from his dashboard, no developer in the loop. Added questions anchor to an existing item, appear seamlessly in the flow, and count toward the scores through the cell-averaging above — the 360-point scale and every past result stay intact. Sensitive additions (children, addiction) get a "Not applicable to us" option that's excluded from the average by construction.
4 Pipelines & link intelligence
iMessage's link-preview robot executes JavaScript — it was following the share page's client-side forward and unfurling the wrong card. The fix runs at the CDN edge: preview bots get a static card with the right OpenGraph tags; humans get a server-side redirect straight into the app.
const BOT_UA =
/facebookexternalhit|facebot|twitterbot|slackbot|linkedinbot|whatsapp|telegrambot|discordbot|applebot|pinterest|bingbot|googlebot|yandex|baiduspider|duckduckbot|skypeuripreview|vkshare|snapchat|iframely|embedly|quora link preview|redditbot|tumblr|bitlybot|nuzzel|xing|outbrain|w3c_validator|preview/i;
export default async (request: Request, context: { next: () => Promise<Response> }) => {
try {
const url = new URL(request.url);
// Already inside the app (or asked for it) — let the static rules serve it.
if (url.searchParams.get('go') === 'app') return context.next();
const ua = request.headers.get('user-agent') || '';
if (BOT_UA.test(ua)) return context.next();
// A real visitor: send them straight into the app, params preserved.
const target = url.pathname === '/assessment-private' ? '/assessment-private' : '/assessment';
const dest = new URL(target, url.origin);
url.searchParams.forEach((value, key) => dest.searchParams.set(key, value));
dest.searchParams.set('go', 'app');
dest.hash = url.hash;
return Response.redirect(dest.toString(), 302);
} catch {
return context.next();
}
};
The couples intake feeds the coach's client vault and email simultaneously. Its availability calendar captures when both partners are free — tap-grid on any device — and the choices ride along into his notification email and vault record, so scheduling starts with the answer already in hand.
Results to takers, notifications to the coach, six-digit login codes for returning takers — all through the practice's own SMTP domain. Getting that reliable in production meant real deliverability work: implicit-TLS port selection after diagnosing serverless egress hangs, single-use hashed access codes with atomic try-counting, and forwarding forensics down to reading the mail server's delivery logs.
notified_at stamp.5 Under the hood
| Table | What it holds |
|---|---|
| assessment_attempts | Completed assessments: 72 answers, computed scores, per-question timing, taker name, privacy flag, custom answers |
| assessment_answer_events | Per-answer telemetry stream (insert-only; private attempts write nothing here by design) |
| assessment_question_overrides | The coach's rewording of any of the 72 questions — wording only, order and scoring frozen |
| assessment_custom_questions | Coach-authored questions, anchored to items; deactivate-never-delete so past answers always resolve |
| assessment_email_codes | Hashed returning-taker codes with TTL, try caps, and per-IP throttles |
| intake_submissions | Couples intake — written through a security-definer RPC that masks legacy keys |
Stack: React 18 · TypeScript · Vite · Tailwind · Framer Motion · Netlify (CDN, Functions, Edge Functions) · Supabase Postgres with RLS · Nodemailer/SMTP · WordPress embedding.