fix(security): resolve audit findings — secrets, env contract, migrations

Removes hardcoded fallback secrets and makes a misconfigured deploy fail
loudly instead of silently falling back to development defaults.

- Remove insecure JWT fallback secrets (messages.module, configuration)
- Remove the 'default-secret' fallback for the 2FA TOTP encryption key and
  allow a dedicated TWO_FACTOR_ENCRYPTION_KEY so rotating JWT_SECRET no
  longer locks out every 2FA user (see docs/2fa-key-rotation.md)
- Require EMAIL_API_URL; drop the hardcoded vendor email endpoint
- Drive WebSocket CORS from CORS_ORIGINS instead of origin:'*'
- Load .env before any Nest module is imported (src/load-env.ts). Decorator
  arguments evaluate at import time, so the gateway previously froze its CORS
  config to the localhost fallback even when CORS_ORIGINS was set
- Add boot-time env validation: missing required vars, weak JWT_SECRET, and
  inverted access/refresh token lifetimes now abort startup
- Enable Redis TLS certificate verification
- Require ADMIN_EMAIL/ADMIN_PASSWORD for the seed; remove the published
  default super-admin credentials and stop printing them
- Add the initial Prisma migration and stop gitignoring prisma/migrations
- Make .env.example an accurate configuration contract (admin bootstrap,
  REDIS_TLS, S3_ENDPOINT, 2FA key, Firebase path; drop the dead SMTP block)
- Add handover documentation: architecture, ER model, sequence and data-flow
  diagrams, 2FA key rotation runbook

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 11:30:36 +05:30
parent 4df4d8c9b5
commit c9b38dc6ab
20 changed files with 2167 additions and 27 deletions

61
docs/2fa-key-rotation.md Normal file
View File

@@ -0,0 +1,61 @@
# Rotating JWT_SECRET without locking out 2FA users
## The problem
`TwoFactorService` encrypts each user's TOTP secret (`User.twoFactorSecret`)
with a key derived from a passphrase:
```
encryptionKey = scryptSync(TWO_FACTOR_ENCRYPTION_KEY ?? JWT_SECRET, 'salt', 32)
```
Historically only `JWT_SECRET` was used. That means **rotating `JWT_SECRET`
changes the 2FA encryption key**, and every already-stored `twoFactorSecret`
becomes undecryptable. Affected users can still enter their password but every
TOTP code is rejected at `POST /auth/2fa/verify` — they are locked out of their
accounts, and support cannot recover it without disabling 2FA per user.
This matters because the handover requires `JWT_SECRET` to be rotated.
## Rule
**Set `TWO_FACTOR_ENCRYPTION_KEY` to the value `JWT_SECRET` had when the 2FA
secrets were encrypted, then rotate `JWT_SECRET` freely.**
Once `TWO_FACTOR_ENCRYPTION_KEY` is set, the two keys are independent and this
problem cannot recur.
## Procedure
1. Before rotating, record the current `JWT_SECRET`.
2. Add to the production environment:
```
TWO_FACTOR_ENCRYPTION_KEY=<the OLD JWT_SECRET value>
```
3. Set the new `JWT_SECRET`.
4. Deploy and restart. Existing 2FA secrets still decrypt; all sessions issued
under the old `JWT_SECRET` are invalidated (users log in again — expected).
5. Verify with a real 2FA-enabled account before announcing the change.
## Checking the blast radius first
```sql
SELECT count(*) FROM "users" WHERE "twoFactorEnabled" = true;
```
If this is `0`, no user is affected: set `TWO_FACTOR_ENCRYPTION_KEY` to a fresh
random value and rotate `JWT_SECRET` independently.
## Re-keying to a fresh 2FA key later
There is no bulk re-encryption script. To move to a brand-new
`TWO_FACTOR_ENCRYPTION_KEY` after secrets already exist, either:
- write a one-off script that decrypts with the old key and re-encrypts with
the new one (`encrypt`/`decrypt` in `src/auth/two-factor/two-factor.service.ts`,
format `iv:authTag:ciphertext`, AES-256-GCM), or
- clear 2FA for all users and have them re-enrol:
```sql
UPDATE "users" SET "twoFactorEnabled" = false, "twoFactorSecret" = NULL;
```
(Disruptive — every 2FA user must re-scan their QR code.)

14
docs/README.md Normal file
View File

@@ -0,0 +1,14 @@
# RE:Quest — system documentation
| Document | Contents |
|---|---|
| [architecture.md](./architecture.md) | Components, runtime topology, auth model, roles, configuration contract, DB change process |
| [data-model.md](./data-model.md) | ER diagram + every entity, field and enum (generated from `prisma/schema.prisma`) |
| [flows.md](./flows.md) | Sequence diagrams: registration, login & 2FA, Google sign-in, connection requests, real-time messaging, subscription/payment & Stripe webhooks, agent verification, file upload; email trigger events; data-flow diagram |
| [2fa-key-rotation.md](./2fa-key-rotation.md) | How to rotate `JWT_SECRET` without locking out every 2FA user |
Diagrams are Mermaid embedded in Markdown: source-controllable, diffable, and
rendered natively by GitHub/GitLab.
`data-model.md` is generated from the Prisma schema — regenerate it after any
schema change rather than editing it by hand.

181
docs/architecture.md Normal file
View File

