import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { QUALITY_REPOSITORY, type ActiveQualityRule, type CreateQualityRuleInput, type QualityRepository, type QualityRuleStatus, type QualityRuleSummary, type UpdateQualityRuleInput } from './quality.repository.js'; import { stableSamplingDecision } from './sampling.js'; interface QualityRuleBody { name?: unknown; customerId?: unknown; lineGroupId?: unknown; ratio?: unknown; status?: unknown; effectiveAt?: unknown; expiresAt?: unknown; } export interface RecordingSamplingContext { recordingId: string; customerId: string | null; lineGroupId: string | null; } export interface RecordingSamplingMatch { ruleId: string; ruleName: string; ratio: string; score: number; selected: boolean; } @Injectable() export class QualityService { constructor(@Inject(QUALITY_REPOSITORY) private readonly quality: QualityRepository) {} listRules(): Promise { return this.quality.listRules(); } getRule(ruleId: string): Promise { return this.quality.getRule(ruleId); } createRule(body: QualityRuleBody, actorId?: string): Promise { const effectiveAt = body.effectiveAt === undefined ? undefined : this.date(body.effectiveAt, 'effectiveAt'); const expiresAt = body.expiresAt === undefined ? undefined : this.nullableDate(body.expiresAt, 'expiresAt'); this.ensureDateRange(effectiveAt, expiresAt); const input: CreateQualityRuleInput = { name: this.limitedString(body.name, 'name', 120), customerId: this.nullableId(body.customerId, 'customerId', 32), lineGroupId: this.nullableId(body.lineGroupId, 'lineGroupId', 32), ratio: this.ratio(body.ratio), status: body.status === undefined ? 'ENABLED' : this.status(body.status), effectiveAt, expiresAt, actorId }; return this.quality.createRule(input); } updateRule(ruleId: string, body: QualityRuleBody, actorId?: string): Promise { const effectiveAt = body.effectiveAt === undefined ? undefined : this.date(body.effectiveAt, 'effectiveAt'); const expiresAt = body.expiresAt === undefined ? undefined : this.nullableDate(body.expiresAt, 'expiresAt'); this.ensureDateRange(effectiveAt, expiresAt); const input: UpdateQualityRuleInput = { name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120), customerId: body.customerId === undefined ? undefined : this.nullableId(body.customerId, 'customerId', 32), lineGroupId: body.lineGroupId === undefined ? undefined : this.nullableId(body.lineGroupId, 'lineGroupId', 32), ratio: body.ratio === undefined ? undefined : this.ratio(body.ratio), status: body.status === undefined ? undefined : this.status(body.status), effectiveAt, expiresAt, actorId }; return this.quality.updateRule(ruleId, input); } enableRule(ruleId: string, actorId?: string): Promise { return this.quality.setRuleStatus(ruleId, 'ENABLED', actorId); } disableRule(ruleId: string, actorId?: string): Promise { return this.quality.setRuleStatus(ruleId, 'DISABLED', actorId); } deleteRule(ruleId: string, actorId?: string): Promise { return this.quality.softDeleteRule(ruleId, actorId); } async samplingFor(context: RecordingSamplingContext, now = new Date()): Promise<{ selected: boolean; matches: RecordingSamplingMatch[] }> { const rules = await this.quality.listActiveRules(now); const matches = this.matchRules(rules, context).map((rule) => { const decision = stableSamplingDecision(rule.id, context.recordingId, rule.ratio); return { ruleId: rule.id, ruleName: rule.name, ratio: rule.ratio, score: decision.score, selected: decision.selected }; }); return { selected: matches.some((match) => match.selected), matches }; } private matchRules(rules: ActiveQualityRule[], context: RecordingSamplingContext): ActiveQualityRule[] { return rules.filter((rule) => { const customerMatches = !rule.customerId || rule.customerId === context.customerId; const lineGroupMatches = !rule.lineGroupId || rule.lineGroupId === context.lineGroupId; return customerMatches && lineGroupMatches; }); } private limitedString(value: unknown, field: string, maxLength: number): string { if (typeof value !== 'string' || value.trim().length === 0) { throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` }); } const trimmed = value.trim(); if (trimmed.length > maxLength) { throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` }); } return trimmed; } private nullableId(value: unknown, field: string, maxLength: number): string | null { if (value === undefined || value === null || value === '') { return null; } return this.limitedString(value, field, maxLength); } private ratio(value: unknown): string { const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : ''; if (!/^(?:100(?:\.0{1,2})?|\d{1,2}(?:\.\d{1,2})?)$/.test(raw)) { throw new BadRequestException({ code: 'QUALITY_RATIO_INVALID', message: 'ratio must be from 0 to 100 with up to 2 decimals.' }); } const numeric = Number.parseFloat(raw); if (!Number.isFinite(numeric) || numeric < 0 || numeric > 100) { throw new BadRequestException({ code: 'QUALITY_RATIO_INVALID', message: 'ratio must be from 0 to 100 with up to 2 decimals.' }); } const [integerPart, fractionPart = ''] = raw.split('.'); return `${integerPart}.${fractionPart.padEnd(2, '0')}`; } private status(value: unknown): QualityRuleStatus { if (value !== 'ENABLED' && value !== 'DISABLED') { throw new BadRequestException({ code: 'STATUS_INVALID', message: 'status is invalid.' }); } return value; } private date(value: unknown, field: string): Date { if (typeof value !== 'string') { throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} must be an ISO date string.` }); } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} must be an ISO date string.` }); } return parsed; } private nullableDate(value: unknown, field: string): Date | null { if (value === null || value === '') { return null; } return this.date(value, field); } private ensureDateRange(effectiveAt?: Date, expiresAt?: Date | null): void { if (effectiveAt && expiresAt && expiresAt <= effectiveAt) { throw new BadRequestException({ code: 'QUALITY_RULE_DATE_RANGE_INVALID', message: 'expiresAt must be after effectiveAt.' }); } } }