diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 6798613de..5f5489b87 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -66,11 +66,11 @@ jobs: working-directory: ./app/frontend run: npm run build env: - NEXT_PUBLIC_ RustAcademy_API_URL: ${{ secrets.NEXT_PUBLIC_ RustAcademy_API_URL }} - NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }} - NEXT_PUBLIC_STELLAR_NETWORK: ${{ secrets.NEXT_PUBLIC_STELLAR_NETWORK }} - NEXT_PUBLIC_ERROR_REPORTING_ENABLED: ${{ secrets.NEXT_PUBLIC_ERROR_REPORTING_ENABLED }} - NEXT_PUBLIC_APP_VERSION: ${{ secrets.NEXT_PUBLIC_APP_VERSION }} + NEXT_PUBLIC_RUST_ACADEMY_API_URL: ${#secrets.NEXT_PUBLIC_RUST_ACADEMY_API_URL}} + NEXT_PUBLIC_SITE_URL: ${#secrets.NEXT_PUBLIC_SITE_URL}} + NEXT_PUBLIC_STELLAR_NETWORK: ${#secrets.NEXT_PUBLIC_STELLAR_NETWORK}} + NEXT_PUBLIC_ERROR_REPORTING_ENABLED: ${#secrets.NEXT_PUBLIC_ERROR_REPORTING_ENABLED}} + NEXT_PUBLIC_APP_VERSION: ${#secrets.NEXT_PUBLIC_APP_VERSION}} security-scan: name: Security Scan @@ -89,10 +89,10 @@ jobs: run: | echo "Checking for hardcoded HTTP URLs in frontend code..." # Search for HTTP URLs, filter out localhost, and check if any remain - if grep -r "http://" app/frontend/src --include="*.ts" --include="*.tsx" --exclude-dir=node_modules 2>/dev/null | grep -v "http://localhost" > /dev/null 2>&1; then - echo "❌ Found hardcoded HTTP URLs (non-localhost). This may cause mixed-content issues in production." + if grep -r http:// app/frontend/src --include="*.ts" --include="*.tsx" --exclude-dir=node_modules 2/dev/nul | grep -v "http://localhost" > /dev/null 2>&1; then + echo "🀌 Found hardcoded HTTP URLs (non-localhost). This may cause mixed-content issues in production." grep -r "http://" app/frontend/src --include="*.ts" --include="*.tsx" --exclude-dir=node_modules | grep -v "http://localhost" exit 1 else - echo "βœ… No problematic HTTP URLs found" - fi + echo "✨ No problematic HTTP URLs found" + fi \ No newline at end of file diff --git a/BackendAcademy/README.md b/BackendAcademy/README.md index 701157ba7..2f4c61510 100644 --- a/BackendAcademy/README.md +++ b/BackendAcademy/README.md @@ -15,36 +15,22 @@ The server starts on `http://localhost:3000` by default (configurable via `PORT` The following environment variables are validated at startup. Copy `.env.example` (or set them in your deployment config) and customize as needed. -| Variable | Required (prod) | Default (dev/test) | Description | -| ---------------------- | ------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `NODE_ENV` | No | `development` | Runtime environment (`development`, `production`, `test`) | -| `PORT` | No | `3000` | Server port | -| `CORS_ORIGIN` | No | `*` | Allowed CORS origin(s): `*` or a comma-separated list | -| `DATABASE_URL` | **Yes** | `postgresql://…/rustacademy_development` | Database connection string. Mandatory in production β€” the service refuses to boot without persistence configured | -| `REDIS_HOST` | **Yes** | `localhost` | Redis host for caching and background jobs. Mandatory in production | -| `REDIS_PORT` | No | `6379` | Redis port | -| `REDIS_PASSWORD` | No | β€” | Optional Redis password | -| `JWT_SECRET` | **Yes** | `development`-only insecure value | Secret key for JWT signing. Required in production and must be β‰₯ 32 chars; the shipped example/development values are rejected | -| `ASSET_SIGNING_SECRET` | **Yes** | `development`-only insecure value | HMAC secret for signed asset URLs. Required in production; an empty secret makes signed URLs forgeable | -| `AI_PROVIDER` | No | `mock` | AI provider (`claude`, `openai`, `mock`) | -| `ANTHROPIC_API_KEY` | When `AI_PROVIDER=claude` | β€” | Anthropic API key | -| `OPENAI_API_KEY` | When `AI_PROVIDER=openai` | β€” | OpenAI API key | -| `AI_MODEL` | No | β€” | AI model override | -| `AI_MAX_TOKENS` | No | `4096` | Max tokens for AI requests | -| `AI_TEMPERATURE` | No | `0.7` | Temperature for AI responses | -| `LOCALE` | No | `en` | Localization locale (currently `en` supported) | -| `ASSETS_UPLOAD_DIR` | No | `./data/uploads` | Directory where uploaded assets are persisted | -| `ASSETS_STATIC_DIR` | No | `./public` | Read-only static asset directory served at `/static` | -| `ASSETS_BASE_URL` | No | `/api/v1/assets` | Base URL advertised inside asset metadata | -| `ASSETS_MAX_SIZE_MB` | No | `10` | Maximum size of a single uploaded asset (MB) | -| `ASSETS_MAX_TOTAL_MB` | No | `1024` | Aggregate byte quota across all stored assets (MB) | -| `ASSETS_MAX_COUNT` | No | `10000` | Maximum number of assets retained by the registry | - -> **Production note:** when `NODE_ENV=production`, startup validation rejects missing -> `DATABASE_URL`, `REDIS_HOST`, `JWT_SECRET` and `ASSET_SIGNING_SECRET`, low-entropy -> secrets, and placeholder values copied from `.env.example`. Development and test -> environments keep explicit (non-production) defaults so local boots stay simple. -> Validation errors never include secret values. +| Variable | Required | Default | Description | +|---|---|---|---| +| `NODE_ENV` | No | `development` | Runtime environment (`development`, `production`, `test`) | +| `PORT` | No | `3000` | Server port | +| `CORS_ORIGIN` | No | `*` | Allowed CORS origin (e.g. `https://RustAcademy.to`) | +| `DATABASE_URL` | No | β€” | Database connection string | +| `REDIS_HOST` | No | `localhost` | Redis host | +| `REDIS_PORT` | No | `6379` | Redis port | +| `JWT_SECRET` | No | β€” | Secret key for JWT signing | +| `AI_PROVIDER` | No | `mock` | AI provider (`claude`, `openai`, `mock`) | +| `ANTHROPIC_API_KEY` | No | β€” | Anthropic API key (for Claude) | +| `OPENAI_API_KEY` | No | β€” | OpenAI API key | +| `AI_MODEL` | No | β€” | AI model override | +| `AI_MAX_TOKENS` | No | `4096` | Max tokens for AI requests | +| `AI_TEMPERATURE` | No | `0.7` | Temperature for AI responses | +| `LOCALE` | No | `en` | Localization locale (currently `en` supported) | ## Cron Scheduling @@ -56,33 +42,33 @@ minute hour day-of-month month day-of-week ### Schedule Configuration -| Variable | Default | Description | -| ----------------------------- | -------------- | ------------------------------ | -| `CRON_CLEANUP_SCHEDULE` | `0 0 * * *` | Daily cleanup at midnight | -| `CRON_ANALYTICS_SCHEDULE` | `0 */6 * * *` | Analytics every 6 hours | +| Variable | Default | Description | +|---|---|---| +| `CRON_CLEANUP_SCHEDULE` | `0 0 * * *` | Daily cleanup at midnight | +| `CRON_ANALYTICS_SCHEDULE` | `0 */6 * * *` | Analytics every 6 hours | | `CRON_NOTIFICATIONS_SCHEDULE` | `*/30 * * * *` | Notifications every 30 minutes | ### Cron Expression Reference -| Expression | Meaning | -| ------------- | ------------------------------------- | -| `* * * * *` | Every minute | -| `*/5 * * * *` | Every 5 minutes | -| `0 * * * *` | Every hour at minute 0 | -| `0 0 * * *` | At midnight daily | -| `0 0 * * 0` | At midnight every Sunday | -| `0 9 * * 1-5` | At 9 AM, Monday–Friday | -| `0 0 1 * *` | At midnight on the 1st of every month | +| Expression | Meaning | +|---|---| +| `* * * * *` | Every minute | +| `*/5 * * * *` | Every 5 minutes | +| `0 * * * *` | Every hour at minute 0 | +| `0 0 * * *` | At midnight daily | +| `0 0 * * 0` | At midnight every Sunday | +| `0 9 * * 1-5` | At 9 AM, Monday–Friday | +| `0 0 1 * *` | At midnight on the 1st of every month | ### Field Ranges -| Field | Allowed Values | -| ------------ | ------------------------ | -| Minute | `0–59` | -| Hour | `0–23` | -| Day of Month | `1–31` | -| Month | `1–12` | -| Day of Week | `0–7` (0 or 7 is Sunday) | +| Field | Allowed Values | +|---|---| +| Minute | `0–59` | +| Hour | `0–23` | +| Day of Month | `1–31` | +| Month | `1–12` | +| Day of Week | `0–7` (0 or 7 is Sunday) | Invalid cron expressions are caught at startup and logged as errors. Use the `/api/jobs/schedules` endpoint to view all configured schedules and their next run times. @@ -124,12 +110,12 @@ The `MigrationService` provides preflight validation and rollback awareness for ### Migration Scripts -| Script | Description | -| -------------------------- | ----------------------------------------------- | +| Script | Description | +|---|---| | `pnpm migration:preflight` | Run preflight checks before applying migrations | -| `pnpm migration:rollback` | Roll back the most recent migration | -| `pnpm migration:history` | View migration history | -| `pnpm migration:dry-run` | Simulate a rollback without executing | +| `pnpm migration:rollback` | Roll back the most recent migration | +| `pnpm migration:history` | View migration history | +| `pnpm migration:dry-run` | Simulate a rollback without executing | ### Preflight Validation @@ -149,11 +135,11 @@ Each preflight check generates a rollback plan describing the steps needed to re Notifications are delivered through a centralized provider interface (`INotificationProvider`), supporting multiple channels: -| Provider | ID | Description | -| -------- | -------- | ----------------------------------------------------------- | -| Email | `email` | Sends notifications via email with template personalization | -| Push | `push` | Delivers push notifications to user devices | -| In-App | `in-app` | Stores notifications in the user's in-app feed | +| Provider | ID | Description | +|---|---|---| +| Email | `email` | Sends notifications via email with template personalization | +| Push | `push` | Delivers push notifications to user devices | +| In-App | `in-app` | Stores notifications in the user's in-app feed | Configure enabled providers via `NOTIFICATION_PROVIDERS` (comma-separated: `email,push,in-app`). @@ -162,23 +148,23 @@ Configure enabled providers via `NOTIFICATION_PROVIDERS` (comma-separated: `emai Email templates use `{{placeholder}}` syntax for personalization. When user data is incomplete, missing fields are replaced with sensible defaults so content never renders blank: -| Placeholder | Fallback | -| --------------------- | ------------------------------- | -| `{{name}}` | "RustAcademy Learner" | -| `{{courseName}}` | "your course" | -| `{{milestoneName}}` | "a new milestone" | -| `{{submissionTitle}}` | "your submission" | -| Any unrecognized key | `[key]` (safe bracket notation) | +| Placeholder | Fallback | +|---|---| +| `{{name}}` | "RustAcademy Learner" | +| `{{courseName}}` | "your course" | +| `{{milestoneName}}` | "a new milestone" | +| `{{submissionTitle}}` | "your submission" | +| Any unrecognized key | `[key]` (safe bracket notation) | ## Notification Batching (#386) Low-priority reminders (streak nudges, course suggestions) can be grouped into batches to reduce noise and improve delivery efficiency: -| Variable | Default | Description | -| ------------------------------ | ------- | --------------------------------- | -| `NOTIFICATION_BATCH_ENABLED` | `false` | Enable batching | -| `NOTIFICATION_BATCH_MAX_SIZE` | `10` | Max notifications per batch | +| Variable | Default | Description | +|---|---|---| +| `NOTIFICATION_BATCH_ENABLED` | `false` | Enable batching | +| `NOTIFICATION_BATCH_MAX_SIZE` | `10` | Max notifications per batch | | `NOTIFICATION_BATCH_WINDOW_MS` | `30000` | Auto-flush window in milliseconds | --- diff --git a/BackendAcademy/jest.config.ts b/BackendAcademy/jest.config.ts index 736cfc18f..8a9bc0faa 100644 --- a/BackendAcademy/jest.config.ts +++ b/BackendAcademy/jest.config.ts @@ -10,6 +10,9 @@ const config: Config = { collectCoverageFrom: ['**/*.(t|j)s'], coverageDirectory: '../coverage', testEnvironment: 'node', + moduleNameMapper: { + '^uuid$': '/common/uuid.shim.ts', + }, // ── Flaky-test / shared-state mitigations (#451) ────────────── clearMocks: true, diff --git a/BackendAcademy/readme.md b/BackendAcademy/readme.md index 701157ba7..2f4c61510 100644 --- a/BackendAcademy/readme.md +++ b/BackendAcademy/readme.md @@ -15,36 +15,22 @@ The server starts on `http://localhost:3000` by default (configurable via `PORT` The following environment variables are validated at startup. Copy `.env.example` (or set them in your deployment config) and customize as needed. -| Variable | Required (prod) | Default (dev/test) | Description | -| ---------------------- | ------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `NODE_ENV` | No | `development` | Runtime environment (`development`, `production`, `test`) | -| `PORT` | No | `3000` | Server port | -| `CORS_ORIGIN` | No | `*` | Allowed CORS origin(s): `*` or a comma-separated list | -| `DATABASE_URL` | **Yes** | `postgresql://…/rustacademy_development` | Database connection string. Mandatory in production β€” the service refuses to boot without persistence configured | -| `REDIS_HOST` | **Yes** | `localhost` | Redis host for caching and background jobs. Mandatory in production | -| `REDIS_PORT` | No | `6379` | Redis port | -| `REDIS_PASSWORD` | No | β€” | Optional Redis password | -| `JWT_SECRET` | **Yes** | `development`-only insecure value | Secret key for JWT signing. Required in production and must be β‰₯ 32 chars; the shipped example/development values are rejected | -| `ASSET_SIGNING_SECRET` | **Yes** | `development`-only insecure value | HMAC secret for signed asset URLs. Required in production; an empty secret makes signed URLs forgeable | -| `AI_PROVIDER` | No | `mock` | AI provider (`claude`, `openai`, `mock`) | -| `ANTHROPIC_API_KEY` | When `AI_PROVIDER=claude` | β€” | Anthropic API key | -| `OPENAI_API_KEY` | When `AI_PROVIDER=openai` | β€” | OpenAI API key | -| `AI_MODEL` | No | β€” | AI model override | -| `AI_MAX_TOKENS` | No | `4096` | Max tokens for AI requests | -| `AI_TEMPERATURE` | No | `0.7` | Temperature for AI responses | -| `LOCALE` | No | `en` | Localization locale (currently `en` supported) | -| `ASSETS_UPLOAD_DIR` | No | `./data/uploads` | Directory where uploaded assets are persisted | -| `ASSETS_STATIC_DIR` | No | `./public` | Read-only static asset directory served at `/static` | -| `ASSETS_BASE_URL` | No | `/api/v1/assets` | Base URL advertised inside asset metadata | -| `ASSETS_MAX_SIZE_MB` | No | `10` | Maximum size of a single uploaded asset (MB) | -| `ASSETS_MAX_TOTAL_MB` | No | `1024` | Aggregate byte quota across all stored assets (MB) | -| `ASSETS_MAX_COUNT` | No | `10000` | Maximum number of assets retained by the registry | - -> **Production note:** when `NODE_ENV=production`, startup validation rejects missing -> `DATABASE_URL`, `REDIS_HOST`, `JWT_SECRET` and `ASSET_SIGNING_SECRET`, low-entropy -> secrets, and placeholder values copied from `.env.example`. Development and test -> environments keep explicit (non-production) defaults so local boots stay simple. -> Validation errors never include secret values. +| Variable | Required | Default | Description | +|---|---|---|---| +| `NODE_ENV` | No | `development` | Runtime environment (`development`, `production`, `test`) | +| `PORT` | No | `3000` | Server port | +| `CORS_ORIGIN` | No | `*` | Allowed CORS origin (e.g. `https://RustAcademy.to`) | +| `DATABASE_URL` | No | β€” | Database connection string | +| `REDIS_HOST` | No | `localhost` | Redis host | +| `REDIS_PORT` | No | `6379` | Redis port | +| `JWT_SECRET` | No | β€” | Secret key for JWT signing | +| `AI_PROVIDER` | No | `mock` | AI provider (`claude`, `openai`, `mock`) | +| `ANTHROPIC_API_KEY` | No | β€” | Anthropic API key (for Claude) | +| `OPENAI_API_KEY` | No | β€” | OpenAI API key | +| `AI_MODEL` | No | β€” | AI model override | +| `AI_MAX_TOKENS` | No | `4096` | Max tokens for AI requests | +| `AI_TEMPERATURE` | No | `0.7` | Temperature for AI responses | +| `LOCALE` | No | `en` | Localization locale (currently `en` supported) | ## Cron Scheduling @@ -56,33 +42,33 @@ minute hour day-of-month month day-of-week ### Schedule Configuration -| Variable | Default | Description | -| ----------------------------- | -------------- | ------------------------------ | -| `CRON_CLEANUP_SCHEDULE` | `0 0 * * *` | Daily cleanup at midnight | -| `CRON_ANALYTICS_SCHEDULE` | `0 */6 * * *` | Analytics every 6 hours | +| Variable | Default | Description | +|---|---|---| +| `CRON_CLEANUP_SCHEDULE` | `0 0 * * *` | Daily cleanup at midnight | +| `CRON_ANALYTICS_SCHEDULE` | `0 */6 * * *` | Analytics every 6 hours | | `CRON_NOTIFICATIONS_SCHEDULE` | `*/30 * * * *` | Notifications every 30 minutes | ### Cron Expression Reference -| Expression | Meaning | -| ------------- | ------------------------------------- | -| `* * * * *` | Every minute | -| `*/5 * * * *` | Every 5 minutes | -| `0 * * * *` | Every hour at minute 0 | -| `0 0 * * *` | At midnight daily | -| `0 0 * * 0` | At midnight every Sunday | -| `0 9 * * 1-5` | At 9 AM, Monday–Friday | -| `0 0 1 * *` | At midnight on the 1st of every month | +| Expression | Meaning | +|---|---| +| `* * * * *` | Every minute | +| `*/5 * * * *` | Every 5 minutes | +| `0 * * * *` | Every hour at minute 0 | +| `0 0 * * *` | At midnight daily | +| `0 0 * * 0` | At midnight every Sunday | +| `0 9 * * 1-5` | At 9 AM, Monday–Friday | +| `0 0 1 * *` | At midnight on the 1st of every month | ### Field Ranges -| Field | Allowed Values | -| ------------ | ------------------------ | -| Minute | `0–59` | -| Hour | `0–23` | -| Day of Month | `1–31` | -| Month | `1–12` | -| Day of Week | `0–7` (0 or 7 is Sunday) | +| Field | Allowed Values | +|---|---| +| Minute | `0–59` | +| Hour | `0–23` | +| Day of Month | `1–31` | +| Month | `1–12` | +| Day of Week | `0–7` (0 or 7 is Sunday) | Invalid cron expressions are caught at startup and logged as errors. Use the `/api/jobs/schedules` endpoint to view all configured schedules and their next run times. @@ -124,12 +110,12 @@ The `MigrationService` provides preflight validation and rollback awareness for ### Migration Scripts -| Script | Description | -| -------------------------- | ----------------------------------------------- | +| Script | Description | +|---|---| | `pnpm migration:preflight` | Run preflight checks before applying migrations | -| `pnpm migration:rollback` | Roll back the most recent migration | -| `pnpm migration:history` | View migration history | -| `pnpm migration:dry-run` | Simulate a rollback without executing | +| `pnpm migration:rollback` | Roll back the most recent migration | +| `pnpm migration:history` | View migration history | +| `pnpm migration:dry-run` | Simulate a rollback without executing | ### Preflight Validation @@ -149,11 +135,11 @@ Each preflight check generates a rollback plan describing the steps needed to re Notifications are delivered through a centralized provider interface (`INotificationProvider`), supporting multiple channels: -| Provider | ID | Description | -| -------- | -------- | ----------------------------------------------------------- | -| Email | `email` | Sends notifications via email with template personalization | -| Push | `push` | Delivers push notifications to user devices | -| In-App | `in-app` | Stores notifications in the user's in-app feed | +| Provider | ID | Description | +|---|---|---| +| Email | `email` | Sends notifications via email with template personalization | +| Push | `push` | Delivers push notifications to user devices | +| In-App | `in-app` | Stores notifications in the user's in-app feed | Configure enabled providers via `NOTIFICATION_PROVIDERS` (comma-separated: `email,push,in-app`). @@ -162,23 +148,23 @@ Configure enabled providers via `NOTIFICATION_PROVIDERS` (comma-separated: `emai Email templates use `{{placeholder}}` syntax for personalization. When user data is incomplete, missing fields are replaced with sensible defaults so content never renders blank: -| Placeholder | Fallback | -| --------------------- | ------------------------------- | -| `{{name}}` | "RustAcademy Learner" | -| `{{courseName}}` | "your course" | -| `{{milestoneName}}` | "a new milestone" | -| `{{submissionTitle}}` | "your submission" | -| Any unrecognized key | `[key]` (safe bracket notation) | +| Placeholder | Fallback | +|---|---| +| `{{name}}` | "RustAcademy Learner" | +| `{{courseName}}` | "your course" | +| `{{milestoneName}}` | "a new milestone" | +| `{{submissionTitle}}` | "your submission" | +| Any unrecognized key | `[key]` (safe bracket notation) | ## Notification Batching (#386) Low-priority reminders (streak nudges, course suggestions) can be grouped into batches to reduce noise and improve delivery efficiency: -| Variable | Default | Description | -| ------------------------------ | ------- | --------------------------------- | -| `NOTIFICATION_BATCH_ENABLED` | `false` | Enable batching | -| `NOTIFICATION_BATCH_MAX_SIZE` | `10` | Max notifications per batch | +| Variable | Default | Description | +|---|---|---| +| `NOTIFICATION_BATCH_ENABLED` | `false` | Enable batching | +| `NOTIFICATION_BATCH_MAX_SIZE` | `10` | Max notifications per batch | | `NOTIFICATION_BATCH_WINDOW_MS` | `30000` | Auto-flush window in milliseconds | --- diff --git a/BackendAcademy/src/admin/admin.module.ts b/BackendAcademy/src/admin/admin.module.ts index 499c2f551..b41868c09 100644 --- a/BackendAcademy/src/admin/admin.module.ts +++ b/BackendAcademy/src/admin/admin.module.ts @@ -2,9 +2,11 @@ import { Module } from '@nestjs/common'; import { AdminController } from './admin.controller'; import { AdminService } from './admin.service'; import { SubmissionsModule } from '../submissions/submissions.module'; +import { ReportsModule } from '../reports/reports.module'; +import { SocialModule } from '../social/social.module'; @Module({ - imports: [SubmissionsModule], + imports: [SubmissionsModule, ReportsModule, SocialModule], controllers: [AdminController], providers: [AdminService], exports: [AdminService], diff --git a/BackendAcademy/src/ai/ai.module.ts b/BackendAcademy/src/ai/ai.module.ts index 1e64927f1..6d2adfb70 100644 --- a/BackendAcademy/src/ai/ai.module.ts +++ b/BackendAcademy/src/ai/ai.module.ts @@ -21,6 +21,10 @@ import { OpenaiProvider } from './providers/openai.provider'; export function validateAiConfig(configService: ConfigService): void { const provider = configService.get('AI_PROVIDER') ?? 'mock'; + if (!['openai', 'claude', 'mock'].includes(provider)) { + throw new Error(`Invalid AI_PROVIDER: ${provider}. Must be 'openai', 'claude', or 'mock'.`); + } + if (provider === 'openai' || provider === 'claude') { const credentialsKey = provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY'; @@ -89,4 +93,4 @@ const aiProviderFactory = { providers: [AiService, PromptTemplateService, aiProviderFactory], exports: [AiService, PromptTemplateService], }) -export class AiModule {} +export class AiModule {} \ No newline at end of file diff --git a/BackendAcademy/src/ai/ai.service.ts b/BackendAcademy/src/ai/ai.service.ts index 5ae88a1b3..c865169db 100644 --- a/BackendAcademy/src/ai/ai.service.ts +++ b/BackendAcademy/src/ai/ai.service.ts @@ -54,10 +54,10 @@ export class AiService { constructor( @Optional() @Inject(AI_PROVIDER) private aiProvider?: AiProvider, - private configService?: ConfigService, - private readonly analyticsService?: AnalyticsService, - private readonly redisService?: RedisService, - private readonly monitoringService?: MonitoringService, + @Optional() private configService?: ConfigService, + @Optional() private readonly analyticsService?: AnalyticsService, + @Optional() private readonly redisService?: RedisService, + @Optional() private readonly monitoringService?: MonitoringService, @Optional() private readonly promptTemplateService?: PromptTemplateService, @Optional() private readonly securityService?: SecurityService, ) { @@ -693,4 +693,4 @@ export class AiService { history.splice(0, history.length - MAX_CHAT_HISTORY_PER_USER); } } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/analytics/analytics.service.ts b/BackendAcademy/src/analytics/analytics.service.ts index 21482edaf..05df945c3 100644 --- a/BackendAcademy/src/analytics/analytics.service.ts +++ b/BackendAcademy/src/analytics/analytics.service.ts @@ -55,6 +55,7 @@ export interface ReconciliationSummary { @Injectable() export class AnalyticsService { + static readonly VALID_EVENT_TYPES = new Set(Object.values(EventType)); private readonly logger = new Logger(AnalyticsService.name); private readonly events: AnalyticsEvent[] = []; diff --git a/BackendAcademy/src/api-info.controller.ts b/BackendAcademy/src/api-info.controller.ts index e0825bad7..6f108079a 100644 --- a/BackendAcademy/src/api-info.controller.ts +++ b/BackendAcademy/src/api-info.controller.ts @@ -2,11 +2,11 @@ import { Controller, Get } from '@nestjs/common'; import { LocalizationService } from './i18n/localization.service'; -@Controller('api') +@Controller('info') export class ApiInfoController { constructor(private readonly l10n: LocalizationService) {} - Get() + @Get() getApiInfo() { return { success: true, diff --git a/BackendAcademy/src/app.controller.ts b/BackendAcademy/src/app.controller.ts index cce879ee6..bc72a5371 100644 --- a/BackendAcademy/src/app.controller.ts +++ b/BackendAcademy/src/app.controller.ts @@ -1,7 +1,7 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, VERSION_NEUTRAL } from '@nestjs/common'; import { AppService } from './app.service'; -@Controller() +@Controller({ version: VERSION_NEUTRAL }) export class AppController { constructor(private readonly appService: AppService) {} diff --git a/BackendAcademy/src/app.module.spec.ts b/BackendAcademy/src/app.module.spec.ts index 2b57de7c6..4b6a6511e 100644 --- a/BackendAcademy/src/app.module.spec.ts +++ b/BackendAcademy/src/app.module.spec.ts @@ -4,12 +4,9 @@ import { AppModule } from './app.module'; describe('AppModule bootstrap registration', () => { it('registers every production feature module at the root', () => { - const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule) as Array< - | { module?: { name: string } } - | { name: string } - >; + const imports = (Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule) ?? []) as any[]; const importedModuleNames = imports.map( - importedModule => importedModule.module?.name ?? importedModule.name, + importedModule => importedModule?.module?.name ?? importedModule?.name, ); expect(importedModuleNames).toEqual( diff --git a/BackendAcademy/src/app.module.ts b/BackendAcademy/src/app.module.ts index ddcd3d4ae..920339a9b 100644 --- a/BackendAcademy/src/app.module.ts +++ b/BackendAcademy/src/app.module.ts @@ -1,6 +1,11 @@ -import { Module, MiddlewareConsumer, NestModule, ValidationPipe } from '@nestjs/common'; -import { APP_GUARD, APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; -import { ThrottleGuard, ThrottleModule } from '@nestjs/throttler'; +import { Module, MiddlewareConsumer, NestModule, ValidationPipe, Injectable, Inject } from '@nestjs/common'; +import { APP_GUARD, APP_FILTER, APP_INTERCEPTOR, APP_PIPE, Reflector } from '@nestjs/core'; +import { + InjectThrottlerOptions, + InjectThrottlerStorage, + ThrottlerGuard, + ThrottlerModule, +} from '@nestjs/throttler'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AdminModule } from './admin/admin.module'; @@ -26,9 +31,9 @@ import { PaymentsModule } from './payments/payments.module'; import { I18nModule } from './i18n/i18n.module'; import { NotificationsModule } from './notifications/notifications.module'; import { HealthModule } from './health/health.module'; -import { CorrelationIDMiddleware } from './common/correlation-id.middleware'; -import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; -import { TransformInterceptor } from './common/interceptors/transform.interceptor'; +import { CorrelationIdMiddleware } from './common/correlation-id.middleware'; +import { HttpExceptionFilter } from './common/http-exception.filter'; +import { ResponseInterceptor } from './common/response.interceptor'; import { AiModule } from './ai/ai.module'; import { JobsModule } from './jobs/jobs.module'; import { LeaderboardModule } from './leaderboard/leaderboard.module'; @@ -47,6 +52,17 @@ import { SessionsModule } from './sessions/sessions.module'; import { AuditModule } from './audit/audit.module'; import { HintsModule } from './hints/hint.module'; +@Injectable() +export class AppThrottlerGuard extends ThrottlerGuard { + constructor( + @InjectThrottlerOptions() options: any, + @InjectThrottlerStorage() storageService: any, + reflector: Reflector, + ) { + super(options, storageService, reflector); + } +} + @Module({ imports: [ AppConfigModule, @@ -59,8 +75,6 @@ import { HintsModule } from './hints/hint.module'; BadgesModule, ChatModule, UsersModule, - ContractsModule, - RedisModule, AuditModule, UserProfileModule, TutorProfileModule, @@ -96,14 +110,15 @@ import { HintsModule } from './hints/hint.module'; controllers: [AppController], providers: [ AppService, - { provide: APP_GUARD, useClass: ThrottleGuard }, - { provide: APP_FILTER, useClass: AllExceptionsFilter }, - { provide: APP_INTERCEPTOR, useClass: TransformInterceptor }, + Reflector, + { provide: APP_GUARD, useClass: AppThrottlerGuard }, + { provide: APP_FILTER, useClass: HttpExceptionFilter }, + { provide: APP_INTERCEPTOR, useClass: ResponseInterceptor }, { provide: APP_PIPE, useValue: new ValidationPipe({ transform: true }) }, ], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { - consumer.apply(CorrelationIDMiddleware).forRoutes('*'); + consumer.apply(CorrelationIdMiddleware).forRoutes('*'); } } \ No newline at end of file diff --git a/BackendAcademy/src/assets/assets.controller.ts b/BackendAcademy/src/assets/assets.controller.ts index 46471a121..f935a8d5e 100644 --- a/BackendAcademy/src/assets/assets.controller.ts +++ b/BackendAcademy/src/assets/assets.controller.ts @@ -29,6 +29,7 @@ import { ApiOperation, ApiParam, ApiProduces, + ApiQuery, ApiResponse, ApiTags, } from '@nestjs/swagger'; @@ -86,7 +87,7 @@ function buildUploadOptions(assetsService: AssetsService): MulterOptions { fields: 10, parts: 12, }, - fileFilter: (_req, file, cb) => { + fileFilter: (_req: any, file: any, cb: any) => { const declared = (file.mimetype || '').toLowerCase(); const isAllowed = ALLOWED_MIME_TYPES.some(({ mime, prefix }) => prefix ? declared.startsWith(mime) : declared === mime, @@ -125,15 +126,11 @@ function buildUploadOptions(assetsService: AssetsService): MulterOptions { }; } -@ApiTags('assets') +@ApiTags('Assets') @Controller('assets') @UseFilters(MulterExceptionFilter) export class AssetsController { - private readonly uploadInterceptor: NestInterceptor; - - constructor(private readonly assetsService: AssetsService) { - this.uploadInterceptor = new FileInterceptor('file', buildUploadOptions(assetsService)); - } + constructor(private readonly assetsService: AssetsService) {} /** * `GET /assets` β€” list all stored assets, optionally sorted. @@ -202,7 +199,7 @@ export class AssetsController { */ @Post() @HttpCode(HttpStatus.CREATED) - @UseInterceptors(this.uploadInterceptor) + @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { @@ -220,7 +217,7 @@ export class AssetsController { @ApiResponse({ status: 400, description: 'Invalid asset payload.' }) @ApiResponse({ status: 413, description: 'Asset exceeds maximum size.' }) async upload( - @UploadedFile() file: Express.Multer.File, + @UploadedFile() file: any, @Body() dto: UploadAssetDto, ): Promise { if (!file) { diff --git a/BackendAcademy/src/assets/assets.module.ts b/BackendAcademy/src/assets/assets.module.ts index 62efe6108..3516bf81b 100644 --- a/BackendAcademy/src/assets/assets.module.ts +++ b/BackendAcademy/src/assets/assets.module.ts @@ -1,7 +1,7 @@ -import {'Module' } from '@nestjs/common'; -import {'AssetsController' } from './assets.controller'; -import {'AssetsService' } from './assets.service'; -import {'SecurityModule' } from '../security/security.module'; +import { Module } from '@nestjs/common'; +import { AssetsController } from './assets.controller'; +import { AssetsService } from './assets.service'; +import { SecurityModule } from '../security/security.module'; /** * Module exposing asset upload, metadata, download, and delete endpoints diff --git a/BackendAcademy/src/assets/assets.service.spec.ts b/BackendAcademy/src/assets/assets.service.spec.ts index 0f363dbdf..bc592cf28 100644 --- a/BackendAcademy/src/assets/assets.service.spec.ts +++ b/BackendAcademy/src/assets/assets.service.spec.ts @@ -4,6 +4,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { promises as fs } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import * as crypto from 'node:crypto'; import { AssetsService, ALLOWED_MIME_TYPES } from './assets.service'; import { SecurityService } from '../security/security.service'; @@ -61,7 +62,10 @@ describe('AssetsService', () => { }, { provide: SecurityService, - useValue: { computeContentHash: (b: Buffer) => 'hash-' + b.length }, + useValue: { + computeContentHash: (b: Buffer) => + crypto.createHash('sha256').update(b).digest('hex'), + }, }, ], }).compile(); @@ -220,7 +224,10 @@ describe('AssetsService', () => { }, { provide: SecurityService, - useValue: { computeContentHash: (b: Buffer) => 'hash-' + b.length }, + useValue: { + computeContentHash: (b: Buffer) => + crypto.createHash('sha256').update(b).digest('hex'), + }, }, ], }).compile(); diff --git a/BackendAcademy/src/assets/assets.service.ts b/BackendAcademy/src/assets/assets.service.ts index c2ba12b61..6978c00e9 100644 --- a/BackendAcademy/src/assets/assets.service.ts +++ b/BackendAcademy/src/assets/assets.service.ts @@ -266,7 +266,7 @@ export class AssetsService implements OnModuleDestroy { this.assertSizeAllowed(params.size); // 2. Establish the real type from the bytes and screen the content. - const detectedMime = await this.detectMime(params.buffer); + const detectedMime = await this.detectMime(params.buffer, declaredMime); this.assertAllowedType(detectedMime, declaredMime); this.assertNotDangerous(params.buffer, detectedMime); @@ -403,7 +403,7 @@ export class AssetsService implements OnModuleDestroy { * already enumerates every binary type this service accepts (PNG, JPEG, * GIF, WEBP, PDF) plus the dangerous families it must reject. */ - private async detectMime(buffer: Buffer): Promise { + private async detectMime(buffer: Buffer, declaredMime?: string): Promise { // Explicit magic-byte table first β€” it is exact and dependency-free. const sig = this.matchSignature(buffer, SIGNATURE_TO_MIME); if (sig) return sig; @@ -415,6 +415,10 @@ export class AssetsService implements OnModuleDestroy { return `application/x-forbidden-${forbidden.label.replace(/[^a-z0-9]/gi, '-')}`; } + if (declaredMime && declaredMime.startsWith('text/')) { + return declaredMime; + } + // No recognizable binary signature. Treat as opaque/denied unless the // caller explicitly declared a (text) type, which is screened separately. return 'application/octet-stream'; diff --git a/BackendAcademy/src/audit/audit.service.ts b/BackendAcademy/src/audit/audit.service.ts index f5b8c93ec..785a23ba0 100644 --- a/BackendAcademy/src/audit/audit.service.ts +++ b/BackendAcademy/src/audit/audit.service.ts @@ -1,4 +1,4 @@ -ο»Ώimport { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { CorrelationLoggerService } from '../logging/logger.service'; export interface AuditEvent { diff --git a/BackendAcademy/src/auth/auth-session.controller.ts b/BackendAcademy/src/auth/auth-session.controller.ts index 8c61f9ee3..8f9c4a5f3 100644 --- a/BackendAcademy/src/auth/auth-session.controller.ts +++ b/BackendAcademy/src/auth/auth-session.controller.ts @@ -63,9 +63,9 @@ export class AuthSessionController { @Get(':userId') @HttpCode(HttpStatus.OK) - getActiveSessions( + async getActiveSessions( @Param('userId') userId: string, - ): Omit[] { + ): Promise[]> { return this.authSessionService.getActiveSessions(userId); } diff --git a/BackendAcademy/src/auth/auth-session.service.ts b/BackendAcademy/src/auth/auth-session.service.ts index 131f20569..ccdccf48a 100644 --- a/BackendAcademy/src/auth/auth-session.service.ts +++ b/BackendAcademy/src/auth/auth-session.service.ts @@ -2,6 +2,8 @@ import { Injectable, UnauthorizedException, Logger, + Inject, + Optional, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { AuditLogService } from '../audit/audit.service'; @@ -47,9 +49,10 @@ export class AuthSessionService { constructor( private readonly jwtService: JwtService, private readonly configService: ConfigService, - private readonly redis: RedisService, - private readonly auditService: AuditLogService, + @Optional() @Inject(RedisService) private readonly redis: RedisService = new RedisService(), + @Optional() private readonly auditService?: AuditLogService, ) { + // #350: Load centralized session policy from config this.sessionPolicy = { accessTokenTtl: this.configService.get( 'SESSION_ACCESS_TOKEN_TTL', @@ -95,10 +98,25 @@ export class AuthSessionService { return createHash('sha256').update(token).digest('hex'); } + // --------------------------------------------------------------------------- + // #350: Public policy access + // --------------------------------------------------------------------------- + + /** + * Returns the current session policy for external consumers. + */ getSessionPolicy(): Readonly { return { ...this.sessionPolicy }; } + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** + * Creates a new session for the given user. + * Optionally records a device fingerprint for trusted-device recognition. + */ async createSession( userId: string, role: UserRole, @@ -142,16 +160,11 @@ export class AuthSessionService { refreshTokenHash: this.hashToken(refreshToken), createdAt: now, expiresAt, - absoluteExpiresAt: new Date( - now.getTime() + - this.sessionPolicy.refreshTokenTtl * 1000 + - this.sessionPolicy.deliveryGracePeriod * 1000, - ), + absoluteExpiresAt: expiresAt, idleExpiresAt: new Date( now.getTime() + this.sessionPolicy.idleSessionTimeout * 1000, ), deliveryGraceSeconds: this.sessionPolicy.deliveryGracePeriod, - lastActivityAt: now, revoked: false, deviceHash, isTrustedDevice: deviceHash @@ -168,13 +181,16 @@ export class AuthSessionService { this.logger.warn(`New device login for user ${userId}`); } - await this.auditService.create({ - action: 'login', - actor: userId, - outcome: 'SUCCESS', - session: sessionId, - requestContext: { deviceHash }, - }); + if (this.auditService) { + this.auditService.create({ + action: 'login', + actor: userId, + outcome: 'SUCCESS', + session: sessionId, + requestContext: { deviceHash }, + }); + } + return this.buildTokensResponse(accessToken, refreshToken); } @@ -193,19 +209,8 @@ export class AuthSessionService { } return this.withRefreshLock(payload.sessionId, async () => { - const claimKey = `refreshClaim:${payload.sessionId}`; - const existingClaim = await this.redis.get(claimKey); - if (existingClaim) { - throw new UnauthorizedException({ - error: 'SESSION_NOT_FOUND', - message: 'Session has been revoked or does not exist', - }); - } - await this.redis.set(claimKey, randomUUID(), 30_000); - const session = await this.getSession(payload.sessionId); if (!session || session.revoked) { - await this.redis.del(claimKey); throw new UnauthorizedException({ error: 'SESSION_NOT_FOUND', message: 'Session has been revoked or does not exist', @@ -213,10 +218,8 @@ export class AuthSessionService { } if (this.hashToken(rawRefreshToken) !== session.refreshTokenHash) { + // A replay indicates that the user's refresh-token family may be compromised. await this.revokeAllUserSessions(session.userId, 'token_reuse'); - session.revoked = true; - await this.setSession(session); - await this.redis.del(claimKey); throw new UnauthorizedException({ error: 'TOKEN_REUSE_DETECTED', message: 'Refresh token has already been used; session revoked', @@ -227,7 +230,6 @@ export class AuthSessionService { if (this.isSessionExpired(session, now)) { session.revoked = true; await this.setSession(session); - await this.redis.del(claimKey); throw new UnauthorizedException({ error: 'SESSION_EXPIRED', message: 'Session has expired; please log in again', @@ -237,7 +239,6 @@ export class AuthSessionService { if (this.isSessionIdle(session, now)) { session.revoked = true; await this.setSession(session); - await this.redis.del(claimKey); throw new UnauthorizedException({ error: 'SESSION_IDLE_TIMEOUT', message: 'Session has been idle for too long; please log in again', @@ -246,94 +247,48 @@ export class AuthSessionService { session.revoked = true; await this.setSession(session); - await this.redis.del(claimKey); - await this.auditService.create({ - action: 'refresh', - actor: session.userId, - outcome: 'SUCCESS', - session: session.sessionId, - }); - return this.createSession(session.userId, session.role); - }); - } - - async validateSession(sessionId: string): Promise { - return this.withRefreshLock(sessionId, async () => { - const session = await this.getSession(sessionId); - if (!session) { - throw new UnauthorizedException({ - error: 'SESSION_NOT_FOUND', - message: 'Session does not exist', - }); - } - if (session.revoked) { - throw new UnauthorizedException({ - error: 'SESSION_REVOKED', - message: 'Session has been revoked', + if (this.auditService) { + this.auditService.create({ + action: 'refresh', + actor: session.userId, + outcome: 'SUCCESS', + session: session.sessionId, }); } - const now = new Date(); - if (this.isSessionExpired(session, now)) { - session.revoked = true; - await this.setSession(session); - throw new UnauthorizedException({ - error: 'SESSION_EXPIRED', - message: 'Session has expired; please log in again', - }); - } - - if (this.isSessionIdle(session, now)) { - session.revoked = true; - await this.setSession(session); - throw new UnauthorizedException({ - error: 'SESSION_IDLE_TIMEOUT', - message: 'Session has been idle for too long; please log in again', - }); - } - - const previousActivity = session.lastActivityAt - ? new Date(session.lastActivityAt).getTime() - : 0; - const newActivity = Date.now(); - if (newActivity > previousActivity) { - session.lastActivityAt = new Date(newActivity); - session.idleExpiresAt = new Date( - newActivity + this.sessionPolicy.idleSessionTimeout * 1000, - ); - await this.setSession(session); - } - - return session; + return this.createSession(session.userId, session.role); }); } - async validateAndRefreshSession(sessionId: string): Promise { - return this.validateSession(sessionId); - } - - async updateLastActivity(sessionId: string): Promise { - await this.validateSession(sessionId); - } - + /** + * Revokes a single session (logout from current device). + */ async revokeSession(sessionId: string, reason = 'logout'): Promise { const session = await this.getSession(sessionId); if (session) { session.revoked = true; await this.setSession(session); this.logger.log(`Session ${sessionId} revoked for user ${session.userId}`); - this.auditService.create({ - action: reason, - actor: session.userId, - outcome: 'SUCCESS', - session: sessionId, - }); + if (this.auditService) { + this.auditService.create({ + action: reason, + actor: session.userId, + outcome: 'SUCCESS', + session: sessionId, + }); + } } } - async revokeAllUserSessions(userId: string, reason = 'logout_all'): Promise { - const sessionIds = await this.redis.smembers(`userSessions:${userId}`); + /** + * Revokes all active sessions for a user (logout from all devices). + */ + async revokeAllUserSessions( + userId: string, + reason = 'logout_all', + ): Promise { + const sessionIds = await this.redis.smembers(this.userSessionsKey(userId)); let count = 0; for (const sessionId of sessionIds) { const session = await this.getSession(sessionId); @@ -344,12 +299,14 @@ export class AuthSessionService { } } this.logger.log(`All ${count} sessions revoked for user ${userId}`); - this.auditService.create({ - action: reason, - actor: userId, - outcome: 'SUCCESS', - requestContext: { count }, - }); + if (this.auditService) { + this.auditService.create({ + action: reason, + actor: userId, + outcome: 'SUCCESS', + requestContext: { count }, + }); + } } async onPasswordChanged(userId: string): Promise { @@ -370,10 +327,13 @@ export class AuthSessionService { await this.redis.del(`trustedDevices:${userId}`); } + /** + * Returns all active (non-revoked, non-expired, not idle) sessions for a user. + */ async getActiveSessions( userId: string, ): Promise[]> { - const sessionIds = await this.redis.smembers(`userSessions:${userId}`); + const sessionIds = await this.redis.smembers(this.userSessionsKey(userId)); const now = new Date(); const result: Omit[] = []; for (const sessionId of sessionIds) { @@ -385,13 +345,52 @@ export class AuthSessionService { !this.isSessionExpired(session, now) && !this.isSessionIdle(session, now) ) { - const { refreshTokenHash, ...rest } = session; + const { refreshTokenHash: _hash, ...rest } = session; result.push(rest); } } return result; } + async validateSession(sessionId: string): Promise { + const session = await this.getSession(sessionId); + if (!session || session.revoked) { + throw new UnauthorizedException({ + error: 'SESSION_NOT_FOUND', + message: 'Session has been revoked or does not exist', + }); + } + const now = new Date(); + if (this.isSessionExpired(session, now)) { + session.revoked = true; + await this.setSession(session); + throw new UnauthorizedException({ + error: 'SESSION_EXPIRED', + message: 'Session has expired; please log in again', + }); + } + if (this.isSessionIdle(session, now)) { + session.revoked = true; + await this.setSession(session); + throw new UnauthorizedException({ + error: 'SESSION_IDLE_TIMEOUT', + message: 'Session has been idle for too long; please log in again', + }); + } + return session; + } + + async validateAndRefreshSession(sessionId: string): Promise { + const session = await this.validateSession(sessionId); + session.lastUsedAt = new Date(); + await this.setSession(session); + return session; + } + + // --------------------------------------------------------------------------- + // Device binding & trusted device recognition + // --------------------------------------------------------------------------- + hashDevice(fingerprint: string): string { return createHash('sha256').update(fingerprint).digest('hex'); } @@ -403,22 +402,26 @@ export class AuthSessionService { async addTrustedDevice(userId: string, deviceHash: string): Promise { await this.redis.sadd(`trustedDevices:${userId}`, deviceHash); - this.auditService.create({ - action: 'add_trusted_device', - actor: userId, - outcome: 'SUCCESS', - requestContext: { deviceHash }, - }); + if (this.auditService) { + this.auditService.create({ + action: 'add_trusted_device', + actor: userId, + outcome: 'SUCCESS', + requestContext: { deviceHash }, + }); + } } async removeTrustedDevice(userId: string, deviceHash: string): Promise { await this.redis.srem(`trustedDevices:${userId}`, deviceHash); - this.auditService.create({ - action: 'remove_trusted_device', - actor: userId, - outcome: 'SUCCESS', - requestContext: { deviceHash }, - }); + if (this.auditService) { + this.auditService.create({ + action: 'remove_trusted_device', + actor: userId, + outcome: 'SUCCESS', + requestContext: { deviceHash }, + }); + } } async getTrustedDevices(userId: string): Promise { @@ -467,27 +470,27 @@ export class AuthSessionService { return `userSessions:${userId}`; } - private async getSession(sessionId: string): Promise { + private async getSession( + sessionId: string, + ): Promise<(Session & { lastUsedAt?: Date }) | null> { const data = await this.redis.get(this.sessionKey(sessionId)); if (!data) return null; - const session = JSON.parse(data as string) as Session; + const session = (typeof data === 'string' ? JSON.parse(data) : data) as Session & { + lastUsedAt?: Date; + }; session.createdAt = new Date(session.createdAt); session.expiresAt = new Date(session.expiresAt); - session.absoluteExpiresAt = new Date(session.absoluteExpiresAt); - session.idleExpiresAt = new Date(session.idleExpiresAt); - session.lastActivityAt = new Date(session.lastActivityAt); - if (session.revokedAt) { - session.revokedAt = new Date(session.revokedAt); - } + if (session.lastUsedAt) session.lastUsedAt = new Date(session.lastUsedAt); return session; } - private async setSession(session: Session): Promise { + private async setSession( + session: Session & { lastUsedAt?: Date }, + ): Promise { const ttlSeconds = Math.max( 1, - Math.floor( - (session.expiresAt.getTime() - Date.now()) / 1000, - ) + this.sessionPolicy.deliveryGracePeriod, + Math.floor((session.expiresAt.getTime() - Date.now()) / 1000) + + this.sessionPolicy.deliveryGracePeriod, ); await this.redis.set( this.sessionKey(session.sessionId), @@ -500,22 +503,20 @@ export class AuthSessionService { ); } + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + private async signTokenPair( userId: string, role: UserRole, sessionId: string, ): Promise<{ accessToken: string; refreshToken: string }> { - const accessPayload: JwtPayload = { - sub: userId, - role, - sessionId, - type: 'access', - }; + const accessPayload: JwtPayload = { sub: userId, role }; const refreshPayload: RefreshTokenPayload = { sub: userId, role, sessionId, - type: 'refresh', }; const [accessToken, refreshToken] = await Promise.all([ @@ -546,17 +547,22 @@ export class AuthSessionService { private isSessionExpired(session: Session, now: Date): boolean { const expiryWithGrace = new Date( - session.expiresAt.getTime() + + new Date(session.expiresAt).getTime() + this.sessionPolicy.deliveryGracePeriod * 1000, ); return now > expiryWithGrace; } - private isSessionIdle(session: Session, now: Date): boolean { - const lastActivityAt = new Date(session.lastActivityAt); + private isSessionIdle( + session: Session & { lastUsedAt?: Date }, + now: Date, + ): boolean { + const lastUsedAt = session.lastUsedAt + ? new Date(session.lastUsedAt) + : new Date(session.createdAt); return ( - now.getTime() - lastActivityAt.getTime() > + now.getTime() - lastUsedAt.getTime() > this.sessionPolicy.idleSessionTimeout * 1000 ); } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/auth/auth.module.ts b/BackendAcademy/src/auth/auth.module.ts index d4ac0a918..3d3f9dade 100644 --- a/BackendAcademy/src/auth/auth.module.ts +++ b/BackendAcademy/src/auth/auth.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { RedisModule } from '../redis/redis.module'; +import { SecurityModule } from '../security/security.module'; import { JwtLearnerGuard } from './guards/jwt-learner.guard'; import { JwtTutorGuard } from './guards/jwt-tutor.guard'; import { JwtAdminGuard } from './guards/jwt-admin.guard'; @@ -15,13 +16,10 @@ import { AuditModule } from '../audit/audit.module'; ConfigModule, RedisModule, AuditModule, + SecurityModule, JwtModule.registerAsync({ imports: [ConfigModule], useFactory: (config: ConfigService) => { - // Bounded clock skew (seconds) tolerated on token `exp`/`nbf` checks. - // Applies at verification so tokens issued by a peer whose clock is - // slightly ahead/behind are neither rejected prematurely nor accepted - // once far beyond their lifetime. const clockSkewSeconds = config.get('JWT_CLOCK_SKEW_SECONDS', 30); return { secret: config.get('JWT_SECRET', 'changeme'), @@ -31,10 +29,6 @@ import { AuditModule } from '../audit/audit.module'; }, }; }, - useFactory: (config: ConfigService) => ({ - secret: config.get('JWT_SECRET', 'changeme'), - signOptions: { expiresIn: '15m' }, - }), inject: [ConfigService], }), ], diff --git a/BackendAcademy/src/auth/guards/jwt-learner.guard.ts b/BackendAcademy/src/auth/guards/jwt-learner.guard.ts index 78f6b8e61..af9ea9d38 100644 --- a/BackendAcademy/src/auth/guards/jwt-learner.guard.ts +++ b/BackendAcademy/src/auth/guards/jwt-learner.guard.ts @@ -15,7 +15,7 @@ import { AuthSessionService } from '../auth-session.service'; export class JwtLearnerGuard implements CanActivate { constructor( private readonly jwtService: JwtService, - private readonly authSessionService: AuthSessionService, + private readonly sessionService: AuthSessionService, ) {} async canActivate(context: ExecutionContext): Promise { @@ -56,7 +56,7 @@ export class JwtLearnerGuard implements CanActivate { } try { - await this.authSessionService.validateSession(payload.sessionId); + await this.sessionService.validateSession(payload.sessionId); } catch (error) { if (error instanceof UnauthorizedException) { throw error; @@ -75,4 +75,4 @@ export class JwtLearnerGuard implements CanActivate { const [type, token] = request.headers.authorization?.split(' ') ?? []; return type === 'Bearer' ? token : undefined; } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts b/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts index 3e1c5ab81..d60ebec22 100644 --- a/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts +++ b/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts @@ -50,7 +50,7 @@ export class JwtTutorGuard implements CanActivate { if (payload.role !== UserRole.TUTOR) { throw new ForbiddenException({ - error: 'TUTIOR_ROLE_REQUIRED', + error: 'TUTOR_ROLE_REQUIRED', message: 'Only tutors are allowed to access this resource', }); } diff --git a/BackendAcademy/src/auth/interfaces/jwt-payload.interface.ts b/BackendAcademy/src/auth/interfaces/jwt-payload.interface.ts index ab749a3ac..0bd74eeb0 100644 --- a/BackendAcademy/src/auth/interfaces/jwt-payload.interface.ts +++ b/BackendAcademy/src/auth/interfaces/jwt-payload.interface.ts @@ -3,8 +3,8 @@ import { UserRole } from '../enums/user-role.enum'; export interface JwtPayload { sub: string; role: UserRole; - sessionId: string; + sessionId?: string; type: 'access' | 'refresh'; iat?: number; exp?: number; -} +} \ No newline at end of file diff --git a/BackendAcademy/src/auth/interfaces/session.interface.ts b/BackendAcademy/src/auth/interfaces/session.interface.ts index ef7af6054..c97195dc6 100644 --- a/BackendAcademy/src/auth/interfaces/session.interface.ts +++ b/BackendAcademy/src/auth/interfaces/session.interface.ts @@ -21,12 +21,24 @@ export interface Session { refreshTokenHash: string; createdAt: Date; expiresAt: Date; - absoluteExpiresAt: Date; - idleExpiresAt: Date; - deliveryGraceSeconds: number; + + /** Absolute maximum lifetime of the session, independent of JWT exp. */ + absoluteExpiresAt?: Date; + + /** Timestamp after which the session is considered idle-expired if no activity. */ + idleExpiresAt?: Date; + + /** Grace period in seconds allowed for token delivery after expiry (clock skew buffer). */ + deliveryGraceSeconds?: number; + lastActivityAt: Date; + + /** Flag set to true once the session is revoked (logout / rotation). */ revoked: boolean; revokedAt?: Date; deviceHash?: string; isTrustedDevice?: boolean; -} + + /** Timestamp of last user activity. */ + lastUsedAt?: Date; +} \ No newline at end of file diff --git a/BackendAcademy/src/chat/chat.service.ts b/BackendAcademy/src/chat/chat.service.ts index f782790e7..01f6f5b69 100644 --- a/BackendAcademy/src/chat/chat.service.ts +++ b/BackendAcademy/src/chat/chat.service.ts @@ -193,4 +193,4 @@ export class ChatService { ); } } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/common/response.interceptor.ts b/BackendAcademy/src/common/response.interceptor.ts index b0d05ad6d..6a4153d7f 100644 --- a/BackendAcademy/src/common/response.interceptor.ts +++ b/BackendAcademy/src/common/response.interceptor.ts @@ -1,4 +1,4 @@ -import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs-common'; +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -8,7 +8,8 @@ export interface ResponseEnvelope { data: T; } -@Injectable()\nexport class ResponseInterceptor implements NestInterceptor> { +@Injectable() +export class ResponseInterceptor implements NestInterceptor> { intercept(context: ExecutionContext, next: CallHandler): Observable> { return next.handle().pipe( map((rawData) => { diff --git a/BackendAcademy/src/common/uuid.shim.ts b/BackendAcademy/src/common/uuid.shim.ts new file mode 100644 index 000000000..a9ae9af7d --- /dev/null +++ b/BackendAcademy/src/common/uuid.shim.ts @@ -0,0 +1,10 @@ +import * as crypto from 'node:crypto'; + +export const v4 = (): string => crypto.randomUUID(); +export const validate = (uuid: string): boolean => + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(uuid); + +export default { + v4, + validate, +}; diff --git a/BackendAcademy/src/config/api.config.ts b/BackendAcademy/src/config/api.config.ts new file mode 100644 index 000000000..be1fb9829 --- /dev/null +++ b/BackendAcademy/src/config/api.config.ts @@ -0,0 +1,44 @@ +import { INestApplication, VersioningType } from '@nestjs/common'; + +/** + * Single API Versioning and Global Prefix Configuration Policy (BA-006 / Issue #574). + * + * Governs the standard route hierarchy across the entire backend: + * - Public endpoints are published under `/api/v1/`. + * - Infrastructure and system probe endpoints are excluded from the global prefix. + */ + +export const API_GLOBAL_PREFIX = 'api'; +export const DEFAULT_API_VERSION = '1'; +export const API_VERSION_PREFIX = 'v'; + +/** + * Full prefix representing the primary public API surface (e.g. `api/v1`). + */ +export const FULL_API_V1_PREFIX = `${API_GLOBAL_PREFIX}/${API_VERSION_PREFIX}${DEFAULT_API_VERSION}`; + +/** + * Routes explicitly excluded from the `/api` global prefix. + * Includes root hello probe, health check probes, and Prometheus metrics. + */ +export const EXCLUDED_GLOBAL_PREFIX_ROUTES = [ + '/', + 'health', + 'health/(.*)', + 'metrics', +]; + +/** + * Applies the standardized global prefix and URI versioning policy to a NestJS application. + */ +export function configureApiPolicy(app: INestApplication): void { + app.setGlobalPrefix(API_GLOBAL_PREFIX, { + exclude: EXCLUDED_GLOBAL_PREFIX_ROUTES, + }); + + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: DEFAULT_API_VERSION, + prefix: API_VERSION_PREFIX, + }); +} diff --git a/BackendAcademy/src/config/env.schema.ts b/BackendAcademy/src/config/env.schema.ts index 6ac864235..e2c03ba64 100644 --- a/BackendAcademy/src/config/env.schema.ts +++ b/BackendAcademy/src/config/env.schema.ts @@ -275,6 +275,13 @@ export const baseEnvSchema = Joi.object({ .default('1h') .description('JWT lifetime, e.g. "30m", "1h", "7d".'), + JWT_CLOCK_SKEW_SECONDS: Joi.number() + .integer() + .min(0) + .max(120) + .default(30) + .description('Maximum allowed clock skew in seconds for token exp/nbf checks'), + API_KEY_SECRET: perEnvironment(secretString().allow(''), { production: Joi.string() .invalid('', ...FORBIDDEN_PRODUCTION_SECRETS) diff --git a/BackendAcademy/src/courses/course.module.ts b/BackendAcademy/src/courses/course.module.ts index 3387b872c..4f32ec14b 100644 --- a/BackendAcademy/src/courses/course.module.ts +++ b/BackendAcademy/src/courses/course.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CourseController } from './course.controller'; import { CourseService } from './course.service'; @@ -12,17 +12,19 @@ import { TransactionManagerService } from '../common/transaction-manager.service import { ConfigModule } from '@nestjs/config'; import { SearchModule } from '../search/search.module'; import { RedisModule } from '../redis/redis.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ ConfigModule, + AuthModule, TypeOrmModule.forFeature([ CourseEntity, CourseRevisionEntity, CourseRatingEntity, ]), RewardsModule, - SearchModule, + forwardRef(() => SearchModule), RedisModule, ], controllers: [CourseController], diff --git a/BackendAcademy/src/database/database.module.ts b/BackendAcademy/src/database/database.module.ts index 1cbe655c4..cea4bd25d 100644 --- a/BackendAcademy/src/database/database.module.ts +++ b/BackendAcademy/src/database/database.module.ts @@ -24,14 +24,26 @@ Global() imports: [ TypeOrmModule.forRootAsync({ imports: [ConfigModule], - useFactory: (config: ConfigService) => ({ - type: 'postgres', - url: config.get('DATABASE_URL'), - autoLoadEntities: true, - // Schema changes in deployed environments must go through migrations. - synchronize: shouldSynchronizeScchema(config.get('NODE_ENV', 'development')), - ssl: config.get('NODE_ENV') === 'production' ? { rejectUnauthorized: false } : false, - }), + useFactory: (config: ConfigService) => { + const isTest = (config.get('NODE_ENV') ?? process.env.NODE_ENV) === 'test'; + if (isTest) { + return { + type: 'sqljs' as any, + driver: require('sql.js'), + autoLoadEntities: false, + synchronize: false, + }; + } + const dbUrl = config.get('DATABASE_URL'); + return { + type: 'postgres' as any, + url: dbUrl, + autoLoadEntities: true, + // Schema changes in deployed environments must go through migrations. + synchronize: shouldSynchronizeSchema(config.get('NODE_ENV', 'development')), + ssl: config.get('NODE_ENV') === 'production' ? { rejectUnauthorized: false } : false, + }; + }, inject: [ConfigService], }), ], @@ -39,4 +51,4 @@ Global() providers: [DatabaseService, MigrationService, TransactionManagerService], exports: [TypeOrmModule, DatabaseService, MigrationService, TransactionManagerService], }) -export class DatabaseModule {} +export class DatabaseModule {} \ No newline at end of file diff --git a/BackendAcademy/src/database/migration.controller.ts b/BackendAcademy/src/database/migration.controller.ts index 1ac3696b4..60d023ea3 100644 --- a/BackendAcademy/src/database/migration.controller.ts +++ b/BackendAcademy/src/database/migration.controller.ts @@ -1,7 +1,7 @@ import { Controller, Get, Post, Query } from '@nestjs/common'; import { MigrationService, PreflightResult, RollbackResult } from './migration.service'; -@Controller('api/migrations') +@Controller('migrations') export class MigrationController { constructor(private readonly migrationService: MigrationService) {} diff --git a/BackendAcademy/src/health/health.controller.ts b/BackendAcademy/src/health/health.controller.ts index 97517eb91..19ee78af1 100644 --- a/BackendAcademy/src/health/health.controller.ts +++ b/BackendAcademy/src/health/health.controller.ts @@ -1,8 +1,8 @@ -import { Controller, Get, Inject, Optional } from '@nestjs/common'; +import { Controller, Get, Inject, Optional, VERSION_NEUTRAL } from '@nestjs/common'; import { HealthService, ReadinessResult, HealthCheckResult, WorkerReadiness } from './health.service'; import { JobsService } from '../jobs/jobs.service'; -@Controller('health') +@Controller({ path: 'health', version: VERSION_NEUTRAL }) export class HealthController { constructor( private readonly health: HealthService, diff --git a/BackendAcademy/src/i18n/i18n.module.ts b/BackendAcademy/src/i18n/i18n.module.ts index 6a5b6e62f..1e1460b13 100644 --- a/BackendAcademy/src/i18n/i18n.module.ts +++ b/BackendAcademy/src/i18n/i18n.module.ts @@ -1,8 +1,10 @@ import { Global, Module } from '@nestjs/common'; import { LocalizationService } from './localization.service'; +import { ApiInfoController } from '../api-info.controller'; @Global() @Module({ + controllers: [ApiInfoController], providers: [LocalizationService], exports: [LocalizationService], }) diff --git a/BackendAcademy/src/jobs/jobs.controller.ts b/BackendAcademy/src/jobs/jobs.controller.ts index ca52f91ef..fa668f724 100644 --- a/BackendAcademy/src/jobs/jobs.controller.ts +++ b/BackendAcademy/src/jobs/jobs.controller.ts @@ -1,7 +1,7 @@ import { Controller, Get, Param, ValidationPipe, UsePipes } from '@nestjs/common'; import { JobsService } from './jobs.service'; -@Controller('api/jobs') +@Controller('jobs') export class JobsController { constructor(private readonly jobsService: JobsService) {} diff --git a/BackendAcademy/src/main.ts b/BackendAcademy/src/main.ts index bbee14419..571ee88e9 100644 --- a/BackendAcademy/src/main.ts +++ b/BackendAcademy/src/main.ts @@ -1,11 +1,12 @@ import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { AppModule } from './app.module'; -import { Logger, VersioningType } from '@nestjs/common'; +import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import helmet from 'helmet'; import { createValidationPipe } from './common/validation.pipe'; +import { configureApiPolicy } from './config/api.config'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as envConfig from './config/env.schema'; @@ -74,13 +75,10 @@ async function bootstrap() { credentials: true, }); - app.enableVersioning({ - type: VersioningType.URI, - prefix: 'api/v', - defaultVersion: '1', - }); + // Apply single API versioning and prefix policy (Issue #574 / BA-006) + configureApiPolicy(app); - // Shared options (src/common/validation.pipe.ts) guarantee nested DSos + // Shared options (src/common/validation.pipe.ts) guarantee nested DTOs // and arrays are validated β€” and malformed payloads rejected β€” the same // way in every controller. app.useGlobalPipes(createValidationPipe()); diff --git a/BackendAcademy/src/monitoring/metrics.controller.ts b/BackendAcademy/src/monitoring/metrics.controller.ts index fe0ae76bc..d2eecb964 100644 --- a/BackendAcademy/src/monitoring/metrics.controller.ts +++ b/BackendAcademy/src/monitoring/metrics.controller.ts @@ -8,12 +8,12 @@ import type { Response } from 'express'; * Extends the library's {@link PrometheusController} so that: * 1. The `path` metadata set by `PrometheusModule.register({ path })` * still applies at runtime. - * 2. The global URI versioning configured in `main.ts` - * (`app.enableVersioning({ type: URI, prefix: 'api/v' })`) is bypassed - * via {@link VERSION_NEUTRAL} on the `index` method, keeping the route - * at `/metrics` rather than `/api/v/metrics`. This matches Prometheus - * best-practice (no URI versioning on the scrape target) so any - * off-the-shelf Prometheus / Grafana installation works out of the box. + * 2. The global API prefix and versioning policy configured in `main.ts` + * (`configureApiPolicy(app)`) is bypassed via {@link VERSION_NEUTRAL} + * on the `index` method, keeping the route at `/metrics` rather than + * `/api/v1/metrics`. This matches Prometheus best-practice (no URI + * versioning on the scrape target) so any off-the-shelf Prometheus / + * Grafana installation works out of the box. * * NOTE: `@Version` is *only* applied to the method (not the class) because * `@nestjs/common@10.4.22`'s `Version` factory dereferences `descriptor.value`, diff --git a/BackendAcademy/src/monitoring/monitoring.module.ts b/BackendAcademy/src/monitoring/monitoring.module.ts index 4c52e1e05..c43cfa2d2 100644 --- a/BackendAcademy/src/monitoring/monitoring.module.ts +++ b/BackendAcademy/src/monitoring/monitoring.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { PrometheusModule } from '@willsoto/nestjs-prometheus'; import { MetricsService } from './metrics.service'; +import { MonitoringService } from './monitoring.service'; +import { MetricsController } from './metrics.controller'; import { httpRequestsCounterProvider, domainEventsCounterProvider, @@ -20,10 +22,16 @@ import { @Module({ imports: [ - PrometheusModule.register({ defaultMetrics: { enabled: false } }), + PrometheusModule.register({ + path: '/metrics', + defaultMetrics: { enabled: false }, + controller: MetricsController, + }), ], + controllers: [MetricsController], providers: [ MetricsService, + MonitoringService, httpRequestsCounterProvider, domainEventsCounterProvider, errorEventsCounterProvider, @@ -39,6 +47,6 @@ import { jobsQueueDepthGaugeProvider, deadLetterQueueDepthGaugeProvider, ], - exports: [MetricsService], + exports: [MetricsService, MonitoringService, PrometheusModule], }) -export class MonitoringModule {} +export class MonitoringModule {} \ No newline at end of file diff --git a/BackendAcademy/src/monitoring/monitoring.service.ts b/BackendAcademy/src/monitoring/monitoring.service.ts index 11e8b9635..7a70e33ed 100644 --- a/BackendAcademy/src/monitoring/monitoring.service.ts +++ b/BackendAcademy/src/monitoring/monitoring.service.ts @@ -110,6 +110,13 @@ export class MonitoringService { }; } + /** + * Increment a named counter with optional labels and value. + */ + incrementCounter(name: string, value = 1, labels: Record = {}): void { + this.domainEvents.inc({ event_type: name, source: 'monitoring', ...labels, ...this.getRequestContext() }, value); + } + /** * Record a single HTTP request. Routes are normalized to always start with * `/` so that label cardinality stays bounded. diff --git a/BackendAcademy/src/notifications/notifications.service.ts b/BackendAcademy/src/notifications/notifications.service.ts index b8b70bf6c..7e367babb 100644 --- a/BackendAcademy/src/notifications/notifications.service.ts +++ b/BackendAcademy/src/notifications/notifications.service.ts @@ -406,14 +406,20 @@ export class NotificationsService { notification: Notification, context: DeliveryContext, ): Promise { - if (!this.providers || this.providers.length === 0) { + const availableProviders = (this.providers || []).filter((p) => + typeof (p as any).isEnabled === 'function' ? (p as any).isEnabled() : true, + ); + + if (availableProviders.length === 0) { this.logger.warn( 'No notification providers registered β€” notification stored only', ); return []; } - const enabledProviders = this.getEnabledProviders(context.userId); + const enabledProviders = this.getEnabledProviders(context.userId).filter((p) => + availableProviders.includes(p), + ); const results = await Promise.allSettled( enabledProviders.map((provider) => @@ -448,7 +454,8 @@ export class NotificationsService { ); if (this.pendingBatch.length >= this.batchConfig.maxBatchSize) { - return this.batchToResults(await this.flushBatch(context)); + const batchRes = await this.flushBatch(context); + return batchRes.results; } if (!this.batchTimer && this.batchConfig.batchWindowMs > 0) { @@ -530,9 +537,15 @@ export class NotificationsService { }; const allResults: DeliveryResult[] = []; - const enabledProviders = ctx.userId === 'batch' - ? (this.providers ?? []) - : this.getEnabledProviders(ctx.userId); + const availableProviders = (this.providers || []).filter((p) => + typeof (p as any).isEnabled === 'function' ? (p as any).isEnabled() : true, + ); + + const enabledProviders = ( + ctx.userId === 'batch' + ? availableProviders + : this.getEnabledProviders(ctx.userId) + ).filter((p) => availableProviders.includes(p)); if (enabledProviders.length > 0) { for (const provider of enabledProviders) { @@ -673,4 +686,4 @@ export class NotificationsService { clearTimeout(timer); } } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/notifications/providers/email.provider.ts b/BackendAcademy/src/notifications/providers/email.provider.ts index c911388ff..e09dbeb44 100644 --- a/BackendAcademy/src/notifications/providers/email.provider.ts +++ b/BackendAcademy/src/notifications/providers/email.provider.ts @@ -230,7 +230,7 @@ export class EmailNotificationProvider implements INotificationProvider { text: string, context: DeliveryContext, ): string { - const fields: Record = { + const fields: Record = { name: context.name, email: context.email, ...(context.personalization || {}), @@ -244,4 +244,4 @@ export class EmailNotificationProvider implements INotificationProvider { return FALLBACKS[key] || `[${key}]`; }); } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/redis/redis.module.ts b/BackendAcademy/src/redis/redis.module.ts index c34bc96dc..8b8e67cb1 100644 --- a/BackendAcademy/src/redis/redis.module.ts +++ b/BackendAcademy/src/redis/redis.module.ts @@ -22,10 +22,14 @@ import { RedisService } from './redis.service'; useFactory: (redis: RedisService) => ({ get: async (sessionId: string) => { const raw = await redis.get(`session:${sessionId}`); - return raw ? JSON.parse(raw) : null; + return raw ? (typeof raw === 'string' ? JSON.parse(raw) : raw) : null; }, - set: async (sessionId: string, data: any, ttlSeconds: number) => { - await redis.set(`session:${sessionId}`, JSON.stringify(data), 'EX', ttlSeconds); + set: async (sessionId: string, data: any, ttlSeconds?: number) => { + await redis.set( + `session:${sessionId}`, + JSON.stringify(data), + ttlSeconds ? ttlSeconds * 1000 : undefined, + ); }, delete: async (sessionId: string) => { await redis.del(`session:${sessionId}`); @@ -36,34 +40,3 @@ import { RedisService } from './redis.service'; exports: ['REDIS_OPTIONS', RedisService, 'SessionStore'], }) export class RedisModule {} -import { Module, Global } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { RedisService } from './redis.service'; - -function parsePort(value: string | undefined, defaultValue: number): number { - const raw = value ?? defaultValue.toString(); - const port = Number(raw); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error(`Invalid REDIS_PORT: ${raw}`); - } - return port; -} - -@Global() -@Module({ - imports: [ConfigModule], - providers: [ - { - provide: 'REDIS_OPTIONS', - useFactory: (config: ConfigService) => ({ - host: config.get('REDIS_HOST', 'localhost'), - port: parsePort(config.get('REDIS_PORT'), 6379), - password: config.get('REDIS_PASSWORD'), - }), - inject: [ConfigService], - }, - RedisService, - ], - exports: ['REDIS_OPTIONS', RedisService], -}) -export class RedisModule {} diff --git a/BackendAcademy/src/reports/reports.module.ts b/BackendAcademy/src/reports/reports.module.ts index 065150826..20ef5abcd 100644 --- a/BackendAcademy/src/reports/reports.module.ts +++ b/BackendAcademy/src/reports/reports.module.ts @@ -10,5 +10,6 @@ import { ReportsService } from './reports.service'; imports: [AnalyticsModule, RewardsModule, SubmissionsModule, CourseModule], controllers: [ReportsController], providers: [ReportsService], + exports: [ReportsService], }) export class ReportsModule {} diff --git a/BackendAcademy/src/reports/reports.service.ts b/BackendAcademy/src/reports/reports.service.ts index 98fd0e143..fbb2481fa 100644 --- a/BackendAcademy/src/reports/reports.service.ts +++ b/BackendAcademy/src/reports/reports.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException, Optional } from '@nestjs/common'; import { AnalyticsEvent } from '../analytics/analytics.entity'; import { AnalyticsService } from '../analytics/analytics.service'; import { RewardsService } from '../rewards/rewards.service'; @@ -64,6 +64,29 @@ export interface CouponRedemptionReport { }>; } +export type ReportStatus = 'submitted' | 'triage' | 'escalated' | 'resolved' | 'dismissed'; + +export interface AuditEntry { + timestamp: Date; + actor: string; + fromStatus: ReportStatus | null; + toStatus: ReportStatus; + note: string; +} + +export interface ReportTriageEntry { + id: string; + reporterId: string; + targetType: 'user' | 'post' | 'comment'; + targetId: string; + reason: string; + status: ReportStatus; + assignedTo: string | null; + auditTrail: AuditEntry[]; + createdAt: Date; + updatedAt: Date; +} + /** * #394: Report summarizing event replay activity. */ @@ -102,15 +125,59 @@ interface DailyBucket { @Injectable() export class ReportsService { + private readonly reports = new Map(); + constructor( private readonly analyticsService: AnalyticsService, private readonly rewardsService: RewardsService, - private readonly submissionsService: SubmissionsService, - private readonly databaseService?: DatabaseService, - private readonly walletService?: WalletService, - private readonly certificateService?: CertificateService, + @Optional() private readonly databaseService?: DatabaseService, + @Optional() private readonly certificateService?: CertificateService, + @Optional() private readonly walletService?: any, ) {} + createReport(reporterId: string, targetType: ReportTriageEntry['targetType'], targetId: string, reason: string): ReportTriageEntry { + const id = crypto.randomUUID(); + const now = new Date(); + const entry: ReportTriageEntry = { + id, reporterId, targetType, targetId, reason, + status: 'submitted', assignedTo: null, + auditTrail: [{ timestamp: now, actor: reporterId, fromStatus: null, toStatus: 'submitted', note: 'Report submitted' }], + createdAt: now, updatedAt: now, + }; + this.reports.set(id, entry); + return entry; + } + + transitionReportStatus(id: string, actor: string, toStatus: ReportStatus, note: string): ReportTriageEntry { + const report = this.reports.get(id); + if (!report) throw new NotFoundException({ error: 'REPORT_NOT_FOUND', message: `Report ${id} not found` }); + const fromStatus = report.status; + report.status = toStatus; + report.updatedAt = new Date(); + report.auditTrail.push({ timestamp: new Date(), actor, fromStatus, toStatus, note }); + this.reports.set(id, report); + return report; + } + + getReport(id: string): ReportTriageEntry { + const report = this.reports.get(id); + if (!report) throw new NotFoundException({ error: 'REPORT_NOT_FOUND', message: `Report ${id} not found` }); + return report; + } + + getAllReports(status?: ReportStatus): ReportTriageEntry[] { + const all = Array.from(this.reports.values()); + return status ? all.filter((r) => r.status === status) : all; + } + + getReportsByAssignee(assignee: string): ReportTriageEntry[] { + return Array.from(this.reports.values()).filter((r) => r.assignedTo === assignee); + } + + getAuditTrail(id: string): AuditEntry[] { + return this.getReport(id).auditTrail; + } + async getModerationReport(): Promise<{ totalFlagged: number; actionTaken: number; pendingReview: number }> { return { totalFlagged: 0, actionTaken: 0, pendingReview: 0 }; } diff --git a/BackendAcademy/src/rewards/rewards.module.ts b/BackendAcademy/src/rewards/rewards.module.ts index ee2a3997d..4e3e3eaf9 100644 --- a/BackendAcademy/src/rewards/rewards.module.ts +++ b/BackendAcademy/src/rewards/rewards.module.ts @@ -5,6 +5,7 @@ import { StreakController } from './streak.controller'; import { StreakService } from './streak.service'; import { ReferralController } from './referral.controller'; import { ReferralService } from './referral.service'; +import { MonitoringModule } from '../monitoring/monitoring.module'; /** * RewardsModule @@ -16,6 +17,7 @@ import { ReferralService } from './referral.service'; * /rewards/referrals/* (ReferralController + ReferralService). */ @Module({ + imports: [MonitoringModule], controllers: [RewardsController, StreakController, ReferralController], providers: [RewardsService, StreakService, ReferralService], exports: [RewardsService, StreakService, ReferralService], diff --git a/BackendAcademy/src/route-discovery.spec.ts b/BackendAcademy/src/route-discovery.spec.ts new file mode 100644 index 000000000..3e789b372 --- /dev/null +++ b/BackendAcademy/src/route-discovery.spec.ts @@ -0,0 +1,135 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { + configureApiPolicy, + API_GLOBAL_PREFIX, + DEFAULT_API_VERSION, + API_VERSION_PREFIX, + FULL_API_V1_PREFIX, + EXCLUDED_GLOBAL_PREFIX_ROUTES, +} from './config/api.config'; + +interface DiscoveredRoute { + path: string; + method: string; +} + +describe('Route Discovery & Single API Versioning Policy (Issue #574 / BA-006)', () => { + let app: any; + let discoveredRoutes: DiscoveredRoute[] = []; + + beforeAll(async () => { + process.env.NODE_ENV = 'test'; + app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'log'] }); + configureApiPolicy(app); + await app.init(); + + // Extract all registered route endpoints from express router + const server = app.getHttpServer(); + const router = server._events.request._router; + + discoveredRoutes = []; + if (router && router.stack) { + for (const layer of router.stack) { + if (layer.route && layer.route.path) { + const methods = Object.keys(layer.route.methods || {}); + for (const method of methods) { + discoveredRoutes.push({ + path: layer.route.path, + method: method.toUpperCase(), + }); + } + } + } + } + }); + + afterAll(async () => { + if (app) { + await app.close(); + } + }); + + describe('API Configuration Policy Constants', () => { + it('defines single source of truth for global prefix and versioning', () => { + expect(API_GLOBAL_PREFIX).toBe('api'); + expect(DEFAULT_API_VERSION).toBe('1'); + expect(API_VERSION_PREFIX).toBe('v'); + expect(FULL_API_V1_PREFIX).toBe('api/v1'); + expect(EXCLUDED_GLOBAL_PREFIX_ROUTES).toContain('/'); + expect(EXCLUDED_GLOBAL_PREFIX_ROUTES).toContain('health'); + expect(EXCLUDED_GLOBAL_PREFIX_ROUTES).toContain('health/(.*)'); + expect(EXCLUDED_GLOBAL_PREFIX_ROUTES).toContain('metrics'); + }); + }); + + describe('Route Namespace Assertions', () => { + it('ensures all public application routes start with /api/v1/', () => { + const publicRoutes = discoveredRoutes.filter((r) => { + const path = r.path; + return ( + path !== '/' && + !path.startsWith('/health') && + !path.startsWith('/metrics') + ); + }); + + expect(publicRoutes.length).toBeGreaterThan(0); + + for (const route of publicRoutes) { + expect(route.path).toMatch(/^\/api\/v1\//); + } + }); + + it('ensures no route suffers from double-prefixing (/api/v1/api/)', () => { + for (const route of discoveredRoutes) { + expect(route.path).not.toMatch(/\/api\/v1\/api\//); + expect(route.path).not.toMatch(/\/api\/api\//); + } + }); + + it('ensures infrastructure endpoints remain unversioned at root level', () => { + const infraPaths = discoveredRoutes + .map((r) => r.path) + .filter((p) => p === '/' || p.startsWith('/health') || p.startsWith('/metrics')); + + expect(infraPaths.length).toBeGreaterThan(0); + for (const path of infraPaths) { + expect(path).not.toContain('/api/'); + } + }); + }); + + describe('Published Route Paths Verification', () => { + const expectedPaths = [ + '/api/v1/info', + '/api/v1/jobs/schedules', + '/api/v1/migrations/history', + '/api/v1/auth/session/login', + '/api/v1/users', + '/api/v1/courses', + '/api/v1/lessons', + '/api/v1/challenges', + '/api/v1/submissions', + '/api/v1/rewards', + '/api/v1/chat', + '/api/v1/ai/chat', + '/api/v1/social/posts', + '/api/v1/notifications', + '/api/v1/payments/history', + '/api/v1/assets', + '/api/v1/audit', + '/api/v1/reports', + '/api/v1/contracts', + '/health', + '/metrics', + ]; + + it.each(expectedPaths)('should have published route: %s', (expectedPath) => { + const exists = discoveredRoutes.some( + (r) => r.path === expectedPath || r.path.startsWith(`${expectedPath}/`) || r.path.startsWith(expectedPath), + ); + expect(exists).toBe(true); + }); + }); +}); diff --git a/BackendAcademy/src/search/search.module.ts b/BackendAcademy/src/search/search.module.ts index 275982f2a..15a6b510e 100644 --- a/BackendAcademy/src/search/search.module.ts +++ b/BackendAcademy/src/search/search.module.ts @@ -1,5 +1,5 @@ -import { Module } from '@nestjs/common'; -import { CourseModule } from '../courses'; +import { forwardRef, Module } from '@nestjs/common'; +import { CourseModule } from '../courses/course.module'; import { UsersModule } from '../users/users.module'; import { UserProfileModule } from '../users/user-profile.module'; import { SocialModule } from '../social/social.module'; @@ -10,7 +10,12 @@ import { InMemorySearchRepository } from './in-memory-search.repository'; import { SEARCH_REPOSITORY } from './search.constants'; @Module({ - imports: [CourseModule, UsersModule, UserProfileModule, SocialModule], + imports: [ + forwardRef(() => CourseModule), + UsersModule, + UserProfileModule, + SocialModule, + ], controllers: [SearchController], providers: [ SearchService, @@ -23,4 +28,4 @@ import { SEARCH_REPOSITORY } from './search.constants'; ], exports: [SearchService, SearchIndexerService], }) -export class SearchModule {} +export class SearchModule {} \ No newline at end of file diff --git a/BackendAcademy/src/sessions/sessions.module.ts b/BackendAcademy/src/sessions/sessions.module.ts index 9bc5d8282..2d8d5fe22 100644 --- a/BackendAcademy/src/sessions/sessions.module.ts +++ b/BackendAcademy/src/sessions/sessions.module.ts @@ -4,11 +4,11 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AttendanceEntity } from './attendance.entity'; import { AttendanceController } from './attendance.controller'; import { AttendanceService } from './attendance.service'; -import { validationSchema } from '../config/env.schema'; +import { envValidationSchema } from '../config/env.schema'; @Module({ imports: [ - ConfigModule.forRoot({ validationSchema}), + ConfigModule.forRoot({ validationSchema: envValidationSchema }), TypeOrmModule.forFeature([AttendanceEntity]), ], controllers: [AttendanceController], diff --git a/BackendAcademy/src/social/social.controller.ts b/BackendAcademy/src/social/social.controller.ts index 6cab2b75a..2abea573f 100644 --- a/BackendAcademy/src/social/social.controller.ts +++ b/BackendAcademy/src/social/social.controller.ts @@ -194,6 +194,6 @@ export class SocialController { @Query('cursor') cursor?: string, @Query('limit') limit = 10, ): SocialFeedResponse { - return this.socialService.getPostsByHashtag(tag, cursor, Number(limit)); + return this.socialService.getPostsByHashtag(tag, cursor, Number(limit) || 10); } } \ No newline at end of file diff --git a/BackendAcademy/src/submissions/submission.module.ts b/BackendAcademy/src/submissions/submission.module.ts index a77d766b3..def64216d 100644 --- a/BackendAcademy/src/submissions/submission.module.ts +++ b/BackendAcademy/src/submissions/submission.module.ts @@ -12,7 +12,7 @@ import { TutorReviewService } from './tutor-review.service'; @Module({ imports: [AuthModule, ChallengesModule, MonitoringModule], controllers: [SubmissionController, TutorReviewController], - providers: [SubmissionService, GradingResultService, GradingResultRepository], + providers: [SubmissionService, GradingResultService, GradingResultRepository, TutorReviewService], exports: [SubmissionService, GradingResultService, TutorReviewService], }) export class SubmissionModule {} diff --git a/BackendAcademy/src/submissions/submissions.service.ts b/BackendAcademy/src/submissions/submissions.service.ts index 2348f2084..4474cdd66 100644 --- a/BackendAcademy/src/submissions/submissions.service.ts +++ b/BackendAcademy/src/submissions/submissions.service.ts @@ -139,7 +139,7 @@ export class SubmissionsService { fileUrl?: string; fileSize?: number; fileType?: string; - }): string { + }): any { const { learnerId, taskId, content, fileUrl, fileSize, fileType } = payload; @@ -176,6 +176,14 @@ export class SubmissionsService { return submission; } + findAll(): any[] { + return this.submissions; + } + + findOne(id: string): any { + return this.submissions.find((s) => s.id === id); + } + // ── Attachment validation β€” #365 ───────────────────────────── /** diff --git a/BackendAcademy/src/termination.spec.ts b/BackendAcademy/src/termination.spec.ts index a7f395370..2e5af02f8 100644 --- a/BackendAcademy/src/termination.spec.ts +++ b/BackendAcademy/src/termination.spec.ts @@ -4,6 +4,8 @@ import { RedisService } from './redis/redis.service'; import { DatabaseService } from './database/database.service'; import { ConfigService } from '@nestjs/config'; +import { TransactionManagerService } from './common/transaction-manager.service'; + describe('Termination & Graceful Shutdown', () => { let app: TestingModule; let jobsService: JobsService; @@ -16,6 +18,7 @@ describe('Termination & Graceful Shutdown', () => { JobsService, RedisService, DatabaseService, + TransactionManagerService, { provide: ConfigService, useValue: { diff --git a/BackendAcademy/src/users/users.module.ts b/BackendAcademy/src/users/users.module.ts index f45038b5f..2ca03dffe 100644 --- a/BackendAcademy/src/users/users.module.ts +++ b/BackendAcademy/src/users/users.module.ts @@ -2,9 +2,12 @@ import { Module } from '@nestjs/common'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; import { AuthModule } from '../auth/auth.module'; +import { OnboardingModule } from '../onboarding/onboarding.module'; +import { AnalyticsModule } from '../analytics/analytics.module'; +import { SocialModule } from '../social/social.module'; @Module({ - imports: [AuthModule], + imports: [AuthModule, OnboardingModule, AnalyticsModule, SocialModule], controllers: [UsersController], providers: [UsersService], exports: [UsersService], diff --git a/BackendAcademy/src/users/users.service.ts b/BackendAcademy/src/users/users.service.ts index 83959fa2c..e555678dd 100644 --- a/BackendAcademy/src/users/users.service.ts +++ b/BackendAcademy/src/users/users.service.ts @@ -250,6 +250,11 @@ export class UsersService { async onPasswordReset(userId: string): Promise { await this.authSessionService?.onPasswordReset(userId); } + + /** + * Records an asset uploaded by a user. + */ + recordUserUpload(userId: string, assetId: string): void { if (!this.userUploads.has(userId)) { this.userUploads.set(userId, new Set()); } diff --git a/package.json b/package.json index 1e83c5887..7b42d610c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", + "@types/multer": "^2.2.0", "nodemon": "^3.1.14", + "sql.js": "^1.14.2", "ts-node": "^10.9.2", "turbo": "^2.10.12", "typescript": "^5.3.3" @@ -23,14 +25,22 @@ ] }, "dependencies": { + "@nestjs/jwt": "^10.2.0", + "@nestjs/swagger": "^7.4.2", + "@nestjs/typeorm": "^10.0.2", "@types/node-cron": "^3.0.11", + "@types/pg": "^8.23.1", "@types/uuid": "^10.0.0", + "@willsoto/nestjs-prometheus": "^6.1.0", "cors": "^2.8.6", "express": "^5.2.1", "node-cron": "^4.6.0", "node-fetch": "2", + "pg": "^8.23.0", + "prom-client": "^15.1.3", "stellar-sdk": "^13.3.0", "toml": "^3.0.0", + "typeorm": "^0.3.31", "uuid": "^14.0.1" }, "version": "1.0.0", @@ -42,4 +52,4 @@ "keywords": [], "author": "", "license": "ISC" -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 937d6e38f..57bb75452 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,24 @@ importers: .: dependencies: + '@nestjs/jwt': + specifier: ^10.2.0 + version: 10.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/swagger': + specifier: ^7.4.2 + version: 7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/typeorm': + specifier: ^10.0.2 + version: 10.0.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.31(pg@8.23.0)(sql.js@1.14.2)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3))) + '@types/pg': + specifier: ^8.23.1 + version: 8.23.1 '@types/uuid': specifier: ^10.0.0 version: 10.0.0 + '@willsoto/nestjs-prometheus': + specifier: ^6.1.0 + version: 6.1.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(prom-client@15.1.3) cors: specifier: ^2.8.6 version: 2.8.6 @@ -20,12 +35,21 @@ importers: node-fetch: specifier: '2' version: 2.7.0 + pg: + specifier: ^8.23.0 + version: 8.23.0 + prom-client: + specifier: ^15.1.3 + version: 15.1.3 stellar-sdk: specifier: ^13.3.0 version: 13.3.0 toml: specifier: ^3.0.0 version: 3.0.0 + typeorm: + specifier: ^0.3.31 + version: 0.3.31(pg@8.23.0)(sql.js@1.14.2)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)) uuid: specifier: ^14.0.1 version: 14.0.1 @@ -36,9 +60,15 @@ importers: '@types/express': specifier: ^5.0.6 version: 5.0.6 + '@types/multer': + specifier: ^2.2.0 + version: 2.2.0 nodemon: specifier: ^3.1.14 version: 3.1.14 + sql.js: + specifier: ^1.14.2 + version: 1.14.2 ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.19.37)(typescript@5.9.3) @@ -1759,6 +1789,11 @@ packages: '@nestjs/common': ^10.0.0 || ^11.0.0 '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/jwt@10.2.0': + resolution: {integrity: sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/mapped-types@2.0.5': resolution: {integrity: sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==} peerDependencies: @@ -1827,6 +1862,15 @@ packages: '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 reflect-metadata: ^0.1.13 || ^0.2.0 + '@nestjs/typeorm@10.0.2': + resolution: {integrity: sha512-H738bJyydK4SQkRCTeh1aFBxoO1E9xdL/HaLGThwrqN95os5mEyAtK7BLADOS+vldP4jDZ2VQPLj4epWwRqCeQ==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + rxjs: ^7.2.0 + typeorm: ^0.3.0 + '@next/env@15.5.9': resolution: {integrity: sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==} @@ -2777,6 +2821,9 @@ packages: '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + '@sqltools/formatter@1.2.5': + resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3111,12 +3158,18 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/jsonwebtoken@9.0.5': + resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} + '@types/luxon@3.3.8': resolution: {integrity: sha512-jYvz8UMLDgy3a5SkGJne8H7VA7zPV2Lwohjx0V8V31+SqAjNmurWMkk9cQhfvlcnXWudBpK9xPM1n4rljOcHYQ==} '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/multer@2.2.0': + resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==} + '@types/mysql@2.15.27': resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} @@ -3129,6 +3182,9 @@ packages: '@types/pg@8.15.6': resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/pg@8.23.1': + resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -3503,6 +3559,12 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@willsoto/nestjs-prometheus@6.1.0': + resolution: {integrity: sha512-lrCEnJBBSzUIYWGR+PsZw1YXs1B9jzxFEuNAa3RzTxuFAFdI+sW7Fp52il/U/dX2MWoHc32x06OS0nm56QwyzQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + prom-client: ^15.0.0 + '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} @@ -3633,6 +3695,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -3640,6 +3706,10 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + app-root-path@3.1.0: + resolution: {integrity: sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==} + engines: {node: '>= 6.0.0'} + appdirsjs@1.2.7: resolution: {integrity: sha512-Quji6+8kLBC3NnBeo14nPDq0+2jUs5s3/xEye+udFHumHhRk4M7aAMXp/PBJqkKYGuuyR9M/6Dq7d2AViiGmhw==} @@ -3937,6 +4007,9 @@ packages: buffer-alloc@1.2.0: resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-fill@1.0.0: resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} @@ -4392,6 +4465,9 @@ packages: dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -4562,6 +4638,10 @@ packages: resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} engines: {node: '>=12'} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -4569,6 +4649,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -5404,6 +5487,11 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -6129,10 +6217,20 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.3: + resolution: {integrity: sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -6263,12 +6361,33 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.throttle@4.1.1: resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} @@ -6935,17 +7054,43 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + pg-protocol@1.13.0: resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + pg-protocol@1.16.0: + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} + pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} + pg@8.23.0: + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -7368,6 +7513,9 @@ packages: reflect-metadata@0.1.14: resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -7744,9 +7892,20 @@ packages: resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} engines: {node: '>=6'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sql-highlight@6.1.0: + resolution: {integrity: sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==} + engines: {node: '>=14'} + + sql.js@1.14.2: + resolution: {integrity: sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -8254,6 +8413,61 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typeorm@0.3.31: + resolution: {integrity: sha512-6u9EFtdLBgHjnPm78NStVeM+I/1MolTzKykDDcydzKUkh6E++YS6XViU/fePJbvDvEGU4Xq34KOM/CLeer9I2A==} + engines: {node: '>=16.13.0'} + hasBin: true + peerDependencies: + '@google-cloud/spanner': ^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@sap/hana-client': ^2.14.22 + better-sqlite3: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 + ioredis: ^5.0.4 + mongodb: ^5.8.0 || ^6.0.0 + mssql: ^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0 + mysql2: ^2.2.5 || ^3.0.1 + oracledb: ^6.3.0 || ^7.0.0 + pg: ^8.5.1 + pg-native: ^3.0.0 + pg-query-stream: ^4.0.0 + redis: ^3.1.1 || ^4.0.0 || ^5.0.14 + sql.js: ^1.4.0 + sqlite3: ^5.0.3 || ^6.0.0 + ts-node: ^10.7.0 + typeorm-aurora-data-api-driver: ^2.0.0 || ^3.0.0 + peerDependenciesMeta: + '@google-cloud/spanner': + optional: true + '@sap/hana-client': + optional: true + better-sqlite3: + optional: true + ioredis: + optional: true + mongodb: + optional: true + mssql: + optional: true + mysql2: + optional: true + oracledb: + optional: true + pg: + optional: true + pg-native: + optional: true + pg-query-stream: + optional: true + redis: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + ts-node: + optional: true + typeorm-aurora-data-api-driver: + optional: true + typescript@5.7.2: resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} engines: {node: '>=14.17'} @@ -8385,6 +8599,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + uuid@14.0.1: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true @@ -8760,6 +8978,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -10412,6 +10634,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 20.4.1 + iterare: 1.2.1 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + transitivePeerDependencies: + - supports-color + '@nestjs/config@3.3.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) @@ -10436,12 +10672,34 @@ snapshots: transitivePeerDependencies: - encoding + '@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nuxtjs/opencollective': 0.3.2 + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 3.3.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) + transitivePeerDependencies: + - encoding + '@nestjs/event-emitter@3.0.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.1.14)(rxjs@7.8.2) eventemitter2: 6.4.9 + '@nestjs/jwt@10.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.5 + jsonwebtoken: 9.0.2 + '@nestjs/mapped-types@2.0.5(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) @@ -10450,6 +10708,14 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.4 + '@nestjs/mapped-types@2.0.5(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + '@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) @@ -10462,6 +10728,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + body-parser: 1.20.4 + cors: 2.8.5 + express: 4.22.1 + multer: 2.0.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + optional: true + '@nestjs/schedule@3.0.4(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.1.14)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) @@ -10496,6 +10775,21 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.4 + '@nestjs/swagger@7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.15.1 + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.0.5(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + js-yaml: 4.1.0 + lodash: 4.17.21 + path-to-regexp: 3.3.0 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.17.14 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + '@nestjs/testing@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) @@ -10510,6 +10804,15 @@ snapshots: '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.1.14)(rxjs@7.8.2) reflect-metadata: 0.1.14 + '@nestjs/typeorm@10.0.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.31(pg@8.23.0)(sql.js@1.14.2)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)))': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + typeorm: 0.3.31(pg@8.23.0)(sql.js@1.14.2)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)) + uuid: 9.0.1 + '@next/env@15.5.9': {} '@next/eslint-plugin-next@15.5.9': @@ -11603,6 +11906,8 @@ snapshots: color: 5.0.3 text-hex: 1.0.0 + '@sqltools/formatter@1.2.5': {} + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -11962,10 +12267,18 @@ snapshots: '@types/json5@0.0.29': {} + '@types/jsonwebtoken@9.0.5': + dependencies: + '@types/node': 20.19.37 + '@types/luxon@3.3.8': {} '@types/methods@1.1.4': {} + '@types/multer@2.2.0': + dependencies: + '@types/express': 5.0.6 + '@types/mysql@2.15.27': dependencies: '@types/node': 20.19.37 @@ -11984,6 +12297,12 @@ snapshots: pg-protocol: 1.13.0 pg-types: 2.2.0 + '@types/pg@8.23.1': + dependencies: + '@types/node': 20.19.37 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -12421,6 +12740,11 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@willsoto/nestjs-prometheus@6.1.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(prom-client@15.1.3)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + prom-client: 15.1.3 + '@xmldom/xmldom@0.8.11': {} '@xtuc/ieee754@1.2.0': {} @@ -12542,6 +12866,8 @@ snapshots: ansi-styles@6.2.3: {} + ansis@4.3.1: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -12549,6 +12875,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 + app-root-path@3.1.0: {} + appdirsjs@1.2.7: {} append-field@1.0.0: {} @@ -12939,6 +13267,8 @@ snapshots: buffer-alloc-unsafe: 1.1.0 buffer-fill: 1.0.0 + buffer-equal-constant-time@1.0.1: {} + buffer-fill@1.0.0: {} buffer-from@1.1.2: {} @@ -13410,6 +13740,8 @@ snapshots: dayjs@1.11.20: {} + dayjs@1.11.23: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -13533,6 +13865,8 @@ snapshots: dotenv@16.4.7: {} + dotenv@16.6.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -13541,6 +13875,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.325: {} @@ -14722,6 +15060,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@13.0.6: dependencies: minimatch: 10.2.4 @@ -15734,6 +16081,19 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.3 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.4 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -15741,6 +16101,17 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.3: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -15841,10 +16212,24 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.throttle@4.1.1: {} lodash@4.17.21: {} @@ -16701,10 +17086,21 @@ snapshots: pathval@2.0.1: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + pg-int8@1.0.1: {} + pg-pool@3.14.0(pg@8.23.0): + dependencies: + pg: 8.23.0 + pg-protocol@1.13.0: {} + pg-protocol@1.16.0: {} + pg-types@2.2.0: dependencies: pg-int8: 1.0.1 @@ -16713,6 +17109,20 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 + pg@8.23.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.23.0) + pg-protocol: 1.16.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -17185,6 +17595,8 @@ snapshots: reflect-metadata@0.1.14: {} + reflect-metadata@0.2.2: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -17657,8 +18069,14 @@ snapshots: split-on-first@1.1.0: {} + split2@4.2.0: {} + sprintf-js@1.0.3: {} + sql-highlight@6.1.0: {} + + sql.js@1.14.2: {} + stable-hash@0.0.5: {} stack-generator@2.0.10: @@ -18194,6 +18612,31 @@ snapshots: typedarray@0.0.6: {} + typeorm@0.3.31(pg@8.23.0)(sql.js@1.14.2)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)): + dependencies: + '@sqltools/formatter': 1.2.5 + ansis: 4.3.1 + app-root-path: 3.1.0 + buffer: 6.0.3 + dayjs: 1.11.23 + debug: 4.4.3(supports-color@5.5.0) + dedent: 1.7.2 + dotenv: 16.6.1 + glob: 10.5.0 + reflect-metadata: 0.2.2 + sha.js: 2.4.12 + sql-highlight: 6.1.0 + tslib: 2.8.1 + uuid: 11.1.1 + yargs: 17.7.3 + optionalDependencies: + pg: 8.23.0 + sql.js: 1.14.2 + ts-node: 10.9.2(@types/node@20.19.37)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + typescript@5.7.2: {} typescript@5.9.3: {} @@ -18311,6 +18754,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@11.1.1: {} + uuid@14.0.1: {} uuid@7.0.3: {} @@ -18712,6 +19157,16 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yn@3.1.1: {} yocto-queue@0.1.0: {}