Merge pull request 'fix(security): resolve client audit findings' (#1) from fix/security-audit into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-08-04 13:18:41 +00:00
20 changed files with 2172 additions and 27 deletions

View File

@@ -15,22 +15,40 @@ ADMIN_URL=http://localhost:3002
DATABASE_URL="postgresql://postgres:password@localhost:5432/real_estate_db?schema=public"
# JWT Authentication
# Required. Minimum 32 chars in production; boot fails if shorter.
JWT_SECRET=your-super-secret-jwt-key-change-in-production
# JWT_REFRESH_EXPIRATION MUST be longer than JWT_ACCESS_EXPIRATION.
JWT_ACCESS_EXPIRATION=15m
JWT_REFRESH_EXPIRATION=7d
# Two-Factor Authentication
# Key used to encrypt stored TOTP secrets. Optional — falls back to JWT_SECRET.
# Set this BEFORE enabling 2FA in a new environment: once secrets are encrypted
# under JWT_SECRET, rotating JWT_SECRET without re-encrypting locks out every
# 2FA user. See docs/2fa-key-rotation.md.
TWO_FACTOR_ENCRYPTION_KEY=
# Admin bootstrap (used by `npm run db:seed` only)
# Required by the seed — there are no defaults. Password must be >= 12 chars.
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=change-me-before-seeding
# Password Hashing
BCRYPT_SALT_ROUNDS=12
# Google OAuth
# NOTE: Google sign-in is handled by NextAuth in the frontend, which posts the
# verified profile to POST /auth/social. These backend vars are currently NOT
# consumed by the backend; the authoritative values live in the frontend env.
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_CALLBACK_URL=http://localhost:3001/auth/google/callback
# Facebook OAuth
FACEBOOK_APP_ID=your-facebook-app-id
FACEBOOK_APP_SECRET=your-facebook-app-secret
FACEBOOK_CALLBACK_URL=http://localhost:3001/auth/facebook/callback
# Facebook OAuth — NOT IN USE (Google is the only enabled social provider).
# Kept only so config/configuration.ts keeps type-checking. Leave blank.
FACEBOOK_APP_ID=
FACEBOOK_APP_SECRET=
FACEBOOK_CALLBACK_URL=
# AWS S3 (File Storage)
AWS_ACCESS_KEY_ID=your-aws-access-key
@@ -38,13 +56,19 @@ AWS_SECRET_ACCESS_KEY=your-aws-secret-key
AWS_REGION=us-east-1
AWS_S3_BUCKET=your-s3-bucket-name
S3_FOLDER_PREFIX=development # Root folder for all uploads (e.g., 'development', 'staging', 'production')
# Custom endpoint for S3-compatible storage (DigitalOcean Spaces, MinIO,
# Contabo). Leave blank for real AWS S3.
# If it ends in /s3 (MinIO behind an nginx path prefix), the SDK signs against
# the host root and browser-facing URLs keep the /s3 prefix.
S3_ENDPOINT=
# Public base for browser-facing URLs when it differs from the signing
# endpoint. Defaults to S3_ENDPOINT.
S3_PUBLIC_ENDPOINT=
# Email (SMTP / SendGrid)
MAIL_HOST=smtp.sendgrid.net
MAIL_PORT=587
MAIL_USER=apikey
MAIL_PASSWORD=your-sendgrid-api-key
MAIL_FROM="Real Estate Platform <noreply@yourdomain.com>"
# Email (REQUIRED — boot fails without it)
# The app POSTs JSON to this endpoint to send all transactional email.
# There is no SMTP path in the code: MAIL_*/SMTP_* variables are NOT read.
EMAIL_API_URL=https://your-email-provider.example.com/v1/send
# Stripe (Payments)
STRIPE_SECRET_KEY=sk_test_your-stripe-secret-key
@@ -52,6 +76,10 @@ STRIPE_WEBHOOK_SECRET=whsec_your-webhook-secret
STRIPE_PUBLISHABLE_KEY=pk_test_your-stripe-publishable-key
# Firebase (Push Notifications)
# Either point at a service-account JSON file (preferred, path is relative to
# the process working directory) or supply the three vars below. If neither is
# present the app still boots and push notifications are silently disabled.
FIREBASE_SERVICE_ACCOUNT_KEY_PATH=./firebase-service-account.json
FIREBASE_PROJECT_ID=your-firebase-project-id
FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nYour-Private-Key\n-----END PRIVATE KEY-----"
@@ -61,6 +89,9 @@ REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# Set to "true" for managed Redis/Valkey over TLS. Certificates are verified —
# a self-signed cert will be rejected.
REDIS_TLS=false
# Rate Limiting
THROTTLE_TTL=60
@@ -70,4 +101,8 @@ THROTTLE_LIMIT=100
LOG_LEVEL=debug
# CORS
# REQUIRED in production (boot fails without it). Consumed by both the HTTP
# server and the WebSocket gateway — if unset, real-time messaging only accepts
# localhost origins and every browser connection from the live domain is
# rejected.
CORS_ORIGINS=http://localhost:3000,http://localhost:3002

1
.gitignore vendored
View File

@@ -43,6 +43,5 @@ temp/
*.pid
*.seed
*.pid.lock
prisma/migrations/
CLAUDE.md
real-estate-2d71e-firebase-adminsdk-fbsvc-2cb74385ab.json

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.

View File

@@ -0,0 +1,793 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('USER', 'AGENT', 'ADMIN', 'SUPER_ADMIN');
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('ACTIVE', 'INACTIVE', 'SUSPENDED', 'PENDING_VERIFICATION');
-- CreateEnum
CREATE TYPE "AuthProvider" AS ENUM ('LOCAL', 'GOOGLE', 'FACEBOOK', 'TWITTER', 'APPLE');
-- CreateEnum
CREATE TYPE "FieldType" AS ENUM ('TEXT', 'TEXTAREA', 'CHECKBOX', 'CHECKBOX_GROUP', 'RADIO', 'SELECT', 'MULTI_SELECT', 'RANGE', 'NUMBER', 'DATE', 'TAG_INPUT', 'FILE', 'REPEATER');
-- CreateEnum
CREATE TYPE "VerificationStatus" AS ENUM ('NONE', 'PENDING_REVIEW', 'APPROVED', 'REJECTED');
-- CreateEnum
CREATE TYPE "ConnectionStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
-- CreateEnum
CREATE TYPE "MessageType" AS ENUM ('TEXT', 'FILE', 'IMAGE', 'SYSTEM');
-- CreateEnum
CREATE TYPE "MessageStatus" AS ENUM ('SENT', 'DELIVERED', 'READ');
-- CreateEnum
CREATE TYPE "SupportChatStatus" AS ENUM ('OPEN', 'CLOSED');
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'PAST_DUE', 'CANCELED', 'UNPAID', 'TRIALING', 'INCOMPLETE');
-- CreateEnum
CREATE TYPE "ReportStatus" AS ENUM ('PENDING', 'REVIEWED', 'RESOLVED', 'DISMISSED');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT,
"role" "UserRole" NOT NULL DEFAULT 'USER',
"status" "UserStatus" NOT NULL DEFAULT 'ACTIVE',
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"emailVerifiedAt" TIMESTAMP(3),
"avatar" TEXT,
"authProvider" "AuthProvider" NOT NULL DEFAULT 'LOCAL',
"googleId" TEXT,
"facebookId" TEXT,
"twitterId" TEXT,
"appleId" TEXT,
"twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
"twoFactorSecret" TEXT,
"twoFactorBackupCodes" TEXT,
"twoFactorVerifiedAt" TIMESTAMP(3),
"notificationPreferences" JSONB,
"privacyPreferences" JSONB,
"fcmTokens" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"lastLoginAt" TIMESTAMP(3),
"isOnline" BOOLEAN NOT NULL DEFAULT false,
"lastSeenAt" TIMESTAMP(3),
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_profiles" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"firstName" TEXT,
"lastName" TEXT,
"phone" TEXT,
"avatar" TEXT,
"city" TEXT,
"state" TEXT,
"country" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_profiles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agent_profiles" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"agentTypeId" TEXT,
"firstName" TEXT,
"lastName" TEXT,
"phone" TEXT,
"avatar" TEXT,
"bio" TEXT,
"headline" TEXT,
"city" TEXT,
"state" TEXT,
"country" TEXT,
"address" TEXT,
"zipCode" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"yearsOfExperience" INTEGER,
"licenseNumber" TEXT,
"companyName" TEXT,
"website" TEXT,
"facebookUrl" TEXT,
"twitterUrl" TEXT,
"linkedinUrl" TEXT,
"instagramUrl" TEXT,
"isVerified" BOOLEAN NOT NULL DEFAULT false,
"verificationStatus" "VerificationStatus" NOT NULL DEFAULT 'NONE',
"verificationNote" TEXT,
"verifiedAt" TIMESTAMP(3),
"verifiedBy" TEXT,
"isProfileComplete" BOOLEAN NOT NULL DEFAULT false,
"profileCompleteness" INTEGER NOT NULL DEFAULT 0,
"isPublic" BOOLEAN NOT NULL DEFAULT true,
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
"isAvailable" BOOLEAN NOT NULL DEFAULT true,
"subscriptionStatus" TEXT,
"totalReviews" INTEGER NOT NULL DEFAULT 0,
"averageRating" DOUBLE PRECISION NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"testimonialToken" TEXT,
CONSTRAINT "agent_profiles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agent_types" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"icon" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "agent_types_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "profile_sections" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"icon" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isGlobal" BOOLEAN NOT NULL DEFAULT false,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"isRepeatable" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "profile_sections_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agent_type_sections" (
"id" TEXT NOT NULL,
"agentTypeId" TEXT NOT NULL,
"sectionId" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isRequired" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "agent_type_sections_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "profile_fields" (
"id" TEXT NOT NULL,
"sectionId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"fieldType" "FieldType" NOT NULL,
"description" TEXT,
"placeholder" TEXT,
"defaultValue" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isRequired" BOOLEAN NOT NULL DEFAULT false,
"isSearchableOnly" BOOLEAN NOT NULL DEFAULT false,
"validation" JSONB,
"options" JSONB,
"rangeConfig" JSONB,
"uiConfig" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "profile_fields_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agent_profile_field_values" (
"id" TEXT NOT NULL,
"agentProfileId" TEXT NOT NULL,
"fieldId" TEXT NOT NULL,
"textValue" TEXT,
"numberValue" DOUBLE PRECISION,
"booleanValue" BOOLEAN,
"jsonValue" JSONB,
"dateValue" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "agent_profile_field_values_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"refreshToken" TEXT,
"userAgent" TEXT,
"ipAddress" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "connection_requests" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"agentProfileId" TEXT NOT NULL,
"status" "ConnectionStatus" NOT NULL DEFAULT 'PENDING',
"message" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"respondedAt" TIMESTAMP(3),
CONSTRAINT "connection_requests_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "conversations" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"agentProfileId" TEXT NOT NULL,
"lastMessageAt" TIMESTAMP(3),
"lastMessageText" VARCHAR(255),
"userUnreadCount" INTEGER NOT NULL DEFAULT 0,
"agentUnreadCount" INTEGER NOT NULL DEFAULT 0,
"userMuted" BOOLEAN NOT NULL DEFAULT false,
"agentMuted" BOOLEAN NOT NULL DEFAULT false,
"userFavorited" BOOLEAN NOT NULL DEFAULT false,
"agentFavorited" BOOLEAN NOT NULL DEFAULT false,
"userClearedAt" TIMESTAMP(3),
"agentClearedAt" TIMESTAMP(3),
"userDeletedAt" TIMESTAMP(3),
"agentDeletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "conversations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "messages" (
"id" TEXT NOT NULL,
"conversationId" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"messageType" "MessageType" NOT NULL DEFAULT 'TEXT',
"fileUrl" TEXT,
"fileName" TEXT,
"fileSize" INTEGER,
"mimeType" TEXT,
"status" "MessageStatus" NOT NULL DEFAULT 'SENT',
"deliveredAt" TIMESTAMP(3),
"readAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "messages_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notifications" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"read" BOOLEAN NOT NULL DEFAULT false,
"actionUrl" TEXT,
"data" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cms_contents" (
"id" TEXT NOT NULL,
"pageSlug" TEXT NOT NULL,
"sectionKey" TEXT NOT NULL,
"content" JSONB NOT NULL,
"isPublished" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "cms_contents_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "testimonials" (
"id" TEXT NOT NULL,
"agentProfileId" TEXT NOT NULL,
"rating" INTEGER NOT NULL,
"text" TEXT NOT NULL,
"authorName" TEXT NOT NULL,
"authorRole" TEXT NOT NULL,
"isPublished" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "testimonials_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "support_chats" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" "SupportChatStatus" NOT NULL DEFAULT 'OPEN',
"lastMessageAt" TIMESTAMP(3),
"lastMessageText" VARCHAR(255),
"userUnreadCount" INTEGER NOT NULL DEFAULT 0,
"adminUnreadCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "support_chats_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "support_messages" (
"id" TEXT NOT NULL,
"chatId" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
"senderRole" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "support_messages_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "subscription_plans" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"stripePriceId" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'usd',
"interval" TEXT NOT NULL DEFAULT 'year',
"features" JSONB,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "subscription_plans_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agent_subscriptions" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"planId" TEXT NOT NULL,
"stripeCustomerId" TEXT NOT NULL,
"stripeSubscriptionId" TEXT,
"status" "SubscriptionStatus" NOT NULL DEFAULT 'INCOMPLETE',
"currentPeriodStart" TIMESTAMP(3),
"currentPeriodEnd" TIMESTAMP(3),
"cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false,
"canceledAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "agent_subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payments" (
"id" TEXT NOT NULL,
"subscriptionId" TEXT,
"userId" TEXT NOT NULL,
"stripePaymentIntentId" TEXT,
"stripeInvoiceId" TEXT,
"amount" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'usd',
"status" TEXT NOT NULL,
"receiptUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "payments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_reports" (
"id" TEXT NOT NULL,
"reporterId" TEXT NOT NULL,
"reportedUserId" TEXT NOT NULL,
"conversationId" TEXT,
"reason" TEXT NOT NULL,
"description" TEXT,
"status" "ReportStatus" NOT NULL DEFAULT 'PENDING',
"adminNotes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_reports_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "contact_messages" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT,
"message" TEXT NOT NULL,
"isRead" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "contact_messages_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verification_history" (
"id" TEXT NOT NULL,
"agentProfileId" TEXT NOT NULL,
"status" "VerificationStatus" NOT NULL,
"note" TEXT,
"adminId" TEXT,
"submittedData" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "verification_history_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" TEXT NOT NULL,
"actorId" TEXT,
"actorRole" TEXT,
"action" TEXT NOT NULL,
"resourceType" TEXT,
"resourceId" TEXT,
"metadata" JSONB,
"ipAddress" TEXT,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "users_googleId_key" ON "users"("googleId");
-- CreateIndex
CREATE UNIQUE INDEX "users_facebookId_key" ON "users"("facebookId");
-- CreateIndex
CREATE UNIQUE INDEX "users_twitterId_key" ON "users"("twitterId");
-- CreateIndex
CREATE UNIQUE INDEX "users_appleId_key" ON "users"("appleId");
-- CreateIndex
CREATE INDEX "users_email_idx" ON "users"("email");
-- CreateIndex
CREATE INDEX "users_role_idx" ON "users"("role");
-- CreateIndex
CREATE INDEX "users_status_idx" ON "users"("status");
-- CreateIndex
CREATE INDEX "users_isOnline_idx" ON "users"("isOnline");
-- CreateIndex
CREATE UNIQUE INDEX "user_profiles_userId_key" ON "user_profiles"("userId");
-- CreateIndex
CREATE INDEX "user_profiles_userId_idx" ON "user_profiles"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "agent_profiles_userId_key" ON "agent_profiles"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "agent_profiles_slug_key" ON "agent_profiles"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "agent_profiles_testimonialToken_key" ON "agent_profiles"("testimonialToken");
-- CreateIndex
CREATE INDEX "agent_profiles_userId_idx" ON "agent_profiles"("userId");
-- CreateIndex
CREATE INDEX "agent_profiles_agentTypeId_idx" ON "agent_profiles"("agentTypeId");
-- CreateIndex
CREATE INDEX "agent_profiles_slug_idx" ON "agent_profiles"("slug");
-- CreateIndex
CREATE INDEX "agent_profiles_city_state_idx" ON "agent_profiles"("city", "state");
-- CreateIndex
CREATE INDEX "agent_profiles_isVerified_idx" ON "agent_profiles"("isVerified");
-- CreateIndex
CREATE INDEX "agent_profiles_verificationStatus_idx" ON "agent_profiles"("verificationStatus");
-- CreateIndex
CREATE INDEX "agent_profiles_isPublic_idx" ON "agent_profiles"("isPublic");
-- CreateIndex
CREATE INDEX "agent_profiles_isFeatured_idx" ON "agent_profiles"("isFeatured");
-- CreateIndex
CREATE INDEX "agent_profiles_isAvailable_idx" ON "agent_profiles"("isAvailable");
-- CreateIndex
CREATE UNIQUE INDEX "agent_types_name_key" ON "agent_types"("name");
-- CreateIndex
CREATE INDEX "agent_types_isActive_idx" ON "agent_types"("isActive");
-- CreateIndex
CREATE UNIQUE INDEX "profile_sections_slug_key" ON "profile_sections"("slug");
-- CreateIndex
CREATE INDEX "profile_sections_isActive_idx" ON "profile_sections"("isActive");
-- CreateIndex
CREATE INDEX "profile_sections_isSystem_idx" ON "profile_sections"("isSystem");
-- CreateIndex
CREATE INDEX "profile_sections_sortOrder_idx" ON "profile_sections"("sortOrder");
-- CreateIndex
CREATE INDEX "agent_type_sections_agentTypeId_idx" ON "agent_type_sections"("agentTypeId");
-- CreateIndex
CREATE INDEX "agent_type_sections_sectionId_idx" ON "agent_type_sections"("sectionId");
-- CreateIndex
CREATE UNIQUE INDEX "agent_type_sections_agentTypeId_sectionId_key" ON "agent_type_sections"("agentTypeId", "sectionId");
-- CreateIndex
CREATE INDEX "profile_fields_sectionId_idx" ON "profile_fields"("sectionId");
-- CreateIndex
CREATE INDEX "profile_fields_isActive_idx" ON "profile_fields"("isActive");
-- CreateIndex
CREATE UNIQUE INDEX "profile_fields_sectionId_slug_key" ON "profile_fields"("sectionId", "slug");
-- CreateIndex
CREATE INDEX "agent_profile_field_values_agentProfileId_idx" ON "agent_profile_field_values"("agentProfileId");
-- CreateIndex
CREATE INDEX "agent_profile_field_values_fieldId_idx" ON "agent_profile_field_values"("fieldId");
-- CreateIndex
CREATE UNIQUE INDEX "agent_profile_field_values_agentProfileId_fieldId_key" ON "agent_profile_field_values"("agentProfileId", "fieldId");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_token_key" ON "sessions"("token");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_refreshToken_key" ON "sessions"("refreshToken");
-- CreateIndex
CREATE INDEX "sessions_userId_idx" ON "sessions"("userId");
-- CreateIndex
CREATE INDEX "sessions_token_idx" ON "sessions"("token");
-- CreateIndex
CREATE INDEX "connection_requests_agentProfileId_status_idx" ON "connection_requests"("agentProfileId", "status");
-- CreateIndex
CREATE INDEX "connection_requests_userId_status_idx" ON "connection_requests"("userId", "status");
-- CreateIndex
CREATE INDEX "connection_requests_status_idx" ON "connection_requests"("status");
-- CreateIndex
CREATE UNIQUE INDEX "connection_requests_userId_agentProfileId_key" ON "connection_requests"("userId", "agentProfileId");
-- CreateIndex
CREATE INDEX "conversations_userId_lastMessageAt_idx" ON "conversations"("userId", "lastMessageAt");
-- CreateIndex
CREATE INDEX "conversations_agentProfileId_lastMessageAt_idx" ON "conversations"("agentProfileId", "lastMessageAt");
-- CreateIndex
CREATE UNIQUE INDEX "conversations_userId_agentProfileId_key" ON "conversations"("userId", "agentProfileId");
-- CreateIndex
CREATE INDEX "messages_conversationId_createdAt_idx" ON "messages"("conversationId", "createdAt");
-- CreateIndex
CREATE INDEX "messages_senderId_idx" ON "messages"("senderId");
-- CreateIndex
CREATE INDEX "messages_status_idx" ON "messages"("status");
-- CreateIndex
CREATE INDEX "notifications_userId_read_idx" ON "notifications"("userId", "read");
-- CreateIndex
CREATE INDEX "notifications_userId_createdAt_idx" ON "notifications"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "cms_contents_pageSlug_idx" ON "cms_contents"("pageSlug");
-- CreateIndex
CREATE UNIQUE INDEX "cms_contents_pageSlug_sectionKey_key" ON "cms_contents"("pageSlug", "sectionKey");
-- CreateIndex
CREATE INDEX "testimonials_agentProfileId_idx" ON "testimonials"("agentProfileId");
-- CreateIndex
CREATE INDEX "support_chats_userId_idx" ON "support_chats"("userId");
-- CreateIndex
CREATE INDEX "support_chats_status_idx" ON "support_chats"("status");
-- CreateIndex
CREATE INDEX "support_messages_chatId_idx" ON "support_messages"("chatId");
-- CreateIndex
CREATE UNIQUE INDEX "subscription_plans_stripePriceId_key" ON "subscription_plans"("stripePriceId");
-- CreateIndex
CREATE INDEX "subscription_plans_isActive_idx" ON "subscription_plans"("isActive");
-- CreateIndex
CREATE UNIQUE INDEX "agent_subscriptions_stripeSubscriptionId_key" ON "agent_subscriptions"("stripeSubscriptionId");
-- CreateIndex
CREATE INDEX "agent_subscriptions_userId_idx" ON "agent_subscriptions"("userId");
-- CreateIndex
CREATE INDEX "agent_subscriptions_status_idx" ON "agent_subscriptions"("status");
-- CreateIndex
CREATE INDEX "agent_subscriptions_stripeCustomerId_idx" ON "agent_subscriptions"("stripeCustomerId");
-- CreateIndex
CREATE UNIQUE INDEX "payments_stripePaymentIntentId_key" ON "payments"("stripePaymentIntentId");
-- CreateIndex
CREATE UNIQUE INDEX "payments_stripeInvoiceId_key" ON "payments"("stripeInvoiceId");
-- CreateIndex
CREATE INDEX "payments_userId_idx" ON "payments"("userId");
-- CreateIndex
CREATE INDEX "payments_subscriptionId_idx" ON "payments"("subscriptionId");
-- CreateIndex
CREATE INDEX "payments_status_idx" ON "payments"("status");
-- CreateIndex
CREATE INDEX "user_reports_reporterId_idx" ON "user_reports"("reporterId");
-- CreateIndex
CREATE INDEX "user_reports_reportedUserId_idx" ON "user_reports"("reportedUserId");
-- CreateIndex
CREATE INDEX "user_reports_status_idx" ON "user_reports"("status");
-- CreateIndex
CREATE INDEX "contact_messages_isRead_idx" ON "contact_messages"("isRead");
-- CreateIndex
CREATE INDEX "contact_messages_createdAt_idx" ON "contact_messages"("createdAt");
-- CreateIndex
CREATE INDEX "verification_history_agentProfileId_idx" ON "verification_history"("agentProfileId");
-- CreateIndex
CREATE INDEX "verification_history_createdAt_idx" ON "verification_history"("createdAt");
-- CreateIndex
CREATE INDEX "audit_logs_actorId_createdAt_idx" ON "audit_logs"("actorId", "createdAt");
-- CreateIndex
CREATE INDEX "audit_logs_resourceType_resourceId_idx" ON "audit_logs"("resourceType", "resourceId");
-- CreateIndex
CREATE INDEX "audit_logs_action_createdAt_idx" ON "audit_logs"("action", "createdAt");
-- CreateIndex
CREATE INDEX "audit_logs_createdAt_idx" ON "audit_logs"("createdAt");
-- AddForeignKey
ALTER TABLE "user_profiles" ADD CONSTRAINT "user_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_profiles" ADD CONSTRAINT "agent_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_profiles" ADD CONSTRAINT "agent_profiles_agentTypeId_fkey" FOREIGN KEY ("agentTypeId") REFERENCES "agent_types"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_type_sections" ADD CONSTRAINT "agent_type_sections_agentTypeId_fkey" FOREIGN KEY ("agentTypeId") REFERENCES "agent_types"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_type_sections" ADD CONSTRAINT "agent_type_sections_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "profile_sections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "profile_fields" ADD CONSTRAINT "profile_fields_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "profile_sections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_profile_field_values" ADD CONSTRAINT "agent_profile_field_values_agentProfileId_fkey" FOREIGN KEY ("agentProfileId") REFERENCES "agent_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_profile_field_values" ADD CONSTRAINT "agent_profile_field_values_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "profile_fields"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "connection_requests" ADD CONSTRAINT "connection_requests_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "connection_requests" ADD CONSTRAINT "connection_requests_agentProfileId_fkey" FOREIGN KEY ("agentProfileId") REFERENCES "agent_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "conversations" ADD CONSTRAINT "conversations_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "conversations" ADD CONSTRAINT "conversations_agentProfileId_fkey" FOREIGN KEY ("agentProfileId") REFERENCES "agent_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "messages" ADD CONSTRAINT "messages_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "messages" ADD CONSTRAINT "messages_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "testimonials" ADD CONSTRAINT "testimonials_agentProfileId_fkey" FOREIGN KEY ("agentProfileId") REFERENCES "agent_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "support_chats" ADD CONSTRAINT "support_chats_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "support_messages" ADD CONSTRAINT "support_messages_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "support_chats"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_subscriptions" ADD CONSTRAINT "agent_subscriptions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_subscriptions" ADD CONSTRAINT "agent_subscriptions_planId_fkey" FOREIGN KEY ("planId") REFERENCES "subscription_plans"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "agent_subscriptions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_reports" ADD CONSTRAINT "user_reports_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_reports" ADD CONSTRAINT "user_reports_reportedUserId_fkey" FOREIGN KEY ("reportedUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "verification_history" ADD CONSTRAINT "verification_history_agentProfileId_fkey" FOREIGN KEY ("agentProfileId") REFERENCES "agent_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "verification_history" ADD CONSTRAINT "verification_history_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -20,6 +20,22 @@ async function main() {
const prisma = new PrismaClient({ adapter });
// Validate up front so a misconfigured run fails before it writes anything.
// No defaults: a seed run without these would create a super-admin whose
// credentials are published in this file.
const adminEmail = process.env.ADMIN_EMAIL;
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminEmail || !adminPassword) {
throw new Error(
'ADMIN_EMAIL and ADMIN_PASSWORD must be set to seed the super-admin account',
);
}
if (adminPassword.length < 12) {
throw new Error('ADMIN_PASSWORD must be at least 12 characters');
}
console.log('🌱 Starting database seeding...\n');
// =============================================
@@ -1256,9 +1272,7 @@ async function main() {
// =============================================
console.log('👤 Seeding Admin User...');
const adminEmail = process.env.ADMIN_EMAIL || 'admin@re-quest.com';
const adminPassword = process.env.ADMIN_PASSWORD || 'Admin@123456';
// adminEmail / adminPassword are validated at the top of main().
// Hash password with Argon2 (more secure than bcrypt)
const hashedPassword = await argon2.hash(adminPassword);
@@ -1286,7 +1300,6 @@ async function main() {
console.log(' ✅ Admin user created successfully!');
console.log(' ───────────────────────────────');
console.log(` 📧 Email: ${adminEmail}`);
console.log(` 🔑 Password: ${adminPassword}`);
console.log(` 👤 Role: ${admin.role}`);
console.log(` 🆔 ID: ${admin.id}`);
console.log(' ───────────────────────────────\n');

View File

@@ -21,8 +21,21 @@ export class TwoFactorService {
private readonly configService: ConfigService,
private readonly jwtService: JwtService,
) {
// Use JWT secret as base for encryption key (derive a 32-byte key)
const secret = this.configService.get<string>('jwt.secret') || 'default-secret';
// Key for the stored TOTP secrets. Prefer a dedicated key so that rotating
// JWT_SECRET does not make every existing 2FA secret undecryptable; fall
// back to JWT_SECRET for records encrypted before that split existed.
// No hardcoded default — an unset key must fail loudly, not silently
// encrypt every user's TOTP secret under a publicly known value.
const secret =
this.configService.get<string>('TWO_FACTOR_ENCRYPTION_KEY') ||
this.configService.get<string>('jwt.secret');
if (!secret) {
throw new Error(
'TWO_FACTOR_ENCRYPTION_KEY (or JWT_SECRET) must be set — refusing to encrypt 2FA secrets with a default key',
);
}
this.encryptionKey = crypto.scryptSync(secret, 'salt', 32);
}

View File

@@ -32,7 +32,7 @@ export class RedisIoAdapter extends IoAdapter {
enableReadyCheck: false,
};
if (useTls) {
opts.tls = { rejectUnauthorized: false };
opts.tls = {};
}
const pubClient = new Redis(opts);

View File

@@ -18,7 +18,7 @@ export const redisProvider = {
},
};
if (useTls) {
opts.tls = { rejectUnauthorized: false };
opts.tls = {};
}
const client = new Redis(opts);

View File

@@ -16,9 +16,9 @@ export default () => ({
// JWT Authentication
jwt: {
secret: process.env.JWT_SECRET || 'super-secret-key',
accessExpiration: process.env.JWT_ACCESS_EXPIRATION || '7d',
refreshExpiration: process.env.JWT_REFRESH_EXPIRATION || '365d',
secret: process.env.JWT_SECRET,
accessExpiration: process.env.JWT_ACCESS_EXPIRATION || '15m',
refreshExpiration: process.env.JWT_REFRESH_EXPIRATION || '7d',
},
// Password Hashing

View File

@@ -0,0 +1,106 @@
/**
* Fail-fast validation of the environment.
*
* Runs before Nest boots so a misconfigured deploy dies at startup with a
* readable message instead of half-working (or silently falling back to a
* development default) in production.
*/
const DURATION_RE = /^(\d+)\s*(ms|s|m|h|d|w|y)$/i;
const DURATION_MS: Record<string, number> = {
ms: 1,
s: 1000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
w: 604_800_000,
y: 31_536_000_000,
};
function parseDuration(value: string | undefined): number | null {
if (!value) return null;
const match = DURATION_RE.exec(value.trim());
if (!match) return null;
return Number(match[1]) * DURATION_MS[match[2].toLowerCase()];
}
export function validateEnv(): void {
const env = process.env;
const isProduction = env.NODE_ENV === 'production';
const errors: string[] = [];
const warnings: string[] = [];
// --- Always required -----------------------------------------------------
const required = ['DATABASE_URL', 'JWT_SECRET', 'EMAIL_API_URL'];
for (const key of required) {
if (!env[key]) errors.push(`${key} is not set`);
}
// --- Required in production ---------------------------------------------
// CORS_ORIGINS must be explicit: without it both the HTTP CORS config and
// the WebSocket gateway fall back to localhost, which rejects every real
// browser request.
if (isProduction) {
for (const key of ['CORS_ORIGINS', 'FRONTEND_URL']) {
if (!env[key]) errors.push(`${key} must be set when NODE_ENV=production`);
}
if (env.CORS_ORIGINS?.includes('localhost')) {
warnings.push('CORS_ORIGINS contains localhost in a production deploy');
}
if (env.JWT_SECRET && env.JWT_SECRET.length < 32) {
errors.push('JWT_SECRET must be at least 32 characters in production');
}
if (env.REDIS_TLS !== 'true') {
warnings.push('REDIS_TLS is not enabled — Redis traffic is unencrypted');
}
if (env.DATABASE_URL?.includes('sslmode=no-verify')) {
warnings.push(
'DATABASE_URL uses sslmode=no-verify — the database certificate is not verified',
);
}
}
// --- Token lifetimes -----------------------------------------------------
// A refresh token shorter than the access token it renews is always a
// misconfiguration: refresh stops working before the access token expires.
const accessRaw = env.JWT_ACCESS_EXPIRATION;
const refreshRaw = env.JWT_REFRESH_EXPIRATION;
const access = parseDuration(accessRaw);
const refresh = parseDuration(refreshRaw);
if (accessRaw && access === null) {
errors.push(`JWT_ACCESS_EXPIRATION is not a valid duration: "${accessRaw}"`);
}
if (refreshRaw && refresh === null) {
errors.push(`JWT_REFRESH_EXPIRATION is not a valid duration: "${refreshRaw}"`);
}
if (access !== null && refresh !== null && refresh <= access) {
errors.push(
`JWT_REFRESH_EXPIRATION (${refreshRaw}) must be longer than JWT_ACCESS_EXPIRATION (${accessRaw}) — these look inverted`,
);
}
if (access !== null && access > DURATION_MS.h) {
warnings.push(
`JWT_ACCESS_EXPIRATION is ${accessRaw} — a leaked access token stays valid that long; 15m is recommended`,
);
}
// --- Report --------------------------------------------------------------
for (const warning of warnings) {
console.warn(`[env] WARNING: ${warning}`);
}
if (errors.length > 0) {
throw new Error(
`Invalid environment configuration:\n${errors.map((e) => ` - ${e}`).join('\n')}`,
);
}
}

View File

@@ -23,9 +23,11 @@ export class EmailService {
private readonly endpoint: string;
constructor(private readonly configService: ConfigService) {
this.endpoint =
this.configService.get<string>('EMAIL_API_URL') ||
'https://email.routes.hiffi.com/superlabs/v1/send';
const endpoint = this.configService.get<string>('EMAIL_API_URL');
if (!endpoint) {
throw new Error('EMAIL_API_URL is not configured');
}
this.endpoint = endpoint;
this.logger.log(`Email service initialized → ${this.endpoint}`);
}

26
src/load-env.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* Loads .env files into process.env BEFORE any Nest module is imported.
*
* Why this file exists: decorator arguments are evaluated at module-import
* time, which is strictly earlier than ConfigModule.forRoot(). MessagesGateway
* reads process.env.CORS_ORIGINS inside its @WebSocketGateway decorator, so
* without this the WebSocket server is built with the localhost fallback even
* when CORS_ORIGINS is set in .env — silently breaking real-time messaging in
* production.
*
* Must be imported as the very first import of main.ts. It has to be a
* side-effect import (not a function call) because TypeScript emits all
* require() calls ahead of any statement in the file.
*
* Precedence matches ConfigModule's envFilePath: ['.env.local', '.env'].
* dotenv never overwrites an already-set variable, so real process env
* (Docker/systemd) always wins over both files.
*/
import * as dotenv from 'dotenv';
import { validateEnv } from './config/env.validation';
dotenv.config({ path: '.env.local' });
dotenv.config({ path: '.env' });
// Fail fast on a bad deploy rather than booting into a broken state.
validateEnv();

View File

@@ -1,3 +1,6 @@
// MUST stay the first import — see the comment in load-env.ts.
import './load-env';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

View File

@@ -25,7 +25,10 @@ interface AuthenticatedSocket extends Socket {
@WebSocketGateway({
cors: {
origin: '*',
origin: process.env.CORS_ORIGINS?.split(',') || [
'http://localhost:3000',
'http://localhost:3002',
],
credentials: true,
},
transports: ['websocket', 'polling'],

View File

@@ -19,7 +19,7 @@ import { RedisPresenceService } from '../common/services/redis-presence.service'
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') || 'fallback-secret',
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: '15m',
},