My Role
Solo developer, full ownership — from discovery conversations with the therapist through architecture, implementation, deployment, and ongoing iteration. I made every technical decision: stack selection, data modeling, integration strategy, auth approach, payment flow design. The therapist's input shaped the domain rules; the engineering was entirely mine.
The therapist is a solo practitioner — no office manager, no tech support, no admin staff. She was duct-taping Calendly, Mailchimp, and Google Docs into a workflow that constantly leaked context between tools. I didn't just replace those tools; I built around how her practice actually works. Therapeutic relationships have stages — discovery, intake, ongoing care — and the software needed to understand and enforce those stages rather than treating every client the same way a generic scheduling app would.
Approach
React Router 7 frontend with NestJS API and PostgreSQL. The frontend handles three distinct surfaces — public booking pages, a client dashboard, and a therapist admin portal — all server-rendered through nested Remix routes. NestJS provides the structured backend with modular separation: auth, bookings, messaging, content/feed, notifications, availability, Stripe payments, and Google Calendar integration. TypeORM manages the schema with explicit migrations.
I chose this stack because the system needed SSR for public pages (SEO matters for a therapist's web presence), real-time data for the dashboards, and enough backend structure to support a dozen modules without the code becoming a maze. NestJS's module system gave me clean boundaries between booking logic, payment processing, calendar sync, and messaging — each with its own service, controller, and test suite.
Directus as a headless CMS for articles, podcasts, and static site content. Twenty CRM (open-source, self-hosted) for client relationship tracking. Both integrate through the NestJS API layer so the frontend never touches them directly.

Key Decisions
The discovery gate as a domain boundary, not a product limitation. Anonymous visitors can only book a free 20-minute discovery call. Paid session types are locked until discoveryCompletedAt is set on the user record. This isn't a paywall — it's how this therapist actually works. She doesn't take clients she hasn't spoken with. I encoded that boundary in the data model, enforced it in the API guards, and reflected it in the UI state. The system understands that the therapeutic relationship has stages.
Guest booking with optional account creation. A prospective client can book a discovery call without creating an account — just name, email, phone. After booking, they get a magic link to optionally create an account for their dashboard. I rejected requiring registration upfront because it adds friction at exactly the moment someone is making a vulnerable decision. The booking itself creates the user record; the account is just access to it.
Magic-link auth over passwords. Passwordless login via Resend (with SMTP fallback). 32-byte tokens, SHA-256 hashed in the database, single-use, 15-minute expiry. Silent success on unknown emails to prevent enumeration. I kept password login available in dev mode only. For a therapy practice where clients log in infrequently, magic links remove the "forgot password" problem entirely.
Stripe Checkout over embedded payment forms. Paid bookings redirect to Stripe-hosted checkout rather than processing cards in my UI. The booking is created with PENDING_PAYMENT status, Stripe handles PCI compliance, and a webhook (checkout.session.completed) confirms the booking and fires all side-effects — emails, in-app notifications, Google Calendar event creation, and a welcome message thread. I return 200 on webhook errors with idempotency guards to prevent Stripe retries from creating duplicate bookings.

What Was Hard
Google Calendar bidirectional sync. The therapist manages her life in Google Calendar, and her availability in the booking system needed to reflect that. Bookings push events to Google Calendar (with automatic Google Meet links for virtual sessions). Google Calendar events pull back as blocked dates so personal appointments reduce available booking slots. The sync runs every 15 minutes via cron, paginates through up to 120 days of events, handles all-day vs. timed events differently, and does a full delete-and-recreate on each cycle to avoid stale timezone data. OAuth token refresh, revocation on disconnect, and graceful degradation when the Google API is unreachable all needed to work correctly. This was the most integration-heavy feature in the project.
Double-booking prevention under concurrent requests. Two clients selecting the same slot at the same time. I used a unique database constraint on date + start time for non-cancelled bookings, wrapped the insert in a serializable transaction with row-level locking, and return a 409 with a friendly message if the slot was taken between page load and submission. Simple in concept, but getting the transaction isolation right with TypeORM required careful testing.
Side-effect orchestration on booking confirmation. When a booking is confirmed (directly for free sessions, via webhook for paid), five things fire asynchronously: confirmation email to client, notification email to therapist, in-app notification records for both, Google Calendar event creation, and a welcome message thread for first-time clients. All are fire-and-forget so the user gets instant feedback, but each failure path needed to be logged and non-blocking. Getting the error handling right — especially when Google Calendar or Resend is temporarily down — took more iteration than the happy path.

The Result
The therapist manages her entire practice from one system. Clients book sessions (free discovery or paid), see their appointment history, receive personalized content (audio recordings, worksheets, guided meditations, links), and message their therapist — all through a single portal that reflects the actual structure of a therapeutic relationship.
The backend has 17 NestJS modules, explicit TypeORM migrations, structured logging, rate limiting, and 225 test files across the project. Booking confirmation orchestrates five async side-effects. The content feed supports four media types with per-client visibility and optional session linking. Six branded HTML email templates handle the full notification lifecycle.
What makes this project different from a generic scheduling app is that the domain rules are in the code, not bolted on. The discovery gate, the messaging access progression, the therapist-only content uploads, the client isolation — these aren't configuration toggles. They're the data model. The system doesn't just schedule appointments; it understands that a therapeutic practice has structure, and it enforces that structure at every layer.

