BetterSpace
Enterprise mental-health platform — 9 apps, 35+ serverless functions, a Gemini/GPT AI assistant, and clinical assessments.
- Role
- Co-Founder & CTO
- Timeline
- 2023 – present
- Team
- 6 people
- Year
- 2023
- React Native
- Expo
- Supabase
- Google Gemini
- TypeScript
- PostgreSQL
Problem
Mental-health support inside organizations is fragmented: an EAP hotline here, a meditation app there, a therapist directory in a PDF somewhere. Employees do not know where to start, HR cannot see aggregate wellbeing without violating privacy, and clinicians lack continuity between sessions. BetterSpace set out to be a single platform spanning self-help, AI support, validated clinical assessment, and live therapy — across mobile and web, for multiple distinct user roles.
Target users: employees (members), therapists, coaches, organization admins, super-admins, and content managers — six roles with genuinely different needs and data-access rules.
Constraints:
- Health-adjacent data demands strict per-role isolation and auditability.
- It must work on mobile-first (React Native/Expo) with web admin surfaces.
- AI support must be reliable enough to sit in front of vulnerable users, which means graceful fallback, not a single point of failure.
Business goal: one platform that an organization can deploy so employees get help fast, clinicians get continuity, and admins get privacy-preserving aggregate insight.
Architecture
BetterSpace is a Supabase-central-hub architecture: 9 client apps (members, therapists, admin, etc.) all talk to one Supabase project that owns Postgres, Auth, Storage, Realtime, and 35+ Edge Functions. There is no separate monolith API server — the "backend" is Postgres with RLS plus a fleet of small serverless functions.
Key facts of the system:
- 9 apps, 35+ serverless functions, 147 database migrations — the schema evolved with the product and every change is a tracked migration.
- 50+ RLS tables enforce the six-role access model at the row level.
- "Rooh" AI assistant: Gemini 2.5 Flash as primary, GPT-4o-mini as fallback, and ElevenLabs for voice responses.
- Webhook system: 30 event types, HMAC-SHA256 signed, with a pg_cron backup delivery sweep so no event is silently lost.
- Clinical assessments: PHQ-9, GAD-7, PSS-10, WHO-5, DASS-21, and Big Five — validated instruments with proper scoring.
- Payments: PayU + RevenueCat (mobile subscriptions) + Cashfree.
- Integrations: Whereby for video sessions, Apple HealthKit and Google Health Connect for passive wellbeing signals.
Try it live
BetterSpace · Live App
A faithful, interactive recreation of the BetterSpace mobile app, running on canned demo data. Switch tabs to chat with the Rooh AI companion, explore the Health dashboard, or log a mood in the Journal — the real screens, no account needed.
Your AI wellness companion. I'm here to listen, support, and guide you on your mental health journey.
Rooh AI is not a licensed therapist. For medical advice, diagnosis, or treatment, consult a healthcare professional.
Interactive demo · sample data, no real account
Engineering Decisions
Supabase as the single hub instead of microservices. Nine apps could have implied nine backends. Instead they share one Postgres with RLS as the authorization layer, so a therapist app and a member app enforce the same rules from the same source of truth. The honest tradeoff has two sides. The cost: the shared schema is a coordination point (147 migrations is the evidence), and it concentrates blast radius — a bad migration or a wrong policy can affect every app at once, which is why migrations are forward-only and reviewed and RLS has a conformance suite. The payoff for a six-person team is larger: there is exactly one place authorization is decided, so we never chased the cross-service consistency bugs (a permission enforced in service A but forgotten in service B) that quietly sink small teams. We accepted a coordination cost we can see and manage over a class of distributed-systems bugs that are invisible until they leak data. If the team were 40 people instead of 6, the calculus would flip toward extracting bounded services — this is a decision scoped to team size, not a universal principle.
Dual-model AI with fallback (Gemini primary, GPT-4o-mini fallback). A mental-health assistant cannot go dark because one provider has an outage or refuses a prompt. Rooh tries Gemini 2.5 Flash first (fast, cheap) and falls back to GPT-4o-mini, then layers ElevenLabs voice on top. Tradeoff: two provider integrations and prompt-compat testing, worth it for availability.
HMAC-SHA256 webhooks with a pg_cron backup sweep. With 30 event types feeding downstream consumers, at-least-once delivery matters. Every webhook is signed; a scheduled pg_cron job re-scans for undelivered events and retries, so a transient consumer outage does not drop clinical or billing events.
Three payment providers by necessity, not preference. RevenueCat handles app-store subscriptions, PayU and Cashfree handle regional web payments. The internal entitlement model normalizes all three so the rest of the app never branches on provider.
Source Code Walkthrough
Rooh's model-fallback wrapper
The assistant is fronted by a single function that guarantees a response even when the primary model fails, so the UI never has to reason about providers.
// Why: a mental-health assistant must degrade gracefully, never hard-fail.
// Why this shape: try fast/cheap first; fall back on error OR refusal;
// surface a safe canned message only if both providers fail.
async function askRooh(input: RoohInput): Promise<RoohReply> {
try {
const primary = await gemini.generate(input, { model: "gemini-2.5-flash" });
if (isUsable(primary)) return withVoice(primary);
} catch (err) {
log.warn("gemini_failed", { err });
}
try {
const fallback = await openai.generate(input, { model: "gpt-4o-mini" });
if (isUsable(fallback)) return withVoice(fallback);
} catch (err) {
log.error("fallback_failed", { err });
}
// Both failed: return a safe, human-reviewed static response + escalation.
return safeFallbackReply(input);
}Performance considerations: primary path is optimized for latency (Gemini Flash ~640ms); the fallback only pays its cost when needed. Improvements: add a short-circuit health check so we skip a known-down provider instead of eating its timeout.
Signed webhook verification
// Why: 30 event types fan out to consumers; a spoofed event could corrupt
// clinical or billing state. Verify HMAC before doing anything.
function verifyWebhook(rawBody: string, signature: string, secret: string) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
// timing-safe compare prevents signature-guessing via response timing.
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
throw new UnauthorizedWebhookError();
}
}Why this way: verification happens on the raw body before JSON parsing, so a tampered payload never reaches business logic. The pg_cron backup sweep handles the delivery side; this handles the authenticity side.
RLS policies backed by a security-definer role helper
With six roles across 50+ tables, the authorization spine is RLS — but a policy
that re-queries a table to check the caller's role can recurse (the role lookup
is itself an RLS-gated read) or leak the join. The pattern that scales is a
security definer helper that resolves the caller's role once, bypassing RLS
for that single trusted lookup, and then every policy is a cheap function call.
-- get_my_role(): resolve the caller's role exactly once, safely.
-- SECURITY DEFINER runs as the function owner so it can read profiles without
-- tripping the very RLS policies that will call it (avoids infinite recursion).
-- STABLE lets the planner cache the result within a statement.
create or replace function get_my_role()
returns text
language sql
stable
security definer
set search_path = public -- pin search_path: a definer fn with a mutable
as $$ -- search_path is a classic privilege-escalation hole
select role from profiles where id = auth.uid()
$$;
create or replace function is_admin()
returns boolean
language sql stable security definer set search_path = public
as $$ select get_my_role() in ('admin', 'super_admin') $$;
-- Example policy: members see only their own assessments; clinicians see the
-- assessments of members assigned to them; admins see all. One table, three
-- access shapes, expressed declaratively — the database is the enforcement
-- point, not the app.
alter table assessment_results enable row level security;
create policy assessment_read on assessment_results
for select using (
user_id = auth.uid() -- the member themselves
or is_admin() -- admins / super-admins
or exists ( -- assigned clinician
select 1 from care_assignments ca
where ca.member_id = assessment_results.user_id
and ca.clinician_id = auth.uid()
)
);Why written this way: set search_path on a security definer function is
non-negotiable — without it, a caller who controls search_path can shadow
profiles with a table they own and impersonate any role. stable means the
role resolves once per statement instead of once per row, which matters when a
policy is evaluated across a large result set. Possible improvements: the
exists subquery on care_assignments benefits from a composite index on
(clinician_id, member_id); without it the clinician path degrades to a scan
on wide reads.
Typed clinical scoring with interpretation bands
Encoding a validated instrument wrong is a clinical safety issue, not a cosmetic bug — a mis-scored PHQ-9 could under-flag someone at risk. So scoring is pure, total, and typed: item ranges are validated at the boundary, the severity bands come straight from the published scoring guides, and every band edge is pinned by a reference-case test.
// scoring/phq9.ts — PHQ-9 depression screen. Pure + total by construction.
// Why typed this tightly: an out-of-range item is a data-integrity bug we want
// to fail loudly at the boundary, never silently clamp into a wrong score.
const PHQ9_ITEM = z.number().int().min(0).max(3); // each item: 0–3
const PHQ9_ANSWERS = z.tuple(Array(9).fill(PHQ9_ITEM) as [typeof PHQ9_ITEM]);
export type PHQ9Severity =
| "minimal" | "mild" | "moderate" | "moderately_severe" | "severe";
export interface PHQ9Result {
total: number; // 0–27
severity: PHQ9Severity;
flagSelfHarm: boolean; // item 9 > 0 → clinical escalation, always
}
export function scorePHQ9(input: unknown): PHQ9Result {
const answers = PHQ9_ANSWERS.parse(input); // reject malformed input up front
const total = answers.reduce((a, b) => a + b, 0);
// Bands from the validated PHQ-9 scoring guide. Exhaustive by construction:
// the ranges partition 0–27 with no gap and no overlap.
const severity: PHQ9Severity =
total <= 4 ? "minimal" :
total <= 9 ? "mild" :
total <= 14 ? "moderate" :
total <= 19 ? "moderately_severe" :
"severe";
// Item 9 (self-harm ideation) is escalated independently of the total — a
// "minimal" total with a non-zero item 9 must still trigger care outreach.
return { total, severity, flagSelfHarm: answers[8] > 0 };
}Why written this way: the function is pure and total — same input, same
output, no I/O — which is exactly what makes it trustworthy to unit-test
exhaustively against the reference vectors in the scoring literature. The
independent flagSelfHarm on item 9 encodes a real clinical rule: the
aggregate severity can be low while a specific answer still demands escalation.
Possible improvements: the band thresholds are per-instrument constants
today; extracting them into a declarative table would let a clinician review
scoring rules without reading TypeScript.
The pg_cron webhook backup sweep
Signature verification proves authenticity; the backup sweep guarantees delivery. A scheduled job re-scans for events that were never acknowledged and re-dispatches them, so a transient consumer outage degrades to "delivered late" instead of "lost".
-- Runs every minute via pg_cron. Re-dispatch events that are past due for a
-- retry and haven't exhausted their attempt budget. FOR UPDATE SKIP LOCKED so
-- concurrent sweeps never grab the same row (at-least-once, not twice-at-once).
select cron.schedule('webhook-backup-sweep', '* * * * *', $$
with due as (
select id
from webhook_deliveries
where status = 'pending'
and attempts < 6
and next_retry_at <= now() -- exponential backoff set on each failure
order by next_retry_at
limit 100 -- bound the batch: predictable load per tick
for update skip locked
)
update webhook_deliveries d
set status = 'dispatching', attempts = attempts + 1
from due
where d.id = due.id
$$);Why written this way: for update skip locked is what makes the sweep safe
to run concurrently and idempotent-friendly — two ticks can't claim the same
delivery. The limit 100 bounds work per tick so a backlog drains steadily
instead of spiking DB load. The attempts < 6 budget with a next_retry_at
backoff prevents a permanently-broken consumer from being hammered forever;
exhausted deliveries fall out of the sweep into a dead-letter status for
inspection. Possible improvements: partitioning webhook_deliveries by day
would keep the sweep's index tight as history grows.
Technical Challenges
- Six-role authorization across 50+ tables. Getting RLS policies right — and keeping them right across 147 migrations — was the hardest ongoing discipline. Every new table starts with its policies, not its columns.
- AI reliability in a sensitive domain. Dual-model fallback plus a human-reviewed safe response was non-negotiable. We treat the assistant as a system that must never leave a user with nothing.
- Delivery guarantees for webhooks. At-least-once delivery with idempotent consumers, backed by a pg_cron retry sweep, so clinical events survive consumer outages.
- Clinical scoring correctness. PHQ-9/GAD-7/PSS-10/WHO-5/DASS-21/Big Five each have specific scoring and interpretation bands; encoding them wrong is a clinical safety issue, so scoring is unit-tested against reference cases.
- Mobile ↔ health integrations. Reconciling Apple HealthKit and Google Health Connect data models into one internal wellbeing signal.
Code Quality & Testing
In a health-adjacent product the cost of a bug is not a bad UX — it is a mis-flagged assessment or a leaked record across roles. So the testing strategy is organized around consequence: the highest-rigor tests sit on clinical correctness and cross-role isolation, and CI treats those as release blockers.
Type safety. TypeScript strict mode is enforced across all 9 apps from a
shared base tsconfig, so a member app and the admin app cannot disagree about
the shape of a Profile or an AssessmentResult — the types are generated from
the Postgres schema and shared, which keeps client code honest against the
source of truth. At runtime, every edge-function input and every inbound webhook
is validated with Zod before it touches business logic; a valid JWT proves who
is calling, not what they sent.
Testing strategy — weighted by clinical and privacy risk.
- Reference-case unit tests for clinical scoring (clinical safety). Each instrument (PHQ-9, GAD-7, PSS-10, WHO-5, DASS-21, Big Five) is tested against the exact reference vectors from its published scoring guide, including every band boundary and the independent self-harm escalation on PHQ-9 item 9. Because the scorers are pure and total, these tests are exhaustive and fast.
// phq9.reference.test.ts — pin every band edge + the self-harm escalation.
describe("scorePHQ9 against published reference cases", () => {
it.each([
{ answers: [0,0,0,0,0,0,0,0,0], total: 0, severity: "minimal" },
{ answers: [1,1,1,1,0,0,0,0,0], total: 4, severity: "minimal" }, // upper edge
{ answers: [1,1,1,1,1,0,0,0,0], total: 5, severity: "mild" }, // band flip
{ answers: [3,3,3,3,3,3,1,0,0], total: 19, severity: "moderately_severe" },
{ answers: [3,3,3,3,3,3,3,3,3], total: 27, severity: "severe" },
])("scores $total as $severity", ({ answers, total, severity }) => {
const r = scorePHQ9(answers);
expect(r.total).toBe(total);
expect(r.severity).toBe(severity);
});
it("escalates on item 9 even when the total is minimal", () => {
// total = 2 (minimal) but self-harm item is non-zero → must still flag.
expect(scorePHQ9([0,0,0,0,0,0,0,0,2]).flagSelfHarm).toBe(true);
});
});- Cross-role RLS conformance probes across 50+ tables. An automated suite authenticates as each of the six roles and asserts the access matrix table by table: a member cannot read another member's assessments; a clinician sees only assigned members; an org admin sees aggregates but not raw clinical rows. This runs against a real Postgres with policies applied, because RLS is a property of the database, not the app. When a new table is added, its row in the access matrix is a required test — "policies before columns" is enforced, not just aspired to.
- Webhook signature + idempotency tests. The suite proves that a tampered
body fails HMAC verification before parsing, that a valid duplicate delivery
is a no-op (idempotent consumer), and that the pg_cron sweep re-dispatches a
simulated dropped event exactly once under concurrent ticks
(
for update skip lockedholds).
Error handling & observability. The dual-model AI path is the clearest
example: every provider call is wrapped, failures are logged as structured
events ({ event, provider, latencyMs, reason }), and both a provider error
and a content refusal route to the same fallback path — so the metric
"fallback rate" captures reliability regardless of failure mode. Webhook
delivery lag and per-provider AI latency percentiles are queryable, which is how
the fire-and-forget delivery bug was caught and later prevented.
Module & service boundaries. The Supabase-central-hub is a deliberate
boundary choice: authorization lives in one place (RLS in Postgres), so nine
apps cannot drift into nine subtly-different permission models. Edge functions
are small and single-purpose (Rooh, scoring, billing, webhooks) with shared
concerns — crypto, validation, provider clients — factored into a _shared
module rather than copy-pasted across functions.
Schema discipline as a quality gate. 147 tracked migrations is not incidental — it is the discipline that makes the shared-schema bet survivable. Every schema change is a reviewed, ordered, forward-only migration; there is no "just alter it in the dashboard." That history is what lets a six-person team reason about a 50+ table schema without fear.
What CI enforces (release blockers). tsc --noEmit strict across all apps;
ESLint; the full clinical-scoring reference suite (a failure here blocks
release outright); the RLS conformance probes; and the webhook
signature/idempotency tests. Migrations are validated to apply cleanly from a
fresh database so the 147-step history stays reproducible.
Benchmarks
Rooh's primary path (Gemini 2.5 Flash) responds around 640ms; the GPT-4o-mini fallback around 910ms — the extra latency is acceptable because it only runs when the primary path fails. The platform's surface area tells the operational story: 9 apps, 35+ serverless functions, and 147 tracked migrations against a single Supabase hub. Six validated clinical instruments are supported with proper scoring. See the charts above.
Lessons Learned
- What succeeded: the Supabase-central-hub model let a small team ship nine coordinated apps without a microservice tax.
- What failed first: early webhook delivery was fire-and-forget and lost events under load; the pg_cron backup sweep fixed it.
- What I would redesign: policy testing was manual too long — RLS policies deserve their own automated conformance suite.
- Remaining tech debt: provider health-checks for AI fallback, and consolidating three payment integrations behind a cleaner entitlement port.
- Future improvements: aggregate, privacy-preserving org dashboards and richer passive-signal fusion from HealthKit/Health Connect.
Media
- Demo recordings of Rooh answering with streamed text and ElevenLabs voice.
- Screenshots of the therapist, member, and admin apps.
- Architecture diagram of the Supabase-central-hub with the nine apps.
Documentation Links
- Platform: betterspace.life
- Internal engineering docs (architecture, DB schema, edge functions, webhooks, deployment) are maintained privately for the platform team.
Build Logs
- Requirements & planning. Mapped six roles and their data-access rules first; everything downstream is a consequence of that model.
- Architecture. Chose one Supabase hub over microservices to fit a small team; RLS as the authorization spine.
- Implementation. Built member/therapist/admin apps against shared schema; 147 migrations track the evolution. Added Rooh with dual-model fallback and ElevenLabs voice.
- Testing. Reference-case tests for clinical scoring; cross-role RLS probes; webhook signature and idempotency tests.
- Deployment. Supabase project as backend; Expo builds for mobile; RevenueCat/PayU/Cashfree for billing; Whereby for video.
- Monitoring. AI provider error rates and latency percentiles; webhook delivery lag with pg_cron backup.
- Retrospective. The hub model was the right call; RLS conformance testing is the next investment.