How Lived is built, and how it scales
A complete architectural reference for the Lived peer-support marketplace: every layer of the current build as it exists in code, the gaps an engineering team needs to know about, and the target production architecture it should grow into.
The platform, layer by layer
Edge and delivery
DNS, TLS, CDN, request filtering
Presentation
Next.js App Router, React Server Components, client islands
Application
Mutations, orchestration, integration handlers
Domain and read models
Business rules, pure calculations, query composition
Data access
ORM, transactions, concurrency control
Data stores
System of record and supporting stores
External services
Third parties the platform depends on
00How to read this
This page combines three kinds of information. Each claim is labelled so engineers know how much weight it can bear.
Verified in code
Everything in sections 01 to 21 comes from a read-only audit of the repository at commit d5ae62e (26 Sep 2026). File and line references such as booking-actions.ts:47 point into src/lib unless another path is given. Items the auditor could not read directly are marked inferred.
Decided, not yet built
Product rules the founder has locked (block behaviour, video session timing, realtime transport, retention periods) are shown with the Target status and cited as "locked decision". They are requirements, not suggestions.
Proposed
Sections 23 to 25 describe a target production architecture. Where the founder has not yet decided, the proposal is marked as such and listed in section 26 for sign-off.
The build in numbers
| Measure | Value | Note |
|---|---|---|
| Prisma models | 30 | Counted from schema.prisma. The audit text says 29; the schema file is authoritative. |
| Enums | 24 | Counted from schema.prisma (the audit text says 29). |
| Models with no application code touching them | 10 | Earning, Payout, PersonalDocument, CalendarConnection, DeletionRequest, AvailabilityMode, TrainingModule, GuideTrainingCompletion, Subcategory, SeekerPreferredCategory |
| Migrations | 9 | Two contain hand-written SQL (a data backfill and a partial unique index) |
| Pages and layouts | 13 pages, 2 layouts | Every route renders dynamically |
| API route handlers | 1 | Auth.js only. No webhook or REST endpoints. |
| Server actions | 24 | The only mutation surface of the application |
| Automated tests | 0 | One manual verification rig for report intake |
| Commits | 35 | First commit 26 Aug 2026, latest 10 Sep 2026 |
Terms such as seeker, guide, intro, package, pay-what-you-can and hold-then-release are defined in the glossary.
01System context
Lived connects seekers (people going through something hard) with guides (vetted people who have lived through the same experience) for paid, pay-what-you-can or free one-to-one peer support. It is explicitly not therapy or professional advice. Today the whole platform is a single Next.js service and a single Postgres database on Render in Frankfurt, calling Stripe and Sentry.
flowchart LR
S["Seeker"]:::actor
G["Guide"]:::actor
A["Admin / moderator"]:::actor
subgraph R["Render, Frankfurt (EU)"]
W["lived-app web service
Next.js 16, Node, starter plan"]
D[("lived-db
Postgres 16, basic-256mb")]
end
ST["Stripe (sandbox)
Checkout, PaymentIntents,
Transfers, Refunds"]
SE["Sentry (EU)
errors and traces"]
CF["Cloudflare DNS
thelivedapp.com (not cut over)"]
ZM["ZeptoMail"]:::off
CAL["Google Calendar /
Microsoft Graph"]:::off
VID["Video provider"]:::off
S --> W
G --> W
A --> W
W --> D
W --> ST
W --> SE
CF -.-> W
W -.->|"not wired"| ZM
W -.->|"not wired"| CAL
W -.->|"not chosen"| VID
classDef actor fill:#e6efe8,stroke:#3d6048,color:#1c2620
classDef off fill:#f6e6e1,stroke:#a13f2a,color:#1c2620,stroke-dasharray:4 3
Current system context. Dashed nodes are declared in configuration but have no code behind them.
Actors
- Seeker: any user with a SeekerProfile. Every signup creates one, including guide signups.
- Guide: a user with a GuideProfile. Must be APPROVED to be visible and bookable.
- Admin:
User.isAdmin = trueand ACTIVE. Only the seed sets this flag.
Data residency
Application, database and error monitoring all run in the EU (Render Frankfurt, Sentry Germany region). Stripe processes payments under its own EU entity. Any new processor (realtime, video, storage, email) must meet the same bar and be added to the retention register.
02Layers and request lifecycle
The application follows the standard App Router shape: server components read through a thin domain layer in src/lib, client components call server actions to mutate, and every write goes through Prisma. There is no separate API tier.
| Layer | Where it lives | Responsibility | Status |
|---|---|---|---|
| Routing and pages | src/app/**/page.tsx, layout.tsx | Server components that load data and compose the screen. All routes use force-dynamic. | Built |
| Client islands | src/components/** | Forms, pickers, modals. Local React state only; call server actions with useTransition or useActionState. | Built |
| Server actions | src/lib/*-actions.ts, src/lib/auth/actions.ts | The mutation API. Authenticate, validate by hand, run a Prisma transaction, call Stripe, revalidate paths. Return { ok, error } objects. | Built |
| Read models | guides.ts, booking.ts, dashboard.ts, threads.ts, moderation.ts, contributions.ts, credits.ts, categories.ts, report-reasons.ts | Query composition and computed values (ratings, session counts, bookability, refund previews). | Built |
| Pure domain logic | cancellation.ts, age.ts, timezone.ts | Side-effect-free maths: refund fractions, 18+ gate, slot timing. | Built |
| Constants and copy | credit-packs.ts, signup-constants.ts, policy-content.ts, terms-content.ts, badges.ts, config.ts | Prices, limits, policy copy, badge explanations, demo flag. | Built |
| Data access | src/lib/db.ts, generated client in src/generated/prisma | Singleton PrismaClient over @prisma/adapter-pg. Cached on globalThis outside production. | No pool config |
| Integration clients | src/lib/stripe.ts, src/instrumentation*.ts | Stripe SDK client and platform fee rate; Sentry initialisation. | Stripe throws at import if key unset |
| Webhooks, jobs, email | None | Asynchronous work of any kind. | Not present |
A typical request
Every page render pays a fixed cost before its own data loads: the root layout is dynamic and SiteHeader calls auth(), getCategories(), isAdminUser() and isSeekerUser() on every request (three database queries, three session reads, no memoisation).
sequenceDiagram
autonumber
participant B as Browser
participant N as Next.js (Render)
participant L as src/lib read models
participant P as Prisma
participant DB as Postgres
B->>N: GET /guides/[id]
N->>N: root layout, auth() reads JWT cookie
N->>P: header queries (categories, isAdmin, isSeeker)
P->>DB: 3 queries
N->>L: getGuideProfile, getBookingContext, getReportReasons
L->>P: queries with includes
P->>DB: SELECT ...
N-->>B: streamed RSC payload and HTML
B->>N: server action bookIntro(guideId, slot)
N->>N: requireSeeker() and validation
N->>P: $transaction (re-check slot, create Booking, claim slot, MetricEvent)
P->>DB: BEGIN ... COMMIT
N->>N: revalidatePath("/guides/[id]")
N-->>B: { ok: true } then router refresh
Read then mutate. Server actions are the only write path, apart from three Stripe confirmations that run during page render (section 09).
Architectural patterns in use
| Pattern | Where | Why it exists | Consequence for the team |
|---|---|---|---|
| Server actions as the only mutation API | All of src/lib/*-actions.ts | One language and one deploy unit for a solo founder | No public API for mobile apps or partners yet. Every action must do its own authorisation. |
| Confirm-on-render for Stripe Checkout | confirmPackageCheckout, confirmContributionCheckout, confirmCreditsCheckout | Avoids webhooks in the demo | If the tab closes before redirect, the payment is never recorded. Launch blocker (F02). |
| Computed, not stored, aggregates | guides.ts (rating, sessionCount, bookability) | Fixed metric drift in the prototype | Correct but expensive; loads every review and completed booking per guide on Discover (F18). |
Conditional updateMany as a compare-and-set | sendMessage, confirmCreditsCheckout | Serializable isolation raised SQLSTATE 25001 on adapter-pg and was rejected | The pattern to copy for any new counter or state claim. |
| Transaction-scoped advisory lock plus partial unique index | createReport, migration 9 | Close the duplicate-report race at both app and DB level | The index is invisible to Prisma; migrate dev may try to drop it (F22). |
| Loose references without foreign keys | Report, MetricEvent, Earning | Reports and metrics must survive user deletion for legal defence and analytics | Referential integrity is the application's job for these columns. |
| Lazy state transitions on read | getPendingContributions flips PROMPTED to EXPIRED | No job runner | State only changes when someone views it. Replace with scheduled jobs. |
| Stripe calls outside DB transactions | cancelBooking (before), completeBooking and contributions (after) | Avoid holding a transaction open across a network call | No compensation or retry if one side fails (F04, F05). |
03Stack and dependencies
A deliberately small dependency set: ten runtime packages. Versions below are the resolved versions in package-lock.json.
Runtime
| Package | Version | Role |
|---|---|---|
next | 16.3.3 | App Router framework, server components, server actions |
react, react-dom | 19.2.8 | UI runtime |
@prisma/client | 7.10.0 | ORM runtime; client generated to src/generated/prisma with the prisma-client generator |
@prisma/adapter-pg, pg | 7.10.0, 8.23.0 | Prisma 7 driver adapter over node-postgres |
next-auth | 5.0.0-beta.32 | Auth.js v5: credentials provider, JWT sessions, no DB adapter |
bcryptjs | 3.0.3 | Password hashing, cost 10 |
stripe | 22.5.0 | Checkout Sessions, PaymentIntents, Transfers, Refunds |
@sentry/nextjs | 10.71.0 | Error monitoring and tracing |
Development
| Package | Version | Role |
|---|---|---|
prisma | 7.10.0 | CLI: generate, migrate, seed |
tailwindcss, @tailwindcss/postcss | 4.3.3 | Styling; tokens in @theme, no config file |
typescript | 5.9.3 | Strict mode, @/* maps to ./src/* |
eslint, eslint-config-next | 9.39.5, 16.3.3 | Core web vitals and TypeScript presets |
tsx | 4.23.12 | Runs the seed and the verification rig |
dotenv | 17.4.2 | Loads .env for Prisma config and seed |
@types/node, @types/pg, @types/react, @types/react-dom | 20, 8, 19, 19 | Type definitions |
Scripts
| Script | Runs | Used by |
|---|---|---|
dev / build / start | next dev / next build / next start | Local, Render build, Render start |
postinstall | prisma generate | Runs automatically after npm install |
db:migrate:dev | prisma migrate dev | Local schema changes |
db:migrate:deploy | prisma migrate deploy | Render preDeployCommand on every deploy |
db:seed | prisma db seed (runs tsx prisma/seed.ts) | Manual, via Render Shell |
lint | eslint | Manual |
Node is not pinned. There is no engines field, .nvmrc or NODE_VERSION. Dependencies require Node 20.19+ or 22.12+. Render will use its default, which can change underneath a deploy. Pin it (F21).
04Repository structure
113 tracked files. Generated and vendored folders are excluded below (node_modules, .next, .git, src/generated/prisma, and the gitignored Prisma agent-skill docs in .agents, .claude/skills, .windsurf).
lived-app/
├── CLAUDE.md working rules, launch blockers, done list, roadmap
├── AGENTS.md auto-generated Next.js agent note
├── README.md overview, local setup, schema notes (refers to a missing lib/payments.ts)
├── render.yaml Render Blueprint: lived-db + lived-app web service
├── next.config.ts empty config wrapped in withSentryConfig
├── prisma.config.ts Prisma 7 config: schema path, migrations, seed command, DATABASE_URL
├── tsconfig.json strict, bundler resolution, @/* alias
├── eslint.config.mjs flat config, next presets, ignores src/generated
├── postcss.config.mjs @tailwindcss/postcss
├── .env.example env var names and comments
├── .claude/settings.local.json Claude Code command allowlist
├── docs/verification/
│ ├── 2026-08-29-payments.md hold-then-release and refund matrix record
│ ├── 2026-08-31-credits.md credit purchase round trip, double-credit guard
│ └── 2026-08-31-out-of-credits-ui.md out-of-credits UI record
├── prisma/
│ ├── schema.prisma 30 models, 24 enums
│ ├── seed.ts idempotent seed; demo data only when IS_DEMO=true
│ ├── seed-data/categories.ts 23 categories, 153 subcategories, risk flags
│ └── migrations/ 9 migrations + migration_lock.toml
├── public/
│ ├── lived-logo.png brand logo
│ └── file/globe/next/vercel/window.svg unused scaffold assets
├── scripts/verification/report-guard/
│ ├── README.md purpose, usage, PGlite caveat
│ ├── run.ts runs real createReport: single | dup | race | clean
│ └── stub-loader.mjs stubs next/cache and auth for Node execution
└── src/
├── instrumentation.ts server and edge Sentry init, onRequestError
├── instrumentation-client.ts browser Sentry init
├── app/
│ ├── layout.tsx fonts, demo banner, header, footer (force-dynamic)
│ ├── page.tsx marketing home, featured guides
│ ├── globals.css Tailwind import and @theme tokens
│ ├── icon.png, apple-icon.png
│ ├── api/auth/[...nextauth]/route.ts Auth.js handlers (only API route)
│ ├── admin/layout.tsx admin gate, emergency banner
│ ├── admin/moderation/page.tsx queue by lifecycle state
│ ├── admin/moderation/[id]/page.tsx case detail and controls
│ ├── bookings/page.tsx my bookings (both roles), prompts, contribution confirm
│ ├── cancellation-policy/page.tsx
│ ├── discover/page.tsx guide discovery
│ ├── guides/[id]/page.tsx guide profile, booking panel, package checkout confirm
│ ├── login/page.tsx
│ ├── messages/page.tsx thread list
│ ├── messages/[threadId]/page.tsx thread view, compose, report
│ ├── signup/guide/page.tsx
│ ├── signup/seeker/page.tsx
│ ├── terms/page.tsx
│ └── wallet/page.tsx credits balance, buy packs, credits confirm
├── components/
│ ├── admin/case-controls.tsx, admin/resolve-form.tsx
│ ├── badge-chip.tsx, badge-popover.tsx, category-pill.tsx
│ ├── booking/booking-panel.tsx
│ ├── bookings/cancel-button.tsx, complete-button.tsx, contribution-prompt.tsx
│ ├── discover/discover-client.tsx, guide-card.tsx
│ ├── login-form.tsx
│ ├── messages/compose-box.tsx
│ ├── report/report-button.tsx
│ ├── signup/guide-signup-form.tsx, seeker-signup-form.tsx
│ ├── site-header.tsx, site-nav.tsx, site-footer.tsx
│ └── wallet/buy-credits.tsx
└── lib/
├── auth/auth.ts Auth.js config
├── auth/actions.ts loginAction, logoutAction
├── auth/admin.ts getAdmin, isAdminUser
├── auth/seeker.ts isSeekerUser (nav only)
├── auth/next-auth.d.ts session.user.id type
├── db.ts Prisma singleton
├── stripe.ts Stripe client, PLATFORM_FEE_RATE
├── config.ts isDemoMode
├── booking.ts, booking-actions.ts
├── cancellation.ts, cancellation-actions.ts
├── contributions.ts, contribution-actions.ts
├── credits.ts, credit-packs.ts, credit-actions.ts
├── message-actions.ts, threads.ts
├── moderation.ts, moderation-actions.ts, report-reasons.ts
├── session-actions.ts, dashboard.ts, guides.ts, categories.ts
├── signup-actions.ts, signup-constants.ts, age.ts
├── badges.ts, policy-content.ts, terms-content.ts
└── timezone.ts
Not in the repository: middleware.ts, error and loading boundaries, any API route besides Auth.js, lib/payments.ts (referenced by README and .env.example), tests, CI configuration, a Dockerfile, the HTML prototype, and the build spec (both live outside the repo in ~/Desktop/lived/).
Hand-off action: move the build spec and category taxonomy into docs/ so the repository is self-describing. CLAUDE.md already claims the spec is in the repo.
05Data model
Thirty Prisma models across six domains, on Postgres 16. IDs are cuid() strings generated by the client, every table has snake_case naming via @@map, money is Decimal(10,2) in EUR, and every foreign key cascades on update. Where no onDelete is written, Prisma's defaults apply: RESTRICT for required relations, SET NULL for optional ones.
Identity and accounts
erDiagram
USER ||--o| SEEKER_PROFILE : "has"
USER ||--o| GUIDE_PROFILE : "has"
USER ||--o| WALLET : "has"
USER ||--o{ CREDIT_PURCHASE : "buys"
USER ||--o{ POLICY_ACCEPTANCE : "accepts"
USER ||--o{ PERSONAL_DOCUMENT : "uploads"
USER ||--o{ CALENDAR_CONNECTION : "connects"
USER ||--o{ NOTIFICATION : "receives"
USER ||--o{ DELETION_REQUEST : "requests"
USER |o--o{ CATEGORY : "proposes"
USER {
string id PK
string email UK
string passwordHash
string name
date dateOfBirth
AccountStatus accountStatus
UserRole lastActiveRole
boolean isAdmin
datetime deletionConfirmedAt
}
WALLET {
string userId PK
int creditBalance
}
CREDIT_PURCHASE {
string id PK
string packKey
int credits
decimal amountEur
CreditPurchaseStatus status
string stripeCheckoutSessionId UK
string stripePaymentIntentId UK
}
Guide supply and catalogue
erDiagram
GUIDE_PROFILE ||--o{ GUIDE_CATEGORY : "listed in"
CATEGORY ||--o{ GUIDE_CATEGORY : "contains"
CATEGORY ||--o{ SUBCATEGORY : "has"
CATEGORY ||--o{ SEEKER_PREFERRED_CATEGORY : "preferred by"
SEEKER_PROFILE ||--o{ SEEKER_PREFERRED_CATEGORY : "prefers"
GUIDE_PROFILE ||--o{ GUIDE_BADGE : "holds"
PERSONAL_DOCUMENT |o--o{ GUIDE_BADGE : "evidences"
GUIDE_PROFILE ||--o{ SESSION_PACKAGE : "offers"
GUIDE_PROFILE ||--o{ AVAILABILITY_SLOT : "publishes"
GUIDE_PROFILE ||--o| AVAILABILITY_MODE : "uses"
GUIDE_PROFILE ||--o{ GUIDE_TRAINING_COMPLETION : "completes"
TRAINING_MODULE ||--o{ GUIDE_TRAINING_COMPLETION : "completed as"
GUIDE_PROFILE {
string id PK
string ownerId UK
GuideStatus status
GuidePricingModel pricingModel
decimal rateEur
string stripeAccountId
boolean stripeChargesEnabled
string country
string story
}
CATEGORY {
string id PK
string name UK
RiskLevel riskLevel
boolean requiresEnhancedVetting
boolean requiresAdvancedSafeguardingBadge
boolean autoCheckIn
CategoryStatus status
}
AVAILABILITY_SLOT {
string id PK
date date
string time
string bookingId UK
}
Bookings and money
erDiagram
SEEKER_PROFILE ||--o{ BOOKING : "books"
GUIDE_PROFILE ||--o{ BOOKING : "hosts"
SESSION_PACKAGE |o--o{ BOOKING : "priced by"
BOOKING |o--o| AVAILABILITY_SLOT : "occupies"
BOOKING ||--o| CONTRIBUTION : "prompts"
BOOKING ||--o| REVIEW : "reviewed by"
GUIDE_PROFILE ||--o{ EARNING : "earns"
GUIDE_PROFILE ||--o{ PAYOUT : "is paid"
BOOKING {
string id PK
BookingType type
BookingStatus status
date dateKey
string hour
decimal amountEur
decimal platformFeeEur
string stripeCheckoutSessionId UK
string stripePaymentIntentId UK
string stripeTransferId UK
string stripeRefundId UK
CancelledBy cancelledBy
decimal refundFraction
decimal refundEur
}
CONTRIBUTION {
string id PK
string bookingId UK
ContributionStatus status
decimal amountEur
datetime expiresAt
string stripeCheckoutSessionId UK
string stripeTransferId UK
}
Messaging, safety and compliance
erDiagram
SEEKER_PROFILE ||--o{ THREAD : "participates"
GUIDE_PROFILE ||--o{ THREAD : "participates"
THREAD ||--o{ MESSAGE : "contains"
REPORT_REASON |o--o{ REPORT : "classifies"
REPORT }o..o| THREAD : "threadId, no FK"
REPORT }o..|| USER : "reporterId, reportedId, no FK"
METRIC_EVENT }o..o| USER : "loose ids, no FK"
THREAD {
string id PK
int seekerMsgCount
int guideMsgCount
boolean seekerHasUnread
boolean guideHasUnread
boolean legalHold
}
MESSAGE {
string id PK
MessageSender sender
varchar250 text
}
REPORT {
string id PK
ReportStatus status
ReportSeverity severity
string reasonKey FK
boolean isEmergency
EnforcementAction enforcementAction
json transcriptSnapshot
string retentionBasis
boolean legalHold
}
Dotted relationships are logical references stored as plain strings. They survive user deletion by design.
Model catalogue
| Model (table) | Domain | Key fields and constraints | Code usage |
|---|---|---|---|
User (users) | Identity | email unique; passwordHash nullable; dateOfBirth date; accountStatus default ACTIVE; isAdmin default false; deletionConfirmedAt; lastActiveRole | Active lastActiveRole and deletionConfirmedAt unused |
SeekerProfile (seeker_profiles) | Identity | ownerId unique, RESTRICT; lookingFor, country, city, location, languages | Active profile fields not editable in UI |
SeekerPreferredCategory | Catalogue | Composite PK (seekerId, categoryId); seeker CASCADE, category RESTRICT | Unused |
GuideProfile (guide_profiles) | Supply | ownerId unique; status default PENDING; pricingModel default PAID; rateEur; stripeAccountId; stripeChargesEnabled; story, bestFitFor, notAFitIf, tags, style; show flags for pronouns and location | Active most fields seed-only; no edit UI |
GuideCategory (guide_categories) | Supply | Composite PK (guideId, categoryId); index on categoryId; 1 to 5 per guide enforced in app | Active |
GuideBadge (guide_badges) | Trust | Unique (guideId, type); optional evidenceDocumentId SET NULL | Read only awarded by seed only |
Category (categories) | Catalogue | name unique; riskLevel; disclaimerLevel; autoCheckIn; requiresEnhancedVetting; requiresAdvancedSafeguardingBadge; isCustom; status; proposedBy; aiSuggestedRiskLevel; sortOrder | Active risk and vetting flags never read |
Subcategory (subcategories) | Catalogue | Unique (categoryId, name); riskLevelOverride | Seed only |
SessionPackage (session_packages) | Supply | Unique (guideId, durationMinutes); priceEur nullable; isActive | Read only created by seed; no guide UI |
AvailabilitySlot (availability_slots) | Supply | Unique (guideId, date, time); time is "HH:MM" text; bookingId unique, SET NULL | Active created by seed only; booking claims and frees |
AvailabilityMode | Supply | PK guideId; mode free text default "manual" | Unused |
Booking (bookings) | Bookings | type INTRO or PACKAGE; status; dateKey and hour; amounts; four unique Stripe ids; cancellation fields; index (seekerId, guideId, type). One intro per pair is app-only. | Active |
Thread (threads) | Messaging | Unique (seekerId, guideId); message counters; unread flags; legalHold | Active no code creates threads |
Message (messages) | Messaging | text VarChar(250); sender; thread CASCADE | Active |
Wallet (wallets) | Money | PK userId; creditBalance Int. A counter, not a ledger. | Active created lazily on first purchase |
CreditPurchase (credit_purchases) | Money | packKey, credits, amountEur; status PENDING or COMPLETED; checkout session id unique and required | Active no FAILED or EXPIRED state |
Earning (earnings) | Money | gross, fee, net; bookingId and contributionId as plain strings | Unused |
Payout (payouts) | Money | amountEur; status PENDING, PAID, FAILED | Unused |
Contribution (contributions) | Money | bookingId unique; status; amountEur; promptedAt, respondedAt, expiresAt; three unique Stripe ids. No fee column. | Active |
Review (reviews) | Trust | bookingId unique; rating Int; emotion; comment | Read only no submission flow |
TrainingModule (training_modules) | Trust | key unique; required; needsDocument; badgeAwarded | Seed only 6 modules |
GuideTrainingCompletion | Trust | Composite PK (guideId, trainingModuleId) | Seed only |
PolicyAcceptance (policy_acceptances) | Compliance | policyKey, version, acceptedAt | Write only written at signup, never read |
ReportReason (report_reasons) | Safety | PK key; label; description; isEmergency; active; sortOrder | Active 10 seeded reasons |
Report (reports) | Safety | Case model: status, severity, assignedReviewer, reasonKey FK, detail, notes, outcome, enforcementAction, responseDeadline, notified flags, isEmergency, transcriptSnapshot jsonb, retentionBasis, legalHold, appeal fields. Party ids without FK. Partial unique index outside Prisma. | Active responseDeadline never written |
PersonalDocument (personal_documents) | Trust | type; status; fileUrl nullable for post-sweep clearing | Unused |
CalendarConnection (calendar_connections) | Integrations | Unique (userId, provider); accessToken and refreshToken as plaintext columns | Unused must encrypt before use |
Notification (notifications) | Comms | type free text; title; body; readAt | Write only never displayed |
DeletionRequest (deletion_requests) | Compliance | status REQUESTED, PROCESSING, COMPLETED; deletionConfirmedAt | Unused |
MetricEvent (metric_events) | Analytics | type; loose user, guide, seeker, booking ids; amountEur; metadata jsonb; index (type, occurredAt). Append-only by convention. | Write only no consumer |
Enums (24) and where each is used
| Enum | Values | Usage |
|---|---|---|
AccountStatus | ACTIVE, SUSPENDED, PENDING_DELETION, DELETED | ACTIVE checked at login and in getAdmin. SUSPENDED set by moderation. PENDING_DELETION never used; DELETED only read. |
UserRole | SEEKER, GUIDE | Only for lastActiveRole, which no code reads or writes |
GuideBadgeType | SAFETY_TRAINED, ID_VERIFIED, LIVED_EXPERIENCE, ADVANCED_SAFEGUARDING | Displayed; SAFETY_TRAINED gates Discover. Nothing awards badges. |
RiskLevel | STANDARD, SENSITIVE, HIGH_RISK | Seed only |
DisclaimerLevel | STANDARD, STRONG | Seed only |
CategoryStatus | PENDING, APPROVED, REJECTED | APPROVED filters catalogue and signup |
GuideStatus | PENDING, APPROVED, REJECTED, SUSPENDED | PENDING default; APPROVED gates visibility and booking; SUSPENDED by moderation; REJECTED unused |
GuidePricingModel | PAID, PAY_WHAT_YOU_CAN, FREE | Booking rules, pricing labels, contribution prompts |
BookingType | INTRO, PACKAGE | Booking, completion, dashboard |
BookingStatus | CONFIRMED, CANCELLED, COMPLETED | All values written |
CancelledBy | SEEKER, GUIDE | Refund maths and cancel UI |
MessageSender | SEEKER, GUIDE | Messages, transcripts |
CreditPurchaseStatus | PENDING, COMPLETED | Credit checkout |
PayoutStatus | PENDING, PAID, FAILED | Unused |
ContributionStatus | PROMPTED, MADE, SKIPPED, EXPIRED | All values written |
ReportStatus | SUBMITTED, UNDER_REVIEW, ACTIONED, DISMISSED | Moderation lifecycle |
AppealStatus | PENDING, UPHELD, REJECTED | UPHELD never written |
ReportSeverity | LOW, MEDIUM, HIGH | Admin controls |
EnforcementAction | NONE, WARNING, TEMPORARY_SUSPENSION, PERMANENT_REMOVAL, CONTENT_REMOVED, ESCALATED_EMERGENCY | Resolve form and enforcement |
DocumentType | ID_VERIFICATION, LIVED_EXPERIENCE_EVIDENCE, OTHER | Unused |
DocumentStatus | PENDING, ACCEPTED, REJECTED | Unused |
CalendarProvider | GOOGLE, MICROSOFT | Unused |
DeletionRequestStatus | REQUESTED, PROCESSING, COMPLETED | Unused |
MetricEventType | 19 values (section 12) | 11 emitted, 8 never emitted |
Migrations
| # | Migration | Change | Hand-written SQL |
|---|---|---|---|
| 1 | 20260825225744_init | All original enums, tables, indexes and FKs, including single guide_profiles.categoryId and required reports.reason | No |
| 2 | 20260826140000_add_stripe_connect_fields | Guide Stripe account fields; booking checkout session id (unique) | No |
| 3 | 20260826150000_add_contribution_checkout_session | Contribution checkout session id (unique) | No |
| 4 | 20260827000000_add_hold_then_release_fields | Booking refund, payment intent, refund and transfer ids; contribution intent and transfer ids; all unique | No |
| 5 | 20260829000000_add_user_is_admin | users.isAdmin | No |
| 6 | 20260829120000_add_report_reasons | report_reasons table; reports gain reasonKey FK and detail; reason becomes nullable | Hand-edited DDL |
| 7 | 20260830000000_guide_multi_category | Creates guide_categories, backfills from guide_profiles.categoryId, then drops the column | Yes, data backfill |
| 8 | 20260831000000_add_credit_purchases | Adds CREDITS_PURCHASED metric; credit purchase enum and table | ALTER TYPE ... ADD VALUE |
| 9 | 20260907000000_report_open_duplicate_guard | Partial unique index reports_one_open_per_reason | Yes, not representable in Prisma schema |
Database objects outside Prisma. The partial unique index reports_one_open_per_reason on (reporterId, reportedId, reasonKey) where status is SUBMITTED or UNDER_REVIEW, reasonKey is set and not other. Prisma does not know it exists; a future migrate dev can generate a DROP for it. Report intake also takes a runtime pg_advisory_xact_lock(hashtext(reporterId), hashtext(reportedId)) through $executeRaw (not $queryRaw, which broke intake in commit 7b8f864). There are no triggers, functions, views, extensions or row-level security policies.
Values computed at read time
| Value | Rule | Computed in |
|---|---|---|
| Guide rating | Mean of Review.rating, or null | guides.ts |
| Session count | Count of COMPLETED bookings | guides.ts |
| Discover visibility | APPROVED and holds SAFETY_TRAINED | getDiscoverGuides only; the profile page and booking actions check APPROVED alone (F07) |
introRequired, hasIntro, canBookPaidPackage | Not FREE; an intro CONFIRMED or COMPLETED; not PAID, or Stripe account connected and charges enabled | booking.ts |
| Available slots | Unbooked and in the future in the guide's country timezone | booking.ts, timezone.ts |
| Refund fraction, refund and transfer amounts | Refund matrix; stored on the booking at cancel time | cancellation.ts |
| Platform fee | 15% of gross; stored on the booking | stripe.ts, booking-actions.ts |
| Out-of-credits block | Seeker, 5 or more seeker messages, balance 0 | Thread page; enforced in sendMessage |
| Contribution suggestions | 0.5x, 1x and 1.5x the package price (fallback €15), minimum €1 | contributions.ts |
| Repeat-offence flag | 3 or more ACTIONED reports, or 1 non-dismissed emergency | moderation.ts |
Stored counters, maintained by writes: Thread.seekerMsgCount, guideMsgCount, both unread flags, and Wallet.creditBalance.
06State machines
Every status field, its transitions, what triggers them and the guards that apply. All application writes live in src/lib; nothing in src/app or src/components writes directly.
Booking
stateDiagram-v2 direction LR [*] --> CONFIRMED: bookIntro (free intro) [*] --> CONFIRMED: bookPackage (FREE or PWYC guide) [*] --> Checkout: bookPackage (PAID guide) Checkout --> CONFIRMED: confirmPackageCheckout on return page Checkout --> [*]: abandoned, nothing recorded CONFIRMED --> COMPLETED: completeBooking (guide) CONFIRMED --> CANCELLED: cancelBooking (either party) COMPLETED --> [*] CANCELLED --> [*]
| Transition | Function | Guards | Gaps |
|---|---|---|---|
| New intro, CONFIRMED | bookIntro (booking-actions.ts:47) | Seeker profile; guide APPROVED and not FREE; open slot; no existing CONFIRMED or COMPLETED intro for the pair; slot re-read in transaction | No future-time check on the slot; self-booking allowed; SAFETY_TRAINED and accountStatus not checked; one-intro rule not enforced by the DB |
| New package (FREE, PWYC), CONFIRMED | bookPackage (:108) | Intro required unless FREE; active package owned by guide; slot re-check | Same as above |
| PAID package to Checkout | bookPackage (:157) | Package priced; guide Stripe account connected and charges enabled | No DB row until return |
| Checkout to CONFIRMED | confirmPackageCheckout (:251) | Idempotent on checkout session id; session paid; package active; slot open | No caller auth; guide id from URL not matched to metadata; guide status unchecked; amount taken from package, not amount_total; lost slot means paid with no booking and no refund |
| CONFIRMED to COMPLETED | completeBooking (session-actions.ts:22) | Owning guide; status CONFIRMED | No check that the session time has passed; transfer runs after commit and cannot be retried |
| CONFIRMED to CANCELLED | cancelBooking (cancellation-actions.ts:90) | Booking participant; CONFIRMED; payment intent present when charged | Stripe refund and transfer before DB write, no conditional claim and no idempotency key |
For pay-what-you-can bookings the schema says amountEur is filled once the contribution resolves. No code does this, so PWYC bookings always look uncharged.
Contribution (pay-what-you-can)
stateDiagram-v2 direction LR [*] --> PROMPTED: completeBooking, PWYC package, expires in 7 days PROMPTED --> MADE: makeContribution with 0 EUR PROMPTED --> Checkout: makeContribution above 0 Checkout --> MADE: confirmContributionCheckout, then 85 percent transfer PROMPTED --> SKIPPED: skipContribution PROMPTED --> EXPIRED: lazily when the seeker opens /bookings
Ownership guards apply to make and skip. The paid confirmation has no caller check, takes the transfer destination from the guide_id URL parameter, does not check expiry, and updates without a conditional claim (F01).
Credit purchase and wallet
stateDiagram-v2 direction LR [*] --> PENDING: startCreditsCheckout creates session then row PENDING --> COMPLETED: confirmCreditsCheckout, atomic claim, wallet upsert PENDING --> PENDING: tab closed or unpaid, stays forever
Wallet.creditBalance goes up in confirmCreditsCheckout and down by one in sendMessage when the seeker has used their five free messages, via an updateMany guarded by creditBalance > 0. Spends are not recorded individually.
Report (moderation case)
stateDiagram-v2 direction LR [*] --> SUBMITTED: createReport (lock, per-reason guard, emergency routing) SUBMITTED --> UNDER_REVIEW: claimReport (admin) UNDER_REVIEW --> ACTIONED: resolveReport with enforcement UNDER_REVIEW --> DISMISSED: resolveReport with NONE ACTIONED --> UNDER_REVIEW: markAppealed DISMISSED --> UNDER_REVIEW: markAppealed
- Severity: HIGH on emergency creation, otherwise LOW; changed by
setSeverityorescalateToEmergency. - Emergency: only ever set to true (at creation, on escalation, or by ESCALATED_EMERGENCY). Sets
legalHoldand notifies every active admin. - Appeal:
markAppealedsets PENDING and reopens;resolveReportthen always writes REJECTED, even if the outcome changes. UPHELD is never written (F10). - Enforcement: suspension or removal sets user SUSPENDED and guide SUSPENDED; CONTENT_REMOVED sets thread legal hold only; WARNING and NONE notify only. There is no distinct "removed" state.
- Lift:
liftEnforcementsets user ACTIVE and guide APPROVED regardless of the guide's previous status, and records nothing on the report (F09).
Account and guide status
stateDiagram-v2
[*] --> ACTIVE: signup
ACTIVE --> SUSPENDED: applyEnforcement
SUSPENDED --> ACTIVE: liftEnforcement
ACTIVE --> PENDING_DELETION: not built
PENDING_DELETION --> DELETED: not built
stateDiagram-v2
[*] --> PENDING: guide signup
PENDING --> APPROVED: seed only, no admin action
PENDING --> REJECTED: never written
APPROVED --> SUSPENDED: applyEnforcement
SUSPENDED --> APPROVED: liftEnforcement
Account status is enforced only at login and in getAdmin(). JWT callbacks never re-read the database, so a suspended user keeps full access until their token expires (default 30 days, inferred). A code comment claims the session dies on the next request; it does not (F03).
Other stateful fields
- Thread: counters and unread flags change on send; the viewer's unread flag clears when they open the thread;
legalHoldis set by emergencies and content removal and never cleared. No application code creates threads. - Training, badges, documents: seed only. Nothing aggregates module completion into SAFETY_TRAINED.
- Blocks: no model, enum or code.
- Never written by the app: Category PENDING and REJECTED, Payout, Earning, DeletionRequest, CalendarConnection, AvailabilityMode,
User.lastActiveRole, Review (seed only),Notification.readAt, SessionPackage and AvailabilitySlot creation (seed only).
07Routes and server actions
Thirteen pages, two layouts and one Auth.js route handler. There is no middleware, no error.tsx, not-found.tsx or loading.tsx, and every route is dynamic because the root layout sets force-dynamic.
| Path | Area | Access | Reads | Writes during render |
|---|---|---|---|---|
/ | Public | Anyone | Discover guides (first three shown), categories | None |
/discover | Public | Anyone | All bookable guides; filtering by category, language and text happens in the browser | None |
/guides/[id] | Public | Anyone; APPROVED guides only, else 404. Report button for logged-in non-owners. | Guide profile, booking context, report reasons, existing thread | confirmPackageCheckout when ?checkout_session_id is present, with no auth |
/terms, /cancellation-policy | Public | Anyone | Static content modules | None |
/login | Auth | Anyone; logged-in users are not redirected | Demo credentials hint when IS_DEMO | None |
/signup/seeker, /signup/guide | Auth | Anyone | Approved categories (guide form) | None |
/api/auth/[...nextauth] | Auth | Auth.js | Session, CSRF token, sign-in callback, sign-out | |
/wallet | Seeker | Logged in with seeker profile; otherwise an inline message | Wallet balance | confirmCreditsCheckout plus revalidatePath from inside render |
/bookings | Shared | Logged in; inline message otherwise | Bookings as seeker and as guide, follow-up prompts, pending contributions | confirmContributionCheckout; lazy contribution expiry |
/messages | Messaging | Logged in | Threads with last message and count | None |
/messages/[threadId] | Messaging | Participants only, else 404. No admin override. | Thread, messages, reasons, wallet balance for the out-of-credits state | markThreadRead |
/admin/* layout | Admin | getAdmin(), else 404 | Open emergencies banner | None |
/admin | Admin | No page exists (404) | None | None |
/admin/moderation | Admin | Admin | All reports grouped by status, repeat-offence flags | None |
/admin/moderation/[id] | Admin | Admin | Case, both parties including email, transcript snapshot, other cases | None |
Missing areas: guide dashboard, profile editing, availability and package management, Stripe Connect onboarding, training, document upload, seeker account and settings, notifications, video sessions, admin areas beyond moderation.
Server actions
| Action | Module | Called from | Authorisation | Writes | Revalidates |
|---|---|---|---|---|---|
loginAction | auth/actions.ts | LoginForm | Public | JWT cookie | Redirect /discover |
logoutAction | auth/actions.ts | SiteNav | None needed | Clears cookie | Redirect /discover |
createSeekerAccount | signup-actions.ts | Seeker signup form | Public, validated | User, SeekerProfile, PolicyAcceptance | Auto sign-in |
createGuideAccount | signup-actions.ts | Guide signup form | Public, validated | User, SeekerProfile, GuideProfile, GuideCategory, PolicyAcceptance | Auto sign-in |
bookIntro | booking-actions.ts | BookingPanel | Seeker | Booking, slot, MetricEvent | /guides/[id] |
bookPackage | booking-actions.ts | BookingPanel | Seeker | Booking, slot, MetricEvent, or Stripe redirect | /guides/[id] |
confirmPackageCheckout | booking-actions.ts | Render of /guides/[id] | None | Booking, slot, MetricEvent | None |
completeBooking | session-actions.ts | CompleteButton | Owning guide | Booking, MetricEvent, Contribution, then Stripe transfer id | /bookings |
previewCancellation | cancellation-actions.ts | CancelButton | Participant | None | None |
cancelBooking | cancellation-actions.ts | CancelButton | Participant | Stripe refund and transfer, Booking, slot, MetricEvent | /bookings |
makeContribution | contribution-actions.ts | ContributionPrompt | Owning seeker | Contribution and MetricEvent, or Stripe redirect | /bookings |
skipContribution | contribution-actions.ts | ContributionPrompt | Owning seeker | Contribution, MetricEvent | /bookings |
confirmContributionCheckout | contribution-actions.ts | Render of /bookings | None | Contribution, MetricEvent, Stripe transfer | None |
startCreditsCheckout | credit-actions.ts | BuyCredits | Seeker | CreditPurchase PENDING | Stripe redirect |
confirmCreditsCheckout | credit-actions.ts | Render of /wallet | None (credits the purchase owner) | CreditPurchase, Wallet, MetricEvent | /wallet |
sendMessage | message-actions.ts | ComposeBox | Thread participant | Thread counters and flags, Wallet, Message | /messages, /messages/[id] |
createReport | moderation-actions.ts | ReportButton | Logged in, not self | Report, Notification, Thread.legalHold | Admin paths |
claimReport, saveInvestigationNotes, setSeverity, escalateToEmergency, markAppealed | moderation-actions.ts | CaseControls | Admin | Report fields, notifications | Admin paths |
resolveReport, liftEnforcement | moderation-actions.ts | ResolveForm, CaseControls | Admin | Report, User, GuideProfile, Notification, MetricEvent | Admin paths, /discover |
Because the three confirm functions are exported from "use server" modules, Next.js may also expose them as callable action endpoints (inferred, not verified against the action manifest).
08Auth, access control and onboarding
Auth.js v5 with a credentials provider and stateless JWT sessions. Roles are derived from which profiles a user owns, plus a single admin flag. All gating happens in pages, layouts and actions; there is no middleware.
Authentication
- Credentials only;
authorizelooks up the email, compares with bcrypt (cost 10) and rejects non-ACTIVE accounts. - JWT strategy,
trustHost: true, sign-in page/login. The token carries only the user id. - Env:
AUTH_SECRET,AUTH_URL(must match the public host; declared in bothrender.yamland.env.example). - Passwords: minimum 8 characters, no maximum, no complexity rules.
Not present
- Email verification (legal launch blocker)
- Password reset
- Rate limiting or lockout
- MFA
- Session revocation on suspension
- Google sign-in (deferred to the calendar OAuth stage)
Login is case-sensitive, signup is not. Signup lowercases the email before storing it; authorize looks it up exactly as typed. A user who signs up as "Maya@x.com" cannot log in with the same casing (F08).
Access matrix
| Route or action | Anonymous | Seeker | Guide | Admin |
|---|---|---|---|---|
| Public pages, login, signup | Yes | Yes | Yes | Yes |
| Guide profile view | APPROVED guides | Yes | Yes | Yes |
| Report button | Hidden | Yes, not own profile | Yes | Yes |
/bookings, /messages | Inline prompt | Yes | Yes | Yes |
| Thread view | Inline prompt | Participant | Participant | Participant only |
/wallet | Inline prompt | Yes | Yes (guides also own a seeker profile) | If seeker |
/admin/** | 404 | 404 | 404 | Yes |
bookIntro, bookPackage | No | Yes (self-booking not blocked) | As seeker | As seeker |
completeBooking | No | No | Own bookings, any guide status | No |
| Cancel booking | No | Own | Own | No |
| Contribute or skip | No | Own | Own, as seeker | No |
| Buy credits | No | Yes | Yes | No |
sendMessage | No | 5 free then credits | Unlimited | Participant only |
| Three confirm-checkout functions | Run | Run | Run | Run |
| Moderation actions | No | No | No | Yes |
A suspended user with a live token passes every non-admin check, because no action checks accountStatus.
Signup flows
Shared validation, in order
- Name not empty
- Email format, trimmed and lowercased
- Password at least 8 characters
- Date of birth parses and is not in the future
- Age attestation checkbox ticked (validated, not stored)
- Terms checkbox ticked
- Hard gate: age 18 or over from date of birth
Age uses server-local date getters on a UTC-midnight date, so it can be off by one day at timezone boundaries (inferred).
Seeker and guide
Seeker: one transaction creates User with an empty SeekerProfile and a PolicyAcceptance (terms_of_service, version 2026-08-30), then signs in. No wallet until first purchase.
Guide: also requires 1 to 5 APPROVED categories. Creates User, SeekerProfile and GuideProfile (status PENDING, pricing PAID, derived initial and a fixed avatar colour) with GuideCategory rows. Nothing else is collected, no admin is notified and no GUIDE_APPLIED metric is written.
Guide lifecycle capabilities
| Capability | Status | Detail |
|---|---|---|
| Approve or reject a guide application | Not present | New guides stay PENDING forever. Only the seed approves. |
| Category risk and vetting gating | Schema only | Risk level, enhanced vetting, safeguarding badge, auto check-in and disclaimer level are never read |
| Badge awarding | Schema only | Displayed from seed data; nothing awards them |
| Training modules | Schema only | Six modules seeded; no UI or completion action |
| Identity and lived-experience documents | Not present | No upload or storage |
| Stripe Connect onboarding | Not present | Account fields set by seed only |
| Suspension and reinstatement | Built | Through moderation enforcement |
| Custom category proposals | Schema only | Deliberately deferred |
| Account deletion | Not present | See section 12 |
09Payments
Stripe Connect using separate charges and transfers, the "hold then release" model: the platform account takes the full payment through hosted Checkout and transfers the guide's 85% only when the session is completed. Everything runs in Stripe test mode. The model touches e-money rules and needs solicitor and Stripe compliance sign-off before production.
Stripe objects in use
Checkout Sessions (platform account, EUR, card), PaymentIntents (retrieve only), Transfers with source_transaction and transfer_group, Refunds. Six files call Stripe.
Not present
Webhooks, Connect account creation and onboarding links, account.updated sync, Stripe idempotency keys, Stripe.js or Elements, and any demo-mode short-circuit (IS_DEMO does not gate payments).
Where money rules are defined
| Rule | Location | Value |
|---|---|---|
| Platform fee | stripe.ts:17 PLATFORM_FEE_RATE | 0.15; guide keeps 85% |
| Credit packs | credit-packs.ts:15 CREDIT_PACKS | 10 for €0.99, 25 for €1.99, 50 for €3.49 |
| Free seeker messages | credits.ts:18 FREE_SEEKER_MESSAGES | 5 per seeker per thread. Duplicated as literal 5 in compose-box.tsx, wallet/page.tsx, message-actions.ts:145 |
| Seeker refund tiers | cancellation.ts:59 | 24h or more: 1; 12h: 0.5; 3h: 0.25; under 3h: 0 |
| Package prices | SessionPackage.priceEur | Per guide, per duration |
| Contribution window | session-actions.ts:73 | 7 days |
| Contribution suggestions | contributions.ts:40 | Package price (or €15) times 0.5, 1 and 1.5, minimum €1 |
| Policy copy | policy-content.ts | Cancellation schedule, fee disclosure, intro and PWYC notes |
Paid package: book, complete, release
sequenceDiagram autonumber actor S as Seeker participant A as Next.js actions participant ST as Stripe (platform) participant DB as Postgres actor G as Guide S->>A: bookPackage(guide, package, slot) A->>ST: checkout.sessions.create (price, metadata) A-->>S: redirect to Stripe Checkout S->>ST: pays ST-->>S: redirect /guides/[id]?checkout_session_id=... S->>A: page render runs confirmPackageCheckout A->>DB: lookup by checkout session id (idempotency) A->>ST: sessions.retrieve, require paid A->>DB: transaction: re-check slot, create Booking CONFIRMED with amount and 15 percent fee, claim slot, metrics Note over A,DB: If the tab closes before this point, nothing is recorded G->>A: completeBooking(bookingId) A->>DB: transaction: COMPLETED, metrics, PWYC prompt if relevant A->>ST: paymentIntents.retrieve then transfers.create 85 percent to guide account A->>DB: store stripeTransferId Note over A,ST: If the transfer fails, the booking stays COMPLETED with no transfer and cannot be retried
Cancellation and refunds
sequenceDiagram autonumber actor U as Seeker or guide participant A as cancelBooking participant ST as Stripe participant DB as Postgres U->>A: previewCancellation then cancelBooking A->>A: computeRefund(hours until slot in guide timezone, canceller) A->>ST: refunds.create (seeker share) if above 0 A->>ST: transfers.create (guide share) if above 0 A->>DB: transaction: CANCELLED with refund fields and Stripe ids, free slot, metric Note over A,ST: No conditional claim and no idempotency key, so a double submit can refund twice
| Who cancels | Notice | Seeker refund (€100 gross) | Guide transfer | Platform keeps |
|---|---|---|---|---|
| Seeker | 24h or more | €85.00 (100% of guide share) | €0 | €15 |
| Seeker | 12 to 24h | €42.50 | €42.50 | €15 |
| Seeker | 3 to 12h | €21.25 | €63.75 | €15 |
| Seeker | Under 3h or past | €0 | €85.00 | €15 |
| Guide | Any | €100 (full gross) | €0 | €0 |
| Either | Free intro, FREE or PWYC booking | No charge, nothing to refund | ||
Completed bookings cannot be cancelled. There is no refund path for credits or contributions, and moderation suspensions do not cancel or refund anything.
Credits
startCreditsCheckout resolves the pack server-side, creates a platform Checkout Session with metadata {userId, packKey}, inserts a PENDING CreditPurchase and redirects. On return, confirmCreditsCheckout returns early if already COMPLETED, requires a paid session, then in one transaction claims the row with updateMany where status = PENDING, upserts the wallet increment and writes CREDITS_PURCHASED. Double-crediting is prevented; lost redirects are not recovered.
Pay-what-you-can
Completing a PWYC package creates a PROMPTED contribution with a 7-day expiry. The seeker can give €0 (recorded as MADE, no Stripe call), skip, or pay any amount above zero through Checkout. On return, confirmContributionCheckout records the amount from amount_total and immediately transfers 85% to the guide found from the URL's guide_id. The platform fee is not stored, and Booking.amountEur is never updated for PWYC bookings, so revenue reporting cannot be reconstructed from bookings alone.
Money correctness summary. No webhooks (F02), three unauthenticated confirmations (F01), transfers with no retry (F04), a possible double refund (F05), a paid-but-unbooked path with no automatic refund (F06), and no ledger: Earning and Payout are unused and the wallet is a bare counter (F30, F31). These must be resolved before real money moves.
10Messaging and sessions
Messaging works end to end for existing threads: seekers get five free messages per thread and then spend one credit per message; guides always message free. Delivery is by server action and page revalidation, with no realtime channel. Video sessions are not built.
Send path
flowchart TD
A["ComposeBox submit"] --> B{"sendMessage: logged in and thread participant?"}
B -- "no" --> X["Conversation not found"]
B -- "yes" --> C{"Text 1 to 250 chars?"}
C -- "no" --> Y["Validation error"]
C -- "yes" --> D{"Sender is seeker?"}
D -- "guide" --> E["guideMsgCount +1, seekerHasUnread = true"]
D -- "seeker" --> F{"updateMany where seekerMsgCount below 5"}
F -- "1 row" --> G["Free message used, guideHasUnread = true"]
F -- "0 rows" --> H{"wallet.updateMany where creditBalance above 0"}
H -- "1 row" --> I["Credit spent, count +1, guideHasUnread = true"]
H -- "0 rows" --> Z["OutOfCreditsError, transaction rolls back"]
E --> M["message.create"]
G --> M
I --> M
M --> R["revalidatePath thread and list"]
One transaction at READ COMMITTED. Conditional updates provide the concurrency guarantee.
| Aspect | Status | Detail |
|---|---|---|
| Starting a conversation | Not present | No code creates a Thread. Threads exist only from the seed. A seeker cannot message a new guide. |
| Read path | Built | Thread list ordered by last update; thread view loads all messages ascending; non-participants get 404 |
| Unread state | Partial | Flags are set and cleared correctly but never displayed in the list or navigation |
| Out-of-credits UX | Built | Banner with wallet link, remaining allowance tally, textarea stays editable; Enter sends, Shift+Enter adds a newline |
| Realtime delivery | Not present | No polling, WebSocket or SSE. Recipients see new messages only on navigation or refresh. |
| Metrics | Not present | MESSAGE_SENT is defined but never emitted |
| Legal hold | Partial | Set by moderation; deliberately not consulted when sending; no redaction |
Sessions
Today a session is a booking row. The guide clicks "Mark complete" manually; there is no check that the session time has passed and no automatic completion. The intro length (15 minutes) exists only in copy; package durations come from SessionPackage.durationMinutes.
Locked decision for video sessions. Sessions happen inside Lived on the thread page, not through an external link. Desktop shows a video panel beside the live thread; mobile uses a dedicated session route with the thread as a side panel. An upcoming-session banner (alert only, no early entry) appears in the thread for both parties from 30 minutes before. Video entry opens exactly at start time, with about 5 minutes grace after the end. Placement and routes are built first; the provider is embedded later.
Locked decision for transport. Server actions and revalidatePath are acceptable for the demo only. A real realtime transport, either WebSockets over Redis pub/sub or a managed service such as Pusher or Ably, is required before launch.
11Moderation and safety
Report intake and the admin case workflow are the most mature part of the build, with protection against duplicate reports at application and database level. Blocking, notifications, email alerts and a proper permissions model are not built.
Report intake
- Reasons live in
report_reasons: off_platform, harassment, sexual_inappropriate, hate, spam_scam, impersonation, clinical_advice, wellbeing_concern, immediate_danger (the only emergency), other. - Entry points: the guide profile (logged in, not own profile, thread attached if one exists) and the thread view. The modal shows reason descriptions, an emergency-services notice for immediate danger, and an optional detail field capped at 1,000 characters.
- Validation: logged in, not self, reason active, reported user exists. The emergency flag comes from the reason row, never the client. A transcript snapshot is taken only if the reporter is a participant in the cited thread.
- Duplicate guard: inside the transaction, an advisory lock on the reporter and reported pair, then at most one open report per reason. Reason
otheris capped at five open. The partial unique index backs this up, and unique violations (P2002 or SQLSTATE 23505) map to the same friendly error. - Emergencies: severity HIGH, legal hold on the report and cited thread, and a Notification row for every active admin. Nobody reads those rows; the only visible signal is the red banner in the admin layout.
Admin workflow
The queue at /admin/moderation sorts emergencies first, then oldest, grouped by lifecycle state, with repeat-offence banners. The case page shows both parties, reason, transcript snapshot, retention basis, legal hold, other cases against the same person and notified flags. Admins can claim, write notes, set severity, escalate, resolve (reason of at least 10 characters), mark appealed and lift enforcement.
| Working | Schema only | Not present |
|---|---|---|
| Report modal with DB-sourced reasons and emergency notice | responseDeadline never written | Block and unblock |
| Per-reason guard, advisory lock, partial unique index | AppealStatus UPHELD never set | Notification inbox |
| Queue, case detail, claim, notes, severity, escalate, resolve, appeal, lift | retentionBasis shown, never varied | Email, push or realtime emergency alerts |
| Emergency banner and repeat-offence flag | Legal hold never cleared or swept | User-initiated appeals |
| Suspension blocks login and hides the guide | Category auto check-in not wired | RBAC and audit log; per-message removal |
The report success message tells users that blocking is a separate action. That action does not exist yet.
Locked decision for blocking. Either party can block; it is a full mutual cut-off and the blocked person is never told. A block cancels every non-completed booking between the pair, including a paid checkout still confirming, and refunds the seeker. Seeker-initiated blocks follow the refund matrix: more than 24 hours before start, 100% refund with no review; inside 24 hours, 50% refunded immediately and 50% held for admin review. Guide-initiated blocks refund the seeker 100% with no review. Blocking is disabled during the video window. Implementation: two DB transactions around the Stripe refund (block plus a CANCELLING state first, then refund, then finalise) so the block never depends on Stripe; a new CancellationReason field (USER_REQUEST, BLOCK) rather than extending CancelledBy; unblock is a soft delete (unblockedAt), only the blocker can unblock, and admins can see history but cannot unblock. The report modal will offer a combined report-and-block option.
Pending legal sign-off: the emergency escalation procedure (country-specific safeguarding contacts and steps) is a policy document requiring professional review. The hooks exist; the procedure does not.
12GDPR, retention and metrics
The schema has hooks for retention and deletion; none of the processes behind them exist. Metrics are written into an append-only event table that nothing reads yet.
Retention fields
| Field | Written by | Read by | Status |
|---|---|---|---|
Thread.legalHold | Emergency routing, CONTENT_REMOVED enforcement | Nothing (send path deliberately ignores it) | Partial |
Report.legalHold | Create (emergency), escalate, resolve (emergency or permanent removal) | Admin case page | Partial |
Report.retentionBasis | Schema default "legitimate_interest_legal_defence" | Admin case page | Default only |
Report.transcriptSnapshot | createReport | Admin case page | Built |
User.deletionConfirmedAt, DeletionRequest | Nothing | Nothing | Schema only |
AccountStatus PENDING_DELETION, DELETED | Nothing | DELETED read in enforcement; only ACTIVE may log in | Schema only |
PersonalDocument.fileUrl (nullable for sweeps) | Nothing | Nothing | Schema only |
PolicyAcceptance | Signup | Nothing | Write only |
Not present
- Data export (subject access)
- Account deletion and anonymisation
- Retention sweeps, cron or background jobs
- Re-acceptance when policy versions change
- Cancellation policy acceptance record
Locked policy inputs (solicitor review pending)
- Session transcripts: 4 weeks minimum, 12 months maximum
- Trust and safety enforcement and vetting evidence: 6 years
- Technical and log data: 12 months
- Policy changes: 30 days notice, immediate for safety or legal
- 18+ for every user, every category, every market
Metric events
| Emitted | Where | Defined, never emitted |
|---|---|---|
| BOOKING_CREATED, PACKAGE_BOOKED | booking-actions.ts | GUIDE_APPLIED |
| SESSION_COMPLETED, INTRO_COMPLETED, CONTRIBUTION_PROMPTED | session-actions.ts | GUIDE_APPROVED |
| BOOKING_CANCELLED (amount is the refund) | cancellation-actions.ts | GUIDE_ACTIVATED_AFTER_TRAINING |
| CONTRIBUTION_MADE, CONTRIBUTION_SKIPPED | contribution-actions.ts | BADGE_EARNED |
| CREDITS_PURCHASED | credit-actions.ts | FIRST_PAID_SESSION, REPEAT_PAID_SESSION |
| GUIDE_SUSPENDED, GUIDE_REINSTATED | moderation-actions.ts | REVIEW_SUBMITTED, MESSAGE_SENT |
Events are written inside the same transaction as the change they describe, which keeps them consistent. There are no consumers: no dashboards, exports or aggregate queries.
13UI and design system
Tailwind v4 with design tokens lifted from the HTML prototype into an @theme block in globals.css. Inter for text, Playfair Display for headings, loaded through next/font. No component library, icon set or dark mode.
bg #faf8f5surface #ffffffborder #e8ddd0forest #2e3a33text-muted #5c6b5etext-faint #9aa89csage #4a6b52terracotta #e07a5fterracotta-wash #f5e8e4terracotta-dark #8b3a28dusty-blue #cfe7e6sand #f0eae0gold #c9a84cRadius: --radius-card 16px, --radius-sm 10px; two card shadows. Breakpoints are Tailwind defaults (the nav comment assumes the prototype's 720px, but Tailwind's md is 768px). A few colours are hard-coded outside the tokens in the footer, home page and avatar fallback.
Components
| Component | Type | Purpose |
|---|---|---|
site-header, site-footer | Server | Header loads session, categories, admin and seeker flags; static footer |
site-nav | Client | Desktop and mobile nav, categories dropdown, logout |
discover/guide-card, badge-chip, category-pill | Server | Guide summary card, static badge label, category pills with "+N" overflow |
discover/discover-client | Client | In-memory filtering by category, language, text |
badge-popover | Client | Accessible tap, hover and focus explanation of each badge |
booking/booking-panel | Client | Intro or package mode, slot picker, fee disclosure |
bookings/cancel-button, complete-button, contribution-prompt | Client | Cancel with refund preview, guide completion, PWYC amount picker |
messages/compose-box | Client | 250-character limit, allowance tally, out-of-credits banner |
report/report-button | Client | Report modal |
admin/case-controls, admin/resolve-form | Client | Moderation controls and resolution |
login-form, signup/*-signup-form, wallet/buy-credits | Client | Auth forms and credit pack purchase |
State and forms: local React state only (useState, useTransition, useActionState), router.refresh() after mutations, no context providers or data-fetching libraries. Validation is native HTML attributes on the client and hand-written checks on the server; there is no schema library. Small helpers such as fmt() are duplicated across page files.
14Integrations
| Service | Status | Client and env | If unavailable |
|---|---|---|---|
| Postgres | Built | db.ts, DATABASE_URL | Every page fails: the header queries the DB on every request |
| Auth.js | Built | auth/auth.ts, AUTH_SECRET, AUTH_URL | Missing secret breaks auth(), which the header calls, so every page fails |
| Stripe | Partial | stripe.ts, STRIPE_SECRET_KEY. Publishable key and webhook secret declared, unused. | Module throws at import, taking down /guides/[id], /bookings, /wallet |
| Sentry | Built | instrumentation*.ts, next.config.ts; DSNs, org, auth token | Silent no-op |
| ZeptoMail | Not present | Env names only; from-address set in render.yaml | n/a |
| Google Calendar, Microsoft Graph | Not present | Env names only; CalendarConnection unused | n/a |
| Video | Not present | No SDK, routes or constants | n/a |
| File storage | Not present | No SDK or upload endpoint | n/a |
| Currency and FX | Not present | EUR hard-coded in six Stripe call sites | n/a |
| Timezones | Partial | timezone.ts: IE, UK, NL mapped, everything else Europe/Berlin; no DST handling; no viewer conversion | n/a |
| Google Fonts | Built | next/font/google, fetched at build (inferred) | Build-time only |
15Security posture
The fundamentals are sound: React escapes output, the one raw SQL statement is parameterised, secrets stay out of the repo, and admin routes are checked against the database. The gaps are in the perimeter (headers, rate limits, middleware) and in a handful of unauthenticated or under-validated entry points.
| Control | Status | Detail |
|---|---|---|
| Output escaping | OK | React only; no dangerouslySetInnerHTML. One DB value flows into an inline style (avatarColor). |
| SQL injection | OK | Only a tagged-template $executeRaw; no unsafe raw calls |
| CSRF | Framework default | Server actions rely on Next's Origin and Host check; Auth.js uses its own token. allowedOrigins not set. |
| Secrets | OK | .env* ignored except the example; Render secrets are sync: false. A shared demo password and a Stripe test account id are committed in the seed (test data). |
| Rate limiting | Not present | Login, signup, Checkout creation, messaging and reporting are unthrottled |
| Security headers | Not present | No CSP, HSTS, X-Frame-Options or Referrer-Policy |
| Middleware | Not present | No central auth or header enforcement |
| Authorisation on mutations | Mostly | 21 of 24 actions check the caller correctly. The three confirm-checkout functions do not. No non-admin action checks accountStatus. |
| Input validation | Gaps | No maximum on contribution amounts, investigation notes or resolution text; booking dates passed straight to new Date(); sendMessage trims without a type check |
| Redirect URLs | Risk | Stripe success and cancel URLs are built from the request Host header |
| Error leakage | Risk | Booking actions return raw error.message to the client |
| Token storage | Schema only | Calendar OAuth token columns are plaintext; the schema requires encryption before use |
| Admin model | Stopgap | Single isAdmin boolean; no roles, least privilege or audit log. Admins can see both parties' emails. |
16Performance and data access
Fine at demo scale. Nothing is cached, nothing is paginated, and several read paths load whole tables into memory. These are the first things to break as usage grows.
| Area | Current behaviour | Scaling risk |
|---|---|---|
| Rendering | Every route force-dynamic, including static pages | No CDN or full-route caching; every hit reaches Node and Postgres |
| Header | 3 DB queries and 3 auth() calls per request, no memoisation | Fixed tax on every page view |
| Discover and home | Loads every eligible guide with all reviews and completed bookings, aggregates in JS | Grows with total reviews and bookings, not with guides shown |
| Moderation queue | Loads all reports; 2 count queries per reported user | N+1 fan-out |
| Messaging | Thread view loads every message; list loads every thread | Long threads and active users |
| Bookings dashboard | All bookings for both roles with includes | Linear in history |
| Availability | Loads every unbooked slot including past ones, filters in JS | Past slots accumulate forever |
| Connections | One pg pool with defaults (max 10, inferred); no pooler | Multiple instances will exhaust basic-plan connection limits |
| Revalidation | revalidatePath per mutation; no tags | Acceptable; move to tags when caching is introduced |
17Errors and observability
In place
- Sentry on server, edge and browser; request errors captured through
onRequestError - Traces at 100% sampling
- Actions return
{ ok, error }objects rendered inline - Known failures mapped: unique violations, out of credits, invalid credentials
Missing
error.tsx,global-error.tsx,not-found.tsx,loading.tsx- Structured logging (no
consolecalls insrc) - Health check route and Render health check path
- User context, PII scrubbing (
beforeSend), Replay - Try/catch around Stripe calls; one throw after commit in
completeBooking - Alerting on payment or moderation failures
18Deploy, environments and operations
Infrastructure is declared as code in render.yaml. A push to main builds and deploys automatically; migrations run before the new version starts. There is one environment: the demo/staging service. Environment variable changes alone do not trigger a deploy; a manual redeploy is required.
flowchart LR DEV["Local: next dev + prisma dev
or Docker Postgres 16"] -->|"git push main"| GH["GitHub
THELIVEDAPP-LTD/lived-app"] GH -->|"auto-deploy on commit"| B["Render build
npm install (postinstall: prisma generate)
next build"] B --> PD["preDeploy
prisma migrate deploy"] PD --> RUN["next start
lived-app, starter plan"] RUN --> DB[("lived-db
Postgres 16")] SH["Render Shell
npm run db:seed (manual)"] -.-> DB
Declared in render.yaml
- Database
lived-db: Frankfurt,basic-256mb, Postgres 16 - Web service
lived-app: Node, Frankfurt,starter - Build
npm install && npm run build, startnpm run start, pre-deploynpm run db:migrate:deploy DATABASE_URLfrom the database;IS_DEMO,AUTH_URLandZEPTOMAIL_FROM_ADDRESSas literals; all secretssync: false
Not declared
- Health check path, instance count, autoscaling
- Branch, autoDeploy, NODE_VERSION, env groups
- IP allow-list on the database, backups or PITR
- Workers, cron jobs, Key Value, disks
- A production service (deliberately deferred)
Environment variables
| Variable | Local .env | render.yaml | Read by code |
|---|---|---|---|
DATABASE_URL | Yes | From database | db.ts, seed, Prisma config |
IS_DEMO | Yes | Literal "true" | config.ts (banner, login hint), seed |
AUTH_SECRET | Yes | Secret | Auth.js implicitly |
AUTH_URL | No | Literal public URL | Auth.js implicitly. Update at domain cutover in both render.yaml and .env.example. |
STRIPE_SECRET_KEY | Yes | Secret | stripe.ts |
STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET | No | Secret | Not referenced |
ZEPTOMAIL_API_TOKEN, ZEPTOMAIL_FROM_ADDRESS | No | Secret, literal | Not referenced |
SENTRY_DSN, NEXT_PUBLIC_SENTRY_DSN, SENTRY_ORG, SENTRY_AUTH_TOKEN | No | Secret | Instrumentation, next.config, build plugin |
GOOGLE_CALENDAR_CLIENT_ID/SECRET, MICROSOFT_GRAPH_CLIENT_ID/SECRET | No | Secret | Not referenced |
Seed data
Always seeded (every environment, idempotent upserts): 23 categories (16 standard, 6 sensitive of which 3 auto check-in, 1 high-risk "Domestic Abuse & Coercive Control"), 153 subcategories, 6 training modules and 10 report reasons.
Only when IS_DEMO=true: 10 demo users sharing one password, 5 APPROVED guides with safety training and badges (Maya paid, James pay-what-you-can, Aisha free, plus Victor and Wes), 2 seekers, an admin, 7 session packages, 45 availability slots relative to the seed date, a wallet, one completed intro with a review, 2 threads (one is the out-of-credits fixture), and 8 moderation cases across all states.
Demo logins (staging only)
| Account | Role | Use it to test |
|---|---|---|
maya@demo.thelivedapp.com | Guide, paid, ID verified | Guide side of bookings, completion and transfers |
sam@demo.thelivedapp.com | Seeker with credits | Booking, messaging, cancellation |
tess@demo.thelivedapp.com | Seeker with no credits | Out-of-credits state |
admin@demo.thelivedapp.com | Admin | Moderation queue and case actions |
All demo accounts share the password demo-password. Other seeded users (James, Aisha, Victor, Wes, Rob, Nadia) use the same password; check prisma/seed.ts for their exact addresses. Stripe connected-account details in the seed are deliberately left out of this document.
The seed's only guard is the IS_DEMO string; nothing checks which database it points at. Re-running it resets Victor's and Wes's guide status to APPROVED and adds new slots. The demo seeker fixture has drifted and needs a deliberate reset before demos.
Operational references
Render web service srv-da72k1e7bikc73emf3h0, database dpg-da72jmu7bikc73eme1n0-a, Blueprint exs-da72btvavr4c738941g0. Domains thelivedapp.com and the-lived-app.com are on Cloudflare; the app still serves from lived-app.onrender.com.
19Testing and quality
There are no automated tests and no CI. Quality so far rests on strict TypeScript, ESLint, and manual live verification recorded in docs/verification.
What exists
npm run lint;tscrun ad hoc- Report-guard rig: runs the real
createReportfrom Node with stubbed auth and cache, scenarios single, dup, race, clean. Prints results; no assertions. The race scenario needs real Postgres, not PGlite. - Verification records for payments and the refund matrix, credit purchases, and the out-of-credits UI
Untested critical paths
- Login, suspension block, admin gate
- Signup validation and 18+ gate
- Booking races, intro rule, checkout idempotency
- Completion transfer, cancellation and refund matrix
- Contributions, credits, free-message claim
- Moderation enforcement, lift, emergency routing
- Timezone maths, seed idempotency, migrations (including the unmanaged index)
20Git state
Remote github.com/THELIVEDAPP-LTD/lived-app, branch main in sync with origin, clean working tree, no stashes. 35 commits from 26 Aug to 10 Sep 2026. Latest: d5ae62e "fix(report): re-add the advisory lock via $executeRaw, verified locally first".
Last 30 commits
| Hash | Date | Message |
|---|---|---|
d5ae62e | 2026-09-10 | fix(report): re-add the advisory lock via $executeRaw, verified locally first |
2937bd1 | 2026-09-10 | fix(report): revert the advisory lock, restoring report intake |
7b8f864 | 2026-09-07 | fix(report): close the duplicate-guard race with an index and an advisory lock |
b3f80aa | 2026-09-06 | fix(report): scope the duplicate-report guard to the reason category |
8c89eb5 | 2026-09-06 | fix(report): constrain modal height and stop backdrop click discarding detail |
7027f4f | 2026-09-01 | docs(credits): live verification record for the 2c out-of-credits UI |
a5fc869 | 2026-09-01 | feat(messages): Enter to send, and promote the allowance tally |
fa21ed5 | 2026-09-01 | feat(messages): remaining-allowance tally and guide-side note |
2b0779a | 2026-08-31 | copy(credits): one blocked message at a time, single error variant |
7a3f4b7 | 2026-08-31 | copy(credits): reframe messaging limit as intent, not restriction (2c) |
62cdd48 | 2026-08-31 | feat(credits): out-of-credits block UI in thread view (2c) |
636d281 | 2026-08-31 | docs(credits): live verification record for 2b credit purchases |
4bbfea6 | 2026-08-31 | feat(credits): wallet + buy-credits page (2b, acquire side) |
e70f00e | 2026-08-30 | feat(messages): send/compose write path, seeker 5-free-then-credit, atomic free-slot claim |
3efcdb2 | 2026-08-30 | Guide multi-category + self-service signup |
2dfdd48 | 2026-08-30 | Document AUTH_URL in render.yaml and .env.example |
25b1176 | 2026-08-29 | Add report intake UI (user-facing "Report" trigger) |
4c7a45a | 2026-08-29 | Add moderation admin interface (spec §9a) |
6e2cca4 | 2026-08-29 | Drop the @AGENTS.md import from CLAUDE.md |
6d50341 | 2026-08-29 | Add CLAUDE.md project working rules |
24e070a | 2026-08-29 | Record payments verification: hold-then-release + full refund matrix |
d18d6fe | 2026-08-27 | Switch to hold-then-release payments; add graduated cancellation/refunds |
2eb0437 | 2026-08-27 | Use the real logo in the header, not the invented circle+text mark |
db3a2b8 | 2026-08-26 | Fix favicon, add full-catalog category browsing, mobile hamburger nav |
84a9453 | 2026-08-26 | Use the real Lived logo as the hero's central brand element |
6927b6c | 2026-08-26 | Build the real marketing home page |
d5c16ae | 2026-08-26 | Resolve IS_DEMO drift: render.yaml now matches the live service |
be2ae50 | 2026-08-26 | Fix login on Render: trust the proxy host in NextAuth config |
2232a40 | 2026-08-26 | Add §8 pay-what-you-can contribution flow with real Stripe Checkout |
37ae51c | 2026-08-26 | Wire paid bookings to real Stripe Connect direct charges (test mode) |
21Prototype vs production
The single-file HTML prototype (about 6,500 lines, outside the repo) is the functional reference. Production covers discovery, profiles, booking, payments, messaging, reporting and moderation. The list below is what the prototype has and production does not; the mapping from prototype function names is inferred.
| Capability | Prototype evidence | Priority for launch |
|---|---|---|
| Start a conversation | startThread, getOrCreateThread | Required |
| Guide application form (story, location, rate, pronouns) | submitGuideApp | Required |
| Admin guide approval, rejection, roster, suspend and reinstate | Admin tabs | Required |
| Guide portal: profile, pricing, availability diary, packages, training, documents, earnings, payouts, bank details, notifications, settings, appeals | renderGuidePortal, gp-* | Required |
| Seeker portal: profile, settings, wallet history, notifications | renderSeekerPortal, sp-* | Required |
| Blocking | submitReportBlock | Required |
| Reviews | submitReview | Required |
| In-app notifications and unread badges | renderUserNotifications, updateMsgBadge | Required |
| Account deletion and data export | submitDeletionRequest, requestDataExport | Required (GDPR) |
| Calendar sync | Mock buttons | Required (locked decision) |
| Rescheduling | proposeReschedule | Should have |
| Viewer-timezone slot conversion | convertSlotToViewerTimezone | Should have |
| AI matching and crisis or regulated-advice triage on Discover | runAIMatch, detectTriageFlag | Should have (triage is a safety feature) |
| Admin analytics: marketplace health, revenue, investor stats, category and deletion requests | Admin tabs | Should have |
| Multi-currency display | convertCurrency with a static rate table | Later; needs a live FX source |
| Featured-guide selection, role switching | getFeaturedReason, switchToPortal | Later |
22Findings register
Every defect, risk and gap from the audit, ranked. Critical items can lose or misdirect money, or let a sanctioned user keep acting. High items block launch or break a core journey. Medium items are hardening and scale work.
| ID | Severity | Area | Finding | Evidence | Remedy |
|---|---|---|---|---|---|
| F01 | Critical | Payments | The three Checkout confirmations run during page render with no caller check. The contribution confirmation takes the transfer destination from the guide_id URL parameter, so a crafted URL can send the 85% share to a different connected account. | contribution-actions.ts:151-233; booking-actions.ts:251; credit-actions.ts:107 | Derive every party from the database record or Stripe metadata, never the URL. Move fulfilment to webhooks; keep the return page as an authenticated, idempotent fast path. |
| F02 | Critical | Payments | No Stripe webhooks. If a payer closes the tab before the redirect, the credits, booking or contribution is never recorded. Pending credit purchases stay PENDING forever. | No webhook route; CLAUDE.md launch blocker | Signed /api/webhooks/stripe handling checkout.session.completed, checkout.session.expired, charge.refunded, account.updated, with an event table for idempotency and a reconciliation job. |
| F03 | Critical | Auth | Suspension does not end existing sessions. JWTs carry only the user id and callbacks never re-read status, and no non-admin action checks accountStatus. | auth/auth.ts; moderation-actions.ts:377 comment is wrong | Add a sessionVersion on User checked in the session callback (short cache), bump it on suspension, and add a shared requireActiveUser() guard to every action. |
| F04 | Critical | Payments | Guide transfer runs after the booking is committed as COMPLETED. If Stripe fails, the guide is never paid and the status guard blocks any retry. The same applies to contribution transfers. | session-actions.ts:105-131; contribution-actions.ts:213-233 | Record a transfer state (PENDING, SENT, FAILED), use Stripe idempotency keys derived from the booking id, and retry from a job. |
| F05 | Critical | Payments | A double-submitted cancellation can issue two refunds: Stripe calls happen before any conditional claim, with no idempotency key. | cancellation-actions.ts:90-179 | Claim first with updateMany to a CANCELLING state, then call Stripe with an idempotency key, then finalise. This is the locked block-cancel design; apply it to all cancellations. |
| F06 | Critical | Payments | If the slot or package disappears between payment and confirmation, the seeker is charged, no booking is created and they are told to contact support. No automatic refund. | booking-actions.ts:245-249, 287-294 | Hold the slot at Checkout creation (pending booking with expiry), or refund automatically in the fulfilment handler. |
| F07 | High | Trust | Guides who are APPROVED but not safety-trained are hidden from Discover yet bookable through a direct link. Booking also ignores the guide's category vetting requirements. | guides.ts:34-39 vs guides.ts:96, booking-actions.ts:56, 118 | One isBookable(guide) policy used by Discover, profile, booking and confirmation, including category risk rules. |
| F08 | High | Auth | Signup stores lowercased emails but login looks them up as typed, so mixed-case logins fail. | auth/auth.ts:24-40; signup-actions.ts:30 | Normalise in authorize; consider a citext column or a lowercase unique index. |
| F09 | High | Safety | Lifting enforcement sets the guide to APPROVED even if they were PENDING before suspension, and writes nothing to the report. | moderation-actions.ts:563-597 | Store the pre-enforcement status and restore it; record the lift on the case and in an audit log. |
| F10 | High | Safety | Appeals always resolve as REJECTED, even when the outcome changes. UPHELD is never written. Users cannot appeal themselves. | moderation-actions.ts:502 | Explicit appeal decision in the resolve form; user-initiated appeal entry point. |
| F11 | High | Supply | There is no path from PENDING to APPROVED. Every real guide who signs up is stuck. | Section 08 | Admin application review with approve, reject and reasons, feeding the onboarding pipeline in section 23. |
| F12 | High | Messaging | No code creates threads, so a seeker cannot start a conversation with a guide. | Only seed calls thread.upsert | "Message this guide" entry point with upsert on the unique (seeker, guide) pair and block checks. |
| F13 | High | Security | No rate limiting, no security headers, no middleware; Stripe return URLs built from the Host header. | Section 15 | Cloudflare rules plus app-level limits in Redis; headers in next.config or middleware; a fixed APP_URL for redirects. |
| F14 | High | Bookings | Booking actions do not check the slot is in the future, allow self-booking, skip accountStatus, and accept unvalidated dates. One intro per pair is not enforced by the database. | booking-actions.ts:41-106 | Validate inputs with a schema library; add a partial unique index on intros; share the isBookable and active-user guards. |
| F15 | High | Resilience | The Stripe module throws at import when the key is missing, taking down three pages that do not all need Stripe to render. | stripe.ts:11-15 | Lazy client getter; fail the action, not the page. |
| F16 | High | Data protection | Calendar OAuth tokens would be stored in plaintext columns. | schema.prisma:960-966 | Application-level envelope encryption with a key held outside the database before calendar work starts. |
| F17 | High | Security | Booking actions return raw exception messages to the browser. | booking-actions.ts:104, 231, 345 | Map to user-facing messages; send detail to Sentry. |
| F18 | High | Performance | Unbounded queries everywhere; Discover loads every review and completed booking for every guide. | Section 16 | Pagination, a guide stats table updated on write, tag-based caching. |
| F20 | High | Quality | No automated tests and no CI on a codebase that moves money. | Section 19 | Unit tests for pure maths, integration tests against Postgres 16 and stripe-mock, CI on every pull request. |
| F24 | High | Safety | Emergency reports create notifications nobody reads. The only signal is a banner that someone must happen to see. | moderation-actions.ts:38-60 | Email and push alerts to on-call moderators, acknowledgement tracking and response deadlines. |
| F30 | High | Finance | No money ledger. Earning and Payout are unused, contribution fees are not stored, PWYC bookings carry no amount. Revenue and payouts cannot be reconciled from the database. | Sections 05, 09 | Double-entry style ledger entries written in the same transaction as each money movement. |
| F19 | Medium | Performance | Header costs 3 queries and 3 session reads per request. | site-header.tsx | Memoise with React cache(); put role flags in the token. |
| F21 | Medium | Ops | Node version unpinned. | package.json, render.yaml | engines, .nvmrc and NODE_VERSION. |
| F22 | Medium | Data | Partial unique index is invisible to Prisma and can be dropped by a future migration. | Migration 9 | CI check with prisma migrate diff that fails if the index is missing. |
| F23 | Medium | Observability | Sentry traces at 100%, no PII scrubbing, no error boundaries. | Section 17 | Lower sample rate, beforeSend scrubbing, global-error.tsx. |
| F25 | Medium | Analytics | Eight metric types never emitted; nothing reads metrics. | Section 12 | Emit remaining events; nightly rollups into reporting tables. |
| F26 | Medium | Time | Three-country timezone map, default Berlin, no DST handling, no viewer conversion. | timezone.ts | Store slots as UTC instants plus an IANA zone on the guide; convert for viewers. |
| F27 | Medium | Docs | README and .env.example reference a missing lib/payments.ts; IS_DEMO does not gate payments; spec and prototype live outside the repo; several comments contradict code. | Sections 04, 13 | Documentation pass; bring spec into docs/. |
| F28 | Medium | Ops | Seed only guarded by IS_DEMO; re-running changes guide status. | prisma/seed.ts | Refuse to run demo seed unless the database is on an allow-list. |
| F29 | Medium | Data | Many models unused and some fields misleading (direct-charge comment, lastActiveRole). | Section 05 | Keep models the roadmap needs; remove or document the rest. |
| F31 | Medium | Finance | Wallet is a counter; individual credit spends are not recorded. | message-actions.ts | Wallet transactions table; balance derived or reconciled from it. |
| F32 | Medium | Payments | Contribution amount has no upper bound or check against Stripe's minimum charge. | contribution-actions.ts:54 | Bounds and a friendly minimum. |
23Target production architecture
The target keeps what works: one Next.js codebase, Prisma on Postgres, Render in Frankfurt, Stripe Connect with hold-then-release. It adds the pieces a money-moving, safety-critical marketplace needs: an edge, webhooks, a worker with scheduled jobs, a shared cache and queue, realtime, file storage, and separate environments. Items marked "locked" come from the founder's decision log; everything else is proposed.
flowchart TB U["Seekers, guides, moderators"] --> CF["Cloudflare
DNS, TLS, WAF, bot and rate rules, CDN"] CF --> WEB subgraph RENDER["Render, Frankfurt (production)"] WEB["Web service, 2+ instances
Next.js pages, server actions,
webhook, health and realtime-auth routes"] WK["Background worker
outbox and job consumer"] CRON["Cron jobs
sweeps, reconciliation, rollups"] KV[("Key Value (Redis)
rate limits, queue, pub/sub, cache")] POOL["Connection pooler"] PG[("Postgres primary
point-in-time recovery")] RR[("Read replica
admin and analytics")] end WEB --> KV WK --> KV WEB --> POOL WK --> POOL CRON --> POOL POOL --> PG PG -.-> RR ST["Stripe Connect"] -->|"signed webhooks"| WEB WEB --> ST WK --> ST WK --> ZM["ZeptoMail"] WK --> CAL["Google Calendar,
Microsoft Graph"] WEB --> OBJ["EU object storage
documents and exports, encrypted"] WEB --> RT["Realtime transport"] WEB --> VID["Video provider"] WEB --> OBS["Sentry EU, log drain, uptime"] WK --> OBS
Target production topology. A staging copy mirrors it at smaller sizes with Stripe in test mode and demo seed allowed.
Environments
| Environment | Purpose | Data | Stripe | Deploys from |
|---|---|---|---|---|
| Local | Development | Docker Postgres 16 (not PGlite, which hides concurrency bugs); demo seed | stripe-mock, then a verification-only sandbox with a restricted key (locked) | Working copy |
| CI | Tests and checks | Ephemeral Postgres 16 service | stripe-mock | Every pull request |
| Staging | Demo and pre-release verification (today's lived-app) | Own database, demo seed, IS_DEMO=true | Test mode | main |
| Production | Real users on thelivedapp.com | Own database, reference seed only, PITR, IP allow-list | Live, after compliance sign-off | Tagged release or promoted build |
Use Render environment groups so secrets are managed once per environment, and set AUTH_URL and a new APP_URL per environment.
Application tier
- Stateless web instances. JWT sessions and no in-memory state already make horizontal scaling straightforward, once rate limits and pub/sub move to Redis.
- Middleware for security headers, authenticated-area redirects and request ids.
- A shared guard module:
requireUser,requireActiveUser,requireSeeker,requireGuide,requirePermission, and oneisBookablepolicy. Every action starts with one of them. - Schema-validated inputs for every action (a single validation library, shared between client and server).
- Route handlers only where server actions do not fit:
/api/webhooks/stripe,/api/webhooks/calendar,/api/health, realtime token issuing, data export download. - Transactional outbox: any side effect (email, notification, transfer, calendar write, metric rollup) is recorded as a job row in the same transaction as the state change, then executed by the worker. This removes every "Stripe after commit with no retry" path.
Background jobs
| Job | Trigger | What it does | Idempotency |
|---|---|---|---|
| Stripe event processor | Webhook enqueue | Fulfils credits, bookings and contributions; syncs account.updated; records refunds and disputes | Stripe event id primary key |
| Checkout reconciliation | Every 15 minutes | Retrieves sessions for pending purchases and holds; fulfils or expires them | Conditional status claim |
| Transfer sender and retry | Outbox, plus sweep | Sends guide transfers for completed sessions and contributions; retries failures with backoff | Stripe idempotency key from booking or contribution id |
| Session lifecycle | Every 5 minutes | Opens and closes video windows, auto-completes or flags sessions after end plus grace (rule to be decided) | Conditional status claim |
| Contribution expiry | Hourly | PROMPTED to EXPIRED after 7 days | updateMany by expiry |
| Notifications and email | Outbox | In-app notifications, ZeptoMail sends, emergency alerts to on-call moderators | Job id |
| Retention sweep | Daily | Deletes or redacts transcripts, documents and logs past their period unless under legal hold | Deterministic by date |
| Deletion processor | Daily | Executes deletion requests: anonymise user, keep what the retention register requires | Request status |
| Data export builder | On request | Assembles a subject-access export to object storage with an expiring link | Request id |
| Calendar sync | Provider webhooks and polling | Imports guide busy time; writes confirmed bookings to both calendars | External event ids |
| Housekeeping and rollups | Nightly | Prunes past unbooked slots, recomputes guide stats, rolls up metrics | Recompute |
Payments, target flow
sequenceDiagram autonumber actor S as Seeker participant W as Web participant ST as Stripe participant DB as Postgres participant Q as Worker S->>W: bookPackage W->>DB: create Booking PENDING_PAYMENT holding the slot, expires in 30 min W->>ST: checkout.sessions.create with idempotency key S->>ST: pays ST->>W: POST /api/webhooks/stripe checkout.session.completed W->>W: verify signature W->>DB: insert StripeEvent (id unique) and outbox job in one transaction W-->>ST: 200 Q->>DB: claim job, Booking PENDING_PAYMENT to CONFIRMED, ledger entries ST-->>S: redirect to return page S->>W: return page shows status from DB (authenticated)
- Release: when the session completes, an outbox job sends the 85% transfer with an idempotency key and writes the ledger.
- Connect onboarding: create connected accounts and onboarding links from the guide portal;
charges_enabledandpayouts_enabledcome only fromaccount.updated. - Ledger: one row per money movement (charge, platform fee, transfer, refund, contribution, credit purchase, credit spend, payout), written in the same transaction as the state change. The existing
EarningandPayoutmodels can be the starting point. - Payout schedule: monthly as standard with an optional fortnightly early payout that carries a 20% platform fee instead of 15% (locked policy, from the legal documents).
- Block-driven cancellation: two transactions around the Stripe refund with a CANCELLING state and a
CancellationReasonfield (locked). - Compliance gate: live mode only after solicitor and Stripe confirm the hold-then-release model does not require an e-money licence (locked).
Identity and access
- Email verification before booking or messaging, and password reset, both via ZeptoMail with single-use hashed tokens.
- Normalised emails and a case-insensitive unique index.
- Session revocation through a
sessionVersionchecked in the session callback. - Rate limits on login, signup, reset, checkout creation, messaging and reporting, stored in Redis.
- Staff roles replacing
isAdmin: for example support, moderator, senior moderator and administrator, each with explicit permissions; multi-moderator assignment; an append-only audit log of every staff action, including every view of a transcript or personal document. - Google sign-in later, from the same Google Cloud project as calendar OAuth (locked sequencing).
Guide onboarding pipeline
flowchart LR A["Application
story, location, languages,
categories, pricing model"] --> B{"Staff review"} B -- "reject with reason" --> R["REJECTED"] B -- "approve" --> C["Required training
safety basics, crisis awareness,
boundaries"] C --> D["SAFETY_TRAINED badge
awarded automatically"] D --> E{"Category risk rules"} E -- "enhanced vetting or
high-risk category" --> F["ID verification and
advanced safeguarding"] E -- "standard" --> G F --> G["Stripe Connect onboarding
(paid and PWYC guides)"] G --> H["Availability, packages,
calendar sync"] H --> I["Bookable
single isBookable policy"]
Badges stay independent, not a ladder (locked). Lived-experience evidence and ID documents go to encrypted EU object storage, are reviewed by staff with every access logged, and are swept after verification according to the retention register.
Messaging, notifications and sessions
- Thread creation from the guide profile and after booking, respecting blocks.
- Realtime delivery of messages, unread counts and session state (locked requirement; provider open).
- Notification centre reading the existing
Notificationtable, with email fallback and user preferences. - Video sessions inside the thread page with the locked timing rules; a
SessionRoomrecord per booking storing provider room id, join times and end time for disputes. - Reviews after completion, emotion-led language with hidden numeric weights (locked).
Data protection
- Retention register implemented as code: each data class has a period, a legal basis and a sweep. Legal hold overrides sweeps.
- Subject access export and account deletion pipelines using the existing
DeletionRequestmodel and account states. - Encryption: provider-managed at rest for the database and storage, plus application-level encryption for OAuth tokens and document keys.
- Processor register kept alongside the retention register, with EU residency for every new vendor.
- Policy re-acceptance on version change, reading
PolicyAcceptance.
Operations and quality
/api/healthchecking the database and Redis, wired to Render's health check and an external uptime monitor.- Structured JSON logs with request ids, shipped to a log drain; Sentry with lower trace sampling, PII scrubbing and user ids.
- Alerts on webhook failures, job retries exhausted, emergency reports unacknowledged, and error-rate spikes.
- Point-in-time recovery on production, a tested restore runbook, and the database IP allow-list set.
- CI on every pull request: lint, type-check, unit tests, integration tests against Postgres 16 and stripe-mock, a migration drift check that protects the partial index, and a Playwright smoke suite for sign-up, booking, payment, messaging and reporting.
Proposed schema additions
| Addition | Purpose | Source |
|---|---|---|
StripeEvent | Webhook idempotency and audit | Proposed |
Job (outbox) | Reliable side effects with retries | Proposed |
LedgerEntry (or activate Earning, Payout) | Reconcilable money movements | Proposed |
WalletTransaction | Credit purchases and spends | Proposed |
Block with unblockedAt; Booking.cancellationReason; CANCELLING status | Blocking and safe cancellation | Locked |
| PENDING_PAYMENT booking status with hold expiry | Prevent paid-but-unbooked | Proposed |
Transfer state on Booking and Contribution; Contribution.platformFeeEur | Retryable, reportable transfers | Proposed |
| Partial unique index on one active intro per pair | Enforce the intro rule in the database | Proposed |
VerificationToken, PasswordResetToken, User.sessionVersion | Email verification, reset, revocation | Proposed |
StaffRole and AuditLog | RBAC and immutable staff audit trail | Locked requirement, proposed shape |
| Guide application fields and review outcome | Application and approval workflow | Proposed |
SessionRoom | Video session record | Proposed |
GuideStats | Materialised rating and session count | Proposed |
| Slot start as UTC instant plus guide IANA timezone | Correct time maths across DST and countries | Proposed |
NotificationPreference | Per-channel opt-in | Proposed |
24Scaling path
Lived's load profile is modest per user (a handful of bookings and messages per week) but spiky around evenings and session start times. Correctness matters far more than throughput at launch. Scale in stages, triggered by measurements rather than dates.
| Stage | Indicative size | Infrastructure | Application changes |
|---|---|---|---|
| 1. Launch | Up to about 1,000 monthly active users | Two web instances, one worker, cron, small Redis, production Postgres with PITR and a pooler | Everything in phases 0 to 2 of the roadmap. Pagination everywhere. Header memoised. |
| 2. Growth | About 10,000 | Autoscaled web, more worker concurrency, larger database tier, read replica | GuideStats table; tag-based caching of Discover and public profiles; admin and analytics on the replica; metric rollups |
| 3. Scale | About 100,000 | Web and worker scaled independently; managed realtime at higher tier; CDN caching of public pages | Postgres full-text search or a dedicated search service for matching; monthly partitioning of metric_events and messages; analytics exported to a warehouse; archive old threads |
Signals to watch
- p95 latency of Discover and thread pages; database CPU and connection count; pool wait time.
- Webhook processing lag and job queue depth.
- Realtime concurrent connections around session start times.
- Moderation queue age, especially emergency acknowledgement time.
Multi-region is not needed: users and data residency are EU. Keep a single Frankfurt region and invest in backups and recovery time instead.
25Roadmap to production
Phases are ordered by dependency. Each phase ends with live verification on the staging environment, which is the project's acceptance standard.
| Phase | Goal | Scope | Findings closed |
|---|---|---|---|
| 0. Correctness and security | Nothing can lose, misdirect or double-move money; sanctions take effect immediately | Authenticate and re-derive parties in all confirmations; claim-then-call pattern for cancellations; transfer state and retries; slot hold or auto-refund; session revocation and active-user guard; email normalisation; single bookable policy; lift and appeal fixes; input validation; lazy Stripe client; error mapping; pin Node | F01, F03 to F10, F14, F15, F17, F21 |
| 1. Complete the core loop | A real guide can apply, be approved and get booked; a seeker can find, message, book, meet and review | Guide application and staff review; training and badge awarding; Connect onboarding; guide portal (profile, availability, packages, earnings); seeker account area; thread creation; reviews; blocking (locked design); notification centre; ZeptoMail with email verification and password reset | F11, F12, F24 (part) |
| 2. Production infrastructure | The platform runs itself reliably | Separate production service and database; Cloudflare cutover; webhooks, outbox, worker and cron; Redis rate limiting; security headers and middleware; realtime; video provider and session routes; calendar OAuth with encrypted tokens; ledger; RBAC and audit log; GDPR export, deletion and sweeps; health checks, logging, alerting, PITR; tests and CI; pagination and caching | F02, F13, F16, F18 to F20, F22, F23, F25, F26, F28, F30, F31 |
| 3. Launch gates | Legal and operational readiness | Solicitor sign-off on the retention register and the emergency-escalation procedure; solicitor and Stripe confirmation of hold-then-release; email verification live; switch Stripe to live mode; penetration test; load test around session start; moderator on-call rota and runbooks; demo data removed from production | Launch blockers in CLAUDE.md |
| 4. Scale | Grow with measured demand | Stages 2 and 3 of the scaling path; AI matching and safety triage; admin analytics; multi-currency display with a live FX source; rescheduling; viewer timezones | F26 (full), remaining gaps |
26Decisions and open questions
Locked decisions the build must honour
| Area | Decision | Built? |
|---|---|---|
| Positioning | Peer support only; never therapy, counselling or professional advice. Keep a hard boundary with clinical care in data and copy. | Yes in copy and report reasons |
| Stack | Next.js App Router with TypeScript, Prisma 7 and Postgres, Auth.js v5, Stripe Connect separate charges and transfers, Tailwind v4, Render Frankfurt | Yes |
| Fees | 15% platform fee on paid sessions; guide share released on completion | Yes |
| Messaging | 5 free seeker messages per thread, then 1 credit each; guides free. Packs 10 for €0.99, 25 for €1.99, 50 for €3.49 | Yes |
| Booking | Mandatory free 15-minute intro before paid sessions, except fully free guides | Partial not DB-enforced |
| Refunds | Seeker: 24h or more 100% of guide share, 12 to 24h 50%, 3 to 12h 25%, under 3h 0%. Guide cancels: 100% of gross | Yes |
| Trust | Four independent badges with tap or hover explanations | Display only |
| Categories | 1 to 5 per guide; three pills plus "+N" on cards | Yes |
| Age | 18+ for everyone, everywhere | Yes |
| Reports and blocks | Per-reason report guard with "other" exempt; block design as described in section 11 | Reports only |
| Sessions | In-app video on the thread page with the 30-minute banner, entry at start, about 5 minutes grace | No |
| Transport | Realtime required before launch | No |
| Calendar | Real Google Calendar and Microsoft Graph sync in both directions | No |
| Reviews | Emotion-led language, hidden numeric weights | Read only |
| Retention | Transcripts 4 weeks to 12 months; safety and vetting evidence 6 years; logs 12 months | No |
| Revenue | No advertising, data monetisation, crisis paywalls, priority booking or discount bundles | Constraint |
Open questions for sign-off
| # | Question | Recommendation |
|---|---|---|
| 1 | Realtime: managed service or self-hosted WebSockets over Redis pub/sub? | Managed, with EU data residency and a DPA. Fewer moving parts for a small team; revisit on cost at stage 3. |
| 2 | Video provider | Shortlist providers with EU residency, embeddable SDKs, no recording by default and per-minute pricing. Decide before building session routes' media layer. |
| 3 | When does the guide's money release: at completion, or after the 7-day dispute window in the policy documents? | Reconcile the two before live mode. Holding until the dispute window closes simplifies refunds but strengthens the e-money question. |
| 4 | How is a session marked complete: guide click, automatic after end plus grace, or seeker confirmation? | Automatic after end plus grace using video join records, with a dispute route. Removes the manual step and the "complete before it happened" risk. |
| 5 | Stripe Connect account type and dashboard access for guides | Decide with Stripe during compliance review. |
| 6 | Staff role set and permissions | Start with support, moderator, senior moderator and administrator; document what each can see. |
| 7 | Three parked category-risk calls: Recovery, Pregnancy Loss, and the structure of Domestic Abuse (the seed uses the defaults) | Decide before guide onboarding enforces category risk. |
| 8 | Object storage provider for documents and exports | Any S3-compatible store with an EU-only jurisdiction option and server-side encryption. |
| 9 | Matching and safety triage from the prototype (AI matching, crisis and regulated-advice detection) | Treat triage as a safety feature with its own review; defer AI matching until after launch. |
| 10 | Suspended guides' future bookings | Cancel and refund automatically on suspension or removal; today nothing happens. |
27Glossary
| Term | Meaning |
|---|---|
| Seeker | A person looking for support. Every account has a seeker profile. |
| Guide | A vetted person offering support from their own lived experience. |
| Intro | A free 15-minute first session, required before paid sessions with non-free guides. |
| Package | A bookable session of a set duration and price offered by a guide. |
| Pricing model | PAID (fixed price), PAY_WHAT_YOU_CAN (optional contribution after the session) or FREE. |
| Contribution | The optional post-session payment for pay-what-you-can guides; €0 is a valid, unshamed answer. |
| Hold then release | The platform takes the full payment and transfers the guide's share only after the session. |
| Credits | Prepaid units seekers spend on messages after their free allowance. |
| Bookable | A guide seekers can book: approved, trained and, for paid guides, able to take payments. |
| Legal hold | A flag that exempts a thread or report from deletion because of a safety or legal need. |
| Confirm on render | The current pattern of recording a Stripe payment when the return page loads, instead of by webhook. |
| Outbox | A table of pending side effects written with a state change and executed by a worker. |