@@ -0,0 +1,181 @@
# RE:Quest — system architecture
Handover documentation. Diagrams are Mermaid inside Markdown so they are
editable and diffable in version control.
- [Data model / ERD](./data-model.md)
- [Workflow (sequence) diagrams](./flows.md)
- [2FA key rotation runbook](./2fa-key-rotation.md)
---
## 1. Components
| Component | Stack | Repo | Notes |
|---|---|---|---|
| API | NestJS (Node), REST + Socket.IO | `backend` | Global prefix `/api/v1`; Swagger at `/docs` when enabled |
| Web app | Next.js App Router, NextAuth | `frontend` | Public site + user and agent portals |
| Admin panel | Next.js App Router | `adminpanel` | Internal dashboard, no `next/image` remote loading |
| Mobile app | Flutter | `mobile-app` | Talks to the same REST + Socket.IO API |
| Database | PostgreSQL (DigitalOcean Managed) | — | Prisma ORM, 24 entities |
| Cache / pub-sub | Redis / Valkey (DigitalOcean Managed) | — | Presence tracking + Socket.IO adapter for multi-instance fan-out |
| Object storage | S3-compatible (DigitalOcean Spaces) | — | Browser uploads via presigned URLs; DB stores object keys only |
| Payments | Stripe | — | Checkout Sessions + Billing Portal + webhooks |
| Push | Firebase Cloud Messaging | — | Admin SDK server-side, FCM tokens client-side |
| Email | HTTP JSON gateway (`EMAIL_API_URL`) | — | **No SMTP path exists in the code** |
| Auth (social) | Google via NextAuth | — | Frontend verifies, then posts profile to `POST /auth/social` |
### Component diagram
```mermaid
flowchart TB
subgraph Clients
WEB[Web app<br/>Next.js]
ADM[Admin panel<br/>Next.js]
MOB[Mobile app<br/>Flutter]
end
subgraph API["Backend API — NestJS"]
REST[REST controllers<br/>/api/v1]
WS[Socket.IO gateway<br/>MessagesGateway]
EV[Event emitter<br/>in-process]
end
subgraph Data
PG[(PostgreSQL<br/>Prisma)]
RD[(Redis / Valkey<br/>presence + SIO adapter)]
S3[(Object storage<br/>S3-compatible)]
end
subgraph ThirdParty["Third-party services"]
STR[Stripe]
FCM[Firebase FCM]
MAIL[Email gateway<br/>EMAIL_API_URL]
GOO[Google OAuth]
end
WEB --> REST
ADM --> REST
MOB --> REST
WEB <--> WS
ADM <--> WS
MOB <--> WS
WEB -.NextAuth.-> GOO
WEB -- presigned PUT --> S3
MOB -- presigned PUT --> S3
REST --> PG
REST --> RD
REST --> S3
WS --> PG
WS --> RD
REST --> EV
WS --> EV
EV --> MAIL
EV --> FCM
REST --> STR
STR -- webhook --> REST
```
Notes on the diagram:
- **Files never stream through the API.** Clients ask the API for a presigned
URL (`POST /upload/*-presigned-url`) and then PUT directly to object storage.
Only the resulting object key is persisted.
- **Redis serves two distinct purposes**: `RedisPresenceService` (who is
online) and the Socket.IO Redis adapter, which is what lets more than one API
instance broadcast to the same rooms. Running multiple replicas without Redis
would silently break cross-instance message delivery.
- **Side effects are event-driven.** Controllers emit in-process events
(`user.registered`, `notification.message`, …); `EmailListener` and the
notification service subscribe. Email/push failures are logged, not
propagated to the caller.
---
## 2. Runtime topology
```mermaid
flowchart LR
U[Browser / device] --> CDN[re-quest.com<br/>admin.re-quest.com]
CDN --> APIH[prod.api.re-quest.com]
APIH --> N1[API instance 1]
APIH --> N2[API instance N]
N1 <--> RD[(Redis / Valkey)]
N2 <--> RD
N1 --> PG[(PostgreSQL)]
N2 --> PG
```
`CORS_ORIGINS` must list every client origin. It is consumed twice — by the
HTTP CORS middleware **and** by the Socket.IO gateway. If it is unset, both
fall back to `localhost` and every browser request from the live domain is
rejected.
---
## 3. Authentication model
| Concern | Mechanism |
|---|---|
| Credential storage | Argon2id hashes (`User.password`) |
| Session tokens | JWT access + refresh, both signed with `JWT_SECRET` |
| Access token lifetime | `JWT_ACCESS_EXPIRATION` (recommended `15m`) |
| Refresh token lifetime | `JWT_REFRESH_EXPIRATION` (recommended `7d`, **must exceed the access lifetime**) |
| Refresh token storage | `Session` rows in PostgreSQL — revocable via `/auth/logout-all` |
| Client-side token storage | `localStorage` (web) — XSS-exfiltratable; migration to httpOnly cookies is an open item |
| Social login | Google only. NextAuth verifies in the frontend and posts the verified profile to `POST /auth/social` |
| 2FA | TOTP (speakeasy). Secret is AES-256-GCM encrypted at rest with a key derived from `TWO_FACTOR_ENCRYPTION_KEY` (falling back to `JWT_SECRET`) |
| WebSocket auth | JWT passed in `handshake.auth.token`; verified on connect, socket disconnected on failure |
The Facebook and Twitter OAuth variables present in some environments are
**not consumed by the backend**. Google is the only enabled social provider.
---
## 4. Roles and authorisation
`UserRole` is one of `USER`, `AGENT`, `ADMIN`, `SUPER_ADMIN`. Route access is
enforced by guards on the controllers; the admin panel is a client of the same
API and holds no privileges of its own.
Known gap: agent approval does **not** verify an active paid subscription on
the server. Subscription state is displayed to the admin, but approval is not
gated on it in the backend.
---
## 5. Configuration contract
`backend/.env.example` is the authoritative list. Validation runs at boot
(`src/config/env.validation.ts`) and the process **exits** if:
- `DATABASE_URL`, `JWT_SECRET` or `EMAIL_API_URL` is missing
- `NODE_ENV=production` and `CORS_ORIGINS` or `FRONTEND_URL` is missing
- `NODE_ENV=production` and `JWT_SECRET` is shorter than 32 characters
- `JWT_REFRESH_EXPIRATION` is not longer than `JWT_ACCESS_EXPIRATION`
Warnings (logged, non-fatal): access token lifetime over one hour, `REDIS_TLS`
disabled in production, `sslmode=no-verify` in `DATABASE_URL`, `localhost` in a
production `CORS_ORIGINS`.
`.env` files are loaded by `src/load-env.ts`, which must remain the first
import of `main.ts` — see the comment in that file for why.
---
## 6. Database change process
The schema is versioned under `backend/prisma/migrations`. `prisma db push`
must not be used against any shared environment.
An environment whose schema was created with `db push` (as production was)
needs baselining once, before the first `migrate deploy`:
```bash
npx prisma migrate resolve --applied 20260721102313_init
npx prisma migrate deploy
```
Without the baseline, `migrate deploy` fails with "relation already exists".

