Compare commits
3 Commits
4df4d8c9b5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b677251cc | |||
| 567487a87c | |||
| c9b38dc6ab |
55
.env.example
55
.env.example
@@ -15,22 +15,40 @@ ADMIN_URL=http://localhost:3002
|
|||||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/real_estate_db?schema=public"
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/real_estate_db?schema=public"
|
||||||
|
|
||||||
# JWT Authentication
|
# JWT Authentication
|
||||||
|
# Required. Minimum 32 chars in production; boot fails if shorter.
|
||||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
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_ACCESS_EXPIRATION=15m
|
||||||
JWT_REFRESH_EXPIRATION=7d
|
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
|
# Password Hashing
|
||||||
BCRYPT_SALT_ROUNDS=12
|
BCRYPT_SALT_ROUNDS=12
|
||||||
|
|
||||||
# Google OAuth
|
# 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_ID=your-google-client-id
|
||||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||||
GOOGLE_CALLBACK_URL=http://localhost:3001/auth/google/callback
|
GOOGLE_CALLBACK_URL=http://localhost:3001/auth/google/callback
|
||||||
|
|
||||||
# Facebook OAuth
|
# Facebook OAuth — NOT IN USE (Google is the only enabled social provider).
|
||||||
FACEBOOK_APP_ID=your-facebook-app-id
|
# Kept only so config/configuration.ts keeps type-checking. Leave blank.
|
||||||
FACEBOOK_APP_SECRET=your-facebook-app-secret
|
FACEBOOK_APP_ID=
|
||||||
FACEBOOK_CALLBACK_URL=http://localhost:3001/auth/facebook/callback
|
FACEBOOK_APP_SECRET=
|
||||||
|
FACEBOOK_CALLBACK_URL=
|
||||||
|
|
||||||
# AWS S3 (File Storage)
|
# AWS S3 (File Storage)
|
||||||
AWS_ACCESS_KEY_ID=your-aws-access-key
|
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_REGION=us-east-1
|
||||||
AWS_S3_BUCKET=your-s3-bucket-name
|
AWS_S3_BUCKET=your-s3-bucket-name
|
||||||
S3_FOLDER_PREFIX=development # Root folder for all uploads (e.g., 'development', 'staging', 'production')
|
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)
|
# Email (REQUIRED — boot fails without it)
|
||||||
MAIL_HOST=smtp.sendgrid.net
|
# The app POSTs JSON to this endpoint to send all transactional email.
|
||||||
MAIL_PORT=587
|
# There is no SMTP path in the code: MAIL_*/SMTP_* variables are NOT read.
|
||||||
MAIL_USER=apikey
|
EMAIL_API_URL=https://your-email-provider.example.com/v1/send
|
||||||
MAIL_PASSWORD=your-sendgrid-api-key
|
|
||||||
MAIL_FROM="Real Estate Platform <noreply@yourdomain.com>"
|
|
||||||
|
|
||||||
# Stripe (Payments)
|
# Stripe (Payments)
|
||||||
STRIPE_SECRET_KEY=sk_test_your-stripe-secret-key
|
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
|
STRIPE_PUBLISHABLE_KEY=pk_test_your-stripe-publishable-key
|
||||||
|
|
||||||
# Firebase (Push Notifications)
|
# 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_PROJECT_ID=your-firebase-project-id
|
||||||
FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com
|
FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com
|
||||||
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nYour-Private-Key\n-----END PRIVATE KEY-----"
|
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nYour-Private-Key\n-----END PRIVATE KEY-----"
|
||||||
@@ -61,6 +89,9 @@ REDIS_HOST=localhost
|
|||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=
|
||||||
REDIS_DB=0
|
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
|
# Rate Limiting
|
||||||
THROTTLE_TTL=60
|
THROTTLE_TTL=60
|
||||||
@@ -70,4 +101,8 @@ THROTTLE_LIMIT=100
|
|||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
# CORS
|
# 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
|
CORS_ORIGINS=http://localhost:3000,http://localhost:3002
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,6 +43,5 @@ temp/
|
|||||||
*.pid
|
*.pid
|
||||||
*.seed
|
*.seed
|
||||||
*.pid.lock
|
*.pid.lock
|
||||||
prisma/migrations/
|
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
real-estate-2d71e-firebase-adminsdk-fbsvc-2cb74385ab.json
|
real-estate-2d71e-firebase-adminsdk-fbsvc-2cb74385ab.json
|
||||||
61
docs/2fa-key-rotation.md
Normal file
61
docs/2fa-key-rotation.md
Normal 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
14
docs/README.md
Normal 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
181
docs/architecture.md
Normal 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
516
docs/data-model.md
Normal file
@@ -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`
|
||||||
377
docs/flows.md
Normal file
377
docs/flows.md
Normal 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.
|
||||||
793
prisma/migrations/20260721102313_init/migration.sql
Normal file
793
prisma/migrations/20260721102313_init/migration.sql
Normal 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;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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"
|
||||||
@@ -20,6 +20,22 @@ async function main() {
|
|||||||
|
|
||||||
const prisma = new PrismaClient({ adapter });
|
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');
|
console.log('🌱 Starting database seeding...\n');
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
@@ -1256,9 +1272,7 @@ async function main() {
|
|||||||
// =============================================
|
// =============================================
|
||||||
console.log('👤 Seeding Admin User...');
|
console.log('👤 Seeding Admin User...');
|
||||||
|
|
||||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@re-quest.com';
|
// adminEmail / adminPassword are validated at the top of main().
|
||||||
const adminPassword = process.env.ADMIN_PASSWORD || 'Admin@123456';
|
|
||||||
|
|
||||||
// Hash password with Argon2 (more secure than bcrypt)
|
// Hash password with Argon2 (more secure than bcrypt)
|
||||||
const hashedPassword = await argon2.hash(adminPassword);
|
const hashedPassword = await argon2.hash(adminPassword);
|
||||||
|
|
||||||
@@ -1286,7 +1300,6 @@ async function main() {
|
|||||||
console.log(' ✅ Admin user created successfully!');
|
console.log(' ✅ Admin user created successfully!');
|
||||||
console.log(' ───────────────────────────────');
|
console.log(' ───────────────────────────────');
|
||||||
console.log(` 📧 Email: ${adminEmail}`);
|
console.log(` 📧 Email: ${adminEmail}`);
|
||||||
console.log(` 🔑 Password: ${adminPassword}`);
|
|
||||||
console.log(` 👤 Role: ${admin.role}`);
|
console.log(` 👤 Role: ${admin.role}`);
|
||||||
console.log(` 🆔 ID: ${admin.id}`);
|
console.log(` 🆔 ID: ${admin.id}`);
|
||||||
console.log(' ───────────────────────────────\n');
|
console.log(' ───────────────────────────────\n');
|
||||||
|
|||||||
@@ -21,8 +21,21 @@ export class TwoFactorService {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
) {
|
) {
|
||||||
// Use JWT secret as base for encryption key (derive a 32-byte key)
|
// Key for the stored TOTP secrets. Prefer a dedicated key so that rotating
|
||||||
const secret = this.configService.get<string>('jwt.secret') || 'default-secret';
|
// 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);
|
this.encryptionKey = crypto.scryptSync(secret, 'salt', 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export class RedisIoAdapter extends IoAdapter {
|
|||||||
enableReadyCheck: false,
|
enableReadyCheck: false,
|
||||||
};
|
};
|
||||||
if (useTls) {
|
if (useTls) {
|
||||||
opts.tls = { rejectUnauthorized: false };
|
opts.tls = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const pubClient = new Redis(opts);
|
const pubClient = new Redis(opts);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const redisProvider = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (useTls) {
|
if (useTls) {
|
||||||
opts.tls = { rejectUnauthorized: false };
|
opts.tls = {};
|
||||||
}
|
}
|
||||||
const client = new Redis(opts);
|
const client = new Redis(opts);
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ export default () => ({
|
|||||||
|
|
||||||
// JWT Authentication
|
// JWT Authentication
|
||||||
jwt: {
|
jwt: {
|
||||||
secret: process.env.JWT_SECRET || 'super-secret-key',
|
secret: process.env.JWT_SECRET,
|
||||||
accessExpiration: process.env.JWT_ACCESS_EXPIRATION || '7d',
|
accessExpiration: process.env.JWT_ACCESS_EXPIRATION || '15m',
|
||||||
refreshExpiration: process.env.JWT_REFRESH_EXPIRATION || '365d',
|
refreshExpiration: process.env.JWT_REFRESH_EXPIRATION || '7d',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Password Hashing
|
// Password Hashing
|
||||||
|
|||||||
106
src/config/env.validation.ts
Normal file
106
src/config/env.validation.ts
Normal 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')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,9 +23,11 @@ export class EmailService {
|
|||||||
private readonly endpoint: string;
|
private readonly endpoint: string;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
this.endpoint =
|
const endpoint = this.configService.get<string>('EMAIL_API_URL');
|
||||||
this.configService.get<string>('EMAIL_API_URL') ||
|
if (!endpoint) {
|
||||||
'https://email.routes.hiffi.com/superlabs/v1/send';
|
throw new Error('EMAIL_API_URL is not configured');
|
||||||
|
}
|
||||||
|
this.endpoint = endpoint;
|
||||||
this.logger.log(`Email service initialized → ${this.endpoint}`);
|
this.logger.log(`Email service initialized → ${this.endpoint}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
src/load-env.ts
Normal file
26
src/load-env.ts
Normal 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();
|
||||||
@@ -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 { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe, Logger, BadRequestException } from '@nestjs/common';
|
import { ValidationPipe, Logger, BadRequestException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ interface AuthenticatedSocket extends Socket {
|
|||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
cors: {
|
cors: {
|
||||||
origin: '*',
|
origin: process.env.CORS_ORIGINS?.split(',') || [
|
||||||
|
'http://localhost:3000',
|
||||||
|
'http://localhost:3002',
|
||||||
|
],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
},
|
},
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { RedisPresenceService } from '../common/services/redis-presence.service'
|
|||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (configService: ConfigService) => ({
|
useFactory: (configService: ConfigService) => ({
|
||||||
secret: configService.get<string>('JWT_SECRET') || 'fallback-secret',
|
secret: configService.get<string>('JWT_SECRET'),
|
||||||
signOptions: {
|
signOptions: {
|
||||||
expiresIn: '15m',
|
expiresIn: '15m',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user