Personal tutoring platform for booking programming, mathematics and AI classes.
Live site: gustavoai.dev
Full-stack booking platform for online tutoring sessions. Students can schedule individual sessions or purchase class packs, pay securely inside the app, join live virtual classrooms embedded in the platform, and manage all their bookings from a personal dashboard. An AI assistant powered by Gemini answers questions about services, pricing, and scheduling around the clock. The customer-facing experience is fully bilingual (Spanish / English) with automatic browser-language detection and a manual switcher.
| Technology | Purpose |
|---|---|
| Next.js 16 (App Router) | Full-stack framework; RSC for static sections, client components only where interactivity is needed |
| TypeScript (strict) | End-to-end type safety |
| NextAuth v5 | Google OAuth authentication |
| next-intl | Internationalization: Spanish (default) + English under /en, Accept-Language auto-detection, localized UI, emails, and SEO metadata |
| Supabase (Postgres) | Source of truth for all persistent data: users, bookings, credit packs, payments, audit log |
| Stripe | Integrated payment forms (single sessions and packs); webhook processing for payment confirmation |
| Google Calendar API | Reads real-time availability; creates and deletes calendar events on booking/cancellation |
| Zoom Video SDK | Embedded virtual classroom inside the platform; no external app or install required |
| Upstash Redis | Ephemeral state only: rate limiting, slot locking, availability cache, in-session chat state |
| Gemini API | AI assistant trained on full service details, pricing, and cancellation policy |
| Resend | Transactional email: booking confirmations, cancellation notices, reschedule links |
| Sentry | Error tracking and monitoring in production |
| Jest | Unit and integration tests |
| Playwright | End-to-end tests |
| GitHub Actions | CI: runs the full test suite automatically on every push |
| Vercel | Deployment and hosting |
- Free intro session — 15-minute no-cost meeting to define a plan, no commitment required
- Individual sessions — 1h and 2h paid sessions; payment handled inside the app via an integrated Stripe form
- Class packs — buy 5 or 10 classes at a discount; credits are activated immediately after payment and last 6 months
- Real-time availability — weekly calendar fetches free slots from Google Calendar on demand; slots are soft-locked during checkout to prevent double-booking
- Embedded virtual classroom — sessions run in a Zoom-powered room inside the platform; no install, no redirect
- Personal dashboard — students see all their upcoming and past sessions and can join, reschedule, or cancel from one place
- Email notifications — every booking triggers a confirmation email with calendar link, join link, and one-click reschedule/cancel links
- AI assistant — Gemini chat widget answers questions about services, pricing, cancellation policy, and Gustavo's background without the student needing to send an email
- Bilingual (Spanish / English) — Spanish is the default at unprefixed URLs; English lives under
/en. First-time visitors are auto-routed by browser language, and a navbar switcher lets anyone change language on the fly (persisted in a cookie). The full customer UI and transactional emails are translated; the admin panel stays Spanish - Automatic session closing — a pending termination row is written at booking time; a daily cron sweeps and closes virtual rooms once their grace period has elapsed
- Google OAuth — sign-in required before booking; session is verified server-side on all API routes
The codebase follows a strict layered architecture. Route handlers are thin dispatchers; all business logic lives in the service layer.
src/
├── app/ Route handlers — parse input, call service, format response
├── domain/ Pure types, interfaces, domain errors (zero external deps)
├── services/ Business logic — orchestrates repositories and infrastructure
├── infrastructure/ External adapters — Supabase, Stripe, Google, Zoom, Resend, Redis
├── lib/ Shared utilities — schemas, validation, logger, rate limiting
├── components/ Reusable React components
├── features/ Feature-scoped page components
├── hooks/ React hooks
└── constants/ Static configuration and design tokens
Data flow:
Route handler → Service → Repository interface → Supabase implementation
└→ In-memory implementation (tests)
Key design decisions:
- Repository pattern — all data access goes through interfaces in
src/domain/repositories/. Services receive repositories via constructor injection, making them fully testable with in-memory fakes without mocking. - Redis is ephemeral only — Supabase is the single source of truth for all persistent data. Redis handles rate limiting keys, slot locks, and short-lived availability cache. Nothing in Redis matters after a page refresh.
- Credit atomicity — pack credit decrements use a Postgres stored procedure (
decrement_credit) to prevent race conditions under concurrent requests. - Thin route handlers — handlers parse and validate input with Zod, call one service method, and map domain errors to HTTP responses via a central error-mapping utility. No business logic in routes.
- Serverless-safe session cleanup — every booking writes the session's grace-period deadline to a
pending_terminationstable. A daily cron (/api/internal/session-cleanup) sweeps rows whose deadline has passed and terminates the corresponding Zoom session records. Failure to write the row is non-fatal and does not fail the booking. - Locale-aware routing & SEO —
next-intlmiddleware handlesAccept-Languagedetection and the/enprefix (localePrefix: 'as-needed', so Spanish stays unprefixed). All customer-facing copy lives inmessages/{es,en}.jsonand renders throught()— no hardcoded UI strings. Domain and validation errors travel as codes, translated at the presentation boundary. Public pages emit per-localehrefLang/canonical tags (src/lib/hreflang.ts), and a locale-awaresitemap.ts+robots.tskeep both language variants correctly indexable.
Security is treated as a first-class concern throughout the codebase:
- Authentication — Google OAuth via NextAuth v5. Session is verified server-side on every API route; no URL parameter trust.
- CSRF protection — all state-mutating POST routes validate the
Originheader viaisValidOrigin(). The sole exception is the Stripe webhook, which uses its own HMAC signature verification. - Webhook signature verification — Stripe webhooks are verified with HMAC signatures before any processing occurs. The internal session-cleanup cron is protected by a
CRON_SECRETbearer token. - Input validation — all external input (request bodies, query params) is validated with Zod schemas defined in
src/lib/schemas.ts. Inline validation in route handlers is not permitted. - Tamper-proof action tokens — cancellation and reschedule links in emails use HMAC-SHA256 signed tokens. Tokens are single-use and expire, preventing replay attacks.
- Rate limiting — sliding-window rate limits (Upstash Redis) protect chat, availability, checkout, and credit endpoints against abuse.
- Admin routes — protected by a server-side
isAdmin()check independent of the student auth flow. - No sensitive data in Redis — all persistent student and payment data lives in Supabase (Postgres), not in the cache layer.
- Stripe payment security — card data is handled entirely by Stripe's embedded Elements; no card numbers touch the application server.
- Error tracking — Sentry captures and alerts on unhandled exceptions in production without exposing stack traces to the client.
pnpm test # all Jest tests (unit + integration)
pnpm test:unit # unit tests only
pnpm test:integration # integration tests only
pnpm test:e2e # Playwright end-to-end tests (requires E2E_BASE_URL)- Unit tests — service logic tested with in-memory repository fakes; no real network calls.
- Integration tests — cover API route behaviour end-to-end within the Next.js request cycle.
- E2E tests — Playwright tests cover the full booking and payment flows in a real browser.
- GitHub Actions — the full Jest suite runs automatically on every push and pull request.
- Node.js 22+
- pnpm 10+ (run
corepack enableto get the version pinned inpackage.json) - Supabase project (free tier is sufficient)
- Upstash Redis database (free tier is sufficient)
- Stripe account (session/pack prices are managed in-app, not in Stripe)
- Google Cloud project with Google Calendar API enabled and a service account
- Zoom app with Video SDK credentials
- Resend account
- Sentry project (optional for local dev)
git clone https://github.com/gussttaav/personal-web-booking-app.git
cd personal-web-booking-app
pnpm installCreate .env.local in the project root:
# ── Auth ──────────────────────────────────────────────────────────────
AUTH_SECRET= # openssl rand -hex 32
AUTH_GOOGLE_ID= # Google OAuth client ID
AUTH_GOOGLE_SECRET= # Google OAuth client secret
# ── Supabase ──────────────────────────────────────────────────────────
SUPABASE_URL=https://xxxx.supabase.co
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# ── Stripe ────────────────────────────────────────────────────────────
# Session/pack prices are NOT configured in Stripe — they live in the
# Supabase `pricing` table and are edited from the admin panel (/admin/pricing).
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# ── Upstash Redis (rate limiting + availability cache) ────────────────
UPSTASH_REDIS_REST_URL=https://...
UPSTASH_REDIS_REST_TOKEN=...
# ── Google Calendar (service account) ─────────────────────────────────
GOOGLE_SERVICE_ACCOUNT_EMAIL=xxx@xxx.iam.gserviceaccount.com
GOOGLE_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n"
GOOGLE_CALENDAR_ID=your.email@gmail.com
# ── Zoom Video SDK ─────────────────────────────────────────────────────
ZOOM_SDK_KEY=
ZOOM_SDK_SECRET=
# ── AI assistant ──────────────────────────────────────────────────────
GEMINI_API_KEY=
# ── Resend (transactional email) ──────────────────────────────────────
RESEND_API_KEY=re_...
RESEND_FROM=Gustavo Torres <contacto@gustavoai.dev>
NOTIFY_EMAIL=your.email@gmail.com
# ── Cancellation / Rescheduling tokens ───────────────────────────────
CANCEL_SECRET= # openssl rand -hex 32
# ── Internal crons (cron-job.org → /api/internal/session-cleanup, /api/internal/reconcile-stripe) ──
CRON_SECRET= # openssl rand -hex 32
# ── Sentry ────────────────────────────────────────────────────────────
SENTRY_DSN=
# ── App ───────────────────────────────────────────────────────────────
NEXT_PUBLIC_BASE_URL=http://localhost:3000pnpm dev
# http://localhost:3000stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# Copy the whsec_... shown and set it as STRIPE_WEBHOOK_SECRETThe project is deployed on Vercel. Set all environment variables from .env.local in the Vercel project settings.
Stripe webhook (production)
- Stripe Dashboard → Developers → Webhooks → Add endpoint
- URL:
https://gustavoai.dev/api/stripe/webhook - Events:
checkout.session.completed,charge.refunded - Copy the signing secret → set as
STRIPE_WEBHOOK_SECRETin Vercel
Google Cloud
- Enable Google Calendar API in your project
- Create a Service Account → copy
client_emailandprivate_key - In Google Calendar → your calendar → Settings → Share with specific people → add the service account email with "Make changes to events" permission
- Set
GOOGLE_CALENDAR_IDto your Gmail address
Database
Run migrations after deploying schema changes:
supabase db push
# or apply migration files in supabase/migrations/ via the Supabase dashboardMIT — see LICENSE.
Gustavo Torres Guerrero
gustavoai.dev · LinkedIn · GitHub · contacto@gustavoai.dev