183 lines
6.9 KiB
TypeScript
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.' });
|
|
}
|
|
}
|
|
}
|