diff --git a/docs/database.md b/docs/database.md index 25680b3..ef407b7 100644 --- a/docs/database.md +++ b/docs/database.md @@ -1,128 +1,637 @@ # Database Schema Documentation -## ER Diagram +The ClipCash backend uses **PostgreSQL** with **Prisma** as the ORM. The canonical schema lives in [`prisma/schema.prisma`](../prisma/schema.prisma). +--- + +## Entity Relationship Diagram + +```mermaid +erDiagram + User ||--o{ Video : owns + User ||--o{ UserPlatform : connects + User ||--o{ Subscription : has + User ||--o{ StellarPaymentIntent : creates + User ||--o{ Wallet : has + User ||--o{ Payout : receives + User ||--o{ PayoutMethod : stores + User ||--o{ MagicLink : "auth token" + User ||--o{ RefreshToken : "auth token" + User ||--o{ PasswordResetToken : "auth token" + User ||--o{ EmailVerificationToken : "auth token" + + Video ||--o{ Clip : generates + Clip ||--o{ ClipPost : publishes + Clip ||--o{ Earning : earns + + Wallet ||--o{ Payout : "optional destination" + PayoutMethod ||--o{ Payout : "optional method" + + User { + int id PK + string email UK + string role + string stellarPublicKey + } + + Video { + int id PK + int userId FK + string status + string sourceUrl + } + + Clip { + int id PK + int videoId FK + string clipUrl + string nftStatus + string mintAddress UK + } + + Earning { + int id PK + int clipId FK + float amount + datetime deletedAt + } + + Payout { + int id PK + int userId FK + int walletId FK + int payoutMethodId FK + string status + } + + MonthlyEarning { + int id PK + int userId + int year + int month + } ``` -┌─────────────────┐ -│ User │ -├─────────────────┤ -│ id (PK) │ -│ email │ -│ password │ -│ name │ -│ role │ -│ createdAt │ -│ updatedAt │ -└────────┬────────┘ - │ - ┌────┴─────┬──────────┬─────────┬────────┬─────────────┬────────────────┐ - │ │ │ │ │ │ │ - ▼ ▼ ▼ ▼ ▼ ▼ ▼ -┌────────┐ ┌──────────┐ ┌──────┐ ┌──────┐ ┌─────────┐ ┌─────────┐ ┌──────────────┐ -│ Video │ │UserPlatf│ │Subscrip│ │Wallet│ │Payout│ │PayoutMeth│ │Refresh│ -└────────┘ └──────────┘ └──────┘ └──────┘ └─────────┘ └─────────┘ │Token │ - │ └──────────────┘ - │ - ▼ -┌──────────────┐ -│ Clip │ -├──────────────┤ -│ id (PK) │ -│ videoId (FK) │ -│ clipUrl │ -│ platform │ -│ title │ -│ startTime │ -│ endTime │ -│ duration │ -│ nftStatus │ -│ createdAt │ -└──────┬───────┘ - │ - ┌───┴────┬──────────┐ - │ │ │ - ▼ ▼ ▼ -┌──────┐ ┌────────┐ ┌─────────┐ -│Earning│ │ClipPost│ │NFTRoyalty│ -└──────┘ └────────┘ └─────────┘ + +**Standalone tables** (no Prisma relations, but logically linked by ID columns): + +- `MonthlyEarning.userId` → `User.id` +- `AnomalyAlert.earningId` → `Earning.id`, `AnomalyAlert.userId` → `User.id` +- `StellarWebhookLog`, `PlatformWebhookLog`, `PayoutFeeConfig` — global config/audit tables + +--- + +## Models + +### User + +Core account entity. Supports email/password auth, OAuth (`provider` + `providerId`), MFA, and embedded Stellar wallet fields. + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `email` | String | UNIQUE | Login identifier | +| `password` | String? | | Hashed password; null for OAuth-only accounts | +| `mfaEnabled` | Boolean | | Two-factor authentication flag (default `false`) | +| `mfaSecret` | String? | | TOTP secret when MFA is enabled | +| `provider` | String? | | OAuth provider name (e.g. `google`) | +| `providerId` | String? | | Provider-specific user ID | +| `name` | String? | | Display name | +| `picture` | String? | | Avatar URL | +| `emailVerified` | DateTime? | | When the email was verified | +| `stellarPublicKey` | String? | | User's Stellar public key | +| `walletType` | String? | | Wallet provider (e.g. Freighter, Albedo) | +| `encryptedStellarSecret` | String? | | Encrypted Stellar secret key | +| `role` | String | | Access role (default `"user"`) | +| `createdAt` | DateTime | | Record creation timestamp | +| `updatedAt` | DateTime | | Last update timestamp | + +**Constraints:** `@@unique([provider, providerId])` for OAuth identity. + +**Relations:** 1 → N `Video`, `UserPlatform`, `Subscription`, `StellarPaymentIntent`, `Wallet`, `Payout`, `PayoutMethod`, `MagicLink`, `RefreshToken`, `PasswordResetToken`, `EmailVerificationToken`. + +--- + +### Video + +Source video uploaded or imported (YouTube, URL, etc.) by a user. Drives the clip-generation pipeline. + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `userId` | Int | FK → User | Owner | +| `title` | String? | | Video title | +| `description` | String? | | Video description | +| `sourceType` | String | | Source kind (default `"youtube"`) | +| `sourceUrl` | String | | Original URL or storage reference | +| `thumbnail` | String? | | Thumbnail URL | +| `duration` | Int? | | Duration in seconds | +| `fileSize` | BigInt? | | File size in bytes | +| `status` | String | INDEX | Pipeline status (see [Status values](#status-values)) | +| `processingError` | String? | | Error message when generation fails | +| `processingStats` | Json? | | Processing metrics (see [JSON fields](#json-field-shapes)) | +| `targetPlatforms` | Json? | | Platforms selected for posting | +| `createdAt` | DateTime | | Record creation timestamp | +| `updatedAt` | DateTime | | Last update timestamp | + +**Relations:** N → 1 `User` (CASCADE delete), 1 → N `Clip`. + +**Indexes:** `userId`, `status`. + +--- + +### Clip + +A short clip generated from a source video. Holds timeline bounds, posting metadata, and optional NFT mint data. + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `videoId` | Int | FK → Video | Parent video | +| `clipUrl` | String | | Cloudinary (or storage) URL for the clip | +| `thumbnail` | String? | | Thumbnail URL | +| `platform` | String? | | Target platform hint | +| `title` | String? | | Clip title | +| `caption` | String? | | Post caption / hashtags | +| `startTime` | Float | | Start offset in source video (seconds) | +| `endTime` | Float | | End offset in source video (seconds) | +| `duration` | Int | | Clip duration in seconds | +| `viralityScore` | Float? | | AI/heuristic engagement score | +| `royaltyBps` | Int? | | NFT royalty in basis points (0–1500 = 0–15%) | +| `postStatus` | Json? | | Aggregate posting state (platform-specific JSON allowed) | +| `postedAt` | DateTime? | | When the clip was first posted | +| `metadataUri` | String? | | IPFS/HTTP URI for NFT metadata | +| `mintAddress` | String? | UNIQUE | On-chain NFT contract address | +| `mintedAt` | DateTime? | | When the NFT was minted | +| `nftStatus` | String | | Mint lifecycle (default `"none"`) | +| `createdAt` | DateTime | | Record creation timestamp | +| `updatedAt` | DateTime | | Last update timestamp | + +**Relations:** N → 1 `Video` (CASCADE delete), 1 → N `ClipPost`, `Earning`. + +**Indexes:** `videoId`, `mintAddress` (unique). + +--- + +### ClipPost + +One row per clip × platform publish attempt. Tracks retries and external post IDs. + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `clipId` | Int | FK → Clip | Parent clip | +| `platform` | String | INDEX | Target platform (e.g. `tiktok`, `instagram`) | +| `status` | String | | `pending`, `published`, or `failed` | +| `postId` | String? | | Platform-assigned post identifier | +| `attempts` | Int | | Retry count (default `0`) | +| `error` | String? | | Last failure message | +| `createdAt` | DateTime | | Record creation timestamp | +| `updatedAt` | DateTime | | Last update timestamp | + +**Relations:** N → 1 `Clip` (CASCADE delete). + +**Indexes:** `clipId`, `platform`. + +--- + +### Earning + +Revenue attributed to a clip. Supports soft delete and anomaly flagging. + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `clipId` | Int | FK → Clip | Source clip | +| `amount` | Float | | Earned amount | +| `currency` | String | | ISO currency (default `"USD"`) | +| `date` | DateTime | | Earning date (for aggregation) | +| `source` | String? | | Origin (e.g. `royalty`, platform name) | +| `isAnomaly` | Boolean | | Flagged as suspicious (default `false`) | +| `anomalyReason` | String? | | Why the earning was flagged | +| `createdAt` | DateTime | | Record creation timestamp | +| `deletedAt` | DateTime? | | Soft-delete timestamp | + +**Relations:** N → 1 `Clip` (CASCADE delete). + +--- + +### MonthlyEarning + +Pre-aggregated monthly totals per user. Used for dashboards and reporting. + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `userId` | Int | | Owner (logical FK to `User.id`) | +| `year` | Int | | Calendar year | +| `month` | Int | | Calendar month (1–12) | +| `totalAmount` | Float | | Sum of earnings for the period | +| `currency` | String | | ISO currency (default `"USD"`) | +| `platformBreakdown` | Json? | | Per-platform totals | +| `createdAt` | DateTime | | Record creation timestamp | +| `updatedAt` | DateTime | | Last update timestamp | + +**Constraints:** `@@unique([userId, year, month])`. + +**Indexes:** `userId`, `[year, month]`. + +--- + +### UserPlatform + +OAuth connection to an external publishing platform (TikTok, Instagram, etc.). + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `userId` | Int | FK → User | Owner | +| `platform` | String | | Platform identifier | +| `username` | String? | | Connected account username | +| `accessToken` | String? | | Encrypted OAuth access token | +| `refreshToken` | String? | | Encrypted OAuth refresh token | +| `connectedAt` | DateTime | | When the connection was established | +| `updatedAt` | DateTime | | Last token refresh / update | + +**Relations:** N → 1 `User` (CASCADE delete). + +--- + +### Wallet + +User-linked blockchain wallet used for Stellar payouts. + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `userId` | Int | FK → User | Owner | +| `address` | String | | Wallet address | +| `chain` | String | | Blockchain (default `"stellar"`) | +| `type` | String | | Wallet type / provider | +| `connectedAt` | DateTime | | When the wallet was linked | +| `updatedAt` | DateTime | | Last update timestamp | +| `deletedAt` | DateTime? | | Soft-delete timestamp | + +**Relations:** N → 1 `User` (CASCADE delete), 1 → N `Payout`. + +**Constraints:** `@@unique([address, chain])`. + +**Indexes:** `userId`. + +--- + +### PayoutMethod + +Stored bank or payment details for fiat payouts. Sensitive fields are encrypted at rest. + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `userId` | Int | FK → User | Owner | +| `type` | String | INDEX | Method type (e.g. bank transfer) | +| `isDefault` | Boolean | | Default payout method for the user | +| `encryptedAccountNumber` | String? | | Encrypted account number | +| `encryptedRoutingNumber` | String? | | Encrypted routing number | +| `encryptedSwiftCode` | String? | | Encrypted SWIFT/BIC | +| `encryptedIban` | String? | | Encrypted IBAN | +| `bankName` | String? | | Bank display name | +| `accountHolderName` | String? | | Account holder | +| `country` | String? | | Country code | +| `currency` | String | | Payout currency (default `"USD"`) | +| `lastFourDigits` | String? | | Masked account suffix for UI | +| `createdAt` | DateTime | | Record creation timestamp | +| `updatedAt` | DateTime | | Last update timestamp | +| `deletedAt` | DateTime? | | Soft-delete timestamp | + +**Relations:** N → 1 `User` (CASCADE delete), 1 → N `Payout`. + +**Indexes:** `userId`, `type`. + +--- + +### Payout + +Withdrawal request from user earnings to a wallet or bank method. + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `userId` | Int | FK → User | Recipient | +| `walletId` | Int? | FK → Wallet | Stellar wallet destination | +| `payoutMethodId` | Int? | FK → PayoutMethod | Bank/fiat destination | +| `amount` | Float | | Requested gross amount | +| `currency` | String | | ISO currency (default `"USD"`) | +| `method` | String | | Payout channel (e.g. `stellar`, `bank`) | +| `status` | String | INDEX | Lifecycle status (see [Status values](#status-values)) | +| `transactionId` | String? | | External processor transaction ID | +| `stellarXdr` | String? | | Stellar transaction envelope (XDR) | +| `onChainTxHash` | String? | | Confirmed on-chain hash | +| `confirmedAt` | DateTime? | | On-chain confirmation time | +| `paidAt` | DateTime? | | When funds were delivered | +| `approvedAt` | DateTime? | | Admin approval timestamp | +| `rejectedAt` | DateTime? | | Rejection timestamp | +| `rejectionReason` | String? | | Why the payout was rejected | +| `feeAmount` | Float? | | Applied fee in currency units | +| `feePercentage` | Float? | | Fee rate used | +| `finalAmount` | Float? | | Net amount after fees | +| `retryCount` | Int | | Verification retry count | +| `lastAttemptAt` | DateTime? | | Last verification attempt | +| `createdAt` | DateTime | | Record creation timestamp | +| `updatedAt` | DateTime | | Last update timestamp | + +**Relations:** N → 1 `User` (CASCADE delete), optional N → 1 `Wallet`, optional N → 1 `PayoutMethod`. + +**Indexes:** `payoutMethodId`, `status`. + +--- + +### PayoutFeeConfig + +Global fee rules per payout method. One active row per `method`. + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `method` | String | UNIQUE | Payout method identifier | +| `feePercentage` | Float | | Percentage fee | +| `fixedFee` | Float | | Flat fee (default `0`) | +| `minFee` | Float | | Minimum fee floor | +| `maxFee` | Float? | | Maximum fee cap | +| `isActive` | Boolean | | Whether this config is in use | +| `createdAt` | DateTime | | Record creation timestamp | +| `updatedAt` | DateTime | | Last update timestamp | + +--- + +### Subscription + +User subscription plan (Stripe or Stellar payment). + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `userId` | Int | FK → User | Subscriber | +| `plan` | String | | Plan tier (e.g. Basic, Pro) | +| `status` | String | | Active/cancelled/expired state | +| `paymentMethod` | String | | `stripe` (default) or `stellar` | +| `startDate` | DateTime | | Subscription start | +| `endDate` | DateTime? | | Subscription end (null if ongoing) | +| `stellarTxHash` | String? | | Stellar payment transaction hash | +| `stellarMemo` | String? | | Stellar payment memo | +| `createdAt` | DateTime | | Record creation timestamp | +| `updatedAt` | DateTime | | Last update timestamp | + +**Relations:** N → 1 `User` (CASCADE delete). + +--- + +### StellarPaymentIntent + +Short-lived intent for a Stellar subscription payment before confirmation. + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | String | PK | CUID primary key | +| `userId` | Int | FK → User | Payer | +| `amount` | Float | | Expected payment amount | +| `asset` | String | | Stellar asset code | +| `destination` | String | | Destination account | +| `memo` | String | | Payment memo for matching | +| `status` | String | | Intent status (default `"pending"`) | +| `expiresAt` | DateTime | | Intent expiry | +| `transactionId` | String? | | Matched transaction after payment | +| `plan` | String | | Subscription plan being purchased | +| `createdAt` | DateTime | | Record creation timestamp | +| `updatedAt` | DateTime | | Last update timestamp | + +**Relations:** N → 1 `User` (CASCADE delete). + +--- + +### MagicLink + +Passwordless login tokens (hashed, single-use). + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `userId` | Int | FK → User | Target user | +| `tokenHash` | String | UNIQUE | Hashed token value | +| `expiresAt` | DateTime | | Expiration time | +| `usedAt` | DateTime? | | When the link was consumed | +| `createdAt` | DateTime | | Record creation timestamp | + +**Relations:** N → 1 `User` (CASCADE delete). + +**Indexes:** `tokenHash`. + +--- + +### RefreshToken + +JWT refresh tokens with optional device fingerprint metadata. + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `userId` | Int | FK → User | Token owner | +| `tokenHash` | String | UNIQUE | Hashed refresh token | +| `expiresAt` | DateTime | | Expiration time | +| `revokedAt` | DateTime? | | Revocation time | +| `userAgentHash` | String? | | Hashed User-Agent for session binding | +| `ipAddress` | String? | | Client IP at issuance | +| `acceptLanguage` | String? | | Client Accept-Language header | +| `createdAt` | DateTime | | Record creation timestamp | + +**Relations:** N → 1 `User` (CASCADE delete). + +**Indexes:** `userId`. + +--- + +### PasswordResetToken + +Single-use tokens for password reset flows. + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `userId` | Int | FK → User | Target user | +| `tokenHash` | String | UNIQUE | Hashed token value | +| `expiresAt` | DateTime | | Expiration time | +| `usedAt` | DateTime? | | When the token was consumed | +| `createdAt` | DateTime | | Record creation timestamp | + +**Relations:** N → 1 `User` (CASCADE delete). + +**Indexes:** `userId`, `tokenHash`. + +--- + +### EmailVerificationToken + +Single-use tokens for email verification. + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `userId` | Int | FK → User | Target user | +| `tokenHash` | String | UNIQUE | Hashed token value | +| `expiresAt` | DateTime | | Expiration time | +| `usedAt` | DateTime? | | When the token was consumed | +| `createdAt` | DateTime | | Record creation timestamp | + +**Relations:** N → 1 `User` (CASCADE delete). + +**Indexes:** `userId`, `tokenHash`. + +--- + +### StellarWebhookLog + +Idempotent log of processed Stellar webhook events. + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `transactionId` | String | UNIQUE | Stellar transaction ID | +| `payload` | String | | Raw webhook payload | +| `processedAt` | DateTime | INDEX | Processing timestamp | + +**Indexes:** `transactionId`, `processedAt`. + +--- + +### PlatformWebhookLog + +Audit log for inbound webhooks from external platforms. + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `platform` | String | INDEX | Source platform | +| `eventType` | String | | Webhook event type | +| `payload` | String | | Raw payload body | +| `signature` | String? | | Provided signature header | +| `isValid` | Boolean | | Whether signature verification passed | +| `processedAt` | DateTime | INDEX | Processing timestamp | +| `error` | String? | | Processing error, if any | + +**Indexes:** `platform`, `processedAt`. + +--- + +### AnomalyAlert + +Alerts raised when an earning is flagged as anomalous. + +| Field | Type | Key | Description | +|-------|------|-----|-------------| +| `id` | Int | PK | Auto-increment primary key | +| `earningId` | Int | | Related earning (logical FK) | +| `userId` | Int | INDEX | Affected user (logical FK) | +| `amount` | Float | | Flagged amount | +| `reason` | String | | Why it was flagged | +| `severity` | String | | Alert severity level | +| `isResolved` | Boolean | INDEX | Resolution flag | +| `resolvedAt` | DateTime? | | When resolved | +| `createdAt` | DateTime | INDEX | Alert creation time | + +**Indexes:** `userId`, `isResolved`, `createdAt`. + +--- + +## Relationships & Delete Behavior + +| Parent | Child | On delete | +|--------|-------|-----------| +| `User` | `Video`, `UserPlatform`, `Subscription`, `StellarPaymentIntent`, `Wallet`, `Payout`, `PayoutMethod`, auth tokens | **CASCADE** | +| `Video` | `Clip` | **CASCADE** | +| `Clip` | `ClipPost`, `Earning` | **CASCADE** | +| `Wallet` | `Payout` | No cascade (nullable FK) | +| `PayoutMethod` | `Payout` | No cascade (nullable FK) | + +Deleting a user removes all owned content, connections, payouts, and auth tokens. Deleting a video removes its clips and their posts/earnings. + +--- + +## Indexes Summary + +| Table | Index | Purpose | +|-------|-------|---------| +| `User` | `email` (unique) | Fast login lookup | +| `User` | `[provider, providerId]` (unique) | OAuth identity | +| `Video` | `userId` | List videos by owner | +| `Video` | `status` | Filter by pipeline state | +| `Clip` | `videoId` | List clips for a video | +| `Clip` | `mintAddress` (unique) | NFT lookup | +| `ClipPost` | `clipId`, `platform` | Post status per platform | +| `Wallet` | `userId`, `[address, chain]` (unique) | Wallet lookup | +| `Payout` | `payoutMethodId`, `status` | Payout queues | +| `PayoutMethod` | `userId`, `type` | User payout methods | +| `MonthlyEarning` | `userId`, `[year, month]`, unique composite | Monthly rollups | +| Auth tokens | `tokenHash`, `userId` | Token validation | +| Webhook logs | `transactionId`, `platform`, `processedAt` | Dedup and audit | + +--- + +## JSON Field Shapes + +### `Video.processingStats` + +Documented inline in the schema: + +```json +{ + "momentsFound": 0, + "inputQuality": "string", + "durationSec": 0, + "clipsGenerated": 0, + "timeTakenMs": 0, + "errorDetails": "string (optional)" +} ``` -## Schema Overview - -### Core Entities - -**User** - Core user account entity -- Stores authentication data (email, password/OAuth) -- Wallet integration (Stellar public key, encrypted secret) -- MFA enabled flag -- Relationships: 1 User -> N Videos, Subscriptions, Wallets, Payouts, etc. - -**Video** - Source video uploaded/imported by user -- Tracks video source (YouTube, upload, etc.) -- Processing status and statistics -- Relationships: 1 Video -> N Clips - -**Clip** - Generated clips from videos -- Inherits videoId to link to source -- Platform-specific metadata (title, caption) -- Viral scoring and timeline data (startTime, endTime) -- NFT metadata (mintAddress, metadataUri, nftStatus) -- Relationships: 1 Clip -> N ClipPost, N Earnings - -### Supporting Entities - -**ClipPost** - Represents a clip posted to a platform -- Tracks post status (pending, posted, failed) -- Retry attempts and error tracking -- Platform-specific postId - -**Earning** - Revenue tracking per clip -- Amount earned from that clip -- Platform attribution -- Payout status - -**Wallet** - User's blockchain wallets -- Stellar wallet storage -- Wallet type tracking - -**Payout** - Batch payout to user -- Status tracking (pending, completed, failed) -- Method used (bank transfer, Stellar, etc.) - -**Subscription** - Platform subscriptions -- User subscription to exclusive content -- Tier information -- Status tracking - -**UserPlatform** - Platform connections -- OAuth tokens for external platforms -- Platform-specific user IDs - -## Relationships & Cascading - -- **User -> Video**: 1:N with CASCADE delete -- **User -> Subscription**: 1:N with CASCADE delete -- **User -> Wallet**: 1:N with CASCADE delete -- **Video -> Clip**: 1:N with CASCADE delete -- **Clip -> ClipPost**: 1:N with CASCADE delete -- **Clip -> Earning**: 1:N with CASCADE delete - -All foreign keys enforce referential integrity and cascade deletes for data consistency. - -## Indexes - -Performance-critical indexes: -- `User.email` - UNIQUE for fast authentication -- `Video.userId` - Fast user video lookup -- `Video.status` - Filter by processing status -- `Clip.videoId` - Fast clip retrieval per video -- `Clip.mintAddress` - UNIQUE for NFT lookups -- `ClipPost.clipId` - Fast post tracking per clip - -## Database Notes - -- **Provider**: PostgreSQL -- **ORM**: Prisma -- Timestamps: `createdAt` (auto), `updatedAt` (auto-updated) -- JSON fields used for: processingStats, targetPlatforms, postStatus -- String enums for status fields (pending, active, completed, failed, etc.) +### `Video.targetPlatforms` + +Array or object of platform identifiers selected for auto-posting (structure varies by upload flow). + +### `Clip.postStatus` + +String enum (`pending`, `posted`, `failed`) or platform-specific JSON when multiple platforms are tracked in one field. + +### `MonthlyEarning.platformBreakdown` + +Per-platform earning totals for the month, e.g. `{ "tiktok": 120.5, "youtube": 80.0 }`. + +--- + +## Status Values + +Status fields are stored as plain strings (not Prisma enums). Application code defines the allowed values: + +| Model / field | Common values | +|---------------|---------------| +| `Video.status` | `pending`, `processing`, `done`, `failed`, `cancelled` | +| `Clip.nftStatus` | `none`, `minting`, `minted`, `failed` | +| `ClipPost.status` | `pending`, `published`, `failed` | +| `Payout.status` | `pending`, `processing`, `completed`, plus approval/rejection states | +| `StellarPaymentIntent.status` | `pending`, confirmed/failed variants | +| `Subscription.status` | Plan lifecycle states (active, cancelled, etc.) | + +--- + +## Conventions + +- **Timestamps:** Most models include `createdAt` (set on insert) and `updatedAt` (auto-updated by Prisma where defined). +- **Soft delete:** `Earning.deletedAt`, `Wallet.deletedAt`, and `PayoutMethod.deletedAt` — queries should filter `deletedAt IS NULL` unless including deleted records intentionally. +- **Sensitive data:** Passwords are hashed; OAuth tokens, bank details, and Stellar secrets are encrypted before storage. Only hashes are stored for magic links and refresh tokens. +- **Currency:** Defaults to `"USD"` on monetary fields; conversion happens in the earnings service layer. +- **Migrations:** Schema changes go through Prisma migrations in `prisma/migrations/`. Run `npx prisma migrate dev` locally or `npx prisma migrate deploy` in production. + +--- + +## Related Documentation + +- [Architecture overview](./architecture.md) — how services use these models in core flows +- [Stellar integration](./stellar-integration.md) — wallets, minting, and on-chain payouts +- [Prisma query optimization](./prisma-query-optimization.md) — indexing and query patterns diff --git a/src/app.module.ts b/src/app.module.ts index 08289a8..7585874 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,6 @@ import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; +import { AppConfigModule } from './config/config.module'; +import { AppConfigService } from './config/app-config.service'; import { ThrottlerModule, ThrottlerGuard, ThrottlerStorage } from '@nestjs/throttler'; import { APP_GUARD } from '@nestjs/core'; import { ThrottlerRedisModule } from './common/throttler/throttler-redis.module'; @@ -34,20 +35,25 @@ import { ScheduleModule } from '@nestjs/schedule'; @Module({ imports: [ - ConfigModule.forRoot({ isGlobal: true }), + AppConfigModule, EventEmitterModule.forRoot(), ScheduleModule.forRoot(), - BullModule.forRoot({ - connection: { - host: process.env.REDIS_HOST ?? 'localhost', - port: parseInt(process.env.REDIS_PORT ?? '6379', 10), - }, + BullModule.forRootAsync({ + imports: [AppConfigModule], + inject: [AppConfigService], + useFactory: (appConfig: AppConfigService) => ({ + connection: { + host: appConfig.redisHost, + port: appConfig.redisPort, + password: appConfig.redisPassword, + }, + }), }), PrismaModule, ThrottlerModule.forRootAsync({ - imports: [ConfigModule, ThrottlerRedisModule], - inject: [ConfigService, ThrottlerStorage], - useFactory: (config: ConfigService, storage: ThrottlerStorage) => ({ + imports: [AppConfigModule, ThrottlerRedisModule], + inject: [AppConfigService, ThrottlerStorage], + useFactory: (appConfig: AppConfigService, storage: ThrottlerStorage) => ({ storage, throttlers: [ { @@ -98,9 +104,8 @@ import { ScheduleModule } from '@nestjs/schedule'; ], skipIf: (context) => { const request = context.switchToHttp().getRequest(); - const whitelist = config.get('THROTTLER_WHITELIST'); - if (!whitelist) return false; - const whitelistedIps = whitelist.split(',').map((ip) => ip.trim()); + const whitelistedIps = appConfig.throttlerWhitelist; + if (whitelistedIps.length === 0) return false; return whitelistedIps.includes(request.ip); }, }), diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 33f7627..edefd20 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,7 +1,6 @@ import { Module } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; -import { ConfigModule } from '@nestjs/config'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { GoogleStrategy } from './strategies/google.strategy'; @@ -20,25 +19,23 @@ import { EmailDeliveryProcessor } from './email-delivery.processor'; import { EncryptionModule } from '../encryption/encryption.module'; import { StellarModule } from '../stellar/stellar.module'; import { AdminGuard } from './guards/admin.guard'; +import { AppConfigModule } from '../config/config.module'; +import { AppConfigService } from '../config/app-config.service'; @Module({ imports: [ - ConfigModule, + AppConfigModule, PrismaModule, EncryptionModule, StellarModule, PassportModule.register({ session: false }), JwtModule.registerAsync({ - useFactory: () => { - const expires = - Number(process.env.JWT_EXPIRES) && Number(process.env.JWT_EXPIRES) > 0 - ? Number(process.env.JWT_EXPIRES) - : 3600; - return { - secret: process.env.JWT_SECRET || 'dev_jwt_secret', - signOptions: { expiresIn: expires }, - }; - }, + imports: [AppConfigModule], + inject: [AppConfigService], + useFactory: (appConfig: AppConfigService) => ({ + secret: appConfig.jwtSecret, + signOptions: { expiresIn: appConfig.jwtExpires }, + }), }), CsrfModule, BullModule.registerQueue({ diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index bfa8b8f..b9d3cd3 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -22,6 +22,7 @@ import { LoginDto } from './dto/login.dto'; import * as speakeasy from 'speakeasy'; import * as QRCode from 'qrcode'; import { StellarService } from '../stellar/stellar.service'; +import { AppConfigService } from '../config/app-config.service'; type JwtUser = { id: number; @@ -29,14 +30,10 @@ type JwtUser = { emailVerified?: Date | null; }; -const REFRESH_TOKEN_EXPIRES_DAYS = - Number(process.env.JWT_REFRESH_EXPIRES_DAYS) > 0 - ? Number(process.env.JWT_REFRESH_EXPIRES_DAYS) - : 14; - @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); + private readonly refreshTokenExpiresDays: number; constructor( private readonly jwtService: JwtService, @@ -46,7 +43,10 @@ export class AuthService { private readonly bruteForceService: BruteForceProtectionService, private readonly encryption: EncryptionService, private readonly stellarService: StellarService, - ) {} + appConfig: AppConfigService, + ) { + this.refreshTokenExpiresDays = appConfig.jwtRefreshExpiresDays; + } /** Generate a custodial Stellar keypair and persist it on the user record. */ private async assignStellarWallet(userId: number): Promise { @@ -146,7 +146,7 @@ export class AuthService { .update(rawToken) .digest('hex'); const expiresAt = new Date( - Date.now() + REFRESH_TOKEN_EXPIRES_DAYS * 24 * 60 * 60 * 1000, + Date.now() + this.refreshTokenExpiresDays * 24 * 60 * 60 * 1000, ); await this.prisma.refreshToken.create({ diff --git a/src/auth/brute-force-protection.service.ts b/src/auth/brute-force-protection.service.ts index 0bdba76..df24e8a 100644 --- a/src/auth/brute-force-protection.service.ts +++ b/src/auth/brute-force-protection.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { RedisService } from '../redis/redis.service'; +import { AppConfigService } from '../config/app-config.service'; export interface BruteForceConfig { maxAttempts: number; @@ -15,23 +15,14 @@ export class BruteForceProtectionService { private readonly redis: ReturnType; constructor( - private configService: ConfigService, + appConfig: AppConfigService, private redisService: RedisService, ) { this.redis = this.redisService.getClient(); this.config = { - maxAttempts: this.configService.get( - 'BRUTE_FORCE_MAX_ATTEMPTS', - 5, - ), - lockoutDuration: this.configService.get( - 'BRUTE_FORCE_LOCKOUT_DURATION', - 900, - ), // 15 minutes - windowDuration: this.configService.get( - 'BRUTE_FORCE_WINDOW_DURATION', - 900, - ), // 15 minutes + maxAttempts: appConfig.bruteForceMaxAttempts, + lockoutDuration: appConfig.bruteForceLockoutDuration, + windowDuration: appConfig.bruteForceWindowDuration, }; } diff --git a/src/auth/cookie.service.ts b/src/auth/cookie.service.ts index c6fe841..7a5bd9e 100644 --- a/src/auth/cookie.service.ts +++ b/src/auth/cookie.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Response } from 'express'; +import { AppConfigService } from '../config/app-config.service'; export interface CookieOptions { httpOnly: boolean; @@ -16,22 +17,11 @@ export class CookieService { private readonly accessTokenTtlMs: number; private readonly refreshTokenTtlMs: number; - constructor() { - this.useSecure = process.env.COOKIE_SECURE !== 'false'; // default true - const raw = (process.env.COOKIE_SAME_SITE ?? 'lax').toLowerCase(); - this.sameSite = raw === 'strict' || raw === 'none' ? raw : 'lax'; - - const jwtExpires = - Number(process.env.JWT_EXPIRES) > 0 - ? Number(process.env.JWT_EXPIRES) - : 3600; - const refreshDays = - Number(process.env.JWT_REFRESH_EXPIRES_DAYS) > 0 - ? Number(process.env.JWT_REFRESH_EXPIRES_DAYS) - : 14; - - this.accessTokenTtlMs = jwtExpires * 1000; - this.refreshTokenTtlMs = refreshDays * 24 * 60 * 60 * 1000; + constructor(private readonly appConfig: AppConfigService) { + this.useSecure = appConfig.cookieSecure; + this.sameSite = appConfig.cookieSameSite; + this.accessTokenTtlMs = appConfig.jwtExpires * 1000; + this.refreshTokenTtlMs = appConfig.jwtRefreshExpiresDays * 24 * 60 * 60 * 1000; } private baseOptions(maxAge: number): CookieOptions { diff --git a/src/auth/email-delivery.processor.ts b/src/auth/email-delivery.processor.ts index 49dbc2a..6e430a3 100644 --- a/src/auth/email-delivery.processor.ts +++ b/src/auth/email-delivery.processor.ts @@ -1,6 +1,5 @@ import { Logger } from '@nestjs/common'; import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq'; -import { ConfigService } from '@nestjs/config'; import { Job } from 'bullmq'; import { MailService } from './mail.service'; import { MetricsService } from '../metrics/metrics.service'; @@ -17,7 +16,7 @@ import { getBullMQWorkerConfig } from '../config/bullmq.config'; * Default: 5 concurrent jobs (email sending is I/O-bound and can handle more parallelism) */ @Processor(EMAIL_DELIVERY_QUEUE, { - concurrency: getBullMQWorkerConfig(new ConfigService()).emailDeliveryConcurrency, + concurrency: getBullMQWorkerConfig().emailDeliveryConcurrency, }) export class EmailDeliveryProcessor extends WorkerHost { private readonly logger = new Logger(EmailDeliveryProcessor.name); @@ -27,7 +26,7 @@ export class EmailDeliveryProcessor extends WorkerHost { private readonly metricsService: MetricsService, ) { super(); - const config = getBullMQWorkerConfig(configService); + const config = getBullMQWorkerConfig(); this.logger.log( `Email delivery worker initialized with concurrency: ${config.emailDeliveryConcurrency}`, ); diff --git a/src/auth/mail.service.ts b/src/auth/mail.service.ts index ef7ed4d..39abcfa 100644 --- a/src/auth/mail.service.ts +++ b/src/auth/mail.service.ts @@ -1,20 +1,21 @@ import { Injectable, Logger } from '@nestjs/common'; import * as nodemailer from 'nodemailer'; import { EmailDeliveryJobData } from './email-delivery.queue'; +import { AppConfigService } from '../config/app-config.service'; @Injectable() export class MailService { private readonly logger = new Logger(MailService.name); private transporter: nodemailer.Transporter; - constructor() { + constructor(private readonly appConfig: AppConfigService) { this.transporter = nodemailer.createTransport({ - host: process.env.SMTP_HOST || 'smtp.ethereal.email', - port: Number(process.env.SMTP_PORT) || 587, - secure: process.env.SMTP_SECURE === 'true', + host: appConfig.smtpHost, + port: appConfig.smtpPort, + secure: appConfig.smtpSecure, auth: { - user: process.env.SMTP_USER, - pass: process.env.SMTP_PASS, + user: appConfig.smtpUser, + pass: appConfig.smtpPass, }, }); } @@ -22,7 +23,7 @@ export class MailService { async sendTemplatedEmail(job: EmailDeliveryJobData): Promise { const content = this.buildTemplate(job.template, job.context.token); const info = await this.transporter.sendMail({ - from: process.env.SMTP_FROM || '"Clips App" ', + from: this.appConfig.smtpFrom, to: job.to, subject: job.subject, text: content.text, @@ -41,7 +42,7 @@ export class MailService { html?: string; }): Promise { const info = await this.transporter.sendMail({ - from: process.env.SMTP_FROM || '"Clips App" ', + from: this.appConfig.smtpFrom, to: options.to, subject: options.subject, text: options.text, @@ -81,7 +82,7 @@ export class MailService { } private buildTemplate(template: EmailDeliveryJobData['template'], token: string) { - const baseUrl = process.env.APP_BASE_URL || 'http://localhost:3000'; + const baseUrl = this.appConfig.appBaseUrl; if (template === 'magic-link') { const link = `${baseUrl}/auth/verify-magic?token=${token}`; return { diff --git a/src/auth/strategies/google.strategy.ts b/src/auth/strategies/google.strategy.ts index bfaa4e7..4bbf5cd 100644 --- a/src/auth/strategies/google.strategy.ts +++ b/src/auth/strategies/google.strategy.ts @@ -2,16 +2,18 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy, Profile } from 'passport-google-oauth20'; import { AuthService } from '../auth.service'; +import { AppConfigService } from '../../config/app-config.service'; @Injectable() export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { - constructor(private readonly authService: AuthService) { + constructor( + private readonly authService: AuthService, + appConfig: AppConfigService, + ) { super({ - clientID: process.env.GOOGLE_CLIENT_ID || 'google-client-id', - clientSecret: process.env.GOOGLE_CLIENT_SECRET || 'google-client-secret', - callbackURL: - process.env.GOOGLE_CALLBACK_URL || - 'http://localhost:3000/auth/google/callback', + clientID: appConfig.googleClientId, + clientSecret: appConfig.googleClientSecret, + callbackURL: appConfig.googleCallbackUrl, scope: ['profile', 'email'], passReqToCallback: false, }); diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts index 9cb394d..70bb3e0 100644 --- a/src/auth/strategies/jwt.strategy.ts +++ b/src/auth/strategies/jwt.strategy.ts @@ -1,6 +1,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import { AppConfigService } from '../../config/app-config.service'; export type JwtPayload = { sub: number; @@ -10,11 +11,11 @@ export type JwtPayload = { @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { - constructor() { + constructor(appConfig: AppConfigService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: process.env.JWT_SECRET || 'dev_jwt_secret', + secretOrKey: appConfig.jwtSecret, }); } diff --git a/src/clips/ayrshare.service.ts b/src/clips/ayrshare.service.ts index f3ea904..f83769c 100644 --- a/src/clips/ayrshare.service.ts +++ b/src/clips/ayrshare.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import { AppConfigService } from '../config/app-config.service'; export interface AyrsharePostResult { platform: string; @@ -16,9 +17,13 @@ export interface AyrsharePostResult { @Injectable() export class AyrshareService { private readonly logger = new Logger(AyrshareService.name); - private readonly apiKey = process.env.AYRSHARE_API_KEY ?? ''; + private readonly apiKey: string; private readonly baseUrl = 'https://app.ayrshare.com/api'; + constructor(appConfig: AppConfigService) { + this.apiKey = appConfig.ayrshareApiKey; + } + async post( mediaUrl: string, caption: string, diff --git a/src/clips/clip-generation.processor.ts b/src/clips/clip-generation.processor.ts index 6e75635..56ecdab 100644 --- a/src/clips/clip-generation.processor.ts +++ b/src/clips/clip-generation.processor.ts @@ -1,6 +1,5 @@ import { Logger } from '@nestjs/common'; import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq'; -import { ConfigService } from '@nestjs/config'; import { Job, UnrecoverableError } from 'bullmq'; import { EventEmitter2 } from '@nestjs/event-emitter'; import type { Clip } from './clip.entity'; @@ -80,7 +79,7 @@ const JOB_TIMEOUT_MS = 30 * 60 * 1000; * 100% → done (DB updated, all done) */ @Processor(CLIP_GENERATION_QUEUE, { - concurrency: getBullMQWorkerConfig(new ConfigService()).clipGenerationConcurrency, + concurrency: getBullMQWorkerConfig().clipGenerationConcurrency, }) export class ClipGenerationProcessor extends WorkerHost { private readonly logger = new Logger(ClipGenerationProcessor.name); @@ -94,7 +93,7 @@ export class ClipGenerationProcessor extends WorkerHost { private readonly prisma: PrismaService, ) { super(); - const config = getBullMQWorkerConfig(new ConfigService()); + const config = getBullMQWorkerConfig(); this.logger.log( `Clip generation worker initialized with concurrency: ${config.clipGenerationConcurrency}`, ); diff --git a/src/clips/clips.gateway.ts b/src/clips/clips.gateway.ts index 4212e91..3a99f39 100644 --- a/src/clips/clips.gateway.ts +++ b/src/clips/clips.gateway.ts @@ -13,6 +13,7 @@ import type { ClipFailedPayload, } from './clips.events'; import { WS_CLIP_PROGRESS, WS_CLIP_COMPLETED, WS_CLIP_FAILED } from './clips.events'; +import { getAllowedOrigins } from '../config/env.validation'; /** * WebSocket gateway for real-time clip-generation progress. @@ -32,7 +33,7 @@ import { WS_CLIP_PROGRESS, WS_CLIP_COMPLETED, WS_CLIP_FAILED } from './clips.eve @WebSocketGateway({ namespace: '/clips', cors: { - origin: process.env.ALLOWED_ORIGINS?.split(',') ?? ['http://localhost:3000'], + origin: getAllowedOrigins(), credentials: true, }, }) diff --git a/src/clips/clips.module.ts b/src/clips/clips.module.ts index b6e70ee..aa85cd1 100644 --- a/src/clips/clips.module.ts +++ b/src/clips/clips.module.ts @@ -20,6 +20,8 @@ import { ClipPublishService } from './clip-publish.service'; import { RedisModule } from '../redis/redis.module'; import { QueueRateLimitGuard } from '../common/guards/queue-rate-limit.guard'; import { UserPlatformModule } from '../user-platform/user-platform.module'; +import { AppConfigModule } from '../config/config.module'; +import { AppConfigService } from '../config/app-config.service'; @Module({ imports: [ @@ -56,9 +58,13 @@ import { UserPlatformModule } from '../user-platform/user-platform.module'; StellarModule, CircuitBreakerModule, // JwtModule used by ClipsGateway to verify WebSocket handshake tokens - JwtModule.register({ - secret: process.env.JWT_SECRET ?? 'dev_jwt_secret', - signOptions: { expiresIn: '7d' }, + JwtModule.registerAsync({ + imports: [AppConfigModule], + inject: [AppConfigService], + useFactory: (appConfig: AppConfigService) => ({ + secret: appConfig.jwtSecret, + signOptions: { expiresIn: '7d' }, + }), }), ], controllers: [ClipsController], diff --git a/src/clips/cloudinary.service.ts b/src/clips/cloudinary.service.ts index 59e3035..f4553e1 100644 --- a/src/clips/cloudinary.service.ts +++ b/src/clips/cloudinary.service.ts @@ -3,6 +3,7 @@ import { v2 as cloudinary } from 'cloudinary'; import * as streamifier from 'streamifier'; import * as fs from 'fs'; import { CircuitBreakerService, CircuitBreakerConfig } from '../common/circuit-breaker/circuit-breaker.service'; +import { AppConfigService } from '../config/app-config.service'; export interface CloudinaryUploadResult { secure_url: string; @@ -14,6 +15,7 @@ export interface CloudinaryUploadResult { @Injectable() export class CloudinaryService { private readonly logger = new Logger(CloudinaryService.name); + private readonly cloudName: string | undefined; private readonly circuitBreakerConfig: CircuitBreakerConfig = { name: 'cloudinary-upload', @@ -29,11 +31,15 @@ export class CloudinaryService { samplingDuration: 60000, }; - constructor(private readonly circuitBreakerService: CircuitBreakerService) { + constructor( + private readonly circuitBreakerService: CircuitBreakerService, + appConfig: AppConfigService, + ) { + this.cloudName = appConfig.cloudinaryCloudName; cloudinary.config({ - cloud_name: process.env.CLOUDINARY_CLOUD_NAME, - api_key: process.env.CLOUDINARY_API_KEY, - api_secret: process.env.CLOUDINARY_API_SECRET, + cloud_name: appConfig.cloudinaryCloudName, + api_key: appConfig.cloudinaryApiKey, + api_secret: appConfig.cloudinaryApiSecret, }); } @@ -113,7 +119,7 @@ export class CloudinaryService { private generateThumbnailUrl(publicId: string, resourceType: string, timeRatio = 0.5): string { if (resourceType !== 'video') return ''; - return `https://res.cloudinary.com/${process.env.CLOUDINARY_CLOUD_NAME}/video/upload/so_${Math.round(timeRatio * 100)}p/${publicId}.jpg`; + return `https://res.cloudinary.com/${this.cloudName}/video/upload/so_${Math.round(timeRatio * 100)}p/${publicId}.jpg`; } async deleteClip(publicId: string): Promise { diff --git a/src/clips/nft-mint.service.ts b/src/clips/nft-mint.service.ts index 4c1dd2b..dff3e38 100644 --- a/src/clips/nft-mint.service.ts +++ b/src/clips/nft-mint.service.ts @@ -10,7 +10,7 @@ import { StellarService } from '../stellar/stellar.service'; import StellarSdk from '@stellar/stellar-sdk'; import { MetricsService } from '../metrics/metrics.service'; import { CircuitBreakerService, CircuitBreakerConfig } from '../common/circuit-breaker/circuit-breaker.service'; -import { ConfigService } from '../config/config.service'; +import { AppConfigService } from '../config/app-config.service'; interface NftAttribute { trait_type: string; @@ -48,7 +48,7 @@ export class NftMintService { private readonly stellarService: StellarService, private readonly metricsService: MetricsService, private readonly circuitBreakerService: CircuitBreakerService, - private readonly config: ConfigService, + private readonly config: AppConfigService, ) {} private get CONTRACT_ID(): string { @@ -332,10 +332,8 @@ export class NftMintService { metadata: NftMetadata, clipId: number, ): Promise { - const pinataJwt = process.env.PINATA_JWT ?? process.env.IPFS_JWT; - const ipfsApiUrl = - process.env.IPFS_API_URL ?? - 'https://api.pinata.cloud/pinning/pinJSONToIPFS'; + const pinataJwt = this.config.pinataJwt; + const ipfsApiUrl = this.config.ipfsApiUrl; if (!pinataJwt) { throw new BadRequestException( diff --git a/src/common/guards/admin.guard.ts b/src/common/guards/admin.guard.ts index 2112241..7c22dc7 100644 --- a/src/common/guards/admin.guard.ts +++ b/src/common/guards/admin.guard.ts @@ -4,16 +4,19 @@ import { ExecutionContext, UnauthorizedException, } from '@nestjs/common'; +import { AppConfigService } from '../../config/app-config.service'; /** * Simple admin guard: requires `x-admin-secret` header matching ADMIN_SECRET env var. */ @Injectable() export class AdminGuard implements CanActivate { + constructor(private readonly appConfig: AppConfigService) {} + canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); const secret = request.headers['x-admin-secret']; - const expected = process.env.ADMIN_SECRET; + const expected = this.appConfig.adminSecret; if (!expected || secret !== expected) { throw new UnauthorizedException('Admin access required'); diff --git a/src/config/app-config.service.ts b/src/config/app-config.service.ts new file mode 100644 index 0000000..c02eacf --- /dev/null +++ b/src/config/app-config.service.ts @@ -0,0 +1,268 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { getAllowedOrigins, parseCsvEnv } from './env.validation'; + +@Injectable() +export class AppConfigService { + readonly clipJobMaxAttempts = 5; + readonly clipJobBackoffDelayMs = 2000; + readonly nftMintJobMaxAttempts = 3; + readonly nftMintJobBackoffDelayMs = 2000; + readonly clipPostingJobMaxAttempts = 3; + readonly clipPostingJobBackoffDelayMs = 2000; + readonly emailDeliveryJobMaxAttempts = 3; + readonly emailDeliveryJobBackoffDelayMs = 1000; + readonly queueRateLimitWindowSeconds = 3600; + readonly clipGenerationMaxConcurrentPerUser = 5; + + constructor(private readonly config: ConfigService) {} + + get nodeEnv(): string { + return this.config.get('NODE_ENV', 'development'); + } + + get isProduction(): boolean { + return this.nodeEnv === 'production'; + } + + get port(): number { + return this.config.get('PORT', 3000); + } + + get logLevel(): string { + return (this.config.get('LOG_LEVEL', 'info') ?? 'info').toLowerCase(); + } + + get databaseUrl(): string | undefined { + return this.config.get('DATABASE_URL'); + } + + get encryptionSecret(): string | undefined { + return this.config.get('ENCRYPTION_SECRET'); + } + + get jwtSecret(): string { + return this.config.get('JWT_SECRET', 'dev_jwt_secret'); + } + + get jwtExpires(): number { + const value = this.config.get('JWT_EXPIRES', 3600); + return value > 0 ? value : 3600; + } + + get jwtRefreshExpiresDays(): number { + const value = this.config.get('JWT_REFRESH_EXPIRES_DAYS', 14); + return value > 0 ? value : 14; + } + + get googleClientId(): string { + return this.config.get('GOOGLE_CLIENT_ID', 'google-client-id'); + } + + get googleClientSecret(): string { + return this.config.get('GOOGLE_CLIENT_SECRET', 'google-client-secret'); + } + + get googleCallbackUrl(): string { + return this.config.get( + 'GOOGLE_CALLBACK_URL', + 'http://localhost:3000/auth/google/callback', + ); + } + + get appBaseUrl(): string { + return this.config.get('APP_BASE_URL', 'http://localhost:3000'); + } + + get smtpHost(): string { + return this.config.get('SMTP_HOST', 'smtp.ethereal.email'); + } + + get smtpPort(): number { + return this.config.get('SMTP_PORT', 587); + } + + get smtpSecure(): boolean { + return this.config.get('SMTP_SECURE', false); + } + + get smtpUser(): string | undefined { + return this.config.get('SMTP_USER'); + } + + get smtpPass(): string | undefined { + return this.config.get('SMTP_PASS'); + } + + get smtpFrom(): string { + return this.config.get('SMTP_FROM', '"Clips App" '); + } + + get allowedOrigins(): string[] { + return getAllowedOrigins(); + } + + get redisHost(): string { + return this.config.get('REDIS_HOST', 'localhost'); + } + + get redisPort(): number { + return this.config.get('REDIS_PORT', 6379); + } + + get redisPassword(): string | undefined { + return this.config.get('REDIS_PASSWORD') || undefined; + } + + get bullmqClipGenerationConcurrency(): number { + return this.config.get('BULLMQ_CLIP_GENERATION_CONCURRENCY', 2); + } + + get bullmqEmailDeliveryConcurrency(): number { + return this.config.get('BULLMQ_EMAIL_DELIVERY_CONCURRENCY', 5); + } + + get bruteForceMaxAttempts(): number { + return this.config.get('BRUTE_FORCE_MAX_ATTEMPTS', 5); + } + + get bruteForceLockoutDuration(): number { + return this.config.get('BRUTE_FORCE_LOCKOUT_DURATION', 900); + } + + get bruteForceWindowDuration(): number { + return this.config.get('BRUTE_FORCE_WINDOW_DURATION', 900); + } + + get throttlerWhitelist(): string[] { + return parseCsvEnv(this.config.get('THROTTLER_WHITELIST')); + } + + get cookieSecure(): boolean { + return this.config.get('COOKIE_SECURE', true); + } + + get cookieSameSite(): 'strict' | 'lax' | 'none' { + const raw = (this.config.get('COOKIE_SAME_SITE', 'lax') ?? 'lax').toLowerCase(); + return raw === 'strict' || raw === 'none' ? raw : 'lax'; + } + + get stellarNetwork(): string { + return this.config.get('STELLAR_NETWORK', 'testnet'); + } + + get creatorRoyaltyBps(): number { + return this.config.get('CREATOR_ROYALTY_BPS', 1000); + } + + get platformRoyaltyBps(): number { + return this.config.get('PLATFORM_ROYALTY_BPS', 100); + } + + get platformWallet(): string { + return ( + this.config.get('PLATFORM_WALLET') || + this.config.get('PLATFORM_WALLET_ADDRESS') || + '' + ); + } + + get sorobanNftContractId(): string { + return this.config.get('SOROBAN_NFT_CONTRACT_ID', ''); + } + + get pinataJwt(): string | undefined { + return this.config.get('PINATA_JWT') || this.config.get('IPFS_JWT'); + } + + get ipfsApiUrl(): string { + return ( + this.config.get('IPFS_API_URL') ?? + 'https://api.pinata.cloud/pinning/pinJSONToIPFS' + ); + } + + get cloudinaryCloudName(): string | undefined { + return this.config.get('CLOUDINARY_CLOUD_NAME'); + } + + get cloudinaryApiKey(): string | undefined { + return this.config.get('CLOUDINARY_API_KEY'); + } + + get cloudinaryApiSecret(): string | undefined { + return this.config.get('CLOUDINARY_API_SECRET'); + } + + get ayrshareApiKey(): string { + return this.config.get('AYRSHARE_API_KEY', ''); + } + + get metricsToken(): string | undefined { + return this.config.get('METRICS_TOKEN'); + } + + get leaderboardEnabled(): boolean { + return this.config.get('LEADERBOARD_ENABLED', false); + } + + get webhookSecret(): string | undefined { + return this.config.get('WEBHOOK_SECRET'); + } + + get tiktokWebhookSecret(): string | undefined { + return this.config.get('TIKTOK_WEBHOOK_SECRET'); + } + + get youtubeWebhookSecret(): string | undefined { + return this.config.get('YOUTUBE_WEBHOOK_SECRET'); + } + + get adminEmails(): string[] { + return parseCsvEnv(this.config.get('ADMIN_EMAILS')); + } + + get adminSecret(): string | undefined { + return this.config.get('ADMIN_SECRET'); + } + + get anomalyThresholdMultiplier(): number { + return this.config.get('ANOMALY_THRESHOLD_MULTIPLIER', 3); + } + + get minEarningsForAnalysis(): number { + return this.config.get('MIN_EARNINGS_FOR_ANALYSIS', 10); + } + + get anomalyLookbackDays(): number { + return this.config.get('ANOMALY_LOOKBACK_DAYS', 30); + } + + get enableSwaggerUi(): boolean { + return this.config.get('ENABLE_SWAGGER_UI', false); + } + + get gracefulShutdownTimeoutMs(): number { + return this.config.get('GRACEFUL_SHUTDOWN_TIMEOUT_MS', 30000); + } + + get payoutVerifierIntervalMs(): number { + return this.config.get('PAYOUT_VERIFIER_INTERVAL_MS', 60000); + } + + get earningsCacheTtlSeconds(): number { + return this.config.get('EARNINGS_CACHE_TTL', 3600); + } + + get bullJobRetentionDays(): number { + return this.config.get('BULL_JOB_RETENTION_DAYS', 30); + } + + get anthropicApiKey(): string | undefined { + return this.config.get('ANTHROPIC_API_KEY'); + } + + get anthropicModel(): string { + return this.config.get('ANTHROPIC_MODEL', 'claude-4.1'); + } +} diff --git a/src/config/bullmq.config.ts b/src/config/bullmq.config.ts index 578f455..462e980 100644 --- a/src/config/bullmq.config.ts +++ b/src/config/bullmq.config.ts @@ -27,19 +27,31 @@ export interface BullMQWorkerConfig { * Load BullMQ worker configuration from environment variables * with sensible defaults for each queue type. */ +function readConcurrency( + configService: ConfigService | undefined, + key: string, + defaultValue: string, +): number { + const fromConfig = configService?.get(key); + const raw = fromConfig ?? process.env[key] ?? defaultValue; + return parseInt(raw, 10); +} + export function getBullMQWorkerConfig( - configService: ConfigService, + configService?: ConfigService, ): BullMQWorkerConfig { return { // Clip generation: CPU-intensive, default to 2 concurrent jobs - clipGenerationConcurrency: parseInt( - configService.get('BULLMQ_CLIP_GENERATION_CONCURRENCY', '2'), - 10, + clipGenerationConcurrency: readConcurrency( + configService, + 'BULLMQ_CLIP_GENERATION_CONCURRENCY', + '2', ), // Email delivery: I/O-bound, default to 5 concurrent jobs - emailDeliveryConcurrency: parseInt( - configService.get('BULLMQ_EMAIL_DELIVERY_CONCURRENCY', '5'), - 10, + emailDeliveryConcurrency: readConcurrency( + configService, + 'BULLMQ_EMAIL_DELIVERY_CONCURRENCY', + '5', ), }; } diff --git a/src/config/config.module.ts b/src/config/config.module.ts index 22c75de..79e71b9 100644 --- a/src/config/config.module.ts +++ b/src/config/config.module.ts @@ -1,9 +1,17 @@ -import { Module, Global } from '@nestjs/common'; -import { ConfigService } from './config.service'; +import { Global, Module } from '@nestjs/common'; +import { ConfigModule as NestConfigModule } from '@nestjs/config'; +import { AppConfigService } from './app-config.service'; +import { validateEnv } from './env.validation'; @Global() @Module({ - providers: [ConfigService], - exports: [ConfigService], + imports: [ + NestConfigModule.forRoot({ + isGlobal: true, + validate: validateEnv, + }), + ], + providers: [AppConfigService], + exports: [AppConfigService], }) -export class ConfigModule {} +export class AppConfigModule {} diff --git a/src/config/config.service.ts b/src/config/config.service.ts deleted file mode 100644 index 8c35d23..0000000 --- a/src/config/config.service.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class ConfigService { - readonly earningsCacheTtlSeconds = parseInt(process.env.EARNINGS_CACHE_TTL ?? '3600', 10); - - readonly leaderboardEnabled = process.env.LEADERBOARD_ENABLED === 'true'; - - readonly creatorRoyaltyBps = parseInt(process.env.CREATOR_ROYALTY_BPS ?? '1000', 10); - - readonly platformRoyaltyBps = parseInt(process.env.PLATFORM_ROYALTY_BPS ?? '100', 10); - - readonly clipJobMaxAttempts = 5; - - readonly clipJobBackoffDelayMs = 2000; - - readonly nftMintJobMaxAttempts = 3; - - readonly nftMintJobBackoffDelayMs = 2000; - - readonly clipPostingJobMaxAttempts = 3; - - readonly clipPostingJobBackoffDelayMs = 2000; - - readonly emailDeliveryJobMaxAttempts = 3; - - readonly emailDeliveryJobBackoffDelayMs = 1000; - - readonly queueRateLimitWindowSeconds = 3600; - - readonly clipGenerationMaxConcurrentPerUser = 5; - - readonly adminEmails = (process.env.ADMIN_EMAILS ?? '').split(',').filter(Boolean); - - readonly sorobanNftContractId = process.env.SOROBAN_NFT_CONTRACT_ID || ''; - - readonly platformWallet = process.env.PLATFORM_WALLET || ''; - - readonly tiktokWebhookSecret = process.env.TIKTOK_WEBHOOK_SECRET || ''; - - readonly youtubeWebhookSecret = process.env.YOUTUBE_WEBHOOK_SECRET || ''; - - readonly redisHost = process.env.REDIS_HOST ?? 'localhost'; - - readonly redisPort = parseInt(process.env.REDIS_PORT ?? '6379', 10); - - readonly redisPassword = process.env.REDIS_PASSWORD; -} diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts new file mode 100644 index 0000000..22b6ddf --- /dev/null +++ b/src/config/env.validation.spec.ts @@ -0,0 +1,42 @@ +import { validateEnv } from './env.validation'; + +describe('validateEnv', () => { + const baseConfig = { + NODE_ENV: 'development', + JWT_SECRET: 'dev_jwt_secret', + }; + + it('accepts a minimal development configuration', () => { + expect(() => validateEnv(baseConfig)).not.toThrow(); + }); + + it('rejects invalid BullMQ concurrency values', () => { + expect(() => + validateEnv({ + ...baseConfig, + BULLMQ_CLIP_GENERATION_CONCURRENCY: 0, + }), + ).toThrow(/BULLMQ_CLIP_GENERATION_CONCURRENCY/); + }); + + it('requires critical secrets in production', () => { + expect(() => + validateEnv({ + NODE_ENV: 'production', + JWT_SECRET: 'dev_jwt_secret', + }), + ).toThrow(/ENCRYPTION_SECRET/); + }); + + it('accepts a valid production configuration', () => { + expect(() => + validateEnv({ + NODE_ENV: 'production', + DATABASE_URL: 'postgresql://user:pass@localhost:5432/db', + ENCRYPTION_SECRET: 'a-secure-production-secret', + JWT_SECRET: 'a-secure-jwt-secret', + SOROBAN_NFT_CONTRACT_ID: 'CABC123', + }), + ).not.toThrow(); + }); +}); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts new file mode 100644 index 0000000..848e473 --- /dev/null +++ b/src/config/env.validation.ts @@ -0,0 +1,372 @@ +import { plainToInstance, Transform } from 'class-transformer'; +import { + IsBoolean, + IsIn, + IsInt, + IsNumber, + IsOptional, + IsString, + Max, + Min, + validateSync, +} from 'class-validator'; + +const DEV_JWT_SECRET = 'dev_jwt_secret'; + +export class EnvironmentVariables { + @IsOptional() + @IsString() + NODE_ENV?: string; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + @Max(65535) + PORT?: number; + + @IsOptional() + @IsString() + LOG_LEVEL?: string; + + @IsOptional() + @IsString() + DATABASE_URL?: string; + + @IsOptional() + @IsString() + ENCRYPTION_SECRET?: string; + + @IsOptional() + @IsString() + JWT_SECRET?: string; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + JWT_EXPIRES?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + JWT_REFRESH_EXPIRES_DAYS?: number; + + @IsOptional() + @IsString() + GOOGLE_CLIENT_ID?: string; + + @IsOptional() + @IsString() + GOOGLE_CLIENT_SECRET?: string; + + @IsOptional() + @IsString() + GOOGLE_CALLBACK_URL?: string; + + @IsOptional() + @IsString() + APP_BASE_URL?: string; + + @IsOptional() + @IsString() + SMTP_HOST?: string; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + SMTP_PORT?: number; + + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + SMTP_SECURE?: boolean; + + @IsOptional() + @IsString() + SMTP_USER?: string; + + @IsOptional() + @IsString() + SMTP_PASS?: string; + + @IsOptional() + @IsString() + SMTP_FROM?: string; + + @IsOptional() + @IsString() + ALLOWED_ORIGINS?: string; + + @IsOptional() + @IsString() + REDIS_HOST?: string; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + @Max(65535) + REDIS_PORT?: number; + + @IsOptional() + @IsString() + REDIS_PASSWORD?: string; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + @Max(200) + BULLMQ_CLIP_GENERATION_CONCURRENCY?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + @Max(50) + BULLMQ_EMAIL_DELIVERY_CONCURRENCY?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + BRUTE_FORCE_MAX_ATTEMPTS?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + BRUTE_FORCE_LOCKOUT_DURATION?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + BRUTE_FORCE_WINDOW_DURATION?: number; + + @IsOptional() + @IsString() + THROTTLER_WHITELIST?: string; + + @IsOptional() + @Transform(({ value }) => value !== 'false') + @IsBoolean() + COOKIE_SECURE?: boolean; + + @IsOptional() + @IsIn(['strict', 'lax', 'none']) + COOKIE_SAME_SITE?: 'strict' | 'lax' | 'none'; + + @IsOptional() + @IsString() + STELLAR_NETWORK?: string; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(0) + PLATFORM_ROYALTY_BPS?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(0) + CREATOR_ROYALTY_BPS?: number; + + @IsOptional() + @IsString() + PLATFORM_WALLET?: string; + + @IsOptional() + @IsString() + PLATFORM_WALLET_ADDRESS?: string; + + @IsOptional() + @IsString() + SOROBAN_NFT_CONTRACT_ID?: string; + + @IsOptional() + @IsString() + PINATA_JWT?: string; + + @IsOptional() + @IsString() + IPFS_JWT?: string; + + @IsOptional() + @IsString() + IPFS_API_URL?: string; + + @IsOptional() + @IsString() + CLOUDINARY_CLOUD_NAME?: string; + + @IsOptional() + @IsString() + CLOUDINARY_API_KEY?: string; + + @IsOptional() + @IsString() + CLOUDINARY_API_SECRET?: string; + + @IsOptional() + @IsString() + AYRSHARE_API_KEY?: string; + + @IsOptional() + @IsString() + METRICS_TOKEN?: string; + + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + LEADERBOARD_ENABLED?: boolean; + + @IsOptional() + @IsString() + WEBHOOK_SECRET?: string; + + @IsOptional() + @IsString() + TIKTOK_WEBHOOK_SECRET?: string; + + @IsOptional() + @IsString() + YOUTUBE_WEBHOOK_SECRET?: string; + + @IsOptional() + @IsString() + ADMIN_EMAILS?: string; + + @IsOptional() + @IsString() + ADMIN_SECRET?: string; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsNumber() + ANOMALY_THRESHOLD_MULTIPLIER?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsNumber() + MIN_EARNINGS_FOR_ANALYSIS?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + ANOMALY_LOOKBACK_DAYS?: number; + + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + ENABLE_SWAGGER_UI?: boolean; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1000) + GRACEFUL_SHUTDOWN_TIMEOUT_MS?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1000) + PAYOUT_VERIFIER_INTERVAL_MS?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + EARNINGS_CACHE_TTL?: number; + + @IsOptional() + @Transform(({ value }) => (value !== undefined ? Number(value) : undefined)) + @IsInt() + @Min(1) + BULL_JOB_RETENTION_DAYS?: number; + + @IsOptional() + @IsString() + ANTHROPIC_API_KEY?: string; + + @IsOptional() + @IsString() + ANTHROPIC_MODEL?: string; +} + +function formatValidationErrors(errors: ReturnType): string[] { + return errors.flatMap((error) => + error.constraints ? Object.values(error.constraints) : [], + ); +} + +function collectProductionRequirements(config: Record): string[] { + const isProduction = config.NODE_ENV === 'production'; + if (!isProduction) { + return []; + } + + const errors: string[] = []; + + if (!config.DATABASE_URL) { + errors.push('DATABASE_URL is required in production'); + } + if (!config.ENCRYPTION_SECRET) { + errors.push('ENCRYPTION_SECRET is required in production'); + } + if (!config.JWT_SECRET || config.JWT_SECRET === DEV_JWT_SECRET) { + errors.push('JWT_SECRET must be set to a secure value in production'); + } + if (!config.SOROBAN_NFT_CONTRACT_ID) { + errors.push('SOROBAN_NFT_CONTRACT_ID is required in production'); + } + + return errors; +} + +/** + * Validates environment variables at startup via ConfigModule.forRoot(). + * Throws with a descriptive message when configuration is invalid. + */ +export function validateEnv( + config: Record, +): Record { + const validatedConfig = plainToInstance(EnvironmentVariables, config, { + enableImplicitConversion: true, + }); + + const schemaErrors = formatValidationErrors( + validateSync(validatedConfig, { skipMissingProperties: true }), + ); + const productionErrors = collectProductionRequirements(config); + const errors = [...schemaErrors, ...productionErrors]; + + if (errors.length > 0) { + throw new Error( + `Environment validation failed:\n${errors.map((e) => ` - ${e}`).join('\n')}`, + ); + } + + return config; +} + +/** Parse comma-separated env values into a trimmed string array. */ +export function parseCsvEnv(value: string | undefined): string[] { + if (!value) { + return []; + } + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +/** Shared helper for static decorators that run before DI is available. */ +export function getAllowedOrigins(): string[] { + const origins = parseCsvEnv(process.env.ALLOWED_ORIGINS); + return origins.length > 0 ? origins : ['http://localhost:3000']; +} diff --git a/src/csrf/csrf.module.ts b/src/csrf/csrf.module.ts index 01322ec..4ec7b77 100644 --- a/src/csrf/csrf.module.ts +++ b/src/csrf/csrf.module.ts @@ -1,11 +1,11 @@ import { Module, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; import { APP_GUARD } from '@nestjs/core'; import { CsrfService } from './csrf.service'; import { CsrfGuard } from './csrf.guard'; +import { AppConfigModule } from '../config/config.module'; @Module({ - imports: [ConfigModule], + imports: [AppConfigModule], providers: [ CsrfService, { @@ -17,11 +17,8 @@ import { CsrfGuard } from './csrf.guard'; }) export class CsrfModule { configure(consumer: MiddlewareConsumer) { - const configService = new ConfigService(); - consumer .apply((req: any, res: any, next: any) => { - const csrfService = new CsrfService(configService); // Skip CSRF for GET, HEAD, OPTIONS requests if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { diff --git a/src/csrf/csrf.service.ts b/src/csrf/csrf.service.ts index eba3218..0dcd7ed 100644 --- a/src/csrf/csrf.service.ts +++ b/src/csrf/csrf.service.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { AppConfigService } from '../config/app-config.service'; import * as crypto from 'crypto'; @Injectable() export class CsrfService { - constructor(private configService: ConfigService) {} + constructor(private readonly appConfig: AppConfigService) {} generateToken(): string { return crypto.randomBytes(32).toString('hex'); @@ -18,13 +18,11 @@ export class CsrfService { } setCsrfCookie(res: any, token: string): void { - const isProduction = this.configService.get('NODE_ENV') === 'production'; - res.cookie('_csrf', token, { - httpOnly: false, // Allow JavaScript to read for header inclusion - secure: isProduction, + httpOnly: false, + secure: this.appConfig.isProduction, sameSite: 'strict', - maxAge: 24 * 60 * 60 * 1000, // 24 hours + maxAge: 24 * 60 * 60 * 1000, }); } diff --git a/src/earnings/anomaly-detection.processor.ts b/src/earnings/anomaly-detection.processor.ts index 2f6e189..32b01a9 100644 --- a/src/earnings/anomaly-detection.processor.ts +++ b/src/earnings/anomaly-detection.processor.ts @@ -5,6 +5,7 @@ import { AnomalyDetectionService } from './anomaly-detection.service'; import { MetricsService } from '../metrics/metrics.service'; import { ANOMALY_DETECTION_QUEUE } from './anomaly-detection.queue'; import { MailService } from '../auth/mail.service'; +import { AppConfigService } from '../config/app-config.service'; interface AnomalyDetectionJob { earningId: number; @@ -18,6 +19,7 @@ export class AnomalyDetectionProcessor { private anomalyDetectionService: AnomalyDetectionService, private mailService: MailService, private metricsService: MetricsService, + private readonly appConfig: AppConfigService, ) {} @Process('detect-anomaly') @@ -54,7 +56,7 @@ export class AnomalyDetectionProcessor { reason: string; severity: string; }): Promise { - const adminEmails = process.env.ADMIN_EMAILS?.split(',') || []; + const adminEmails = this.appConfig.adminEmails; if (adminEmails.length === 0) { this.logger.warn('No admin emails configured for anomaly notifications'); diff --git a/src/earnings/anomaly-detection.service.ts b/src/earnings/anomaly-detection.service.ts index f7a28d8..afcdc2c 100644 --- a/src/earnings/anomaly-detection.service.ts +++ b/src/earnings/anomaly-detection.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { AppConfigService } from '../config/app-config.service'; interface AnomalyConfig { thresholdMultiplier: number; @@ -10,13 +11,18 @@ interface AnomalyConfig { @Injectable() export class AnomalyDetectionService { private readonly logger = new Logger(AnomalyDetectionService.name); - private readonly config: AnomalyConfig = { - thresholdMultiplier: parseFloat(process.env.ANOMALY_THRESHOLD_MULTIPLIER ?? '3'), - minEarningsForAnalysis: parseFloat(process.env.MIN_EARNINGS_FOR_ANALYSIS ?? '10'), - lookbackDays: parseInt(process.env.ANOMALY_LOOKBACK_DAYS ?? '30', 10), - }; - - constructor(private prisma: PrismaService) {} + private readonly config: AnomalyConfig; + + constructor( + private prisma: PrismaService, + appConfig: AppConfigService, + ) { + this.config = { + thresholdMultiplier: appConfig.anomalyThresholdMultiplier, + minEarningsForAnalysis: appConfig.minEarningsForAnalysis, + lookbackDays: appConfig.anomalyLookbackDays, + }; + } async detectAnomalies(earningId: number): Promise<{ isAnomaly: boolean; diff --git a/src/earnings/earnings-aggregation.service.ts b/src/earnings/earnings-aggregation.service.ts index 5a353cf..7ca9e30 100644 --- a/src/earnings/earnings-aggregation.service.ts +++ b/src/earnings/earnings-aggregation.service.ts @@ -3,7 +3,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { Currency, EarningsBreakdown } from './earnings.types'; import { CurrencyConversionService } from './currency-conversion.service'; import { RedisService } from '../redis/redis.service'; -import { ConfigService } from '../config/config.service'; +import { AppConfigService } from '../config/app-config.service'; @Injectable() export class EarningsAggregationService { @@ -13,7 +13,7 @@ export class EarningsAggregationService { private prisma: PrismaService, private currencyConversion: CurrencyConversionService, private redisService: RedisService, - private config: ConfigService, + private config: AppConfigService, ) {} private getCacheKey(userId: number, targetCurrency: Currency): string { diff --git a/src/encryption/encryption-cli.ts b/src/encryption/encryption-cli.ts index dfbd839..3c39dcb 100644 --- a/src/encryption/encryption-cli.ts +++ b/src/encryption/encryption-cli.ts @@ -3,17 +3,16 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from '../app.module'; import { UserPlatformService } from '../user-platform/user-platform.service'; -import { ConfigService } from '@nestjs/config'; +import { AppConfigService } from '../config/app-config.service'; async function runMigration() { console.log('🔐 Starting encryption migration for UserPlatform tokens...'); const app = await NestFactory.createApplicationContext(AppModule); const userPlatformService = app.get(UserPlatformService); - const configService = app.get(ConfigService); + const appConfig = app.get(AppConfigService); - // Verify encryption secret is set - const encryptionSecret = configService.get('ENCRYPTION_SECRET'); + const encryptionSecret = appConfig.encryptionSecret; if (!encryptionSecret) { console.error('❌ ENCRYPTION_SECRET environment variable is required'); process.exit(1); diff --git a/src/encryption/encryption.service.ts b/src/encryption/encryption.service.ts index 36331d5..438a2ac 100644 --- a/src/encryption/encryption.service.ts +++ b/src/encryption/encryption.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { AppConfigService } from '../config/app-config.service'; import * as crypto from 'crypto'; @Injectable() @@ -7,12 +7,12 @@ export class EncryptionService { private readonly algorithm = 'aes-256-gcm'; private readonly key: Buffer; - constructor(private configService: ConfigService) { - const secret = this.configService.get('ENCRYPTION_SECRET'); + constructor(private readonly appConfig: AppConfigService) { + const secret = appConfig.encryptionSecret; if (!secret) { throw new Error('ENCRYPTION_SECRET environment variable is required'); } - + // Use SHA-256 to ensure we have exactly 32 bytes for AES-256 this.key = crypto.createHash('sha256').update(secret).digest(); } diff --git a/src/encryption/encryption.spec.ts b/src/encryption/encryption.spec.ts index d894861..cd2f072 100644 --- a/src/encryption/encryption.spec.ts +++ b/src/encryption/encryption.spec.ts @@ -1,28 +1,26 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; +import { AppConfigService } from '../config/app-config.service'; import { EncryptionService } from './encryption.service'; describe('EncryptionService', () => { let service: EncryptionService; - let configService: ConfigService; beforeEach(async () => { - const mockConfigService = { - get: jest.fn().mockReturnValue('test-encryption-secret-32-chars-long'), + const mockAppConfig = { + encryptionSecret: 'test-encryption-secret-32-chars-long', }; const module: TestingModule = await Test.createTestingModule({ providers: [ EncryptionService, { - provide: ConfigService, - useValue: mockConfigService, + provide: AppConfigService, + useValue: mockAppConfig, }, ], }).compile(); service = module.get(EncryptionService); - configService = module.get(ConfigService); }); it('should be defined', () => { @@ -83,8 +81,8 @@ describe('EncryptionService', () => { it('should throw error when decrypting with a different key', () => { const plaintext = 'sensitive-access-token'; const encrypted = service.encrypt(plaintext); - const wrongConfigService = { get: jest.fn().mockReturnValue('different-test-encryption-secret') }; - const wrongService = new EncryptionService(wrongConfigService as any); + const wrongAppConfig = { encryptionSecret: 'different-test-encryption-secret' }; + const wrongService = new EncryptionService(wrongAppConfig as AppConfigService); expect(() => wrongService.decrypt(encrypted)).toThrow('Failed to decrypt sensitive data'); }); @@ -92,8 +90,8 @@ describe('EncryptionService', () => { describe('constructor', () => { it('should throw if ENCRYPTION_SECRET is missing', async () => { - const mockConfigService = { - get: jest.fn().mockReturnValue(undefined), + const mockAppConfig = { + encryptionSecret: undefined, }; await expect( @@ -101,8 +99,8 @@ describe('EncryptionService', () => { providers: [ EncryptionService, { - provide: ConfigService, - useValue: mockConfigService, + provide: AppConfigService, + useValue: mockAppConfig, }, ], }).compile(), diff --git a/src/jobs/queue-cleanup.service.ts b/src/jobs/queue-cleanup.service.ts index 7e9dbf6..c11cfd9 100644 --- a/src/jobs/queue-cleanup.service.ts +++ b/src/jobs/queue-cleanup.service.ts @@ -1,8 +1,8 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { Queue } from 'bullmq'; import { CLIP_GENERATION_QUEUE } from '../clips/clip-generation.queue'; import { EMAIL_DELIVERY_QUEUE } from '../auth/email-delivery.queue'; +import { AppConfigService } from '../config/app-config.service'; const ONE_DAY_MS = 24 * 60 * 60 * 1000; const CLEAN_BATCH_LIMIT = 1000; @@ -15,7 +15,7 @@ export class QueueCleanupService implements OnModuleInit, OnModuleDestroy { private readonly emailQueue: Queue; private cleanupTimer?: NodeJS.Timeout; - constructor(private readonly config: ConfigService) { + constructor(private readonly appConfig: AppConfigService) { const connection = this.getRedisConnection(); this.clipQueue = new Queue(CLIP_GENERATION_QUEUE, { connection }); this.emailQueue = new Queue(EMAIL_DELIVERY_QUEUE, { connection }); @@ -88,8 +88,7 @@ export class QueueCleanupService implements OnModuleInit, OnModuleDestroy { } private getRetentionMilliseconds(): number { - const raw = this.config.get('BULL_JOB_RETENTION_DAYS'); - const retentionDays = Number.parseInt(raw ?? `${DEFAULT_RETENTION_DAYS}`, 10); + const retentionDays = this.appConfig.bullJobRetentionDays; if (Number.isNaN(retentionDays) || retentionDays < 1) { return DEFAULT_RETENTION_DAYS * ONE_DAY_MS; @@ -114,14 +113,10 @@ export class QueueCleanupService implements OnModuleInit, OnModuleDestroy { } private getRedisConnection() { - const host = this.config.get('REDIS_HOST') ?? 'localhost'; - const port = Number.parseInt(this.config.get('REDIS_PORT') ?? '6379', 10); - const password = this.config.get('REDIS_PASSWORD'); - return { - host, - port, - password: password || undefined, + host: this.appConfig.redisHost, + port: this.appConfig.redisPort, + password: this.appConfig.redisPassword, }; } } diff --git a/src/logger/logger.module.ts b/src/logger/logger.module.ts index c9b4c7b..5539322 100644 --- a/src/logger/logger.module.ts +++ b/src/logger/logger.module.ts @@ -1,8 +1,10 @@ import { Global, Module } from '@nestjs/common'; import { AppLoggerService } from './logger.service'; +import { AppConfigModule } from '../config/config.module'; @Global() @Module({ + imports: [AppConfigModule], providers: [AppLoggerService], exports: [AppLoggerService], }) diff --git a/src/logger/logger.service.spec.ts b/src/logger/logger.service.spec.ts index 70b8da2..8cc0b3e 100644 --- a/src/logger/logger.service.spec.ts +++ b/src/logger/logger.service.spec.ts @@ -1,4 +1,13 @@ import { AppLoggerService } from './logger.service'; +import { AppConfigService } from '../config/app-config.service'; + +function createMockAppConfig(overrides: Partial = {}): AppConfigService { + return { + logLevel: 'debug', + isProduction: true, + ...overrides, + } as AppConfigService; +} describe('AppLoggerService', () => { let service: AppLoggerService; @@ -6,16 +15,13 @@ describe('AppLoggerService', () => { let consoleSpy: jest.SpyInstance; beforeEach(() => { - process.env.NODE_ENV = 'production'; - process.env.LOG_LEVEL = 'debug'; - service = new AppLoggerService(); + service = new AppLoggerService(createMockAppConfig()); stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); }); afterEach(() => { jest.restoreAllMocks(); - delete process.env.LOG_LEVEL; }); it('outputs JSON in production', () => { @@ -53,8 +59,7 @@ describe('AppLoggerService', () => { }); it('respects LOG_LEVEL — suppresses debug when level is warn', () => { - process.env.LOG_LEVEL = 'warn'; - service = new AppLoggerService(); + service = new AppLoggerService(createMockAppConfig({ logLevel: 'warn' })); service.debug('should not appear'); expect(stdoutSpy).not.toHaveBeenCalled(); }); diff --git a/src/logger/logger.service.ts b/src/logger/logger.service.ts index fc46d8a..6842b5a 100644 --- a/src/logger/logger.service.ts +++ b/src/logger/logger.service.ts @@ -1,4 +1,5 @@ import { Injectable, LoggerService, Scope } from '@nestjs/common'; +import { AppConfigService } from '../config/app-config.service'; export interface LogContext { requestId?: string; @@ -37,9 +38,9 @@ export class AppLoggerService implements LoggerService { verbose: 4, }; - constructor() { - this.level = (process.env.LOG_LEVEL ?? 'info').toLowerCase(); - this.isProduction = process.env.NODE_ENV === 'production'; + constructor(private readonly appConfig: AppConfigService) { + this.level = appConfig.logLevel; + this.isProduction = appConfig.isProduction; } private shouldLog(level: string): boolean { diff --git a/src/main.ts b/src/main.ts index c313017..01d82b4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,5 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import helmet from 'helmet'; import cookieParser from 'cookie-parser'; @@ -11,6 +10,7 @@ import { AppModule } from './app.module'; import { PayoutsService } from './payouts/payouts.service'; import { MetricsInterceptor } from './metrics/metrics.interceptor'; import { AppLoggerService } from './logger/logger.service'; +import { AppConfigService } from './config/app-config.service'; import { getBullMQWorkerConfig, validateWorkerConfig, @@ -19,11 +19,11 @@ import { async function bootstrap() { const app = await NestFactory.create(AppModule); const logger = new Logger('Bootstrap'); - const isProduction = process.env.NODE_ENV === 'production'; + const appConfig = app.get(AppConfigService); + const isProduction = appConfig.isProduction; // Validate BullMQ worker configuration on startup - const configService = app.get(ConfigService); - const workerConfig = getBullMQWorkerConfig(configService); + const workerConfig = getBullMQWorkerConfig(); try { validateWorkerConfig(workerConfig); logger.log( @@ -113,7 +113,7 @@ async function bootstrap() { logger.log(`OpenAPI spec exported to ${openapiPath}`); // Setup Swagger UI (only in non-production or if explicitly enabled) - const enableSwaggerUI = !isProduction || process.env.ENABLE_SWAGGER_UI === 'true'; + const enableSwaggerUI = !isProduction || appConfig.enableSwaggerUi; if (enableSwaggerUI) { SwaggerModule.setup('api/docs', app, document, { swaggerOptions: { @@ -129,9 +129,7 @@ async function bootstrap() { logger.log('Swagger UI disabled in production. Set ENABLE_SWAGGER_UI=true to enable.'); } - const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [ - 'http://localhost:3000', - ]; + const allowedOrigins = appConfig.allowedOrigins; app.enableCors({ origin: allowedOrigins, credentials: true, // required for cross-origin cookie support @@ -191,7 +189,7 @@ async function bootstrap() { // in-flight work to finish (e.g. BullMQ processors should finish jobs). const shutdown = async (signal: string) => { logger.log(`Received ${signal}, shutting down gracefully...`); - const timeoutMs = Number(process.env.GRACEFUL_SHUTDOWN_TIMEOUT_MS) || 30000; + const timeoutMs = appConfig.gracefulShutdownTimeoutMs; const forceExit = setTimeout(() => { logger.error(`Shutdown timed out after ${timeoutMs}ms — forcing exit.`); process.exit(1); @@ -212,12 +210,12 @@ async function bootstrap() { process.on('SIGTERM', () => void shutdown('SIGTERM')); process.on('SIGINT', () => void shutdown('SIGINT')); - await app.listen(process.env.PORT ?? 3000); + await app.listen(appConfig.port); // Start periodic payout verification to confirm on-chain transactions try { const payoutsService = app.get(PayoutsService); - const intervalMs = parseInt(process.env.PAYOUT_VERIFIER_INTERVAL_MS ?? '60000', 10); + const intervalMs = appConfig.payoutVerifierIntervalMs; // Run once on startup void payoutsService.verifyPendingPayouts().catch((err) => logger.error(`Payout verifier initial run failed: ${err?.message ?? err}`)); diff --git a/src/redis/redis.service.ts b/src/redis/redis.service.ts index 4ccb5a9..4dfc017 100644 --- a/src/redis/redis.service.ts +++ b/src/redis/redis.service.ts @@ -1,16 +1,17 @@ import { Injectable, Logger } from '@nestjs/common'; import Redis from 'ioredis'; +import { AppConfigService } from '../config/app-config.service'; @Injectable() export class RedisService { private readonly logger = new Logger(RedisService.name); private readonly redis: Redis; - constructor() { + constructor(appConfig: AppConfigService) { this.redis = new Redis({ - host: process.env.REDIS_HOST ?? 'localhost', - port: parseInt(process.env.REDIS_PORT ?? '6379', 10), - password: process.env.REDIS_PASSWORD || undefined, + host: appConfig.redisHost, + port: appConfig.redisPort, + password: appConfig.redisPassword, lazyConnect: true, }); diff --git a/src/videos/video.service.ts b/src/videos/video.service.ts index f293e82..10777a4 100644 --- a/src/videos/video.service.ts +++ b/src/videos/video.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../prisma/prisma.service'; +import { AppConfigService } from '../config/app-config.service'; import ffmpeg from 'fluent-ffmpeg'; type ViralMoment = { start: number; end: number; reason: string }; @@ -11,7 +11,7 @@ export class VideoService { constructor( private readonly prisma: PrismaService, - private readonly config: ConfigService, + private readonly appConfig: AppConfigService, ) {} async detectViralTimestamps(videoId: number): Promise { @@ -118,10 +118,8 @@ export class VideoService { } private async callClaudeApi(videoUrl: string) { - const apiKey = - this.config.get('ANTHROPIC_API_KEY') || - process.env.ANTHROPIC_API_KEY; - const model = this.config.get('ANTHROPIC_MODEL') || 'claude-4.1'; + const apiKey = this.appConfig.anthropicApiKey; + const model = this.appConfig.anthropicModel; const maxClips = 30; const minClips = 10; @@ -238,7 +236,7 @@ export class VideoService { provider: string, usage?: { inputTokens?: number; outputTokens?: number }, ) { - const model = this.config.get('ANTHROPIC_MODEL') || 'claude-4.1'; + const model = this.appConfig.anthropicModel; if (usage?.inputTokens || usage?.outputTokens) { this.logger.log( diff --git a/src/webhooks/webhooks.service.ts b/src/webhooks/webhooks.service.ts index 640cb5d..d8380b5 100644 --- a/src/webhooks/webhooks.service.ts +++ b/src/webhooks/webhooks.service.ts @@ -1,18 +1,23 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { EarningsService } from '../earnings/earnings.service'; +import { AppConfigService } from '../config/app-config.service'; import * as crypto from 'crypto'; @Injectable() export class WebhooksService { private readonly logger = new Logger(WebhooksService.name); - private readonly tiktokSecret = process.env.TIKTOK_WEBHOOK_SECRET; - private readonly youtubeSecret = process.env.YOUTUBE_WEBHOOK_SECRET; + private readonly tiktokSecret: string | undefined; + private readonly youtubeSecret: string | undefined; constructor( private prisma: PrismaService, private earningsService: EarningsService, - ) {} + appConfig: AppConfigService, + ) { + this.tiktokSecret = appConfig.tiktokWebhookSecret; + this.youtubeSecret = appConfig.youtubeWebhookSecret; + } async validateTikTokSignature(payload: any, signature: string): Promise { if (!this.tiktokSecret) {