diff --git a/BackendAcademy/readme.md b/BackendAcademy/readme.md index 6f92d9bc8..04da040b4 100644 --- a/BackendAcademy/readme.md +++ b/BackendAcademy/readme.md @@ -88,181 +88,4 @@ When integrating a frontend built with **shadcn/ui**, backend endpoints should p } ] } -``` - ---- - -# AI Hints — How They Are Generated and Used - -The AI subsystem lives in `src/ai/` and exposes three capabilities to the rest of the platform: **chat-based mentoring**, **graduated task hints**, and **AI pre-scoring of code submissions**. This section focuses on hints specifically, then covers the supporting pieces. - -## Architecture Overview - -``` -Client (frontend / mobile) - │ - ▼ - POST /ai/hint ← AiController - │ - ▼ - AiService.getHint() ← business logic, hint store, difficulty routing - │ - ├── [hint found in store] → return stored hint + increment usedCount - └── [no hint in store] → return generic fallback message -``` - -When `AI_PROVIDER=claude` (or `openai`) is set, the `processChatRequest` path uses the provider to generate dynamic responses. The hint path currently uses a pre-seeded in-memory store — dynamic AI-generated hints are the planned Phase 2 upgrade (see below). - -## Request / Response Shape - -### POST `/ai/hint` - -**Request body** (`GetHintDto`): - -```json -{ - "challengeId": "sample-challenge-001", - "userId": "user-abc", - "difficulty": 2 -} -``` - -| Field | Type | Required | Description | -|---|---|---|---| -| `challengeId` | string | ✅ | Identifier of the task or challenge | -| `userId` | string | ✅ | Identifier of the requesting learner | -| `difficulty` | number | ❌ | Hint tier to request (1 = most gentle, 3 = most specific). Defaults to 1 | - -**Response body** (`AiHintResponse`): - -```json -{ - "hint": "Consider edge cases - empty, null, or out-of-range inputs.", - "hintId": "3f2c1a...", - "difficulty": 2 -} -``` - -| Field | Type | Description | -|---|---|---| -| `hint` | string | The hint text shown to the learner | -| `hintId` | string | UUID of the specific hint record (for analytics) | -| `difficulty` | number | Difficulty tier that was actually served (may differ from request if the requested tier was unavailable) | - -If no hints exist for the given `challengeId`, the API returns HTTP 200 with a generic fallback: - -```json -{ - "hint": "No hints available for this challenge yet. Keep trying!", - "hintId": "", - "difficulty": 1 -} -``` - -## Hint Difficulty Tiers - -Hints are designed to be **graduated** — each tier reveals progressively more information so learners are guided without being spoiled. - -| Tier | Intent | Example | -|---|---|---| -| **1** — Conceptual nudge | Reframe the problem; no implementation detail | `"Start by understanding the problem requirements thoroughly."` | -| **2** — Edge-case reminder | Point toward gotchas without giving code | `"Consider edge cases — empty, null, or out-of-range inputs."` | -| **3** — Algorithmic direction | Suggest an approach or pattern | `"Implement brute-force first, then optimize."` | - -When a learner requests tier 2 but only tier 1 is stored, `AiService.getHint()` falls back to the first available hint for that challenge rather than returning nothing. - -## How Hints Are Stored and Seeded - -`AiService` maintains an in-memory `Map` called `hints`. On startup, `initializeSampleHints()` pre-populates it with the three sample tiers for `"sample-challenge-001"`. - -``` -Hint { - id – UUID - challengeId – which challenge this hint belongs to - hint – hint text - difficulty – tier number (1–3) - usedCount – incremented each time the hint is served -} -``` - -`usedCount` is tracked so the analytics layer can identify which hints learners reach most often — a signal that difficulty calibration may need adjustment on a given challenge. - -In production, this in-memory store will be replaced by a database table. The service interface (`getHint`, `AiHintResponse`) will remain unchanged. - -## AI Provider Wiring - -The hint system currently runs entirely off the in-memory store, so it works without any API key configured. The full AI-powered chat path uses a pluggable provider selected at startup: - -``` -AI_PROVIDER=claude → ClaudeProvider (Anthropic Messages API) -AI_PROVIDER=openai → OpenaiProvider (OpenAI Chat Completions API) -(unset / other) → null provider (deterministic fallback responses) -``` - -The factory is defined in `AiModule` and injects the chosen provider into `AiService` via the `AI_PROVIDER` token: - -```typescript -// src/ai/ai.module.ts -const aiProviderFactory = { - provide: AI_PROVIDER, - useFactory: (configService: ConfigService) => { - const provider = configService.get('AI_PROVIDER'); - if (provider === 'openai') return new OpenaiProvider(configService); - if (provider === 'claude') return new ClaudeProvider(configService); - return null; // ← fallback, no external calls - }, - inject: [ConfigService], -}; -``` - -`ClaudeProvider` calls `POST https://api.anthropic.com/v1/messages` using the model specified by `AI_MODEL` (default: `claude-sonnet-4-20250514`), with `AI_MAX_TOKENS` (default: 4096) and `AI_TEMPERATURE` (default: 0.7). - -## Environment Variables - -| Variable | Default | Description | -|---|---|---| -| `AI_PROVIDER` | _(none)_ | `claude` or `openai`; omit to use offline fallback | -| `ANTHROPIC_API_KEY` | _(none)_ | Required when `AI_PROVIDER=claude` | -| `OPENAI_API_KEY` | _(none)_ | Required when `AI_PROVIDER=openai` | -| `AI_MODEL` | `claude-sonnet-4-20250514` | Model name passed to the provider | -| `AI_MAX_TOKENS` | `4096` | Maximum tokens per AI response | -| `AI_TEMPERATURE` | `0.7` | Sampling temperature (0 = deterministic, 1 = creative) | - -Copy `.env.example` and fill in the relevant keys: - -```bash -cp .env.example .env -``` - -## Related Endpoints - -| Method | Path | Description | -|---|---|---| -| `POST` | `/ai/hint` | Fetch a graduated hint for a challenge | -| `POST` | `/ai/chat` | Send a free-form message to the AI Mentor | -| `POST` | `/ai/pre-score` | Submit code for an AI pre-score before tutor review | -| `GET` | `/ai/history/:userId` | Retrieve a user's full chat history | - -### POST `/ai/chat` - -Sends a conversational message to the AI Mentor. The system prompt is fixed to `"You are a helpful Rust programming tutor."` The full message history per user is stored in memory and returned by `GET /ai/history/:userId`. - -Request body fields: `message` (string), `userId` (string), optional `context` (object). - -### POST `/ai/pre-score` - -Performs a static analysis pre-score on submitted Rust code before it enters the tutor review queue. The heuristic checks for: - -- Presence of `fn main()` (+15 pts) -- Use of functions with non-trivial line count (+15 pts) -- Presence of comments (+10 pts) -- Code length > 20 lines (+10 pts) - -Base score is 50. Final score is clamped to [0, 100]. A `confidence` of `0.7` is always reported in the current placeholder — this will be replaced by a model-calibrated confidence value once the full AI grading pipeline is wired in. - -## Planned Enhancements (Phase 2) - -- **Dynamic hint generation** — when no hint is stored for a `challengeId`, fall through to the AI provider to generate one on-demand using the task description as context. -- **Per-user hint gating** — track how many hints a learner has consumed per challenge and reduce XP payout accordingly. -- **Database persistence** — migrate `hints` and `chatHistory` maps to PostgreSQL via the Supabase client. -- **Streaming responses** — switch the chat endpoint to Server-Sent Events for real-time token streaming. +``` \ No newline at end of file diff --git a/BackendAcademy/src/app.module.ts b/BackendAcademy/src/app.module.ts index caade999b..a3ccfe29d 100644 --- a/BackendAcademy/src/app.module.ts +++ b/BackendAcademy/src/app.module.ts @@ -31,6 +31,7 @@ import { MonitoringModule } from './monitoring/monitoring.module'; import { SearchModule } from './search/search.module'; import { PaymentsModule } from './payments/payments.module'; import { SessionsModule } from './sessions/sessions.module'; +import { ReportsModule } from './reports/reports.module'; @Module({ imports: [ @@ -67,6 +68,7 @@ import { SessionsModule } from './sessions/sessions.module'; SearchModule, PaymentsModule, SessionsModule, + ReportsModule, ], controllers: [AppController], providers: [ diff --git a/BackendAcademy/src/reports/dto/daily-summary-query.dto.ts b/BackendAcademy/src/reports/dto/daily-summary-query.dto.ts new file mode 100644 index 000000000..703aca493 --- /dev/null +++ b/BackendAcademy/src/reports/dto/daily-summary-query.dto.ts @@ -0,0 +1,21 @@ +import { Transform } from 'class-transformer'; +import { IsBoolean, IsISO8601, IsOptional } from 'class-validator'; + +export class DailySummaryQueryDto { + @IsOptional() + @IsISO8601() + startDate?: string; + + @IsOptional() + @IsISO8601() + endDate?: string; + + @IsOptional() + @Transform(({ value }) => { + if (value === undefined) return true; + if (typeof value === 'boolean') return value; + return value === 'true'; + }) + @IsBoolean() + includeEmptyDays: boolean = true; +} diff --git a/BackendAcademy/src/reports/reports.controller.ts b/BackendAcademy/src/reports/reports.controller.ts new file mode 100644 index 000000000..f6865d145 --- /dev/null +++ b/BackendAcademy/src/reports/reports.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { DailySummaryQueryDto } from './dto/daily-summary-query.dto'; +import { DailySummaryReport, ReportsService } from './reports.service'; + +@Controller('reports') +export class ReportsController { + constructor(private readonly reportsService: ReportsService) {} + + @Get('daily-summaries/:userId') + async getDailySummaries( + @Param('userId') userId: string, + @Query() query: DailySummaryQueryDto, + ): Promise { + return this.reportsService.getDailySummaryReport( + userId, + query.startDate, + query.endDate, + query.includeEmptyDays, + ); + } +} diff --git a/BackendAcademy/src/reports/reports.module.ts b/BackendAcademy/src/reports/reports.module.ts new file mode 100644 index 000000000..b77789014 --- /dev/null +++ b/BackendAcademy/src/reports/reports.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AnalyticsModule } from '../analytics/analytics.module'; +import { RewardsModule } from '../rewards/rewards.module'; +import { ReportsController } from './reports.controller'; +import { ReportsService } from './reports.service'; + +@Module({ + imports: [AnalyticsModule, RewardsModule], + controllers: [ReportsController], + providers: [ReportsService], +}) +export class ReportsModule {} diff --git a/BackendAcademy/src/reports/reports.service.spec.ts b/BackendAcademy/src/reports/reports.service.spec.ts new file mode 100644 index 000000000..b198d4840 --- /dev/null +++ b/BackendAcademy/src/reports/reports.service.spec.ts @@ -0,0 +1,154 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { AnalyticsService } from '../analytics/analytics.service'; +import { RewardsService } from '../rewards/rewards.service'; +import { ReportsService } from './reports.service'; + +describe('ReportsService', () => { + let service: ReportsService; + + const analyticsService = { + getEventsByUserId: jest.fn(), + } as unknown as AnalyticsService; + + const rewardsService = { + getUserProgression: jest.fn(), + } as unknown as RewardsService; + + beforeEach(() => { + service = new ReportsService(analyticsService, rewardsService); + jest.clearAllMocks(); + }); + + it('builds daily summaries with empty days and progress metrics', async () => { + (analyticsService.getEventsByUserId as jest.Mock).mockResolvedValue([ + { + id: '1', + userId: 'learner-1', + eventType: 'course_completed', + timestamp: new Date('2026-07-01T09:00:00.000Z'), + }, + { + id: '2', + userId: 'learner-1', + eventType: 'lesson_viewed', + timestamp: new Date('2026-07-01T11:00:00.000Z'), + }, + { + id: '3', + userId: 'learner-1', + eventType: 'challenge_started', + timestamp: new Date('2026-07-03T15:30:00.000Z'), + }, + ]); + (rewardsService.getUserProgression as jest.Mock).mockReturnValue({ + xp: 450, + level: 4, + xpToNextLevel: 50, + currentLevelThreshold: 400, + nextLevelThreshold: 500, + streak: { + currentStreak: 2, + lastActivityDate: '2026-07-03T15:30:00.000Z', + }, + }); + + const report = await service.getDailySummaryReport( + 'learner-1', + '2026-07-01T00:00:00.000Z', + '2026-07-03T23:59:59.999Z', + true, + ); + + expect(report.summaries).toHaveLength(3); + expect(report.summaries[0]).toMatchObject({ + date: '2026-07-01', + totalEvents: 2, + uniqueEventTypes: 2, + }); + expect(report.summaries[1]).toMatchObject({ + date: '2026-07-02', + totalEvents: 0, + }); + expect(report.progress).toMatchObject({ + totalDays: 3, + activeDays: 2, + inactiveDays: 1, + activityRate: 66.67, + totalEvents: 3, + uniqueEventTypes: 3, + currentActiveStreak: 1, + longestActiveStreak: 1, + }); + expect(report.progress.rewards.level).toBe(4); + }); + + it('omits empty days when requested', async () => { + (analyticsService.getEventsByUserId as jest.Mock).mockResolvedValue([ + { + id: '1', + userId: 'learner-1', + eventType: 'course_completed', + timestamp: new Date('2026-07-01T09:00:00.000Z'), + }, + ]); + (rewardsService.getUserProgression as jest.Mock).mockReturnValue({ + xp: 100, + level: 2, + xpToNextLevel: 20, + currentLevelThreshold: 80, + nextLevelThreshold: 120, + streak: { + currentStreak: 1, + lastActivityDate: '2026-07-01T09:00:00.000Z', + }, + }); + + const report = await service.getDailySummaryReport( + 'learner-1', + '2026-07-01T00:00:00.000Z', + '2026-07-03T23:59:59.999Z', + false, + ); + + expect(report.summaries).toHaveLength(1); + expect(report.summaries[0].date).toBe('2026-07-01'); + expect(report.progress.inactiveDays).toBe(2); + }); + + it('falls back to zeroed rewards progress when progression is missing', async () => { + (analyticsService.getEventsByUserId as jest.Mock).mockResolvedValue([]); + (rewardsService.getUserProgression as jest.Mock).mockImplementation(() => { + throw new NotFoundException('missing'); + }); + + const report = await service.getDailySummaryReport( + 'learner-1', + '2026-07-01T00:00:00.000Z', + '2026-07-01T23:59:59.999Z', + true, + ); + + expect(report.progress.rewards).toEqual({ + xp: 0, + level: 1, + xpToNextLevel: 0, + currentLevelThreshold: 0, + nextLevelThreshold: null, + currentStreak: 0, + lastActivityDate: null, + }); + }); + + it('throws for invalid date ranges', async () => { + (analyticsService.getEventsByUserId as jest.Mock).mockResolvedValue([]); + + await expect( + service.getDailySummaryReport( + 'learner-1', + '2026-07-03T00:00:00.000Z', + '2026-07-01T23:59:59.999Z', + true, + ), + ).rejects.toThrow(BadRequestException); + }); +}); diff --git a/BackendAcademy/src/reports/reports.service.ts b/BackendAcademy/src/reports/reports.service.ts new file mode 100644 index 000000000..e20ce7351 --- /dev/null +++ b/BackendAcademy/src/reports/reports.service.ts @@ -0,0 +1,280 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { AnalyticsEvent } from '../analytics/analytics.entity'; +import { AnalyticsService } from '../analytics/analytics.service'; +import { RewardsService } from '../rewards/rewards.service'; + +export interface DailyActivitySummary { + date: string; + totalEvents: number; + uniqueEventTypes: number; + firstActivityAt: string | null; + lastActivityAt: string | null; + eventBreakdown: Record; +} + +export interface DailyActivityProgress { + totalDays: number; + activeDays: number; + inactiveDays: number; + activityRate: number; + totalEvents: number; + uniqueEventTypes: number; + currentActiveStreak: number; + longestActiveStreak: number; + rewards: { + xp: number; + level: number; + xpToNextLevel: number; + currentLevelThreshold: number; + nextLevelThreshold: number | null; + currentStreak: number; + lastActivityDate: string | null; + }; +} + +export interface DailySummaryReport { + userId: string; + window: { + startDate: string; + endDate: string; + }; + summaries: DailyActivitySummary[]; + progress: DailyActivityProgress; +} + +interface DailyBucket { + totalEvents: number; + firstActivityAt: string | null; + lastActivityAt: string | null; + eventBreakdown: Record; +} + +@Injectable() +export class ReportsService { + constructor( + private readonly analyticsService: AnalyticsService, + private readonly rewardsService: RewardsService, + ) {} + + async getDailySummaryReport( + userId: string, + startDate?: string, + endDate?: string, + includeEmptyDays: boolean = true, + ): Promise { + const { start, end } = this.resolveDateWindow(startDate, endDate); + const allEvents = await this.analyticsService.getEventsByUserId(userId); + const filteredEvents = allEvents.filter((event) => + this.isWithinRange(event.timestamp, start, end), + ); + + const fullSummaries = this.buildDailySummaries(filteredEvents, start, end, true); + const summaries = includeEmptyDays + ? fullSummaries + : fullSummaries.filter((summary) => summary.totalEvents > 0); + + return { + userId, + window: { + startDate: start.toISOString(), + endDate: end.toISOString(), + }, + summaries, + progress: this.buildProgress(userId, filteredEvents, fullSummaries), + }; + } + + private buildDailySummaries( + events: AnalyticsEvent[], + start: Date, + end: Date, + includeEmptyDays: boolean, + ): DailyActivitySummary[] { + const buckets = new Map(); + + for (const event of events) { + const dateKey = this.toDateKey(event.timestamp); + const current = buckets.get(dateKey) ?? { + totalEvents: 0, + firstActivityAt: null, + lastActivityAt: null, + eventBreakdown: {}, + }; + + current.totalEvents += 1; + current.eventBreakdown[event.eventType] = + (current.eventBreakdown[event.eventType] ?? 0) + 1; + + const eventIso = event.timestamp.toISOString(); + current.firstActivityAt = + current.firstActivityAt && current.firstActivityAt < eventIso + ? current.firstActivityAt + : eventIso; + current.lastActivityAt = + current.lastActivityAt && current.lastActivityAt > eventIso + ? current.lastActivityAt + : eventIso; + + buckets.set(dateKey, current); + } + + const summaries = includeEmptyDays + ? this.buildDateRange(start, end).map((date) => + this.toSummary(date, buckets.get(date)), + ) + : Array.from(buckets.entries()).map(([date, bucket]) => + this.toSummary(date, bucket), + ); + + return summaries.sort((a, b) => a.date.localeCompare(b.date)); + } + + private buildProgress( + userId: string, + events: AnalyticsEvent[], + summaries: DailyActivitySummary[], + ): DailyActivityProgress { + const activeDays = summaries.filter((summary) => summary.totalEvents > 0).length; + const eventTypes = new Set(events.map((event) => event.eventType)); + const rewards = this.getRewardsProgress(userId); + + return { + totalDays: summaries.length, + activeDays, + inactiveDays: Math.max(summaries.length - activeDays, 0), + activityRate: + summaries.length > 0 ? this.round2((activeDays / summaries.length) * 100) : 0, + totalEvents: events.length, + uniqueEventTypes: eventTypes.size, + currentActiveStreak: this.getCurrentActiveStreak(summaries), + longestActiveStreak: this.getLongestActiveStreak(summaries), + rewards, + }; + } + + private getRewardsProgress(userId: string): DailyActivityProgress['rewards'] { + try { + const progression = this.rewardsService.getUserProgression(userId); + return { + xp: progression.xp, + level: progression.level, + xpToNextLevel: progression.xpToNextLevel, + currentLevelThreshold: progression.currentLevelThreshold, + nextLevelThreshold: progression.nextLevelThreshold, + currentStreak: progression.streak.currentStreak, + lastActivityDate: progression.streak.lastActivityDate, + }; + } catch (error) { + if (!(error instanceof NotFoundException)) { + throw error; + } + + return { + xp: 0, + level: 1, + xpToNextLevel: 0, + currentLevelThreshold: 0, + nextLevelThreshold: null, + currentStreak: 0, + lastActivityDate: null, + }; + } + } + + private toSummary(date: string, bucket?: DailyBucket): DailyActivitySummary { + const eventBreakdown = bucket?.eventBreakdown ?? {}; + + return { + date, + totalEvents: bucket?.totalEvents ?? 0, + uniqueEventTypes: Object.keys(eventBreakdown).length, + firstActivityAt: bucket?.firstActivityAt ?? null, + lastActivityAt: bucket?.lastActivityAt ?? null, + eventBreakdown, + }; + } + + private getCurrentActiveStreak(summaries: DailyActivitySummary[]): number { + let streak = 0; + + for (let index = summaries.length - 1; index >= 0; index -= 1) { + if (summaries[index].totalEvents === 0) { + break; + } + streak += 1; + } + + return streak; + } + + private getLongestActiveStreak(summaries: DailyActivitySummary[]): number { + let longest = 0; + let current = 0; + + for (const summary of summaries) { + if (summary.totalEvents > 0) { + current += 1; + longest = Math.max(longest, current); + } else { + current = 0; + } + } + + return longest; + } + + private buildDateRange(start: Date, end: Date): string[] { + const dates: string[] = []; + const cursor = new Date( + Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate()), + ); + const last = new Date( + Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate()), + ); + + while (cursor <= last) { + dates.push(this.toDateKey(cursor)); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + + return dates; + } + + private toDateKey(date: Date): string { + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String( + date.getUTCDate(), + ).padStart(2, '0')}`; + } + + private isWithinRange(date: Date, start: Date, end: Date): boolean { + return date.getTime() >= start.getTime() && date.getTime() <= end.getTime(); + } + + private resolveDateWindow( + startDate?: string, + endDate?: string, + ): { start: Date; end: Date } { + const end = endDate ? new Date(endDate) : new Date(); + const start = startDate + ? new Date(startDate) + : new Date(end.getTime() - 29 * 24 * 60 * 60 * 1000); + + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + throw new BadRequestException( + 'startDate and endDate must be valid ISO-8601 strings.', + ); + } + + if (start > end) { + throw new BadRequestException( + 'startDate must be earlier than or equal to endDate.', + ); + } + + return { start, end }; + } + + private round2(value: number): number { + return Math.round(value * 100) / 100; + } +} diff --git a/app/backend/src/app.module.ts b/app/backend/src/app.module.ts index 0752e0ae6..ee2d8b54d 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -91,76 +91,9 @@ const validatedEnv = envSchema.validate(process.env, { EnvironmentParityModule, IndexerLagModule, SupportBundleModule, + ChatModule, ...getDynamicModules(validatedEnv), ], - imports: ((): AppImport[] => { - const baseImports: AppImport[] = [ - SentryModule, - AppConfigModule, - // ScheduleModule registered once here — shared by NotificationsModule and ReconciliationModule - ScheduleModule.forRoot(), - EventEmitterModule.forRoot({ - wildcard: true, - delimiter: ".", - }), - ThrottlerModule.forRoot(throttlerModuleProfiles), - SupabaseModule, - HealthModule, - AssetMetadataModule, - StellarModule, - UsernamesModule, - MetricsModule, - AnalyticsModule, - LinksModule, - ScamAlertsModule, - TransactionsModule, - PaymentsModule, - IngestionModule, - ApiKeysModule, - MarketplaceModule, - FiatRampsModule, - RefundsModule, - ExportsModule, - JobQueueModule, - AuditModule, - ContractsModule, - FeatureFlagsModule, - PrivacyModule, - SorobanToolingModule, - EnvironmentParityModule, - IndexerLagModule, - SupportBundleModule, - ChatModule, - ]; - - // In development, if SUPABASE_URL points to a localhost placeholder (i.e. you don't - // have a running Supabase instance), skip loading the Reconciliation module which - // interacts with Supabase and runs scheduled jobs. This avoids noisy network errors - // during local development and recording sessions. - try { - const supabaseUrl = process.env.SUPABASE_URL ?? ""; - const isLocalSupabase = - supabaseUrl.includes("localhost") || supabaseUrl.includes("127.0.0.1"); - - // Only load Reconciliation & Notifications modules when Supabase is real/reachable. - if (!isLocalSupabase) { - baseImports.push(ReconciliationModule as AppImport); - baseImports.push(NotificationsModule as AppImport); - baseImports.push(DeveloperModule as AppImport); - } else { - // eslint-disable-next-line no-console - console.log( - "Skipping Reconciliation & Notifications modules in dev (local Supabase)", - ); - } - } catch (e) { - // If anything goes wrong, default to including the modules. - baseImports.push(ReconciliationModule as AppImport); - baseImports.push(NotificationsModule as AppImport); - baseImports.push(DeveloperModule as AppImport); - } - return baseImports; - })(), providers: [ { provide: APP_GUARD, diff --git a/app/backend/src/common/filters/global-http-exception.filter.ts b/app/backend/src/common/filters/global-http-exception.filter.ts index d4fb7ac50..92eb7cb6b 100644 --- a/app/backend/src/common/filters/global-http-exception.filter.ts +++ b/app/backend/src/common/filters/global-http-exception.filter.ts @@ -59,9 +59,7 @@ export class GlobalHttpExceptionFilter implements ExceptionFilter { const isProduction = this.config.isProduction; // Extract correlation ID for traceability - const correlationId = (request as Record)[ - "correlationId" - ] as string | undefined; + const correlationId = request.correlationId; let status: number = HttpStatus.INTERNAL_SERVER_ERROR; let code = "INTERNAL_SERVER_ERROR"; @@ -82,11 +80,7 @@ export class GlobalHttpExceptionFilter implements ExceptionFilter { retryAfterSeconds, }; - const reqRecord = request as Record; - const rateLimitContext = - (reqRecord["rateLimitContext"] as - | { group?: string; keyType?: string } - | undefined) ?? {}; + const rateLimitContext = request.rateLimitContext ?? {}; const route = this.resolveRoute(request); @@ -103,7 +97,7 @@ export class GlobalHttpExceptionFilter implements ExceptionFilter { this.logger.warn( `[SorobanDomainException] ${body.code}: ${exception.technicalError}`, ); - return response.status(status).json({ + response.status(status).json({ success: false, error: { code: body.code, @@ -112,6 +106,7 @@ export class GlobalHttpExceptionFilter implements ExceptionFilter { ...(body.details && !isProduction ? { details: body.details } : {}), }, }); + return; } else if (exception instanceof HttpException) { status = exception.getStatus(); const res = exception.getResponse() as HttpExceptionResponse; @@ -123,7 +118,7 @@ export class GlobalHttpExceptionFilter implements ExceptionFilter { if ("fields" in res) { const validation = res as ValidationExceptionPayload; - return response.status(status).json({ + response.status(status).json({ success: false, error: { code: "VALIDATION_ERROR", @@ -132,6 +127,7 @@ export class GlobalHttpExceptionFilter implements ExceptionFilter { ...(correlationId ? { request_id: correlationId, correlationId } : {}), }, }); + return; } // ✅ BUSINESS ERRORS diff --git a/app/backend/src/common/interceptors/logging.interceptor.ts b/app/backend/src/common/interceptors/logging.interceptor.ts index 2d1383781..c0ee85236 100644 --- a/app/backend/src/common/interceptors/logging.interceptor.ts +++ b/app/backend/src/common/interceptors/logging.interceptor.ts @@ -44,8 +44,7 @@ export class LoggingInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); const { method, url, body, route } = request; - const correlationId = - (request as Record)['correlationId'] || 'N/A'; + const correlationId = request.correlationId || 'N/A'; const userId = this.extractUserId(request); const routePath = route?.path || url; const now = Date.now(); @@ -134,4 +133,4 @@ export class LoggingInterceptor implements NestInterceptor { } return result; } -} \ No newline at end of file +} diff --git a/app/backend/src/contracts/contract-registry.controller.ts b/app/backend/src/contracts/contract-registry.controller.ts index 7f48ec66e..0d23b37c4 100644 --- a/app/backend/src/contracts/contract-registry.controller.ts +++ b/app/backend/src/contracts/contract-registry.controller.ts @@ -24,7 +24,7 @@ import { import { ContractWritePolicyService } from '../feature-flags/contract-write-policy.service'; interface ApiKeyRequest extends Request { - apiKey: { id: string }; + apiKey?: Request['apiKey']; } @ApiTags('contracts') diff --git a/app/backend/src/crash-reporting/crash-capture.filter.ts b/app/backend/src/crash-reporting/crash-capture.filter.ts index b6632fca8..0b772c5b0 100644 --- a/app/backend/src/crash-reporting/crash-capture.filter.ts +++ b/app/backend/src/crash-reporting/crash-capture.filter.ts @@ -35,7 +35,7 @@ export class CrashCaptureFilter implements ExceptionFilter { : new Error(String(exception)); // Extract user ID from request (adjust based on your auth implementation) - const userId = (request as Record).user?.['id'] || (request as Record).userId; + const userId = request.user?.id || request.userId; // Capture crash report (only if user has opted in) try { diff --git a/app/backend/src/refunds/refunds.controller.ts b/app/backend/src/refunds/refunds.controller.ts index e1d7af988..6e6881f21 100644 --- a/app/backend/src/refunds/refunds.controller.ts +++ b/app/backend/src/refunds/refunds.controller.ts @@ -28,7 +28,7 @@ import { RequiresFlag } from '../feature-flags/requires-flag.decorator'; import { decodeCursor, clampLimit } from '../common/pagination/cursor.util'; interface ApiKeyRequest extends Request { - apiKey: { id: string }; + apiKey?: Request['apiKey']; } @ApiTags('admin/refunds') @@ -55,7 +55,7 @@ export class RefundsController { @Body() dto: InitiateRefundDto, @Req() req: ApiKeyRequest, ) { - const actorId: string = req.apiKey.id; + const actorId = req.apiKey?.id ?? 'api'; return this.refundsService.initiateRefund(dto, actorId); } @@ -71,7 +71,7 @@ export class RefundsController { @Param('id') id: string, @Req() req: ApiKeyRequest, ) { - const actorId: string = req.apiKey.id; + const actorId = req.apiKey?.id ?? 'api'; return this.refundsService.approveRefund(id, actorId); } @@ -88,7 +88,7 @@ export class RefundsController { @Body() body: { notes?: string }, @Req() req: ApiKeyRequest, ) { - const actorId: string = req.apiKey.id; + const actorId = req.apiKey?.id ?? 'api'; return this.refundsService.rejectRefund(id, actorId, body.notes); } diff --git a/app/backend/src/sentry/sentry.filter.ts b/app/backend/src/sentry/sentry.filter.ts index 47872e6b9..ef4478e42 100644 --- a/app/backend/src/sentry/sentry.filter.ts +++ b/app/backend/src/sentry/sentry.filter.ts @@ -66,7 +66,7 @@ export class SentryExceptionFilter implements ExceptionFilter { const context: Record = { method: request.method, url: request.url, - correlationId: (request as Record)['correlationId'], + correlationId: request.correlationId, userAgent: request.headers['user-agent'], ip: request.ip, }; diff --git a/app/backend/src/soroban-tooling/soroban-tooling.controller.ts b/app/backend/src/soroban-tooling/soroban-tooling.controller.ts index 744ff7f8e..f19808077 100644 --- a/app/backend/src/soroban-tooling/soroban-tooling.controller.ts +++ b/app/backend/src/soroban-tooling/soroban-tooling.controller.ts @@ -10,7 +10,7 @@ import { FundingHelperService } from './funding-helper.service'; import { ContractWritePolicyService } from '../feature-flags/contract-write-policy.service'; interface ApiKeyRequest extends Request { - apiKey: { id: string }; + apiKey?: Request['apiKey']; } @ApiTags('developer') diff --git a/app/backend/src/types/custom.d.ts b/app/backend/src/types/custom.d.ts index 928d8563f..39cddd40f 100644 --- a/app/backend/src/types/custom.d.ts +++ b/app/backend/src/types/custom.d.ts @@ -2,7 +2,36 @@ declare module 'nest-winston'; declare module 'winston'; -declare namespace Express { +declare global { + namespace Express { + interface Request { + apiKey?: { + id: string; + name: string; + scopes: string[]; + rateLimit: number; + organization_id?: string | null; + userId?: string; + }; + organizationContext?: { + organizationId?: string; + role: "admin" | "member" | "read_only"; + }; + correlationId?: string; + user?: { + id?: string; + }; + userId?: string; + publicKey?: string; + rateLimitContext?: { + group?: string; + keyType?: string; + }; + } + } +} + +declare module "express-serve-static-core" { interface Request { apiKey?: { id: string; @@ -10,11 +39,22 @@ declare namespace Express { scopes: string[]; rateLimit: number; organization_id?: string | null; + userId?: string; }; organizationContext?: { organizationId?: string; role: "admin" | "member" | "read_only"; }; + correlationId?: string; + user?: { + id?: string; + }; + userId?: string; + publicKey?: string; + rateLimitContext?: { + group?: string; + keyType?: string; + }; } } diff --git a/app/backend/tsconfig.json b/app/backend/tsconfig.json index 58ca0e905..1d524311a 100644 --- a/app/backend/tsconfig.json +++ b/app/backend/tsconfig.json @@ -21,6 +21,6 @@ "esModuleInterop": true, "resolveJsonModule": true }, - "include": ["src/**/*", "test/**/*", "*.config.ts", "*.setup.ts"], + "include": ["src/**/*", "src/**/*.d.ts", "test/**/*", "*.config.ts", "*.setup.ts"], "exclude": ["node_modules", "dist"] }