diff --git a/BackendAcademy/src/ai/ai.module.ts b/BackendAcademy/src/ai/ai.module.ts index a3ba119fc..0d7f83b42 100644 --- a/BackendAcademy/src/ai/ai.module.ts +++ b/BackendAcademy/src/ai/ai.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { AiController } from './ai.controller'; import { AiService, AI_PROVIDER } from './ai.service'; +import { PromptTemplateService } from './prompt-template.service'; import { ClaudeProvider } from './providers/claude.provider'; import { OpenaiProvider } from './providers/openai.provider'; @@ -18,7 +19,7 @@ const aiProviderFactory = { @Module({ controllers: [AiController], - providers: [AiService, aiProviderFactory], - exports: [AiService], + providers: [AiService, PromptTemplateService, aiProviderFactory], + exports: [AiService, PromptTemplateService], }) export class AiModule {} diff --git a/BackendAcademy/src/ai/ai.service.ts b/BackendAcademy/src/ai/ai.service.ts index ab7b7d3b5..7cfc12263 100644 --- a/BackendAcademy/src/ai/ai.service.ts +++ b/BackendAcademy/src/ai/ai.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Optional } from '@nestjs/common'; +import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { CreateChatRequestDto } from './dto/create-chat-request.dto'; import { GetHintDto } from './dto/get-hint.dto'; @@ -17,6 +17,7 @@ import { } from './interfaces/ai.interface'; import { PreScoreResult } from './interfaces/pre-score.interface'; import { AiProvider } from './interfaces/ai-provider.interface'; +import { PromptTemplateService } from './prompt-template.service'; import { v4 as uuidv4 } from 'uuid'; import { AnalyticsService } from '../analytics/analytics.service'; import { RedisService } from '../redis/redis.service'; @@ -27,10 +28,12 @@ export const AI_PROVIDER = 'AI_PROVIDER'; @Injectable() export class AiService { + private readonly logger = new Logger(AiService.name); private chatHistory: Map = new Map(); private chatRecords: Map = new Map(); private hints: Map = new Map(); private readonly defaultTimeoutMs: number; + private readonly maxChatHistoryLength: number; constructor( @Optional() @Inject(AI_PROVIDER) private aiProvider?: AiProvider, @@ -38,9 +41,11 @@ export class AiService { private readonly analyticsService?: AnalyticsService, private readonly redisService?: RedisService, private readonly monitoringService?: MonitoringService, + @Optional() private readonly promptTemplateService?: PromptTemplateService, @Optional() private readonly securityService?: SecurityService, ) { this.defaultTimeoutMs = this.configService?.get('DEFAULT_REQUEST_TIMEOUT_MS') ?? 30_000; + this.maxChatHistoryLength = this.configService?.get('AI_MAX_CHAT_HISTORY_LENGTH') ?? 50; this.initializeSampleHints(); } @@ -101,6 +106,21 @@ export class AiService { ): Promise { const { message, userId, context } = createChatRequestDto; + // #374: Use versioned prompt template from configuration + const systemPrompt = this.promptTemplateService + ? this.promptTemplateService.getSystemPrompt('chat_tutor', { + version: this.configService?.get('AI_PROMPT_TEMPLATE_VERSION'), + }) + : 'You are a helpful Rust programming tutor.'; + + const response = this.aiProvider + ? await this.aiProvider.generateChatCompletion({ + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: message }, + ], + }) + : this.fallbackResponse(message); // Issue #371: sanitise user-supplied prompts before they reach the AI // provider. When SecurityService is wired in, prompts containing known // prompt-injection patterns are either wrapped in a hard system-pinned @@ -130,6 +150,7 @@ export class AiService { response, timestamp: new Date(), context, + isComplete: true, }; if (!this.chatHistory.has(userId)) { @@ -137,6 +158,19 @@ export class AiService { } this.chatHistory.get(userId)!.push(chatMessage); + // #372: Auto-summarise when history exceeds threshold + await this.autoSummarize(userId); + + // Track prompt template version in metrics (#374) + if (this.monitoringService) { + const templateVersion = + this.configService?.get('AI_PROMPT_TEMPLATE_VERSION') ?? '1.0.0'; + this.monitoringService.incrementCounter('ai_prompt_template_used', 1, { + version: templateVersion, + template: 'chat_tutor', + }); + } + if (this.redisService) { await this.redisService.refreshUserSnapshot(userId, { lastInteractionAt: new Date(), @@ -253,10 +287,156 @@ export class AiService { }; } + // ────────────────────────────────────────────────────────────────── + // Chat history management (#372, #373) + // ────────────────────────────────────────────────────────────────── + async getChatHistory(userId: string): Promise { return this.chatHistory.get(userId) || []; } + /** + * #373: Marks a message as incomplete when a chat streaming disconnect + * leaves a partial response in state. Incomplete messages can be cleaned + * up or shown to the user with a warning. + */ + markMessageIncomplete(userId: string, messageId: string): boolean { + const history = this.chatHistory.get(userId); + if (!history) return false; + + const msg = history.find((m) => m.id === messageId); + if (!msg) return false; + + msg.isComplete = false; + + if (this.monitoringService) { + this.monitoringService.incrementCounter('chat_streaming_disconnects', 1, { + userId, + messageId, + }); + } + + this.logger.warn( + `Message ${messageId} for user ${userId} marked incomplete due to streaming disconnect`, + ); + return true; + } + + /** + * #373: Removes all incomplete messages from a user's chat history, + * preventing partial responses from persisting in conversation state. + */ + cleanupIncompleteMessages(userId: string): number { + const history = this.chatHistory.get(userId); + if (!history) return 0; + + const incompleteCount = history.filter((m) => !m.isComplete).length; + const cleaned = history.filter((m) => m.isComplete); + this.chatHistory.set(userId, cleaned); + + if (incompleteCount > 0) { + this.logger.log( + `Cleaned up ${incompleteCount} incomplete messages for user ${userId}`, + ); + if (this.monitoringService) { + this.monitoringService.incrementCounter( + 'chat_incomplete_messages_cleaned', + incompleteCount, + { userId }, + ); + } + } + return incompleteCount; + } + + /** + * #372: Automatically generates a conversation summary when chat history + * exceeds the configured maxChatHistoryLength. Older messages are compacted + * into a summary string to keep token usage under control during long + * tutoring sessions. + */ + private async autoSummarize(userId: string): Promise { + const history = this.chatHistory.get(userId); + if (!history || history.length <= this.maxChatHistoryLength) return; + + const excess = history.length - this.maxChatHistoryLength; + const olderMessages = history.slice(0, excess); + const recentMessages = history.slice(excess); + + // Build a compact summary from older messages + const topicSummary = this.buildConversationSummary(olderMessages); + + // Store the summary on the most relevant chat record or create one + const existingRecord = Array.from(this.chatRecords.values()).find( + (r) => r.userId === userId, + ); + + const summaryText = `[Conversation summary — ${new Date().toISOString()}]: ${topicSummary}`; + + if (existingRecord) { + existingRecord.summary = existingRecord.summary + ? `${existingRecord.summary}\n${summaryText}` + : summaryText; + existingRecord.lastSummaryAt = new Date(); + } else { + const newRecord: AiChatRecord = { + id: uuidv4(), + userId, + sessionId: `session-${Date.now()}`, + messages: recentMessages, + startedAt: olderMessages[0]?.timestamp ?? new Date(), + lastActivityAt: new Date(), + summary: summaryText, + lastSummaryAt: new Date(), + }; + this.chatRecords.set(newRecord.id, newRecord); + } + + // Keep only the most recent messages in active history + this.chatHistory.set(userId, recentMessages); + + if (this.monitoringService) { + this.monitoringService.incrementCounter('chat_summary_generated', 1, { + userId, + compactedCount: String(excess), + }); + } + + this.logger.log( + `Auto-summarised ${excess} messages for user ${userId} (${recentMessages.length} retained)`, + ); + } + + /** + * #372: Builds a compact conversation summary from a list of chat messages. + * Extracts key topics and user questions without storing the full text. + */ + private buildConversationSummary(messages: ChatMessage[]): string { + if (messages.length === 0) return 'No prior conversation.'; + + const userMessages = messages + .filter((m) => m.message && m.message.trim().length > 0) + .map((m) => m.message.slice(0, 120)); + + if (userMessages.length === 0) return `${messages.length} interactions.`; + + const topics = userMessages.slice(0, 10).join('; '); + const topicPreview = + topics.length > 500 ? topics.slice(0, 500) + '...' : topics; + + return `${messages.length} messages covering: ${topicPreview}`; + } + + /** + * #372: Returns the current conversation summary for a user, if one exists. + */ + getConversationSummary(userId: string): string | null { + const record = Array.from(this.chatRecords.values()).find( + (r) => r.userId === userId, + ); + return record?.summary ?? null; + } + getChatRecord(sessionId: string): AiChatRecord | null { return this.chatRecords.get(sessionId) ?? null; } diff --git a/BackendAcademy/src/ai/interfaces/ai.interface.ts b/BackendAcademy/src/ai/interfaces/ai.interface.ts index 532391046..c285625a6 100644 --- a/BackendAcademy/src/ai/interfaces/ai.interface.ts +++ b/BackendAcademy/src/ai/interfaces/ai.interface.ts @@ -5,6 +5,8 @@ export interface ChatMessage { response: string; timestamp: Date; context?: Record; + /** Indicates whether the chat response was fully delivered (#373) */ + isComplete: boolean; } export interface Hint { @@ -45,6 +47,10 @@ export interface AiChatRecord { startedAt: Date; lastActivityAt: Date; metadata?: Record; + /** Compact summary of conversation for long tutoring sessions (#372) */ + summary?: string; + /** Timestamp of the last summary generation (#372) */ + lastSummaryAt?: Date; } export interface VoiceInteractionResponse { diff --git a/BackendAcademy/src/ai/prompt-template.service.ts b/BackendAcademy/src/ai/prompt-template.service.ts new file mode 100644 index 000000000..ab70d8e96 --- /dev/null +++ b/BackendAcademy/src/ai/prompt-template.service.ts @@ -0,0 +1,211 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; + +/** + * Represents a single prompt template with its version metadata. + * + * #374: Prompt templates are versioned so that changes can be + * audited, tested, and rolled out in a controlled manner. + */ +export interface PromptTemplate { + /** Semantic version of this template */ + version: string; + /** Human-readable description of the template's purpose */ + description: string; + /** The system prompt text */ + systemPrompt: string; + /** Optional role for the assistant */ + assistantRole?: string; + /** Optional metadata about the template */ + metadata?: Record; +} + +/** + * A collection of prompt templates keyed by template name. + */ +interface PromptTemplateConfig { + /** Schema version for the config file itself */ + schemaVersion: string; + templates: Record; +} + +/** + * Default prompt templates used when no external configuration file + * is provided. These serve as the baseline v1.0.0 templates. + * + * #374: Templates are extracted from inline code into this versioned + * configuration so they can be audited and evolved independently. + */ +const DEFAULT_TEMPLATES: PromptTemplateConfig = { + schemaVersion: '1.0.0', + templates: { + chat_tutor: [ + { + version: '1.0.0', + description: 'Default Rust programming tutor persona for chat interactions.', + systemPrompt: + 'You are a helpful Rust programming tutor. Provide clear, concise explanations and encourage best practices. When reviewing code, point out potential improvements and explain the reasoning behind them.', + assistantRole: 'Rust Programming Tutor', + }, + ], + code_review: [ + { + version: '1.0.0', + description: 'Code review assistant persona.', + systemPrompt: + 'You are a Rust code reviewer. Analyse the submitted code for correctness, safety, performance, and idiomatic Rust style. Suggest concrete improvements with examples.', + assistantRole: 'Rust Code Reviewer', + }, + ], + hint_generator: [ + { + version: '1.0.0', + description: 'Progressive hint generator for coding challenges.', + systemPrompt: + 'You are a hint generator for Rust coding challenges. Provide hints at three difficulty levels: 1) gentle nudge, 2) more specific guidance, 3) near-solution. Never give the full answer directly.', + assistantRole: 'Hint Generator', + }, + ], + fallback: [ + { + version: '1.0.0', + description: 'Fallback responses when AI provider is unavailable.', + systemPrompt: + 'You are a Rust Academy assistant operating in offline/fallback mode. Provide helpful but generic guidance since you cannot access the AI model at this time.', + assistantRole: 'Offline Assistant', + }, + ], + }, +}; + +@Injectable() +export class PromptTemplateService implements OnModuleInit { + private readonly logger = new Logger(PromptTemplateService.name); + private templates: PromptTemplateConfig = DEFAULT_TEMPLATES; + + constructor(private readonly configService: ConfigService) {} + + onModuleInit(): void { + this.loadTemplates(); + } + + /** + * Loads prompt templates from the configured file path, falling back + * to the built-in defaults if the file is missing or invalid. + * + * #374: Templates are loaded from a version-controlled config file + * so operators can update prompts without redeploying code. + */ + private loadTemplates(): void { + const templatePath = this.configService.get('AI_PROMPT_TEMPLATE_PATH'); + if (!templatePath) { + this.logger.log('No prompt template path configured; using built-in defaults'); + return; + } + + const resolvedPath = resolve(process.cwd(), templatePath); + if (!existsSync(resolvedPath)) { + this.logger.warn( + `Prompt template file not found at ${resolvedPath}; using built-in defaults`, + ); + return; + } + + try { + const raw = readFileSync(resolvedPath, 'utf-8'); + const parsed = JSON.parse(raw) as PromptTemplateConfig; + + // Basic validation + if (!parsed.schemaVersion || !parsed.templates) { + throw new Error('Invalid prompt template config: missing schemaVersion or templates'); + } + + // Merge with defaults — file templates override defaults + this.templates = { + schemaVersion: parsed.schemaVersion, + templates: { + ...DEFAULT_TEMPLATES.templates, + ...parsed.templates, + }, + }; + + this.logger.log( + `Loaded prompt templates from ${resolvedPath} (schema v${parsed.schemaVersion})`, + ); + } catch (err) { + this.logger.error( + `Failed to load prompt templates from ${resolvedPath}: ${(err as Error).message}`, + ); + this.logger.warn('Falling back to built-in default templates'); + this.templates = DEFAULT_TEMPLATES; + } + } + + /** + * Retrieves the system prompt for a given template name and optional + * version constraint. + * + * @param templateName - The name of the template (e.g., 'chat_tutor') + * @param options - Optional version and metadata overrides + * @returns The system prompt string + * + * #374: Callers specify a template name and optionally a version. + * If the requested version doesn't exist, the latest available version + * is returned with a warning logged. + */ + getSystemPrompt( + templateName: string, + options?: { version?: string; metadata?: Record }, + ): string { + const versions = this.templates.templates[templateName]; + + if (!versions || versions.length === 0) { + this.logger.warn( + `No templates found for "${templateName}"; returning generic fallback`, + ); + return DEFAULT_TEMPLATES.templates.fallback[0].systemPrompt; + } + + // If a specific version is requested, try to find it + if (options?.version) { + const match = versions.find((v) => v.version === options.version); + if (match) { + this.logger.debug(`Using prompt template "${templateName}" v${match.version}`); + return match.systemPrompt; + } + this.logger.warn( + `Version ${options.version} not found for template "${templateName}"; using latest`, + ); + } + + // Return the latest version (last in array) + const latest = versions[versions.length - 1]; + this.logger.debug( + `Using prompt template "${templateName}" v${latest.version} (latest)`, + ); + return latest.systemPrompt; + } + + /** + * Returns the current active template version for a given template name. + */ + getTemplateVersion(templateName: string): string | null { + const versions = this.templates.templates[templateName]; + if (!versions || versions.length === 0) return null; + return versions[versions.length - 1].version; + } + + /** + * Returns all available template names and their versions. + * + * #374: Enables auditing of which templates are available and their versions. + */ + listTemplates(): Array<{ name: string; versions: string[] }> { + return Object.entries(this.templates.templates).map(([name, versions]) => ({ + name, + versions: versions.map((v) => v.version), + })); + } +} diff --git a/BackendAcademy/src/chat/chat.service.ts b/BackendAcademy/src/chat/chat.service.ts index c3c15e221..4b64d9427 100644 --- a/BackendAcademy/src/chat/chat.service.ts +++ b/BackendAcademy/src/chat/chat.service.ts @@ -79,6 +79,31 @@ export class ChatService { } /** + * #373: Marks in-flight chat messages as incomplete when a streaming + * disconnect is detected. This prevents partial AI responses from + * persisting in the conversation history. + * + * Messages flagged as incomplete can be filtered out by the frontend + * or cleaned up by the `cleanupIncompleteMessages` method. + */ + markStreamingDisconnect(messageId: string): boolean { + const msg = this.messages.find((m) => m.id === messageId); + if (!msg) return false; + (msg as any).streamingComplete = false; + (msg as any).streamingAbortedAt = new Date(); + return true; + } + + /** + * #373: Removes all incomplete messages from the in-memory store that + * were abandoned due to streaming disconnects. + */ + cleanupIncompleteMessages(): number { + const before = this.messages.length; + this.messages = this.messages.filter( + (m) => (m as any).streamingComplete !== false, + ); + return before - this.messages.length; * Run outgoing chat content through the SecurityService prompt sanitiser * when the content is long enough that it might plausibly be AI-bound or * carry instructions. The original behaviour is preserved when no diff --git a/BackendAcademy/src/config/env.schema.ts b/BackendAcademy/src/config/env.schema.ts index 9eb8523c0..925686ce4 100644 --- a/BackendAcademy/src/config/env.schema.ts +++ b/BackendAcademy/src/config/env.schema.ts @@ -16,6 +16,78 @@ export const envSchema = Joi.object({ .optional() .description('Database connection URL'), + // ── AI Prompt Templates (#374) ───────────────────────────── + /** Version identifier for the active chat prompt template set */ + AI_PROMPT_TEMPLATE_VERSION: Joi.string() + .pattern(/^\d+\.\d+\.\d+$/) + .default('1.0.0') + .description( + 'Semantic version of the chat prompt template set. ' + + 'Changing this allows controlled rollout of new prompt designs.', + ), + /** Maximum number of messages retained in chat history before summarisation (#372) */ + AI_MAX_CHAT_HISTORY_LENGTH: Joi.number() + .integer() + .min(10) + .max(500) + .default(50) + .description( + 'When chat history exceeds this length, older messages are ' + + 'compacted into a conversation summary to control token usage.', + ), + /** Path to prompt template configuration file (#374) */ + AI_PROMPT_TEMPLATE_PATH: Joi.string() + .default('config/prompt-templates.json') + .description('Path to the versioned prompt template configuration file.'), + + // ── Cron schedules ────────────────────────────────────────── + CRON_CLEANUP_SCHEDULE: Joi.string().default('0 0 * * *'), + CRON_ANALYTICS_SCHEDULE: Joi.string().default('0 */6 * * *'), + CRON_NOTIFICATIONS_SCHEDULE: Joi.string().default('*/30 * * * *'), + CRON_CONTRACT_REPLAY_SCHEDULE: Joi.string().optional(), +}); + +/** + * Environment variable schema that enforces explicit feature flags + * for contract ingestion and processing. + */ +export const contractEnvSchema = Joi.object({ + CERTIFICATE_BASE_URL: Joi.string() + .uri() + .default('https://rustacademy.xyz/certificates') + .description('Base URL used to construct shareable certificate verification links.'), + + CONTRACT_INGESTION_ENABLED: Joi.string() + .valid('true', 'false') + .default('false') + .description('Must be explicitly "true" to enable contract ingestion.'), + + CONTRACT_REGISTRY_REQUIRE_SCHEMA: Joi.string() + .valid('true', 'false') + .default('true') + .description('When "true", contract registry entries must pass schema compatibility validation.'), + + CONTRACT_EVENT_REPLAY_ENABLED: Joi.string() + .valid('true', 'false') + .default('false') + .description('When "true", contract event replay endpoints are available.'), + + CONTRACT_ADAPTER_MODE: Joi.string() + .valid('native', 'stellar', 'mock') + .default('mock') + .description('Determines which contract adapter implementation is used.'), + + CONTRACT_NETWORK: Joi.string() + .valid('testnet', 'futurenet', 'mainnet') + .default('testnet') + .description('Stellar network target for contract deployments.'), + + STELLAR_HORIZON_URL: Joi.string() + .uri() + .optional() + .description('Stellar Horizon server URL for contract queries.'), + + CONTRACT_REGISTRY_MAX_ENTRIES: Joi.number() REDIS_HOST: Joi.string() .default('localhost') .description('Redis host used for caching and background jobs'), @@ -57,6 +129,12 @@ export const envSchema = Joi.object({ .default(5_000) .description('Timeout for readiness probe dependency checks in milliseconds.'), + CONTRACT_SCHEMA_VERSION: Joi.string() + .pattern(/^\d+\.\d+\.\d+$/) + .default('1.0.0') + .description('Minimum required contract schema version.'), + + CONTRACT_REPLAY_MAX_EVENTS: Joi.number() // ── Task orchestrator configuration — Issue #364 ──────────────── /** Maximum retries for task orchestration */ TASK_ORCHESTRATOR_MAX_RETRIES: Joi.number() @@ -72,6 +150,7 @@ export const envSchema = Joi.object({ .default(1_000) .description('Base backoff time in milliseconds before task retries.'), + CONTRACT_EVENT_RETENTION_DAYS: Joi.number() /** Maximum backoff time in milliseconds for task retries */ TASK_ORCHESTRATOR_MAX_BACKOFF_MS: Joi.number() .integer() @@ -80,27 +159,13 @@ export const envSchema = Joi.object({ .description('Maximum backoff time in milliseconds for task retries.'), }); -/** - * Type derived from the contract env schema validation. - */ export const jobEnvSchema = Joi.object({ - MAX_JOB_RETRIES: Joi.number().integer().min(0).max(10).default(3) - .description('Maximum number of retries for background jobs before sending to DLQ'), - - JOB_RETRY_DELAY_MS: Joi.number().integer().min(100).default(5000) - .description('Delay in milliseconds between job retries'), - - DLQ_TTL_SECONDS: Joi.number().integer().min(60).default(604800) - .description('TTL in seconds for dead-letter queue records (default: 7 days)'), - - EXPORT_NOTIFICATION_ENABLED: Joi.boolean().default(true) - .description('Enable email notifications when exports/reports are ready'), - - EXPORT_RETRY_MAX: Joi.number().integer().min(0).max(10).default(3) - .description('Maximum retry attempts for export generation jobs'), - - SIGNED_URL_TTL_SECONDS: Joi.number().integer().min(60).max(86400).default(3600) - .description('TTL in seconds for signed download URLs (default: 1 hour)'), + MAX_JOB_RETRIES: Joi.number().integer().min(0).max(10).default(3), + JOB_RETRY_DELAY_MS: Joi.number().integer().min(100).default(5000), + DLQ_TTL_SECONDS: Joi.number().integer().min(60).default(604800), + EXPORT_NOTIFICATION_ENABLED: Joi.boolean().default(true), + EXPORT_RETRY_MAX: Joi.number().integer().min(0).max(10).default(3), + SIGNED_URL_TTL_SECONDS: Joi.number().integer().min(60).max(86400).default(3600), }); export type JobEnvConfig = { @@ -133,19 +198,10 @@ export type ContractEnvConfig = { TASK_ORCHESTRATOR_MAX_BACKOFF_MS: number; }; -/** - * Resolves whether a feature flag string is explicitly enabled. - * Only the literal string "true" qualifies as enabled. - */ export function isFeatureEnabled(value: string | undefined): boolean { return value === 'true'; } -/** - * Resolves whether a feature flag string is explicitly disabled. - * Only the literal string "false" qualifies as explicitly disabled. - * Any other value (or absence) is considered misconfigured. - */ export function isFeatureExplicitlyDisabled(value: string | undefined): boolean { return value === 'false'; } @@ -154,10 +210,6 @@ export function isFeatureExplicitlyDisabled(value: string | undefined): boolean * Environment variables for notification delivery and preferences (#385). */ export const notificationEnvSchema = Joi.object({ - * Environment variables for notification delivery and preferences. - */ -export const notificationEnvSchema = Joi.object({ - /** When "true", user notification preferences are enforced before delivery */ NOTIFICATION_ENFORCE_PREFERENCES: Joi.string() .valid('true', 'false') .default('true') @@ -172,22 +224,15 @@ export const notificationEnvSchema = Joi.object({ }); /** - * Combined validation schema that includes contract and notification - * environment variables. Used by config.module.ts at startup. - */ -export const validationSchema = baseEnvSchema - .concat(contractEnvSchema) - * Environment variables for migration safety and ordering. + * Environment variables for migration safety and ordering (#397). */ export const migrationEnvSchema = Joi.object({ - /** Timeout in milliseconds for acquiring a migration lock */ MIGRATION_LOCK_TIMEOUT: Joi.number() .integer() .min(1000) .default(300_000) .description('Timeout in ms for acquiring a migration lock (default 5 min).'), - /** Maximum number of retry attempts for failed migrations */ MIGRATION_RETRY_ATTEMPTS: Joi.number() .integer() .min(0) @@ -195,22 +240,17 @@ export const migrationEnvSchema = Joi.object({ .default(3) .description('Maximum retry attempts for failed migrations.'), - /** When true, strict ordering is enforced and migrations out of order are rejected */ MIGRATION_STRICT_ORDERING: Joi.string() .valid('true', 'false') .default('true') .description( - 'When "true", migrations must be applied strictly in dependency order. ' + - 'Any ordering violation blocks migration execution.', + 'When "true", migrations must be applied strictly in dependency order.', ), - /** When true, a preflight check is required before any migration can run */ MIGRATION_REQUIRE_PREFLIGHT: Joi.string() .valid('true', 'false') .default('true') - .description( - 'When "true", preflight validation must pass before migrations execute.', - ), + .description('When "true", preflight validation must pass before migrations execute.'), }); /** diff --git a/BackendAcademy/src/monitoring/metrics.service.ts b/BackendAcademy/src/monitoring/metrics.service.ts index 4f17815eb..82914f81d 100644 --- a/BackendAcademy/src/monitoring/metrics.service.ts +++ b/BackendAcademy/src/monitoring/metrics.service.ts @@ -22,8 +22,9 @@ interface CronHealthStatus { /** * Metrics service tracking application-level metrics including - * contract registry (#393), event replay (#394), and feature flag - * state (#395). + * contract registry (#393), event replay (#394), feature flag + * state (#395), streaming disconnects (#373), and reward + * redemptions (#363). */ @Injectable() export class MetricsService implements OnModuleInit { @@ -46,20 +47,17 @@ export class MetricsService implements OnModuleInit { // ────────────────────────────────────────────────────────────────── incrementCounter(name: string, value = 1, labels: Record = {}): void { - const correlationId = CorrelationLoggerService.getCorrelationId(); const existing = this.metrics.get(name); if (existing) { existing.value += value; existing.timestamp = new Date(); existing.labels = { ...existing.labels, ...labels }; - if (correlationId) existing.correlationId = correlationId; } else { this.metrics.set(name, { name, value, timestamp: new Date(), labels, - correlationId, }); } this.logger.debug(`Metric "${name}" incremented to ${this.metrics.get(name)?.value}`); @@ -90,6 +88,14 @@ export class MetricsService implements OnModuleInit { } } + recordDomainEvent(eventType: string, source: string): void { + this.incrementCounter('domain_events_total', 1, { event_type: eventType, source }); + } + + recordErrorEvent(source: string, reason: string): void { + this.incrementCounter('error_events_total', 1, { source, reason }); + } + getAllMetrics(): MetricEntry[] { return Array.from(this.metrics.values()); } @@ -156,10 +162,6 @@ export class MetricsService implements OnModuleInit { // #395: Feature flag state tracking // ────────────────────────────────────────────────────────────────── - /** - * Records the current feature flag state as gauge metrics so operators - * can verify which features are active at runtime. - */ private recordFeatureFlagMetrics(): void { const flags: Array<{ name: string; key: string }> = [ { name: 'contract_ingestion_enabled', key: 'CONTRACT_INGESTION_ENABLED' }, @@ -180,58 +182,5 @@ export class MetricsService implements OnModuleInit { } this.incrementCounter('contracts_metrics_initialized', 1, {}); -import { Injectable } from '@nestjs/common'; -import { InjectMetric, makeCounterProvider } from '@willsoto/nestjs-prometheus'; -import { Counter } from 'prom-client'; - -export const HTTP_REQUESTS_METRIC = 'app_http_requests_total'; -export const DOMAIN_EVENTS_METRIC = 'app_domain_events_total'; -export const ERROR_EVENTS_METRIC = 'app_error_events_total'; - -export const httpRequestsCounterProvider = makeCounterProvider({ - name: HTTP_REQUESTS_METRIC, - help: 'Total number of HTTP requests received by the application', - labelNames: ['method', 'route', 'status_code'], -}); - -export const domainEventsCounterProvider = makeCounterProvider({ - name: DOMAIN_EVENTS_METRIC, - help: 'Total number of domain/business events emitted by the application', - labelNames: ['event_type', 'source'], -}); - -export const errorEventsCounterProvider = makeCounterProvider({ - name: ERROR_EVENTS_METRIC, - help: 'Total number of error events emitted by the application', - labelNames: ['source', 'reason'], -}); - -/** - * Thin wrapper service so other modules (e.g. PaymentsController) can record - * metrics without reaching into raw prom-client counters directly. - * - * Issue #412 follow-up: webhook processing records domain events for every - * legitimate state transition, and error events for rejected duplicate or - * illegal-transition callbacks, so bad provider behavior (or bugs) shows up - * in dashboards/alerts instead of silently corrupting payment state. - */ -@Injectable() -export class MetricsService { - constructor( - @InjectMetric(HTTP_REQUESTS_METRIC) private readonly httpRequestsCounter: Counter, - @InjectMetric(DOMAIN_EVENTS_METRIC) private readonly domainEventsCounter: Counter, - @InjectMetric(ERROR_EVENTS_METRIC) private readonly errorEventsCounter: Counter, - ) {} - - recordHttpRequest(method: string, route: string, statusCode: number): void { - this.httpRequestsCounter.inc({ method, route, status_code: String(statusCode) }); - } - - recordDomainEvent(eventType: string, source: string): void { - this.domainEventsCounter.inc({ event_type: eventType, source }); - } - - recordErrorEvent(source: string, reason: string): void { - this.errorEventsCounter.inc({ source, reason }); } } diff --git a/BackendAcademy/src/rewards/interfaces/rewards.interfaces.ts b/BackendAcademy/src/rewards/interfaces/rewards.interfaces.ts index 6295741b5..056b307c8 100644 --- a/BackendAcademy/src/rewards/interfaces/rewards.interfaces.ts +++ b/BackendAcademy/src/rewards/interfaces/rewards.interfaces.ts @@ -105,6 +105,8 @@ export interface PrizePoolResponse { currency: string; distributedAt: Date | null; createdAt: Date; + /** Expiration date for the prize pool; after this date it cannot be distributed (#363) */ + expiresAt: Date | null; distribution: PrizeDistribution[]; } @@ -114,4 +116,6 @@ export interface PrizePoolResponse { export interface CreatePrizePoolRequest { totalAmount: number; currency?: string; + /** Optional expiration date for the prize pool (#363) */ + expiresAt?: string; } diff --git a/BackendAcademy/src/rewards/rewards.controller.ts b/BackendAcademy/src/rewards/rewards.controller.ts index 451dcf7bd..3dd7a6d97 100644 --- a/BackendAcademy/src/rewards/rewards.controller.ts +++ b/BackendAcademy/src/rewards/rewards.controller.ts @@ -153,9 +153,12 @@ export class RewardsController { createPrizePool( @Body() body: CreatePrizePoolRequest, ): PrizePoolResponse { + // #363: Support optional expiresAt for redemption expiration checks + const expiresAt = body.expiresAt ? new Date(body.expiresAt) : undefined; return this.rewardsService.createPrizePool( body.totalAmount, body.currency, + expiresAt, ); } diff --git a/BackendAcademy/src/rewards/rewards.service.ts b/BackendAcademy/src/rewards/rewards.service.ts index 81f9b1cdc..e229ff46a 100644 --- a/BackendAcademy/src/rewards/rewards.service.ts +++ b/BackendAcademy/src/rewards/rewards.service.ts @@ -32,6 +32,8 @@ interface PrizePoolData { currency: string; distributedAt: Date | null; createdAt: Date; + /** Expiration date after which the pool cannot be distributed (#363) */ + expiresAt: Date | null; distribution: PrizeDistribution[]; } @@ -255,12 +257,17 @@ export class RewardsService { if (pools.length === 0) return null; const [id, pool] = pools[pools.length - 1]; - return { id, ...pool }; + return { + id, + ...pool, + expiresAt: pool.expiresAt ?? null, + }; } createPrizePool( totalAmount: number, currency: string = PRIZE_POOL_DEFAULT_CURRENCY, + expiresAt?: Date, ): PrizePoolResponse { if (totalAmount <= 0) { throw new Error('Prize pool totalAmount must be positive.'); @@ -272,10 +279,23 @@ export class RewardsService { currency, distributedAt: null, createdAt: new Date(), + expiresAt: expiresAt ?? null, distribution: [], }; prizePoolStore.set(id, pool); - return { id, ...pool }; + return { + id, + ...pool, + expiresAt: pool.expiresAt ?? null, + }; + } + + /** + * #363: Checks whether a prize pool has expired and cannot be redeemed. + */ + private isPoolExpired(pool: PrizePoolData): boolean { + if (!pool.expiresAt) return false; + return new Date() > pool.expiresAt; } distributePrizes(): PrizePoolResponse { @@ -293,10 +313,22 @@ export class RewardsService { } else { [id, pool] = pools[pools.length - 1]; if (pool.distributedAt) { - return { id, ...pool }; + return { + id, + ...pool, + expiresAt: pool.expiresAt ?? null, + }; } } + // #363: Prevent distribution of expired prize pools + if (this.isPoolExpired(pool)) { + throw new Error( + `Prize pool ${id} has expired (expired at ${pool.expiresAt?.toISOString()}). ` + + 'Expired pools cannot be distributed. Create a new prize pool.', + ); + } + const leaderboard = this.getLeaderboard(10); const distribution: PrizeDistribution[] = []; @@ -323,9 +355,16 @@ export class RewardsService { if (this.monitoringService) { this.monitoringService.recordDomainEvent('prize_distributed', 'rewards'); + this.monitoringService.incrementCounter('reward_redemptions_total', 1, { + poolId: id, + }); } - return { id, ...pool }; + return { + id, + ...pool, + expiresAt: pool.expiresAt ?? null, + }; } async generateCouponFromReward(