516
docs/data-model.md Normal file
View File

@@ -0,0 +1,516 @@
# Data model (entityrelationship)
Generated from `backend/prisma/schema.prisma` — 24 entities. Regenerate after any schema change; the schema is the source of truth.
## ER diagram
```mermaid
erDiagram
AgentProfile ||--o{ AgentProfileFieldValue : agentProfile
AgentProfile ||--o{ ConnectionRequest : agentProfile
AgentProfile ||--o{ Conversation : agentProfile
AgentProfile ||--o{ Testimonial : agentProfile
AgentProfile ||--o{ VerificationHistory : agentProfile
AgentSubscription o|--o{ Payment : subscription
AgentType o|--o{ AgentProfile : agentType
AgentType ||--o{ AgentTypeSection : agentType
Conversation ||--o{ Message : conversation
ProfileField ||--o{ AgentProfileFieldValue : field
ProfileSection ||--o{ AgentTypeSection : section
ProfileSection ||--o{ ProfileField : section
SubscriptionPlan ||--o{ AgentSubscription : plan
SupportChat ||--o{ SupportMessage : chat
User o|--o{ AuditLog : actor
User o|--o{ VerificationHistory : admin
User ||--o{ AgentSubscription : user
User ||--o{ ConnectionRequest : user
User ||--o{ Conversation : user
User ||--o{ Message : sender
User ||--o{ Notification : user
User ||--o{ Payment : user
User ||--o{ Session : user
User ||--o{ SupportChat : user
User ||--o{ UserReport : reportedUser
User ||--o{ UserReport : reporter
User ||--o| AgentProfile : user
User ||--o| UserProfile : user
```
## Entities
### AgentProfile
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `userId` | `String` | unique |
| `slug` | `String` | unique |
| `agentTypeId` | `String?` | |
| `firstName` | `String?` | |
| `lastName` | `String?` | |
| `phone` | `String?` | |
| `avatar` | `String?` | |
| `bio` | `String?` | |
| `headline` | `String?` | |
| `city` | `String?` | |
| `state` | `String?` | |
| `country` | `String?` | |
| `address` | `String?` | |
| `zipCode` | `String?` | |
| `latitude` | `Float?` | |
| `longitude` | `Float?` | |
| `yearsOfExperience` | `Int?` | |
| `licenseNumber` | `String?` | |
| `companyName` | `String?` | |
| `website` | `String?` | |
| `facebookUrl` | `String?` | |
| `twitterUrl` | `String?` | |
| `linkedinUrl` | `String?` | |
| `instagramUrl` | `String?` | |
| `isVerified` | `Boolean` | default `false` |
| `verificationStatus` | `VerificationStatus` | enum, default `NONE` |
| `verificationNote` | `String?` | Admin note (rejection reason) |
| `verifiedAt` | `DateTime?` | |
| `verifiedBy` | `String?` | Admin user ID who verified |
| `isProfileComplete` | `Boolean` | default `false` |
| `profileCompleteness` | `Int` | default `0` |
| `isPublic` | `Boolean` | default `true` |
| `isFeatured` | `Boolean` | default `false` |
| `isAvailable` | `Boolean` | default `true`, Agent availability status for connect requests |
| `subscriptionStatus` | `String?` | "ACTIVE", "NONE", etc. |
| `totalReviews` | `Int` | default `0` |
| `averageRating` | `Float` | default `0` |
| `createdAt` | `DateTime` | default `now(` |
| `updatedAt` | `DateTime` | |
| `user` | `User` | FK, relation |
| `agentType` | `AgentType?` | FK, relation |
| `fieldValues` | `AgentProfileFieldValue[]` | relation |
| `testimonialToken` | `String?` | unique |
| `connectionRequests` | `ConnectionRequest[]` | relation |
| `conversations` | `Conversation[]` | relation |
| `testimonials` | `Testimonial[]` | relation |
| `verificationHistory` | `VerificationHistory[]` | relation |
### AgentProfileFieldValue
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `agentProfileId` | `String` | |
| `fieldId` | `String` | |
| `textValue` | `String?` | For TEXT, TEXTAREA |
| `numberValue` | `Float?` | For NUMBER, RANGE |
| `booleanValue` | `Boolean?` | For single CHECKBOX |
| `jsonValue` | `Json?` | For MULTI_SELECT, RADIO, complex data |
| `dateValue` | `DateTime?` | For DATE |
| `createdAt` | `DateTime` | default `now(` |
| `updatedAt` | `DateTime` | |
| `agentProfile` | `AgentProfile` | FK, relation |
| `field` | `ProfileField` | FK, relation |
### AgentSubscription
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `userId` | `String` | |
| `planId` | `String` | |
| `stripeCustomerId` | `String` | cus_xxx |
| `stripeSubscriptionId` | `String?` | unique, sub_xxx |
| `status` | `SubscriptionStatus` | enum, default `INCOMPLETE` |
| `currentPeriodStart` | `DateTime?` | |
| `currentPeriodEnd` | `DateTime?` | |
| `cancelAtPeriodEnd` | `Boolean` | default `false` |
| `canceledAt` | `DateTime?` | |
| `createdAt` | `DateTime` | default `now(` |
| `updatedAt` | `DateTime` | |
| `user` | `User` | FK, relation |
| `plan` | `SubscriptionPlan` | FK, relation |
| `payments` | `Payment[]` | relation |
### AgentType
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `name` | `String` | unique |
| `description` | `String?` | |
| `icon` | `String?` | |
| `isActive` | `Boolean` | default `true` |
| `sortOrder` | `Int` | default `0` |
| `createdAt` | `DateTime` | default `now(` |
| `updatedAt` | `DateTime` | |
| `agents` | `AgentProfile[]` | relation |
| `agentTypeSections` | `AgentTypeSection[]` | relation |
### AgentTypeSection
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `agentTypeId` | `String` | |
| `sectionId` | `String` | |
| `sortOrder` | `Int` | default `0`, Order specific to this agent type |
| `isRequired` | `Boolean` | default `false`, Is this section required for this type |
| `createdAt` | `DateTime` | default `now(` |
| `updatedAt` | `DateTime` | |
| `agentType` | `AgentType` | FK, relation |
| `section` | `ProfileSection` | FK, relation |
### AuditLog
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `actorId` | `String?` | null = system / unauthenticated |
| `actorRole` | `String?` | USER, AGENT, ADMIN, SUPER_ADMIN, SYSTEM |
| `action` | `String` | see AuditAction enum in audit.constants.ts |
| `resourceType` | `String?` | User, AgentProfile, Subscription, Payment, etc. |
| `resourceId` | `String?` | |
| `metadata` | `Json?` | { before, after, reason, params, ... } |
| `ipAddress` | `String?` | |
| `userAgent` | `String?` | |
| `createdAt` | `DateTime` | default `now(` |
| `actor` | `User?` | FK, relation |
### CmsContent
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `pageSlug` | `String` | |
| `sectionKey` | `String` | |
| `content` | `Json` | |
| `isPublished` | `Boolean` | default `true` |
| `createdAt` | `DateTime` | default `now(` |
| `updatedAt` | `DateTime` | |
### ConnectionRequest
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `userId` | `String` | User sending the request |
| `agentProfileId` | `String` | Agent receiving the request |
| `status` | `ConnectionStatus` | enum, default `PENDING` |
| `message` | `String?` | Optional message from user |
| `createdAt` | `DateTime` | default `now(` |
| `updatedAt` | `DateTime` | |
| `respondedAt` | `DateTime?` | When agent responded |
| `user` | `User` | FK, relation |
| `agentProfile` | `AgentProfile` | FK, relation |
### ContactMessage
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `name` | `String` | |
| `email` | `String` | |
| `phone` | `String?` | |
| `message` | `String` | |
| `isRead` | `Boolean` | default `false` |
| `createdAt` | `DateTime` | default `now(` |
### Conversation
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `userId` | `String` | Regular user in the conversation |
| `agentProfileId` | `String` | Agent in the conversation |
| `lastMessageAt` | `DateTime?` | |
| `lastMessageText` | `String?` | |
| `userUnreadCount` | `Int` | default `0`, Unread count for the user |
| `agentUnreadCount` | `Int` | default `0`, Unread count for the agent |
| `userMuted` | `Boolean` | default `false` |
| `agentMuted` | `Boolean` | default `false` |
| `userFavorited` | `Boolean` | default `false` |
| `agentFavorited` | `Boolean` | default `false` |
| `userClearedAt` | `DateTime?` | |
| `agentClearedAt` | `DateTime?` | |
| `userDeletedAt` | `DateTime?` | |
| `agentDeletedAt` | `DateTime?` | |
| `createdAt` | `DateTime` | default `now(` |
| `updatedAt` | `DateTime` | |
| `user` | `User` | FK, relation |
| `agentProfile` | `AgentProfile` | FK, relation |
| `messages` | `Message[]` | relation |
### Message
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `conversationId` | `String` | |
| `senderId` | `String` | User ID of the sender (can be user or agent's user) |
| `content` | `String` | |
| `messageType` | `MessageType` | enum, default `TEXT` |
| `fileUrl` | `String?` | |
| `fileName` | `String?` | |
| `fileSize` | `Int?` | File size in bytes |
| `mimeType` | `String?` | |
| `status` | `MessageStatus` | enum, default `SENT` |
| `deliveredAt` | `DateTime?` | |
| `readAt` | `DateTime?` | |
| `createdAt` | `DateTime` | default `now(` |
| `updatedAt` | `DateTime` | |
| `conversation` | `Conversation` | FK, relation |
| `sender` | `User` | FK, relation |
### Notification
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `userId` | `String` | |
| `type` | `String` | 'connection', 'message', 'system', 'update', 'request' |
| `title` | `String` | |
| `description` | `String` | |
| `read` | `Boolean` | default `false` |
| `actionUrl` | `String?` | |
| `data` | `Json?` | Extra metadata (conversationId, connectionRequestId, etc.) |
| `createdAt` | `DateTime` | default `now(` |
| `user` | `User` | FK, relation |
### Payment
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `subscriptionId` | `String?` | |
| `userId` | `String` | |
| `stripePaymentIntentId` | `String?` | unique, pi_xxx |
| `stripeInvoiceId` | `String?` | unique, in_xxx |
| `amount` | `Int` | cents |
| `currency` | `String` | default `"usd"` |
| `status` | `String` | "succeeded", "failed", "pending" |
| `receiptUrl` | `String?` | |
| `createdAt` | `DateTime` | default `now(` |
| `subscription` | `AgentSubscription?` | FK, relation |
| `user` | `User` | FK, relation |
### ProfileField
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `sectionId` | `String` | |
| `name` | `String` | e.g., "State", "Years in Business" |
| `slug` | `String` | Unique within section: e.g., "state", "years_in_business" |
| `fieldType` | `FieldType` | enum |
| `description` | `String?` | Help text shown to user |
| `placeholder` | `String?` | Placeholder text for input |
| `defaultValue` | `String?` | Default value (JSON for complex types) |
| `sortOrder` | `Int` | default `0` |
| `isActive` | `Boolean` | default `true` |
| `isRequired` | `Boolean` | default `false` |
| `isSearchableOnly` | `Boolean` | default `false`, If true, shown in edit form & search, but hidden on public profile |
| `validation` | `Json?` | { min, max, minLength, maxLength, pattern, etc. } |
| `options` | `Json?` | [{ value: "...", label: "...", sortOrder: 0 }] |
| `rangeConfig` | `Json?` | { min: 0, max: 100, step: 1 } |
| `uiConfig` | `Json?` | { columns: 2, showInPreview: true, etc. } |
| `createdAt` | `DateTime` | default `now(` |
| `updatedAt` | `DateTime` | |
| `section` | `ProfileSection` | FK, relation |
| `fieldValues` | `AgentProfileFieldValue[]` | relation |
### ProfileSection
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `name` | `String` | e.g., "Location", "Experience", "Specialization" |
| `slug` | `String` | unique, URL-friendly identifier |
| `description` | `String?` | |
| `icon` | `String?` | Icon name or URL |
| `sortOrder` | `Int` | default `0` |
| `isActive` | `Boolean` | default `true` |
| `isGlobal` | `Boolean` | default `false`, If true, applies to ALL agent types |
| `isSystem` | `Boolean` | default `false`, If true, section cannot be deleted (system default) |
| `isRepeatable` | `Boolean` | default `false`, If true, user can add multiple entries (e.g., certifications) |
| `createdAt` | `DateTime` | default `now(` |
| `updatedAt` | `DateTime` | |
| `fields` | `ProfileField[]` | relation |
| `agentTypeSections` | `AgentTypeSection[]` | relation |
### Session
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `userId` | `String` | |
| `token` | `String` | unique |
| `refreshToken` | `String?` | unique |
| `userAgent` | `String?` | |
| `ipAddress` | `String?` | |
| `expiresAt` | `DateTime` | |
| `createdAt` | `DateTime` | default `now(` |
| `user` | `User` | FK, relation |
### SubscriptionPlan
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `name` | `String` | "Professional Annual" |
| `description` | `String?` | |
| `stripePriceId` | `String` | unique, Stripe Price ID (price_xxx) |
| `amount` | `Int` | Amount in cents (49900) |
| `currency` | `String` | default `"usd"` |
| `interval` | `String` | default `"year"`, "month" | "year" |
| `features` | `Json?` | ["Feature 1", "Feature 2"] |
| `isActive` | `Boolean` | default `true` |
| `sortOrder` | `Int` | default `0` |
| `createdAt` | `DateTime` | default `now(` |
| `updatedAt` | `DateTime` | |
| `subscriptions` | `AgentSubscription[]` | relation |
### SupportChat
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `userId` | `String` | |
| `status` | `SupportChatStatus` | enum, default `OPEN` |
| `lastMessageAt` | `DateTime?` | |
| `lastMessageText` | `String?` | |
| `userUnreadCount` | `Int` | default `0` |
| `adminUnreadCount` | `Int` | default `0` |
| `createdAt` | `DateTime` | default `now(` |
| `updatedAt` | `DateTime` | |
| `user` | `User` | FK, relation |
| `messages` | `SupportMessage[]` | relation |
### SupportMessage
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `chatId` | `String` | |
| `senderId` | `String` | |
| `senderRole` | `String` | "USER" or "ADMIN" |
| `content` | `String` | |
| `createdAt` | `DateTime` | default `now(` |
| `chat` | `SupportChat` | FK, relation |
### Testimonial
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `agentProfileId` | `String` | |
| `rating` | `Int` | 1-5 |
| `text` | `String` | |
| `authorName` | `String` | |
| `authorRole` | `String` | "Home Buyer", "Investor", etc. |
| `isPublished` | `Boolean` | default `true` |
| `createdAt` | `DateTime` | default `now(` |
| `updatedAt` | `DateTime` | |
| `agentProfile` | `AgentProfile` | FK, relation |
### User
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `email` | `String` | unique |
| `password` | `String?` | Null for social login users |
| `role` | `UserRole` | enum, default `USER` |
| `status` | `UserStatus` | enum, default `ACTIVE` |
| `emailVerified` | `Boolean` | default `false` |
| `emailVerifiedAt` | `DateTime?` | |
| `avatar` | `String?` | Profile picture URL |
| `authProvider` | `AuthProvider` | enum, default `LOCAL` |
| `googleId` | `String?` | unique |
| `facebookId` | `String?` | unique |
| `twitterId` | `String?` | unique |
| `appleId` | `String?` | unique |
| `twoFactorEnabled` | `Boolean` | default `false` |
| `twoFactorSecret` | `String?` | Encrypted TOTP secret |
| `twoFactorBackupCodes` | `String?` | JSON array of hashed backup codes |
| `twoFactorVerifiedAt` | `DateTime?` | When 2FA was enabled |
| `notificationPreferences` | `Json?` | { email: {...}, push: {...} } |
| `privacyPreferences` | `Json?` | { privacySettings: {...}, dataSettings: {...} } |
| `fcmTokens` | `Json?` | [{ token, device, createdAt }] |
| `createdAt` | `DateTime` | default `now(` |
| `updatedAt` | `DateTime` | |
| `lastLoginAt` | `DateTime?` | |
| `isOnline` | `Boolean` | default `false` |
| `lastSeenAt` | `DateTime?` | |
| `userProfile` | `UserProfile?` | relation |
| `agentProfile` | `AgentProfile?` | relation |
| `sessions` | `Session[]` | relation |
| `connectionRequests` | `ConnectionRequest[]` | relation |
| `conversations` | `Conversation[]` | relation |
| `messages` | `Message[]` | relation |
| `notifications` | `Notification[]` | relation |
| `supportChats` | `SupportChat[]` | relation |
| `subscriptions` | `AgentSubscription[]` | relation |
| `payments` | `Payment[]` | relation |
| `reportsSubmitted` | `UserReport[]` | relation |
| `reportsReceived` | `UserReport[]` | relation |
| `verificationActions` | `VerificationHistory[]` | relation |
| `auditLogs` | `AuditLog[]` | relation |
### UserProfile
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `userId` | `String` | unique |
| `firstName` | `String?` | |
| `lastName` | `String?` | |
| `phone` | `String?` | |
| `avatar` | `String?` | |
| `city` | `String?` | |
| `state` | `String?` | |
| `country` | `String?` | |
| `createdAt` | `DateTime` | default `now(` |
| `updatedAt` | `DateTime` | |
| `user` | `User` | FK, relation |
### UserReport
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `reporterId` | `String` | |
| `reportedUserId` | `String` | |
| `conversationId` | `String?` | |
| `reason` | `String` | |
| `description` | `String?` | |
| `status` | `ReportStatus` | enum, default `PENDING` |
| `adminNotes` | `String?` | |
| `createdAt` | `DateTime` | default `now(` |
| `updatedAt` | `DateTime` | |
| `reporter` | `User` | FK, relation |
| `reportedUser` | `User` | FK, relation |
### VerificationHistory
| Field | Type | Notes |
|---|---|---|
| `id` | `String` | PK, default `uuid(` |
| `agentProfileId` | `String` | |
| `status` | `VerificationStatus` | enum |
| `note` | `String?` | |
| `adminId` | `String?` | |
| `submittedData` | `Json?` | Snapshot of agent profile + documents at submission time |
| `createdAt` | `DateTime` | default `now(` |
| `agentProfile` | `AgentProfile` | FK, relation |
| `admin` | `User?` | FK, relation |
## Enums
- **AuthProvider**: `LOCAL`, `GOOGLE`, `FACEBOOK`, `TWITTER`, `APPLE`
- **ConnectionStatus**: `PENDING`, `ACCEPTED`, `REJECTED`
- **FieldType**: `TEXT // Single line text input`, `TEXTAREA // Multi-line text area`, `CHECKBOX // Single checkbox (Yes/No)`, `CHECKBOX_GROUP // Multiple checkboxes in grid (select multiple)`, `RADIO // Radio buttons (select one)`, `SELECT // Dropdown (select one)`, `MULTI_SELECT // Dropdown with multi-select`, `RANGE // Slider with min/max`, `NUMBER // Number input`, `DATE // Date picker`, `TAG_INPUT // Tag input (add custom tags)`, `FILE // File upload (documents, images)`, `REPEATER // Repeatable group of fields (e.g., certification + years)`
- **MessageStatus**: `SENT`, `DELIVERED`, `READ`
- **MessageType**: `TEXT`, `FILE`, `IMAGE`, `SYSTEM // For system messages like "connection accepted"`
- **ReportStatus**: `PENDING`, `REVIEWED`, `RESOLVED`, `DISMISSED`
- **SubscriptionStatus**: `ACTIVE`, `PAST_DUE`, `CANCELED`, `UNPAID`, `TRIALING`, `INCOMPLETE`
- **SupportChatStatus**: `OPEN`, `CLOSED`
- **UserRole**: `USER`, `AGENT`, `ADMIN`, `SUPER_ADMIN`
- **UserStatus**: `ACTIVE`, `INACTIVE`, `SUSPENDED`, `PENDING_VERIFICATION`
- **VerificationStatus**: `NONE // Agent hasn't uploaded documents`, `PENDING_REVIEW // Documents uploaded, awaiting admin review`, `APPROVED // Admin approved verification`, `REJECTED // Admin rejected verification`

377
docs/flows.md Normal file
View File

@@ -0,0 +1,377 @@
# Workflow (sequence) diagrams
Core flows, traced against the delivered source. Mermaid sequence diagrams —
editable and diffable in version control.
Endpoints are relative to the API prefix `/api/v1`.
---
## 1. Registration & email verification
```mermaid
sequenceDiagram
participant C as Client
participant API as AuthController
participant S as AuthService
participant DB as PostgreSQL
participant EV as EventEmitter
participant EL as EmailListener
participant M as Email gateway
C->>API: POST /auth/register
API->>S: register(dto)
S->>DB: find user by email
alt email already exists
S-->>C: 409 Conflict (states existing role)
else new user
S->>S: argon2.hash(password)
S->>DB: create User (+ profile)
S->>DB: create verification token
S->>EV: emit "user.registered"
S-->>C: 201 Created
end
EV-->>EL: user.registered
EL->>M: POST EMAIL_API_URL (verification email)
C->>API: POST /auth/verify-email { token }
API->>DB: mark emailVerified
API->>EV: emit "user.email-verified"
EV-->>EL: user.email-verified
EL->>M: POST EMAIL_API_URL (welcome email, role-aware)
```
Email delivery is asynchronous and best-effort: a failure is logged by
`EmailService` and does **not** fail the registration request.
---
## 2. Login (with 2FA branch)
```mermaid
sequenceDiagram
participant C as Client
participant API as AuthController
participant S as AuthService
participant T as TwoFactorService
participant DB as PostgreSQL
C->>API: POST /auth/login { email, password }
API->>DB: load user
API->>S: argon2.verify
alt invalid credentials
S-->>C: 401 Unauthorized
else 2FA enabled
S->>T: generateTempToken(userId)
S-->>C: 200 { requiresTwoFactor: true, tempToken }
C->>API: POST /auth/2fa/verify { tempToken, code }
API->>T: verifyTempToken(tempToken)
T->>DB: read twoFactorSecret (encrypted)
T->>T: AES-256-GCM decrypt, speakeasy.verify(code)
alt code invalid
T-->>C: 401 Unauthorized
else code valid
T->>DB: create Session (refresh token)
T-->>C: 200 { accessToken, refreshToken }
end
else 2FA disabled
S->>DB: create Session (refresh token)
S-->>C: 200 { accessToken, refreshToken }
end
```
Backup codes follow the same path via `POST /auth/2fa/verify-backup`.
### Token refresh
```mermaid
sequenceDiagram
participant C as Client
participant API as AuthController
participant DB as PostgreSQL
C->>API: POST /auth/refresh { refreshToken }
API->>DB: look up Session
alt session missing / expired / revoked
API-->>C: 401 — client must log in again
else valid
API->>DB: rotate Session
API-->>C: { accessToken, refreshToken }
end
```
`JWT_REFRESH_EXPIRATION` must be longer than `JWT_ACCESS_EXPIRATION`; if it is
shorter, refresh fails before the access token it is meant to renew expires and
users are logged out. Boot validation rejects this configuration.
---
## 3. Google sign-in
```mermaid
sequenceDiagram
participant B as Browser
participant NA as NextAuth (frontend)
participant G as Google OAuth
participant API as AuthController
participant DB as PostgreSQL
B->>NA: click "Continue with Google"
NA->>G: OAuth authorisation code flow
G-->>NA: id_token + profile
NA->>NA: verify token, extract profile
NA->>API: POST /auth/social { provider, email, name, providerId }
API->>DB: find or create User (authProvider = GOOGLE)
API->>DB: create Session
API-->>NA: { accessToken, refreshToken }
NA-->>B: session established
```
The OAuth client ID/secret live in the **frontend** environment. The
`GOOGLE_*` variables in the backend environment are not consumed. Facebook and
Twitter are not enabled.
---
## 4. Connection requests
```mermaid
sequenceDiagram
participant U as User
participant API as ConnectionRequestsController
participant DB as PostgreSQL
participant EV as EventEmitter
participant N as NotificationsService
participant A as Agent
U->>API: POST /connection-requests { agentProfileId, message }
API->>DB: create ConnectionRequest (status PENDING)
API->>EV: emit "notification.connection_request"
EV-->>N: handle
N->>DB: create Notification
N->>A: FCM push + email (per user preferences)
A->>API: PATCH /connection-requests/:id/respond { status }
API->>DB: update status (ACCEPTED / REJECTED)
API->>EV: emit "notification.connection_response"
EV-->>N: handle
N->>U: notification + push
note over DB: on ACCEPTED a Conversation becomes available
```
Supporting reads: `GET /connection-requests/my-requests`, `/received`,
`/received/counts`, `/status/:agentProfileId`.
---
## 5. Real-time messaging
```mermaid
sequenceDiagram
participant C1 as Sender
participant GW as MessagesGateway
participant SVC as MessagesService
participant DB as PostgreSQL
participant RD as Redis
participant C2 as Recipient
C1->>GW: connect (handshake.auth.token = JWT)
GW->>GW: jwtService.verifyAsync
alt invalid token
GW-->>C1: disconnect
else valid
GW->>RD: mark user online (presence)
GW->>GW: join room user:{userId}
GW-->>C1: connected
end
C1->>GW: join_conversation { conversationId }
GW->>GW: join room conversation:{id}
C1->>GW: send_message { conversationId, content }
GW->>SVC: create message
SVC->>DB: insert Message
SVC->>GW: emit "notification.message" (offline delivery)
GW->>RD: publish via Socket.IO Redis adapter
RD-->>GW: fan-out to other API instances
GW-->>C2: new_message
C2->>GW: mark_read { conversationId }
GW->>DB: update read state
GW-->>C1: messages_read
```
Other events: `typing_start` / `typing_stop`, `message_received`,
`user_status_change` (presence broadcast), and the parallel support-chat set
(`support_join`, `support_send_message`, `support_new_message`, …).
Ping interval and timeout are both 5s, so a dropped connection is detected in
roughly 10s rather than the Socket.IO default of ~45s.
CORS on this gateway is driven by `CORS_ORIGINS`, read at module-import time —
see `src/load-env.ts`.
---
## 6. Subscription, payment & Stripe webhooks
```mermaid
sequenceDiagram
participant A as Agent
participant API as StripeController
participant ST as Stripe
participant WH as StripeWebhookController
participant DB as PostgreSQL
A->>API: GET /stripe/plans
API-->>A: SubscriptionPlan list
A->>API: POST /stripe/create-checkout-session { planId }
API->>ST: checkout.sessions.create
ST-->>A: redirect to Stripe Checkout
A->>ST: completes payment
ST->>WH: POST /stripe/webhook (signed)
WH->>WH: constructEvent(rawBody, sig, STRIPE_WEBHOOK_SECRET)
alt signature invalid
WH-->>ST: 400
else valid
alt checkout.session.completed
WH->>DB: create/activate AgentSubscription
else invoice.paid
WH->>DB: record Payment, extend period
else invoice.payment_failed
WH->>DB: mark PAST_DUE
else customer.subscription.updated
WH->>DB: sync status / plan
else customer.subscription.deleted
WH->>DB: mark CANCELED
end
WH-->>ST: 200
end
```
The webhook route needs the **raw** request body for signature verification
(`rawBody: true` is set on the Nest application). Any proxy that rewrites the
body will break signature checks.
Self-service billing management goes through
`POST /stripe/create-portal-session`; cancellation through
`POST /stripe/cancel-subscription`.
---
## 7. Agent verification
```mermaid
sequenceDiagram
participant AG as Agent
participant API as UsersController
participant DB as PostgreSQL
participant AD as Admin
participant EV as EventEmitter
participant N as NotificationsService
AG->>API: POST /users/me/verification/submit
API->>DB: AgentProfile.verificationStatus = PENDING_REVIEW
AD->>API: GET /users?verificationStatus=PENDING_REVIEW
AD->>API: PATCH /users/:id/verification { status }
API->>DB: update status + insert VerificationHistory (admin, reason)
API->>EV: emit "notification.verification"
EV-->>N: handle
N->>AG: notification + push + email
```
`VerificationHistory` is the audit trail: who changed the status, when, and
why. Note that approval is **not** gated on an active subscription in the
backend — see architecture.md §4.
---
## 8. File upload
```mermaid
sequenceDiagram
participant C as Client
participant API as UploadController
participant S3 as Object storage
participant DB as PostgreSQL
C->>API: POST /upload/presigned-url { filename, contentType }
API->>API: validate type/size, build key with S3_FOLDER_PREFIX
API->>S3: sign PUT URL
API-->>C: { uploadUrl, key }
C->>S3: PUT file (direct, bypasses the API)
C->>API: PATCH resource with { key }
API->>DB: store the object KEY (never a full URL)
note over C,S3: reading back
C->>API: GET /upload/presigned-download-url?key=...
API-->>C: time-limited URL
```
Variants: `message-presigned-url`, `avatar-presigned-url`,
`user-avatar-presigned-url`; deletion via `DELETE /upload/:key`.
Because only object **keys** are stored, any storage migration must preserve
the key layout exactly or every existing reference breaks.
---
## 9. Transactional email — trigger events
All email is sent by POSTing JSON to `EMAIL_API_URL`. There is no SMTP path in
the code; `MAIL_*` / `SMTP_*` variables are ignored.
| Event | Listener | Email sent |
|---|---|---|
| `user.registered` | `EmailListener` | Verification email |
| `user.email-verified` | `EmailListener` | Welcome email (USER vs Professional/AGENT variant) |
| `password.reset-requested` | `EmailListener` | Password reset link |
| `password.reset-completed` | `EmailListener` | Confirmation |
| `user.email-change-requested` | `EmailListener` | Verification for the new address |
| notification fan-out | `NotificationsService` | Generic notification email (connection requests/responses, new messages, verification outcome) subject to user preferences |
Templates live in `backend/src/email/templates`.
---
## 10. Data-flow diagram
Where data lives and who it crosses.
```mermaid
flowchart LR
subgraph Clients
W[Web]
AD[Admin]
MO[Mobile]
end
W -- "credentials, profile, messages" --> API
AD -- "moderation actions" --> API
MO -- "credentials, profile, messages" --> API
API[Backend API]
API -- "users, profiles, messages,<br/>subscriptions, audit log" --> PG[(PostgreSQL)]
API -- "presence, socket rooms" --> RD[(Redis)]
API -- "object keys only" --> PG
W -- "file bytes (presigned PUT)" --> S3[(Object storage)]
MO -- "file bytes (presigned PUT)" --> S3
API -- "sign / delete" --> S3
API -- "email address, name, links" --> MAIL[Email gateway]
API -- "device token, title, body" --> FCM[Firebase FCM]
API -- "customer id, price id, amount" --> STRIPE[Stripe]
STRIPE -- "subscription + invoice events" --> API
W -- "OAuth profile" --> GOOGLE[Google OAuth]
classDef pii fill:#fde,stroke:#b47
class MAIL,STRIPE,GOOGLE,FCM pii
```
Personal data leaving the platform boundary (highlighted): email address and
display name to the email gateway; email and billing identifiers to Stripe;
device tokens and notification content to Firebase; email and profile to Google
during sign-in. Message bodies and uploaded files never leave PostgreSQL and
object storage.