Files
lisglosips/apps/api/src/modules/quality/quality.service.ts
T

183 lines
6.9 KiB
TypeScript

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<QualityRuleSummary[]> {
return this.quality.listRules();
}
getRule(ruleId: string): Promise<QualityRuleSummary> {
return this.quality.getRule(ruleId);
}
createRule(body: QualityRuleBody, actorId?: string): Promise<QualityRuleSummary> {
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<QualityRuleSummary> {
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<QualityRuleSummary> {
return this.quality.setRuleStatus(ruleId, 'ENABLED', actorId);
}
disableRule(ruleId: string, actorId?: string): Promise<QualityRuleSummary> {
return this.quality.setRuleStatus(ruleId, 'DISABLED', actorId);
}
deleteRule(ruleId: string, actorId?: string): Promise<QualityRuleSummary> {
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.' });
}
}
}