From c9b38dc6ab80273d2864d81e780405c8ebd38ea1 Mon Sep 17 00:00:00 2001 From: Sathish Date: Tue, 4 Aug 2026 11:30:36 +0530 Subject: [PATCH 1/2] =?UTF-8?q?fix(security):=20resolve=20audit=20findings?= =?UTF-8?q?=20=E2=80=94=20secrets,=20env=20contract,=20migrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 50 +- .gitignore | 1 - docs/2fa-key-rotation.md | 61 ++ docs/README.md | 14 + docs/architecture.md | 181 ++++ docs/data-model.md | 516 ++++++++++++ docs/flows.md | 377 +++++++++ .../20260721102313_init/migration.sql | 793 ++++++++++++++++++ prisma/migrations/migration_lock.toml | 3 + prisma/seed.ts | 21 +- src/auth/two-factor/two-factor.service.ts | 17 +- src/common/adapters/redis-io.adapter.ts | 2 +- src/common/redis/redis.provider.ts | 2 +- src/config/configuration.ts | 6 +- src/config/env.validation.ts | 106 +++ src/email/email.service.ts | 8 +- src/load-env.ts | 26 + src/main.ts | 3 + src/messages/messages.gateway.ts | 5 +- src/messages/messages.module.ts | 2 +- 20 files changed, 2167 insertions(+), 27 deletions(-) create mode 100644 docs/2fa-key-rotation.md create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/data-model.md create mode 100644 docs/flows.md create mode 100644 prisma/migrations/20260721102313_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 src/config/env.validation.ts create mode 100644 src/load-env.ts diff --git a/.env.example b/.env.example index d4b08ee..e870136 100644 --- a/.env.example +++ b/.env.example @@ -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,14 @@ 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. +S3_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 " +# 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 +71,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 +84,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 +96,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 diff --git a/.gitignore b/.gitignore index 3cfc751..8757653 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,5 @@ temp/ *.pid *.seed *.pid.lock -prisma/migrations/ CLAUDE.md real-estate-2d71e-firebase-adminsdk-fbsvc-2cb74385ab.json \ No newline at end of file diff --git a/docs/2fa-key-rotation.md b/docs/2fa-key-rotation.md new file mode 100644 index 0000000..29904b4 --- /dev/null +++ b/docs/2fa-key-rotation.md @@ -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= + ``` +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.) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..325a4af --- /dev/null +++ b/docs/README.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..704f2a9 --- /dev/null +++ b/docs/architecture.md @@ -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
Next.js] + ADM[Admin panel
Next.js] + MOB[Mobile app
Flutter] + end + + subgraph API["Backend API — NestJS"] + REST[REST controllers
/api/v1] + WS[Socket.IO gateway
MessagesGateway] + EV[Event emitter
in-process] + end + + subgraph Data + PG[(PostgreSQL
Prisma)] + RD[(Redis / Valkey
presence + SIO adapter)] + S3[(Object storage
S3-compatible)] + end + + subgraph ThirdParty["Third-party services"] + STR[Stripe] + FCM[Firebase FCM] + MAIL[Email gateway
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
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". diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..572486b --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,516 @@ +# Data model (entity–relationship) + +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` diff --git a/docs/flows.md b/docs/flows.md new file mode 100644 index 0000000..d9d6660 --- /dev/null +++ b/docs/flows.md @@ -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,
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. diff --git a/prisma/migrations/20260721102313_init/migration.sql b/prisma/migrations/20260721102313_init/migration.sql new file mode 100644 index 0000000..9ec78d1 --- /dev/null +++ b/prisma/migrations/20260721102313_init/migration.sql @@ -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; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -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" diff --git a/prisma/seed.ts b/prisma/seed.ts index 629e884..5d3b2ea 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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'); diff --git a/src/auth/two-factor/two-factor.service.ts b/src/auth/two-factor/two-factor.service.ts index c76865a..f811d7b 100644 --- a/src/auth/two-factor/two-factor.service.ts +++ b/src/auth/two-factor/two-factor.service.ts @@ -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('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('TWO_FACTOR_ENCRYPTION_KEY') || + this.configService.get('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); } diff --git a/src/common/adapters/redis-io.adapter.ts b/src/common/adapters/redis-io.adapter.ts index 747ba1f..f42bb38 100644 --- a/src/common/adapters/redis-io.adapter.ts +++ b/src/common/adapters/redis-io.adapter.ts @@ -32,7 +32,7 @@ export class RedisIoAdapter extends IoAdapter { enableReadyCheck: false, }; if (useTls) { - opts.tls = { rejectUnauthorized: false }; + opts.tls = {}; } const pubClient = new Redis(opts); diff --git a/src/common/redis/redis.provider.ts b/src/common/redis/redis.provider.ts index 9d982ca..96bc46f 100644 --- a/src/common/redis/redis.provider.ts +++ b/src/common/redis/redis.provider.ts @@ -18,7 +18,7 @@ export const redisProvider = { }, }; if (useTls) { - opts.tls = { rejectUnauthorized: false }; + opts.tls = {}; } const client = new Redis(opts); diff --git a/src/config/configuration.ts b/src/config/configuration.ts index afee919..b923862 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -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 diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts new file mode 100644 index 0000000..3a2d5be --- /dev/null +++ b/src/config/env.validation.ts @@ -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 = { + 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')}`, + ); + } +} diff --git a/src/email/email.service.ts b/src/email/email.service.ts index f1e63fd..3779616 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -23,9 +23,11 @@ export class EmailService { private readonly endpoint: string; constructor(private readonly configService: ConfigService) { - this.endpoint = - this.configService.get('EMAIL_API_URL') || - 'https://email.routes.hiffi.com/superlabs/v1/send'; + const endpoint = this.configService.get('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}`); } diff --git a/src/load-env.ts b/src/load-env.ts new file mode 100644 index 0000000..e95fa96 --- /dev/null +++ b/src/load-env.ts @@ -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(); diff --git a/src/main.ts b/src/main.ts index 25fde4b..b00551f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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'; diff --git a/src/messages/messages.gateway.ts b/src/messages/messages.gateway.ts index 43abacd..7b867e2 100644 --- a/src/messages/messages.gateway.ts +++ b/src/messages/messages.gateway.ts @@ -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'], diff --git a/src/messages/messages.module.ts b/src/messages/messages.module.ts index 09e26ea..4f424a4 100644 --- a/src/messages/messages.module.ts +++ b/src/messages/messages.module.ts @@ -19,7 +19,7 @@ import { RedisPresenceService } from '../common/services/redis-presence.service' imports: [ConfigModule], inject: [ConfigService], useFactory: (configService: ConfigService) => ({ - secret: configService.get('JWT_SECRET') || 'fallback-secret', + secret: configService.get('JWT_SECRET'), signOptions: { expiresIn: '15m', }, -- 2.43.0 From 567487a87cbe19f9a728020066ed6191afa1f970 Mon Sep 17 00:00:00 2001 From: Sathish Date: Tue, 4 Aug 2026 16:12:43 +0530 Subject: [PATCH 2/2] docs(env): document S3_PUBLIC_ENDPOINT and the /s3 path-prefix behaviour Follow-up to 4df4d8c, which added the variable. Keeps .env.example an accurate configuration contract. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.env.example b/.env.example index e870136..6c2a48f 100644 --- a/.env.example +++ b/.env.example @@ -58,7 +58,12 @@ 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 (REQUIRED — boot fails without it) # The app POSTs JSON to this endpoint to send all transactional email. -- 2.43.0