Case study · A production system, live today

The Breakthrough Couples Platform

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

72-item scored instrument 24 relationship domains 9+ serverless functions RLS-proven privacy model Coach-editable without breaking scoring
forms.breakthroughcouples.com/assessment
The taker flow — screen-recorded from the live site. Landing → name & email → instructions → the question rhythm. Each tap saves itself, auto-advances, and survives a closed tab — takers resume exactly where they left off, on any device.

i How the system fits together

One platform, five moving parts

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:

React SPA
Assessment, intake, coach dashboard, client vault — one Vite build on Netlify's CDN
Netlify Edge + Functions
Bot-aware link routing at the edge; email, access-codes, and vault-unlock as serverless functions
Supabase Postgres
Attempts, answers, custom questions, security settings — every table behind row-level security
WordPress site
The practice's public site embeds the intake via iframe with auto-height messaging
SMTP pipeline
Results, confirmations, and login codes sent through the practice's own domain
The coach's inbox
Every submission lands as a formatted notification — no dashboard-checking required

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

A 72-question assessment that scores itself

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.

/assessment
Assessment landing page
The landing: three parts, honest timing, no account needed.
/assessment
Name and email gate
One gate, two fields. The email keys resume, history, and results delivery.
/assessment
A question screen with the five-point scale
The question rhythm. Serif question, five labeled choices, reverse-framing hints where wording could trip someone, and a pace estimate instead of an anxiety-inducing question counter. Twin hairlines — the brand mark — draw closer together as the parts progress.

The scoring core

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.

assessmentScoring.tsproduction, excerpted
// 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

A link whose results can never reach the coach

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.

/assessment-private
Private link landing with privacy badge
The private link declares itself on arrival…
/assessment-private
Private email gate
…and the email step spells out the guarantee in plain words.

Privacy is enforced at four independent layers, so no single bug can leak a result:

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.

private_attempts_rls_hardening.sqlapplied migration, excerpted
-- 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

He edits the instrument himself — scoring survives

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.

/assessment
A coach-added question with the Not applicable option
A coach-authored question in the live flow — indistinguishable from the core instrument, with the dashed “Not applicable to us” escape hatch. Its answer averages into the domain it follows.
/assessment-results
Coach sign-in, vault-first
One login for everything. The results dashboard rides the practice's existing vault session — person-by-person history, pillar trend charts, one-tap email export (CSV), and a passcode fallback verified server-side with bcrypt.

4 Pipelines & link intelligence

Automation the coach never has to think about

Links that unfurl correctly everywhere

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.

og:image · /assessment
Public share card with couple silhouettes
The public card — couple silhouettes, coach-approved copy.
og:image · /assessment-private
Private share card with lock chip
The private link wears its own card — recipients know at a glance.
assessment-share.tsNetlify edge function, excerpted
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();
  }
};

Intake that books itself

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.

/ (embedded in the WordPress site)
Mutual availability calendar in the couples intake
“When are you both usually free?” — Mon–Sun × morning/afternoon/evening, with a live summary line. Stored structured, rendered readable everywhere it lands.

Email that arrives

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.

mobile · 390px
Mobile view of the assessment landing
Most takers arrive from a text message — mobile is the first-class citizen.
  • Returning takers get a 6-digit emailed code — SHA-256-hashed, 10-minute TTL, single-use, atomically rate-limited in Postgres against concurrent guessing.
  • Every completion emails the taker their full results and the coach a notification — deduplicated by a notified_at stamp.
  • The intake confirms to the couple and briefs the coach — including their shared availability windows.

5 Under the hood

The data model & the discipline

TableWhat it holds
assessment_attemptsCompleted assessments: 72 answers, computed scores, per-question timing, taker name, privacy flag, custom answers
assessment_answer_eventsPer-answer telemetry stream (insert-only; private attempts write nothing here by design)
assessment_question_overridesThe coach's rewording of any of the 72 questions — wording only, order and scoring frozen
assessment_custom_questionsCoach-authored questions, anchored to items; deactivate-never-delete so past answers always resolve
assessment_email_codesHashed returning-taker codes with TTL, try caps, and per-IP throttles
intake_submissionsCouples intake — written through a security-definer RPC that masks legacy keys

How it stays trustworthy

Stack: React 18 · TypeScript · Vite · Tailwind · Framer Motion · Netlify (CDN, Functions, Edge Functions) · Supabase Postgres with RLS · Nodemailer/SMTP · WordPress embedding.