Initial LisgloSIPS V2 implementation
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@lisglosips/api",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/helmet": "13.0.2",
|
||||
"@fastify/static": "8.3.0",
|
||||
"@lisglosips/auth": "workspace:*",
|
||||
"@lisglosips/contracts": "workspace:*",
|
||||
"@lisglosips/database": "workspace:*",
|
||||
"@lisglosips/observability": "workspace:*",
|
||||
"@nestjs/common": "11.1.9",
|
||||
"@nestjs/config": "4.0.2",
|
||||
"@nestjs/core": "11.1.9",
|
||||
"@nestjs/platform-fastify": "11.1.9",
|
||||
"@nestjs/swagger": "11.2.3",
|
||||
"fastify": "5.6.2",
|
||||
"joi": "18.0.2",
|
||||
"nestjs-pino": "4.5.0",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rxjs": "7.8.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
|
||||
describe('LisgloSIPS API health', () => {
|
||||
let app: NestFastifyApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
|
||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
|
||||
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
|
||||
|
||||
const { AppModule } = await import('./modules/app.module.js');
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule]
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('serves liveness', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/v2/health/live').expect(200);
|
||||
|
||||
expect(response.body.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('serves readiness', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/v2/health/ready').expect(200);
|
||||
|
||||
expect(response.body.status).toBe('ok');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'reflect-metadata';
|
||||
import helmet from '@fastify/helmet';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { AppModule } from './modules/app.module.js';
|
||||
import { buildOpenApiDocument } from './openapi.js';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter({
|
||||
trustProxy: true,
|
||||
logger: false
|
||||
}),
|
||||
{
|
||||
bufferLogs: true
|
||||
}
|
||||
);
|
||||
|
||||
app.useLogger(app.get(Logger));
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.register(helmet, {
|
||||
contentSecurityPolicy: false
|
||||
});
|
||||
|
||||
buildOpenApiDocument(app);
|
||||
|
||||
const host = process.env.LISGLOSIPS_HOST ?? '127.0.0.1';
|
||||
const port = Number(process.env.LISGLOSIPS_PORT ?? 3000);
|
||||
|
||||
await app.listen({ host, port });
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import crypto from 'node:crypto';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { API_PREFIX } from '@lisglosips/contracts';
|
||||
import { LOG_REDACT_PATHS } from '@lisglosips/observability';
|
||||
import { appConfig, validationSchema } from '../shared/config.js';
|
||||
import { AuditLogsModule } from './audit-logs/audit-logs.module.js';
|
||||
import { AuditModule } from './audit/audit.module.js';
|
||||
import { AuthModule } from './auth/auth.module.js';
|
||||
import { CustomerGatewayPoliciesModule } from './customer-gateway-policies/customer-gateway-policies.module.js';
|
||||
import { CustomerGatewaysModule } from './customer-gateways/customer-gateways.module.js';
|
||||
import { CustomersModule } from './customers/customers.module.js';
|
||||
import { DashboardModule } from './dashboard/dashboard.module.js';
|
||||
import { DatabaseModule } from './database/database.module.js';
|
||||
import { HealthModule } from './health/health.module.js';
|
||||
import { LandingLineGroupsModule } from './landing-line-groups/landing-line-groups.module.js';
|
||||
import { RechargesModule } from './recharges/recharges.module.js';
|
||||
import { RecordingsModule } from './recordings/recordings.module.js';
|
||||
import { QualityModule } from './quality/quality.module.js';
|
||||
import { RolesModule } from './roles/roles.module.js';
|
||||
import { SecurityModule } from './security/security.module.js';
|
||||
import { UsersModule } from './users/users.module.js';
|
||||
import { VendorGatewaysModule } from './vendor-gateways/vendor-gateways.module.js';
|
||||
import { VendorsModule } from './vendors/vendors.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
cache: true,
|
||||
load: [appConfig],
|
||||
validationSchema,
|
||||
validationOptions: {
|
||||
abortEarly: false,
|
||||
allowUnknown: true
|
||||
}
|
||||
}),
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
level: process.env.LISGLOSIPS_LOG_LEVEL ?? 'info',
|
||||
name: process.env.LISGLOSIPS_SERVICE_NAME ?? 'api',
|
||||
genReqId: (request) =>
|
||||
request.headers[process.env.LISGLOSIPS_REQUEST_ID_HEADER ?? 'x-request-id']?.toString() ?? crypto.randomUUID(),
|
||||
redact: {
|
||||
paths: LOG_REDACT_PATHS,
|
||||
censor: '[REDACTED]'
|
||||
},
|
||||
customProps: () => ({
|
||||
apiPrefix: API_PREFIX
|
||||
})
|
||||
}
|
||||
}),
|
||||
DatabaseModule,
|
||||
SecurityModule,
|
||||
AuditModule,
|
||||
AuthModule,
|
||||
DashboardModule,
|
||||
CustomersModule,
|
||||
CustomerGatewaysModule,
|
||||
CustomerGatewayPoliciesModule,
|
||||
RechargesModule,
|
||||
VendorsModule,
|
||||
VendorGatewaysModule,
|
||||
LandingLineGroupsModule,
|
||||
QualityModule,
|
||||
RecordingsModule,
|
||||
UsersModule,
|
||||
RolesModule,
|
||||
AuditLogsModule,
|
||||
HealthModule
|
||||
]
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, Inject, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermissions } from '../security/security.metadata.js';
|
||||
import { AuditLogsService } from './audit-logs.service.js';
|
||||
|
||||
@ApiTags('audit')
|
||||
@Controller('audit-logs')
|
||||
export class AuditLogsController {
|
||||
constructor(@Inject(AuditLogsService) private readonly auditLogsService: AuditLogsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('audit.view')
|
||||
list(@Query() query: Record<string, unknown>) {
|
||||
return this.auditLogsService.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('audit.view')
|
||||
get(@Param('id') id: string) {
|
||||
return this.auditLogsService.get(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditLogsController } from './audit-logs.controller.js';
|
||||
import { PrismaAuditLogsRepository, AUDIT_LOGS_REPOSITORY } from './audit-logs.repository.js';
|
||||
import { AuditLogsService } from './audit-logs.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [AuditLogsController],
|
||||
providers: [
|
||||
AuditLogsService,
|
||||
PrismaAuditLogsRepository,
|
||||
{
|
||||
provide: AUDIT_LOGS_REPOSITORY,
|
||||
useExisting: PrismaAuditLogsRepository
|
||||
}
|
||||
]
|
||||
})
|
||||
export class AuditLogsModule {}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export interface AuditLogQuery {
|
||||
module?: string;
|
||||
action?: string;
|
||||
userId?: string;
|
||||
objectType?: string;
|
||||
objectId?: string;
|
||||
result?: 'SUCCESS' | 'FAILURE';
|
||||
take: number;
|
||||
skip: number;
|
||||
}
|
||||
|
||||
export interface AuditLogSummary {
|
||||
id: string;
|
||||
requestId: string;
|
||||
userId: string | null;
|
||||
username: string | null;
|
||||
roleNames: string | null;
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
module: string;
|
||||
action: string;
|
||||
objectType: string;
|
||||
objectId: string | null;
|
||||
result: 'SUCCESS' | 'FAILURE';
|
||||
errorCode: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface AuditLogDetail extends AuditLogSummary {
|
||||
beforeSummary: unknown;
|
||||
afterSummary: unknown;
|
||||
}
|
||||
|
||||
export interface AuditLogsRepository {
|
||||
list(query: AuditLogQuery): Promise<{ items: AuditLogSummary[]; total: number }>;
|
||||
get(id: string): Promise<AuditLogDetail>;
|
||||
}
|
||||
|
||||
export const AUDIT_LOGS_REPOSITORY = Symbol('AUDIT_LOGS_REPOSITORY');
|
||||
|
||||
@Injectable()
|
||||
export class PrismaAuditLogsRepository implements AuditLogsRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: AuditLogQuery): Promise<{ items: AuditLogSummary[]; total: number }> {
|
||||
const where = {
|
||||
module: query.module,
|
||||
action: query.action,
|
||||
userId: query.userId,
|
||||
objectType: query.objectType,
|
||||
objectId: query.objectId,
|
||||
result: query.result
|
||||
};
|
||||
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
take: query.take,
|
||||
skip: query.skip
|
||||
}),
|
||||
this.prisma.auditLog.count({ where })
|
||||
]);
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
async get(id: string): Promise<AuditLogDetail> {
|
||||
const auditLog = await this.prisma.auditLog.findUnique({ where: { id } });
|
||||
|
||||
if (!auditLog) {
|
||||
throw new NotFoundException({ code: 'AUDIT_LOG_NOT_FOUND', message: 'Audit log not found.' });
|
||||
}
|
||||
|
||||
return auditLog;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { AUDIT_LOGS_REPOSITORY, type AuditLogQuery, type AuditLogsRepository } from './audit-logs.repository.js';
|
||||
|
||||
@Injectable()
|
||||
export class AuditLogsService {
|
||||
constructor(@Inject(AUDIT_LOGS_REPOSITORY) private readonly auditLogs: AuditLogsRepository) {}
|
||||
|
||||
list(rawQuery: Record<string, unknown>) {
|
||||
const query: AuditLogQuery = {
|
||||
module: this.optionalString(rawQuery.module),
|
||||
action: this.optionalString(rawQuery.action),
|
||||
userId: this.optionalString(rawQuery.userId),
|
||||
objectType: this.optionalString(rawQuery.objectType),
|
||||
objectId: this.optionalString(rawQuery.objectId),
|
||||
result: rawQuery.result === undefined ? undefined : this.result(rawQuery.result),
|
||||
take: this.positiveInt(rawQuery.take, 50, 100),
|
||||
skip: this.positiveInt(rawQuery.skip, 0, 10_000)
|
||||
};
|
||||
|
||||
return this.auditLogs.list(query);
|
||||
}
|
||||
|
||||
get(id: string) {
|
||||
return this.auditLogs.get(id);
|
||||
}
|
||||
|
||||
private optionalString(value: unknown): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new BadRequestException({ code: 'QUERY_INVALID', message: 'Query parameter is invalid.' });
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private result(value: unknown): 'SUCCESS' | 'FAILURE' {
|
||||
if (value !== 'SUCCESS' && value !== 'FAILURE') {
|
||||
throw new BadRequestException({ code: 'AUDIT_RESULT_INVALID', message: 'Audit result is invalid.' });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private positiveInt(value: unknown, defaultValue: number, max: number): number {
|
||||
if (value === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 0 || parsed > max) {
|
||||
throw new BadRequestException({ code: 'QUERY_INVALID', message: 'Query parameter is invalid.' });
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { CallHandler, ExecutionContext, Inject, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { catchError, from, map, mergeMap, Observable, of, throwError } from 'rxjs';
|
||||
import { AUDIT_METADATA_KEY, type AuditMetadata } from './audit.metadata.js';
|
||||
import { AuditService } from './audit.service.js';
|
||||
import { redactSensitive } from './redact.js';
|
||||
|
||||
@Injectable()
|
||||
export class AuditInterceptor implements NestInterceptor {
|
||||
constructor(
|
||||
@Inject(Reflector) private readonly reflector: Reflector,
|
||||
@Inject(AuditService) private readonly auditService: AuditService
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const metadata = this.reflector.getAllAndOverride<AuditMetadata>(AUDIT_METADATA_KEY, [context.getHandler(), context.getClass()]);
|
||||
if (!metadata) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
const params = request.params as Record<string, string> | undefined;
|
||||
const objectId = metadata.objectIdParam ? params?.[metadata.objectIdParam] : undefined;
|
||||
const beforeSummary = {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
params: request.params,
|
||||
query: request.query,
|
||||
body: request.body
|
||||
};
|
||||
|
||||
return next.handle().pipe(
|
||||
mergeMap((responseBody) =>
|
||||
from(
|
||||
this.auditService.write({
|
||||
...this.auditService.baseFromRequest(request),
|
||||
module: metadata.module,
|
||||
action: metadata.action,
|
||||
objectType: metadata.objectType,
|
||||
objectId,
|
||||
beforeSummary,
|
||||
afterSummary: redactSensitive(responseBody),
|
||||
result: 'SUCCESS'
|
||||
})
|
||||
).pipe(
|
||||
catchError(() => of(undefined)),
|
||||
map(() => responseBody)
|
||||
)
|
||||
),
|
||||
catchError((error: unknown) => {
|
||||
const writeFailure = this.auditService.write({
|
||||
...this.auditService.baseFromRequest(request),
|
||||
module: metadata.module,
|
||||
action: metadata.action,
|
||||
objectType: metadata.objectType,
|
||||
objectId,
|
||||
beforeSummary,
|
||||
result: 'FAILURE',
|
||||
errorCode: this.errorCode(error)
|
||||
});
|
||||
|
||||
return from(writeFailure).pipe(
|
||||
catchError(() => of(undefined)),
|
||||
mergeMap(() => throwError(() => error))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private errorCode(error: unknown): string {
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const response = (error as { response?: unknown }).response;
|
||||
if (response && typeof response === 'object' && 'code' in response) {
|
||||
return String((response as { code: unknown }).code);
|
||||
}
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.name : 'UNKNOWN_ERROR';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const AUDIT_METADATA_KEY = 'lisglosips:audit';
|
||||
|
||||
export interface AuditMetadata {
|
||||
module: string;
|
||||
action: string;
|
||||
objectType: string;
|
||||
objectIdParam?: string;
|
||||
}
|
||||
|
||||
export const AuditAction = (metadata: AuditMetadata) => SetMetadata(AUDIT_METADATA_KEY, metadata);
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { AuditInterceptor } from './audit.interceptor.js';
|
||||
import { PrismaAuditRepository, AUDIT_REPOSITORY } from './audit.repository.js';
|
||||
import { AuditService } from './audit.service.js';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
AuditService,
|
||||
PrismaAuditRepository,
|
||||
{
|
||||
provide: AUDIT_REPOSITORY,
|
||||
useExisting: PrismaAuditRepository
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: AuditInterceptor
|
||||
}
|
||||
],
|
||||
exports: [AuditService, AUDIT_REPOSITORY]
|
||||
})
|
||||
export class AuditModule {}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export interface AuditEntryInput {
|
||||
requestId: string;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
roleNames?: string;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
module: string;
|
||||
action: string;
|
||||
objectType: string;
|
||||
objectId?: string;
|
||||
beforeSummary?: unknown;
|
||||
afterSummary?: unknown;
|
||||
result: 'SUCCESS' | 'FAILURE';
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
export interface AuditRepository {
|
||||
write(input: AuditEntryInput): Promise<void>;
|
||||
}
|
||||
|
||||
export const AUDIT_REPOSITORY = Symbol('AUDIT_REPOSITORY');
|
||||
|
||||
function auditId(): string {
|
||||
return `aud_${crypto.randomUUID().replaceAll('-', '')}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaAuditRepository implements AuditRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async write(input: AuditEntryInput): Promise<void> {
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
id: auditId(),
|
||||
requestId: input.requestId,
|
||||
userId: input.userId,
|
||||
username: input.username,
|
||||
roleNames: input.roleNames,
|
||||
ip: input.ip,
|
||||
userAgent: input.userAgent,
|
||||
module: input.module,
|
||||
action: input.action,
|
||||
objectType: input.objectType,
|
||||
objectId: input.objectId,
|
||||
beforeSummary: this.json(input.beforeSummary),
|
||||
afterSummary: this.json(input.afterSummary),
|
||||
result: input.result,
|
||||
errorCode: input.errorCode
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private json(value: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return Prisma.JsonNull;
|
||||
}
|
||||
|
||||
return value as Prisma.InputJsonValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from './audit.repository.js';
|
||||
import { redactSensitive } from './redact.js';
|
||||
import type { AuthenticatedRequest } from '../security/security.metadata.js';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(@Inject(AUDIT_REPOSITORY) private readonly repository: AuditRepository) {}
|
||||
|
||||
async write(input: AuditEntryInput): Promise<void> {
|
||||
await this.repository.write({
|
||||
...input,
|
||||
beforeSummary: redactSensitive(input.beforeSummary),
|
||||
afterSummary: redactSensitive(input.afterSummary)
|
||||
});
|
||||
}
|
||||
|
||||
baseFromRequest(request: FastifyRequest): Pick<AuditEntryInput, 'requestId' | 'userId' | 'username' | 'roleNames' | 'ip' | 'userAgent'> {
|
||||
const authRequest = request as AuthenticatedRequest;
|
||||
const requestIdHeader = request.headers['x-request-id'];
|
||||
|
||||
return {
|
||||
requestId: requestIdHeader?.toString() ?? request.id,
|
||||
userId: authRequest.currentUser?.id,
|
||||
username: authRequest.currentUser?.username,
|
||||
roleNames: authRequest.currentUser?.roles.join(','),
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent']
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const SENSITIVE_KEY_PATTERN = /password|token|authorization|cookie|secret|ha1|hash/i;
|
||||
|
||||
export function redactSensitive(value: unknown, depth = 0): unknown {
|
||||
if (depth > 5) {
|
||||
return '[TRUNCATED]';
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => redactSensitive(item, depth + 1));
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||||
result[key] = SENSITIVE_KEY_PATTERN.test(key) ? '[REDACTED]' : redactSensitive(nested, depth + 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Body, Controller, HttpCode, Inject, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { Public } from '../security/security.metadata.js';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { parseCookie, serializeCookie } from './cookie.js';
|
||||
|
||||
interface LoginBody {
|
||||
username?: unknown;
|
||||
password?: unknown;
|
||||
}
|
||||
|
||||
@ApiTags('auth')
|
||||
@Public()
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(@Inject(AuthService) private readonly authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Login with username and password' })
|
||||
async login(@Body() body: LoginBody, @Req() request: FastifyRequest, @Res({ passthrough: true }) reply: FastifyReply) {
|
||||
const { username, password } = this.readCredentials(body);
|
||||
const result = await this.authService.login(username, password, this.requestContext(request));
|
||||
|
||||
this.setRefreshCookie(reply, result.tokens.refreshToken, result.tokens.refreshMaxAgeSeconds);
|
||||
|
||||
return result.response;
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Rotate refresh token and issue a new access token' })
|
||||
async refresh(@Req() request: FastifyRequest, @Res({ passthrough: true }) reply: FastifyReply) {
|
||||
const refreshToken = parseCookie(request.headers.cookie, this.authService.getRefreshCookieName());
|
||||
const result = await this.authService.refresh(refreshToken, this.requestContext(request));
|
||||
|
||||
this.setRefreshCookie(reply, result.tokens.refreshToken, result.tokens.refreshMaxAgeSeconds);
|
||||
|
||||
return result.response;
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Revoke the current refresh session' })
|
||||
async logout(@Req() request: FastifyRequest, @Res({ passthrough: true }) reply: FastifyReply): Promise<void> {
|
||||
const refreshToken = parseCookie(request.headers.cookie, this.authService.getRefreshCookieName());
|
||||
|
||||
await this.authService.logout(refreshToken);
|
||||
reply.header(
|
||||
'Set-Cookie',
|
||||
serializeCookie(this.authService.getRefreshCookieName(), '', {
|
||||
maxAgeSeconds: 0,
|
||||
secure: this.cookieSecure()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private readCredentials(body: LoginBody): { username: string; password: string } {
|
||||
if (typeof body.username !== 'string' || typeof body.password !== 'string' || !body.username.trim() || !body.password) {
|
||||
throw new UnauthorizedException({
|
||||
code: 'AUTH_INVALID_CREDENTIALS',
|
||||
message: 'Invalid username or password.'
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
username: body.username,
|
||||
password: body.password
|
||||
};
|
||||
}
|
||||
|
||||
private requestContext(request: FastifyRequest): { ip?: string; userAgent?: string } {
|
||||
return {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent']
|
||||
};
|
||||
}
|
||||
|
||||
private setRefreshCookie(reply: FastifyReply, refreshToken: string, maxAgeSeconds: number): void {
|
||||
reply.header(
|
||||
'Set-Cookie',
|
||||
serializeCookie(this.authService.getRefreshCookieName(), refreshToken, {
|
||||
maxAgeSeconds,
|
||||
secure: this.cookieSecure()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private cookieSecure(): boolean {
|
||||
return process.env.AUTH_COOKIE_SECURE !== 'false';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import crypto from 'node:crypto';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { hashPasswordArgon2id, sha256Token } from '@lisglosips/auth';
|
||||
import { AUTH_REPOSITORY, type AuthRepository, type AuthSessionRecord, type AuthUserRecord, type CreateSessionInput } from './auth.types.js';
|
||||
|
||||
class E2eAuthRepository implements AuthRepository {
|
||||
user: AuthUserRecord;
|
||||
sessions = new Map<string, AuthSessionRecord>();
|
||||
|
||||
private constructor(passwordHash: string) {
|
||||
this.user = {
|
||||
id: 'usr_e2e',
|
||||
username: 'operator',
|
||||
displayName: 'Operator',
|
||||
passwordHash,
|
||||
passwordAlgo: 'argon2id',
|
||||
status: 'ENABLED',
|
||||
failedLoginCount: 0,
|
||||
lockedUntil: null,
|
||||
requirePasswordChange: false,
|
||||
roles: ['admin']
|
||||
};
|
||||
}
|
||||
|
||||
static async create(secret: string): Promise<E2eAuthRepository> {
|
||||
return new E2eAuthRepository(await hashPasswordArgon2id(secret, { memoryKiB: 1024, passes: 1 }));
|
||||
}
|
||||
|
||||
async findUserByUsername(username: string): Promise<AuthUserRecord | null> {
|
||||
return username === this.user.username ? { ...this.user, roles: [...this.user.roles] } : null;
|
||||
}
|
||||
|
||||
async markLoginSuccess(): Promise<void> {
|
||||
this.user.failedLoginCount = 0;
|
||||
this.user.lockedUntil = null;
|
||||
}
|
||||
|
||||
async markLoginFailure(_userId: string, failedLoginCount: number, lockedUntil: Date | null): Promise<void> {
|
||||
this.user.failedLoginCount = failedLoginCount;
|
||||
this.user.lockedUntil = lockedUntil;
|
||||
}
|
||||
|
||||
async createSession(input: CreateSessionInput): Promise<AuthSessionRecord> {
|
||||
const session: AuthSessionRecord = {
|
||||
id: `ses_e2e_${this.sessions.size + 1}`,
|
||||
userId: input.userId,
|
||||
refreshTokenHash: input.refreshTokenHash,
|
||||
expiresAt: input.expiresAt,
|
||||
revokedAt: null,
|
||||
user: { ...this.user, roles: [...this.user.roles] }
|
||||
};
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async findActiveSessionByRefreshTokenHash(refreshTokenHash: string, now: Date): Promise<AuthSessionRecord | null> {
|
||||
return (
|
||||
[...this.sessions.values()].find(
|
||||
(session) => session.refreshTokenHash === refreshTokenHash && !session.revokedAt && session.expiresAt > now
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async revokeSession(sessionId: string, revokedAt: Date): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.revokedAt = revokedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('LisgloSIPS Auth API', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let repo: E2eAuthRepository;
|
||||
let secret: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
|
||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
|
||||
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
|
||||
process.env.AUTH_COOKIE_SECURE = 'false';
|
||||
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
|
||||
|
||||
secret = crypto.randomUUID();
|
||||
repo = await E2eAuthRepository.create(secret);
|
||||
|
||||
const { AppModule } = await import('../app.module.js');
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule]
|
||||
})
|
||||
.overrideProvider(AUTH_REPOSITORY)
|
||||
.useValue(repo)
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('logs in, refreshes with cookie rotation, and logs out', async () => {
|
||||
const login = await request(app.getHttpServer())
|
||||
.post('/api/v2/auth/login')
|
||||
.send({ username: 'operator', password: secret })
|
||||
.expect(200);
|
||||
const loginCookie = login.headers['set-cookie'][0];
|
||||
const firstRefreshToken = /lisglosips_refresh=([^;]+)/.exec(loginCookie)?.[1] ?? '';
|
||||
|
||||
expect(login.body.accessToken).toBeTypeOf('string');
|
||||
expect(loginCookie).toContain('HttpOnly');
|
||||
expect([...repo.sessions.values()][0].refreshTokenHash).toBe(sha256Token(decodeURIComponent(firstRefreshToken)));
|
||||
|
||||
const refresh = await request(app.getHttpServer()).post('/api/v2/auth/refresh').set('Cookie', loginCookie).expect(200);
|
||||
const refreshCookie = refresh.headers['set-cookie'][0];
|
||||
|
||||
expect(refresh.body.accessToken).toBeTypeOf('string');
|
||||
expect([...repo.sessions.values()][0].revokedAt).toBeInstanceOf(Date);
|
||||
await request(app.getHttpServer()).post('/api/v2/auth/refresh').set('Cookie', loginCookie).expect(401);
|
||||
|
||||
await request(app.getHttpServer()).post('/api/v2/auth/logout').set('Cookie', refreshCookie).expect(204);
|
||||
await request(app.getHttpServer()).post('/api/v2/auth/refresh').set('Cookie', refreshCookie).expect(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller.js';
|
||||
import { PrismaAuthRepository } from './auth.repository.js';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { AUTH_REPOSITORY } from './auth.types.js';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
PrismaAuthRepository,
|
||||
{
|
||||
provide: AUTH_REPOSITORY,
|
||||
useExisting: PrismaAuthRepository
|
||||
}
|
||||
],
|
||||
exports: [AuthService]
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
import type { AuthRepository, AuthSessionRecord, AuthUserRecord, CreateSessionInput } from './auth.types.js';
|
||||
|
||||
function sessionId(): string {
|
||||
return `ses_${crypto.randomUUID().replaceAll('-', '')}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaAuthRepository implements AuthRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async findUserByUsername(username: string): Promise<AuthUserRecord | null> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { username },
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!user || user.deletedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
passwordHash: user.passwordHash,
|
||||
passwordAlgo: user.passwordAlgo,
|
||||
status: user.status,
|
||||
failedLoginCount: user.failedLoginCount,
|
||||
lockedUntil: user.lockedUntil,
|
||||
requirePasswordChange: user.requirePasswordChange,
|
||||
roles: user.userRoles.map((userRole) => userRole.role.name)
|
||||
};
|
||||
}
|
||||
|
||||
async markLoginSuccess(userId: string, ip?: string): Promise<void> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
failedLoginCount: 0,
|
||||
lockedUntil: null,
|
||||
lastLoginAt: new Date(),
|
||||
lastLoginIp: ip
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async markLoginFailure(userId: string, failedLoginCount: number, lockedUntil: Date | null): Promise<void> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
failedLoginCount,
|
||||
lockedUntil
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async createSession(input: CreateSessionInput): Promise<AuthSessionRecord> {
|
||||
const session = await this.prisma.userSession.create({
|
||||
data: {
|
||||
id: sessionId(),
|
||||
userId: input.userId,
|
||||
refreshTokenHash: input.refreshTokenHash,
|
||||
userAgent: input.userAgent,
|
||||
ip: input.ip,
|
||||
expiresAt: input.expiresAt,
|
||||
rotatedFromId: input.rotatedFromId
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
refreshTokenHash: session.refreshTokenHash,
|
||||
expiresAt: session.expiresAt,
|
||||
revokedAt: session.revokedAt,
|
||||
user: {
|
||||
id: session.user.id,
|
||||
username: session.user.username,
|
||||
displayName: session.user.displayName,
|
||||
passwordHash: session.user.passwordHash,
|
||||
passwordAlgo: session.user.passwordAlgo,
|
||||
status: session.user.status,
|
||||
failedLoginCount: session.user.failedLoginCount,
|
||||
lockedUntil: session.user.lockedUntil,
|
||||
requirePasswordChange: session.user.requirePasswordChange,
|
||||
roles: session.user.userRoles.map((userRole) => userRole.role.name)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async findActiveSessionByRefreshTokenHash(refreshTokenHash: string, now: Date): Promise<AuthSessionRecord | null> {
|
||||
const session = await this.prisma.userSession.findUnique({
|
||||
where: { refreshTokenHash },
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!session || session.revokedAt || session.expiresAt <= now || session.user.deletedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
refreshTokenHash: session.refreshTokenHash,
|
||||
expiresAt: session.expiresAt,
|
||||
revokedAt: session.revokedAt,
|
||||
user: {
|
||||
id: session.user.id,
|
||||
username: session.user.username,
|
||||
displayName: session.user.displayName,
|
||||
passwordHash: session.user.passwordHash,
|
||||
passwordAlgo: session.user.passwordAlgo,
|
||||
status: session.user.status,
|
||||
failedLoginCount: session.user.failedLoginCount,
|
||||
lockedUntil: session.user.lockedUntil,
|
||||
requirePasswordChange: session.user.requirePasswordChange,
|
||||
roles: session.user.userRoles.map((userRole) => userRole.role.name)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async revokeSession(sessionId: string, revokedAt: Date): Promise<void> {
|
||||
await this.prisma.userSession.updateMany({
|
||||
where: {
|
||||
id: sessionId,
|
||||
revokedAt: null
|
||||
},
|
||||
data: {
|
||||
revokedAt
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'reflect-metadata';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import crypto from 'node:crypto';
|
||||
import { hashPasswordArgon2id, sha256Token } from '@lisglosips/auth';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import type { AuthRepository, AuthSessionRecord, AuthUserRecord, CreateSessionInput } from './auth.types.js';
|
||||
|
||||
class MemoryAuthRepository implements AuthRepository {
|
||||
user: AuthUserRecord;
|
||||
sessions = new Map<string, AuthSessionRecord>();
|
||||
|
||||
private constructor(passwordHash: string) {
|
||||
this.user = {
|
||||
id: 'usr_test',
|
||||
username: 'operator',
|
||||
displayName: 'Operator',
|
||||
passwordHash,
|
||||
passwordAlgo: 'argon2id',
|
||||
status: 'ENABLED',
|
||||
failedLoginCount: 0,
|
||||
lockedUntil: null,
|
||||
requirePasswordChange: false,
|
||||
roles: ['admin']
|
||||
};
|
||||
}
|
||||
|
||||
static async create(secret: string): Promise<MemoryAuthRepository> {
|
||||
return new MemoryAuthRepository(
|
||||
await hashPasswordArgon2id(secret, {
|
||||
memoryKiB: 1024,
|
||||
passes: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async findUserByUsername(username: string): Promise<AuthUserRecord | null> {
|
||||
return username === this.user.username ? { ...this.user, roles: [...this.user.roles] } : null;
|
||||
}
|
||||
|
||||
async markLoginSuccess(_userId: string, _ip?: string): Promise<void> {
|
||||
this.user.failedLoginCount = 0;
|
||||
this.user.lockedUntil = null;
|
||||
}
|
||||
|
||||
async markLoginFailure(_userId: string, failedLoginCount: number, lockedUntil: Date | null): Promise<void> {
|
||||
this.user.failedLoginCount = failedLoginCount;
|
||||
this.user.lockedUntil = lockedUntil;
|
||||
}
|
||||
|
||||
async createSession(input: CreateSessionInput): Promise<AuthSessionRecord> {
|
||||
const session: AuthSessionRecord = {
|
||||
id: `ses_${this.sessions.size + 1}`,
|
||||
userId: input.userId,
|
||||
refreshTokenHash: input.refreshTokenHash,
|
||||
expiresAt: input.expiresAt,
|
||||
revokedAt: null,
|
||||
user: { ...this.user, roles: [...this.user.roles] }
|
||||
};
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async findActiveSessionByRefreshTokenHash(refreshTokenHash: string, now: Date): Promise<AuthSessionRecord | null> {
|
||||
return (
|
||||
[...this.sessions.values()].find(
|
||||
(session) => session.refreshTokenHash === refreshTokenHash && !session.revokedAt && session.expiresAt > now
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async revokeSession(sessionId: string, revokedAt: Date): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.revokedAt = revokedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function config() {
|
||||
const values = new Map<string, unknown>([
|
||||
['auth.accessTokenSecret', 'test-only-access-token-secret-min-32-bytes'],
|
||||
['auth.accessTokenTtlSeconds', 900],
|
||||
['auth.refreshTokenTtlDays', 7],
|
||||
['auth.lockMaxFailures', 3],
|
||||
['auth.lockWindowSeconds', 60],
|
||||
['auth.tokenIssuer', 'lisglosips-api'],
|
||||
['auth.tokenAudience', 'lisglosips-web']
|
||||
]);
|
||||
|
||||
return {
|
||||
get: (key: string) => values.get(key)
|
||||
};
|
||||
}
|
||||
|
||||
describe('AuthService', () => {
|
||||
let repo: MemoryAuthRepository;
|
||||
let service: AuthService;
|
||||
let secret: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
secret = crypto.randomUUID();
|
||||
repo = await MemoryAuthRepository.create(secret);
|
||||
service = new AuthService(repo, config() as never);
|
||||
});
|
||||
|
||||
it('logs in and stores only the refresh token digest', async () => {
|
||||
const result = await service.login(' OPERATOR ', secret, { ip: '127.0.0.1', userAgent: 'vitest' });
|
||||
const session = [...repo.sessions.values()][0];
|
||||
|
||||
expect(result.response.user.username).toBe('operator');
|
||||
expect(result.response.accessToken.split('.')).toHaveLength(3);
|
||||
expect(session.refreshTokenHash).toBe(sha256Token(result.tokens.refreshToken));
|
||||
expect(session.refreshTokenHash).not.toBe(result.tokens.refreshToken);
|
||||
});
|
||||
|
||||
it('locks the user after repeated failures', async () => {
|
||||
await expect(service.login('operator', crypto.randomUUID())).rejects.toMatchObject({ status: 401 });
|
||||
await expect(service.login('operator', crypto.randomUUID())).rejects.toMatchObject({ status: 401 });
|
||||
await expect(service.login('operator', crypto.randomUUID())).rejects.toMatchObject({ status: 401 });
|
||||
|
||||
expect(repo.user.failedLoginCount).toBe(3);
|
||||
expect(repo.user.lockedUntil).toBeInstanceOf(Date);
|
||||
await expect(service.login('operator', secret)).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it('rotates refresh sessions and rejects reuse', async () => {
|
||||
const login = await service.login('operator', secret);
|
||||
const refresh = await service.refresh(login.tokens.refreshToken);
|
||||
const [firstSession, secondSession] = [...repo.sessions.values()];
|
||||
|
||||
expect(firstSession.revokedAt).toBeInstanceOf(Date);
|
||||
expect(secondSession.refreshTokenHash).toBe(sha256Token(refresh.tokens.refreshToken));
|
||||
await expect(service.refresh(login.tokens.refreshToken)).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it('revokes refresh session on logout', async () => {
|
||||
const login = await service.login('operator', secret);
|
||||
|
||||
await service.logout(login.tokens.refreshToken);
|
||||
|
||||
expect([...repo.sessions.values()][0].revokedAt).toBeInstanceOf(Date);
|
||||
await expect(service.refresh(login.tokens.refreshToken)).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
ACCESS_TOKEN_TYPE,
|
||||
PASSWORD_ALGO_ARGON2ID,
|
||||
REFRESH_COOKIE_NAME,
|
||||
randomToken,
|
||||
sha256Token,
|
||||
signAccessToken,
|
||||
verifyPasswordArgon2id
|
||||
} from '@lisglosips/auth';
|
||||
import type { RuntimeConfig } from '../../shared/config.js';
|
||||
import { AUTH_REPOSITORY, type AuthRepository, type AuthUserRecord } from './auth.types.js';
|
||||
|
||||
export interface AuthRequestContext {
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
refreshMaxAgeSeconds: number;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
tokenType: typeof ACCESS_TOKEN_TYPE;
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
requirePasswordChange: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@Inject(AUTH_REPOSITORY) private readonly repository: AuthRepository,
|
||||
@Inject(ConfigService) private readonly config: ConfigService<RuntimeConfig, true>
|
||||
) {}
|
||||
|
||||
async login(username: string, password: string, context: AuthRequestContext = {}): Promise<{ response: AuthResponse; tokens: AuthTokens }> {
|
||||
const normalizedUsername = username.trim().toLowerCase();
|
||||
const user = await this.repository.findUserByUsername(normalizedUsername);
|
||||
const now = new Date();
|
||||
|
||||
if (!user) {
|
||||
throw this.invalidCredentials();
|
||||
}
|
||||
|
||||
if (this.isLocked(user, now) || user.status !== 'ENABLED' || !user.passwordHash || user.passwordAlgo !== PASSWORD_ALGO_ARGON2ID) {
|
||||
await this.registerFailure(user, now);
|
||||
throw this.invalidCredentials();
|
||||
}
|
||||
|
||||
if (!(await verifyPasswordArgon2id(password, user.passwordHash))) {
|
||||
await this.registerFailure(user, now);
|
||||
throw this.invalidCredentials();
|
||||
}
|
||||
|
||||
await this.repository.markLoginSuccess(user.id, context.ip);
|
||||
const tokens = await this.issueTokens(user, context);
|
||||
|
||||
return {
|
||||
response: this.buildResponse(user, tokens.accessToken),
|
||||
tokens
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string | null, context: AuthRequestContext = {}): Promise<{ response: AuthResponse; tokens: AuthTokens }> {
|
||||
if (!refreshToken) {
|
||||
throw this.invalidCredentials();
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const session = await this.repository.findActiveSessionByRefreshTokenHash(sha256Token(refreshToken), now);
|
||||
|
||||
if (!session || session.user.status !== 'ENABLED') {
|
||||
throw this.invalidCredentials();
|
||||
}
|
||||
|
||||
await this.repository.revokeSession(session.id, now);
|
||||
const tokens = await this.issueTokens(session.user, context, session.id);
|
||||
|
||||
return {
|
||||
response: this.buildResponse(session.user, tokens.accessToken),
|
||||
tokens
|
||||
};
|
||||
}
|
||||
|
||||
async logout(refreshToken: string | null): Promise<void> {
|
||||
if (!refreshToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const session = await this.repository.findActiveSessionByRefreshTokenHash(sha256Token(refreshToken), now);
|
||||
|
||||
if (session) {
|
||||
await this.repository.revokeSession(session.id, now);
|
||||
}
|
||||
}
|
||||
|
||||
getRefreshCookieName(): string {
|
||||
return REFRESH_COOKIE_NAME;
|
||||
}
|
||||
|
||||
private async issueTokens(user: AuthUserRecord, context: AuthRequestContext, rotatedFromId?: string): Promise<AuthTokens> {
|
||||
const accessTtlSeconds = this.config.get('auth.accessTokenTtlSeconds', { infer: true });
|
||||
const refreshTtlDays = this.config.get('auth.refreshTokenTtlDays', { infer: true });
|
||||
const refreshMaxAgeSeconds = refreshTtlDays * 24 * 60 * 60;
|
||||
const refreshToken = randomToken();
|
||||
const expiresAt = new Date(Date.now() + refreshMaxAgeSeconds * 1000);
|
||||
|
||||
await this.repository.createSession({
|
||||
userId: user.id,
|
||||
refreshTokenHash: sha256Token(refreshToken),
|
||||
userAgent: context.userAgent,
|
||||
ip: context.ip,
|
||||
expiresAt,
|
||||
rotatedFromId
|
||||
});
|
||||
|
||||
const accessToken = signAccessToken(
|
||||
{
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
roles: user.roles,
|
||||
typ: 'access'
|
||||
},
|
||||
{
|
||||
secret: this.config.get('auth.accessTokenSecret', { infer: true }),
|
||||
issuer: this.config.get('auth.tokenIssuer', { infer: true }),
|
||||
audience: this.config.get('auth.tokenAudience', { infer: true }),
|
||||
ttlSeconds: accessTtlSeconds
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
refreshMaxAgeSeconds
|
||||
};
|
||||
}
|
||||
|
||||
private buildResponse(user: AuthUserRecord, accessToken: string): AuthResponse {
|
||||
return {
|
||||
tokenType: ACCESS_TOKEN_TYPE,
|
||||
accessToken,
|
||||
expiresIn: this.config.get('auth.accessTokenTtlSeconds', { infer: true }),
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
roles: user.roles,
|
||||
requirePasswordChange: user.requirePasswordChange
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async registerFailure(user: AuthUserRecord, now: Date): Promise<void> {
|
||||
const maxFailures = this.config.get('auth.lockMaxFailures', { infer: true });
|
||||
const lockWindowSeconds = this.config.get('auth.lockWindowSeconds', { infer: true });
|
||||
const failedLoginCount = user.failedLoginCount + 1;
|
||||
const lockedUntil = failedLoginCount >= maxFailures ? new Date(now.getTime() + lockWindowSeconds * 1000) : null;
|
||||
|
||||
await this.repository.markLoginFailure(user.id, failedLoginCount, lockedUntil);
|
||||
}
|
||||
|
||||
private isLocked(user: AuthUserRecord, now: Date): boolean {
|
||||
return Boolean(user.lockedUntil && user.lockedUntil > now);
|
||||
}
|
||||
|
||||
private invalidCredentials(): UnauthorizedException {
|
||||
return new UnauthorizedException({
|
||||
code: 'AUTH_INVALID_CREDENTIALS',
|
||||
message: 'Invalid username or password.'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface AuthUserRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
passwordHash: string | null;
|
||||
passwordAlgo: string | null;
|
||||
status: 'ENABLED' | 'DISABLED';
|
||||
failedLoginCount: number;
|
||||
lockedUntil: Date | null;
|
||||
requirePasswordChange: boolean;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface AuthSessionRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
refreshTokenHash: string;
|
||||
expiresAt: Date;
|
||||
revokedAt: Date | null;
|
||||
user: AuthUserRecord;
|
||||
}
|
||||
|
||||
export interface CreateSessionInput {
|
||||
userId: string;
|
||||
refreshTokenHash: string;
|
||||
userAgent?: string;
|
||||
ip?: string;
|
||||
expiresAt: Date;
|
||||
rotatedFromId?: string;
|
||||
}
|
||||
|
||||
export interface AuthRepository {
|
||||
findUserByUsername(username: string): Promise<AuthUserRecord | null>;
|
||||
markLoginSuccess(userId: string, ip?: string): Promise<void>;
|
||||
markLoginFailure(userId: string, failedLoginCount: number, lockedUntil: Date | null): Promise<void>;
|
||||
createSession(input: CreateSessionInput): Promise<AuthSessionRecord>;
|
||||
findActiveSessionByRefreshTokenHash(refreshTokenHash: string, now: Date): Promise<AuthSessionRecord | null>;
|
||||
revokeSession(sessionId: string, revokedAt: Date): Promise<void>;
|
||||
}
|
||||
|
||||
export const AUTH_REPOSITORY = Symbol('AUTH_REPOSITORY');
|
||||
@@ -0,0 +1,42 @@
|
||||
export function parseCookie(header: string | undefined, name: string): string | null {
|
||||
if (!header) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const part of header.split(';')) {
|
||||
const [rawKey, ...rawValue] = part.trim().split('=');
|
||||
if (rawKey === name) {
|
||||
return decodeURIComponent(rawValue.join('='));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function serializeCookie(
|
||||
name: string,
|
||||
value: string,
|
||||
options: {
|
||||
maxAgeSeconds?: number;
|
||||
httpOnly?: boolean;
|
||||
secure?: boolean;
|
||||
sameSite?: 'Strict' | 'Lax';
|
||||
path?: string;
|
||||
} = {}
|
||||
): string {
|
||||
const segments = [`${name}=${encodeURIComponent(value)}`, `Path=${options.path ?? '/api/v2/auth'}`, 'SameSite=' + (options.sameSite ?? 'Strict')];
|
||||
|
||||
if (options.maxAgeSeconds !== undefined) {
|
||||
segments.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`);
|
||||
}
|
||||
|
||||
if (options.httpOnly ?? true) {
|
||||
segments.push('HttpOnly');
|
||||
}
|
||||
|
||||
if (options.secure ?? true) {
|
||||
segments.push('Secure');
|
||||
}
|
||||
|
||||
return segments.join('; ');
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
import { CustomerGatewayPoliciesService } from './customer-gateway-policies.service.js';
|
||||
|
||||
@ApiTags('customer-gateway-policies')
|
||||
@Controller()
|
||||
export class CustomerGatewayPoliciesController {
|
||||
constructor(@Inject(CustomerGatewayPoliciesService) private readonly policiesService: CustomerGatewayPoliciesService) {}
|
||||
|
||||
@Get('customer-gateways/:id/policies')
|
||||
@RequirePermissions('customer_gateways.view')
|
||||
list(@Param('id') gatewayId: string) {
|
||||
return this.policiesService.list(gatewayId);
|
||||
}
|
||||
|
||||
@Post('customer-gateways/:id/policies')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'policy_create', objectType: 'customer_gateway', objectIdParam: 'id' })
|
||||
create(@Param('id') gatewayId: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.policiesService.create(gatewayId, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Patch('customer-gateway-policies/:id')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'policy_update', objectType: 'customer_gateway_policy', objectIdParam: 'id' })
|
||||
update(@Param('id') policyId: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.policiesService.update(policyId, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Delete('customer-gateway-policies/:id')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'policy_delete', objectType: 'customer_gateway_policy', objectIdParam: 'id' })
|
||||
remove(@Param('id') policyId: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.policiesService.remove(policyId, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post('customer-gateways/:id/policies/reorder')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'policy_reorder', objectType: 'customer_gateway', objectIdParam: 'id' })
|
||||
reorder(@Param('id') gatewayId: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.policiesService.reorder(gatewayId, body as never, currentUser?.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
|
||||
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js';
|
||||
import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js';
|
||||
import type { CurrentUser } from '../security/security.metadata.js';
|
||||
import {
|
||||
CUSTOMER_GATEWAY_POLICIES_REPOSITORY,
|
||||
type CreatePolicyInput,
|
||||
type CustomerGatewayPoliciesRepository,
|
||||
type CustomerGatewayPolicySummary,
|
||||
type UpdatePolicyInput
|
||||
} from './customer-gateway-policies.repository.js';
|
||||
|
||||
class MemoryIdentityRepository implements IdentityRepository {
|
||||
users = new Map<string, CurrentUser>();
|
||||
|
||||
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||
return this.users.get(userId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAuditRepository implements AuditRepository {
|
||||
entries: AuditEntryInput[] = [];
|
||||
|
||||
async write(input: AuditEntryInput): Promise<void> {
|
||||
this.entries.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryPoliciesRepository implements CustomerGatewayPoliciesRepository {
|
||||
policies: CustomerGatewayPolicySummary[] = [
|
||||
this.summary({ id: 'cgp_1', name: 'Mobile prefix', priority: 1, callerMode: 'PREFIX', callerValue: '021' }),
|
||||
this.summary({ id: 'cgp_2', name: 'Fallback', priority: 2 })
|
||||
];
|
||||
outboxEvents = 0;
|
||||
|
||||
async list(gatewayId: string): Promise<CustomerGatewayPolicySummary[]> {
|
||||
return this.policies.filter((policy) => policy.gatewayId === gatewayId).sort((left, right) => left.priority - right.priority);
|
||||
}
|
||||
|
||||
async create(input: CreatePolicyInput): Promise<CustomerGatewayPolicySummary> {
|
||||
const policy = this.summary({
|
||||
id: 'cgp_created',
|
||||
gatewayId: input.gatewayId,
|
||||
lineGroupId: input.lineGroupId,
|
||||
name: input.name,
|
||||
priority: input.priority ?? 3,
|
||||
callerMode: input.callerMode,
|
||||
callerValue: input.callerValue ?? null,
|
||||
calleeMode: input.calleeMode,
|
||||
calleeValue: input.calleeValue ?? null,
|
||||
status: input.status
|
||||
});
|
||||
this.policies.push(policy);
|
||||
this.outboxEvents += 1;
|
||||
return policy;
|
||||
}
|
||||
|
||||
async update(policyId: string, input: UpdatePolicyInput): Promise<CustomerGatewayPolicySummary> {
|
||||
const index = this.policies.findIndex((policy) => policy.id === policyId);
|
||||
const current = this.policies[index] ?? this.summary({ id: policyId, name: 'Missing', priority: 999 });
|
||||
const updated = {
|
||||
...current,
|
||||
lineGroupId: input.lineGroupId ?? current.lineGroupId,
|
||||
name: input.name ?? current.name,
|
||||
priority: input.priority ?? current.priority,
|
||||
callerMode: input.callerMode ?? current.callerMode,
|
||||
callerValue: input.callerValue === undefined ? current.callerValue : input.callerValue,
|
||||
calleeMode: input.calleeMode ?? current.calleeMode,
|
||||
calleeValue: input.calleeValue === undefined ? current.calleeValue : input.calleeValue,
|
||||
status: input.status ?? current.status
|
||||
};
|
||||
this.policies[index] = updated;
|
||||
this.outboxEvents += 1;
|
||||
return updated;
|
||||
}
|
||||
|
||||
async softDelete(policyId: string): Promise<CustomerGatewayPolicySummary> {
|
||||
const policy = await this.update(policyId, { status: 'DISABLED' });
|
||||
this.policies = this.policies.filter((item) => item.id !== policyId);
|
||||
return policy;
|
||||
}
|
||||
|
||||
async reorder(gatewayId: string, policyIds: string[]): Promise<CustomerGatewayPolicySummary[]> {
|
||||
this.policies = this.policies.map((policy) => {
|
||||
const index = policyIds.indexOf(policy.id);
|
||||
return policy.gatewayId === gatewayId && index >= 0 ? { ...policy, priority: index + 1 } : policy;
|
||||
});
|
||||
this.outboxEvents += 1;
|
||||
return this.list(gatewayId);
|
||||
}
|
||||
|
||||
private summary(input: {
|
||||
id: string;
|
||||
gatewayId?: string;
|
||||
lineGroupId?: string;
|
||||
name: string;
|
||||
priority: number;
|
||||
callerMode?: 'ANY' | 'EQUALS' | 'PREFIX';
|
||||
callerValue?: string | null;
|
||||
calleeMode?: 'ANY' | 'EQUALS' | 'PREFIX';
|
||||
calleeValue?: string | null;
|
||||
status?: 'ENABLED' | 'DISABLED';
|
||||
}): CustomerGatewayPolicySummary {
|
||||
return {
|
||||
id: input.id,
|
||||
customerId: 'cus_seed',
|
||||
gatewayId: input.gatewayId ?? 'cgw_seed',
|
||||
lineGroupId: input.lineGroupId ?? 'lg_seed',
|
||||
name: input.name,
|
||||
priority: input.priority,
|
||||
callerMode: input.callerMode ?? 'ANY',
|
||||
callerValue: input.callerValue ?? null,
|
||||
calleeMode: input.calleeMode ?? 'ANY',
|
||||
calleeValue: input.calleeValue ?? null,
|
||||
status: input.status ?? 'ENABLED',
|
||||
createdAt: new Date('2026-06-21T04:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-21T04:00:00.000Z')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('S14 customer gateway policies API', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let audit: MemoryAuditRepository;
|
||||
let repository: MemoryPoliciesRepository;
|
||||
|
||||
const tokenFor = (userId: string) =>
|
||||
signAccessToken(
|
||||
{
|
||||
sub: userId,
|
||||
username: userId,
|
||||
roles: ['test'],
|
||||
typ: 'access'
|
||||
},
|
||||
{
|
||||
secret: 'test-only-access-token-secret-min-32-bytes',
|
||||
issuer: 'lisglosips-api',
|
||||
audience: 'lisglosips-web',
|
||||
ttlSeconds: 900
|
||||
}
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
|
||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
|
||||
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
|
||||
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
|
||||
|
||||
const identities = new MemoryIdentityRepository();
|
||||
audit = new MemoryAuditRepository();
|
||||
repository = new MemoryPoliciesRepository();
|
||||
|
||||
identities.users.set('usr_ops', {
|
||||
id: 'usr_ops',
|
||||
username: 'ops',
|
||||
roles: ['运营管理员'],
|
||||
permissions: ['customer_gateways.view', 'customer_gateways.manage'] as PermissionKey[]
|
||||
});
|
||||
identities.users.set('usr_viewer', {
|
||||
id: 'usr_viewer',
|
||||
username: 'viewer',
|
||||
roles: ['只读'],
|
||||
permissions: ['customer_gateways.view'] as PermissionKey[]
|
||||
});
|
||||
|
||||
const { AppModule } = await import('../app.module.js');
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(IDENTITY_REPOSITORY)
|
||||
.useValue(identities)
|
||||
.overrideProvider(AUDIT_REPOSITORY)
|
||||
.useValue(audit)
|
||||
.overrideProvider(CUSTOMER_GATEWAY_POLICIES_REPOSITORY)
|
||||
.useValue(repository)
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('lists policies ordered by priority for viewers', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v2/customer-gateways/cgw_seed/policies')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.map((policy: { id: string }) => policy.id)).toEqual(['cgp_1', 'cgp_2']);
|
||||
});
|
||||
|
||||
it('rejects policy writes without manage permission', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/customer-gateways/cgw_seed/policies')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.send({ lineGroupId: 'lg_seed', name: 'Denied' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('creates, updates, reorders, and deletes policies with audit and outbox intent', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/customer-gateways/cgw_seed/policies')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({
|
||||
lineGroupId: 'lg_seed',
|
||||
name: 'Callee exact',
|
||||
priority: 3,
|
||||
callerMode: 'ANY',
|
||||
calleeMode: 'EQUALS',
|
||||
calleeValue: '13800138000'
|
||||
})
|
||||
.expect(201)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({
|
||||
id: 'cgp_created',
|
||||
calleeMode: 'EQUALS',
|
||||
calleeValue: '13800138000'
|
||||
});
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v2/customer-gateway-policies/cgp_created')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ callerMode: 'PREFIX', callerValue: '010' })
|
||||
.expect(200);
|
||||
|
||||
const reordered = await request(app.getHttpServer())
|
||||
.post('/api/v2/customer-gateways/cgw_seed/policies/reorder')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ policyIds: ['cgp_created', 'cgp_2', 'cgp_1'] })
|
||||
.expect(201);
|
||||
expect(reordered.body.map((policy: { id: string }) => policy.id)).toEqual(['cgp_created', 'cgp_2', 'cgp_1']);
|
||||
|
||||
await request(app.getHttpServer()).delete('/api/v2/customer-gateway-policies/cgp_2').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200);
|
||||
|
||||
expect(repository.outboxEvents).toBeGreaterThanOrEqual(4);
|
||||
expect(audit.entries.some((entry) => entry.module === 'customer_gateways' && entry.action === 'policy_create')).toBe(true);
|
||||
expect(audit.entries.some((entry) => entry.module === 'customer_gateways' && entry.action === 'policy_reorder')).toBe(true);
|
||||
});
|
||||
|
||||
it('validates match values for non-ANY modes', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/customer-gateways/cgw_seed/policies')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ lineGroupId: 'lg_seed', name: 'Invalid', callerMode: 'PREFIX' })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CustomerGatewayPoliciesController } from './customer-gateway-policies.controller.js';
|
||||
import { CUSTOMER_GATEWAY_POLICIES_REPOSITORY, PrismaCustomerGatewayPoliciesRepository } from './customer-gateway-policies.repository.js';
|
||||
import { CustomerGatewayPoliciesService } from './customer-gateway-policies.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [CustomerGatewayPoliciesController],
|
||||
providers: [
|
||||
CustomerGatewayPoliciesService,
|
||||
{
|
||||
provide: CUSTOMER_GATEWAY_POLICIES_REPOSITORY,
|
||||
useClass: PrismaCustomerGatewayPoliciesRepository
|
||||
}
|
||||
],
|
||||
exports: [CustomerGatewayPoliciesService]
|
||||
})
|
||||
export class CustomerGatewayPoliciesModule {}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export type PolicyStatus = 'ENABLED' | 'DISABLED';
|
||||
export type PolicyMatchMode = 'ANY' | 'EQUALS' | 'PREFIX';
|
||||
|
||||
export interface CustomerGatewayPolicySummary {
|
||||
id: string;
|
||||
customerId: string;
|
||||
gatewayId: string;
|
||||
lineGroupId: string;
|
||||
name: string;
|
||||
priority: number;
|
||||
callerMode: PolicyMatchMode;
|
||||
callerValue: string | null;
|
||||
calleeMode: PolicyMatchMode;
|
||||
calleeValue: string | null;
|
||||
status: PolicyStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreatePolicyInput {
|
||||
gatewayId: string;
|
||||
lineGroupId: string;
|
||||
name: string;
|
||||
priority?: number;
|
||||
callerMode: PolicyMatchMode;
|
||||
callerValue?: string | null;
|
||||
calleeMode: PolicyMatchMode;
|
||||
calleeValue?: string | null;
|
||||
status: PolicyStatus;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePolicyInput {
|
||||
lineGroupId?: string;
|
||||
name?: string;
|
||||
priority?: number;
|
||||
callerMode?: PolicyMatchMode;
|
||||
callerValue?: string | null;
|
||||
calleeMode?: PolicyMatchMode;
|
||||
calleeValue?: string | null;
|
||||
status?: PolicyStatus;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface CustomerGatewayPoliciesRepository {
|
||||
list(gatewayId: string): Promise<CustomerGatewayPolicySummary[]>;
|
||||
create(input: CreatePolicyInput): Promise<CustomerGatewayPolicySummary>;
|
||||
update(policyId: string, input: UpdatePolicyInput): Promise<CustomerGatewayPolicySummary>;
|
||||
softDelete(policyId: string, actorId?: string): Promise<CustomerGatewayPolicySummary>;
|
||||
reorder(gatewayId: string, policyIds: string[], actorId?: string): Promise<CustomerGatewayPolicySummary[]>;
|
||||
}
|
||||
|
||||
export const CUSTOMER_GATEWAY_POLICIES_REPOSITORY = Symbol('CUSTOMER_GATEWAY_POLICIES_REPOSITORY');
|
||||
|
||||
function policyId(): string {
|
||||
return `cgp_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
|
||||
}
|
||||
|
||||
function outboxId(): string {
|
||||
return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaCustomerGatewayPoliciesRepository implements CustomerGatewayPoliciesRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(gatewayId: string): Promise<CustomerGatewayPolicySummary[]> {
|
||||
await this.ensureGateway(gatewayId);
|
||||
const policies = await this.prisma.customerGatewayPolicy.findMany({
|
||||
where: { gatewayId, deletedAt: null },
|
||||
orderBy: [{ priority: 'asc' }]
|
||||
});
|
||||
return policies.map((policy) => this.toSummary(policy));
|
||||
}
|
||||
|
||||
async create(input: CreatePolicyInput): Promise<CustomerGatewayPolicySummary> {
|
||||
const gateway = await this.ensureGateway(input.gatewayId);
|
||||
await this.ensureLineGroup(input.lineGroupId);
|
||||
const priority = input.priority ?? (await this.nextPriority(input.gatewayId));
|
||||
|
||||
try {
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const policy = await tx.customerGatewayPolicy.create({
|
||||
data: {
|
||||
id: policyId(),
|
||||
customerId: gateway.customerId,
|
||||
gatewayId: input.gatewayId,
|
||||
lineGroupId: input.lineGroupId,
|
||||
name: input.name,
|
||||
priority,
|
||||
callerMode: input.callerMode,
|
||||
callerValue: input.callerValue,
|
||||
calleeMode: input.calleeMode,
|
||||
calleeValue: input.calleeValue,
|
||||
status: input.status,
|
||||
createdBy: input.actorId,
|
||||
updatedBy: input.actorId
|
||||
}
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, input.gatewayId, 'customer_gateway_policy.changed');
|
||||
return policy;
|
||||
});
|
||||
return this.toSummary(created);
|
||||
} catch (error) {
|
||||
this.handleUniqueConflict(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(policyIdValue: string, input: UpdatePolicyInput): Promise<CustomerGatewayPolicySummary> {
|
||||
const existing = await this.findActiveOrThrow(policyIdValue);
|
||||
if (input.lineGroupId) {
|
||||
await this.ensureLineGroup(input.lineGroupId);
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const policy = await tx.customerGatewayPolicy.update({
|
||||
where: { id: policyIdValue },
|
||||
data: {
|
||||
lineGroupId: input.lineGroupId,
|
||||
name: input.name,
|
||||
priority: input.priority,
|
||||
callerMode: input.callerMode,
|
||||
callerValue: input.callerValue,
|
||||
calleeMode: input.calleeMode,
|
||||
calleeValue: input.calleeValue,
|
||||
status: input.status,
|
||||
updatedBy: input.actorId,
|
||||
version: { increment: 1 }
|
||||
}
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, existing.gatewayId, 'customer_gateway_policy.changed');
|
||||
return policy;
|
||||
});
|
||||
return this.toSummary(updated);
|
||||
} catch (error) {
|
||||
this.handleUniqueConflict(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async softDelete(policyIdValue: string, actorId?: string): Promise<CustomerGatewayPolicySummary> {
|
||||
const existing = await this.findActiveOrThrow(policyIdValue);
|
||||
const deleted = await this.prisma.$transaction(async (tx) => {
|
||||
const policy = await tx.customerGatewayPolicy.update({
|
||||
where: { id: policyIdValue },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
}
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, existing.gatewayId, 'customer_gateway_policy.changed');
|
||||
return policy;
|
||||
});
|
||||
return this.toSummary(deleted);
|
||||
}
|
||||
|
||||
async reorder(gatewayId: string, policyIds: string[], actorId?: string): Promise<CustomerGatewayPolicySummary[]> {
|
||||
await this.ensureGateway(gatewayId);
|
||||
const current = await this.prisma.customerGatewayPolicy.findMany({
|
||||
where: { gatewayId, deletedAt: null },
|
||||
select: { id: true }
|
||||
});
|
||||
const currentIds = current.map((policy) => policy.id).sort();
|
||||
const requestedIds = [...policyIds].sort();
|
||||
if (currentIds.length !== requestedIds.length || currentIds.some((id, index) => id !== requestedIds[index])) {
|
||||
throw new BadRequestException({ code: 'POLICY_REORDER_SET_MISMATCH', message: 'Reorder must include every active policy exactly once.' });
|
||||
}
|
||||
if (new Set(policyIds).size !== policyIds.length) {
|
||||
throw new BadRequestException({ code: 'POLICY_REORDER_DUPLICATE', message: 'Policy ids must be unique.' });
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (let index = 0; index < policyIds.length; index += 1) {
|
||||
await tx.customerGatewayPolicy.update({
|
||||
where: { id: policyIds[index] },
|
||||
data: {
|
||||
priority: -(index + 1),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
}
|
||||
});
|
||||
}
|
||||
for (let index = 0; index < policyIds.length; index += 1) {
|
||||
await tx.customerGatewayPolicy.update({
|
||||
where: { id: policyIds[index] },
|
||||
data: { priority: index + 1 }
|
||||
});
|
||||
}
|
||||
await this.enqueueConfigOutbox(tx, gatewayId, 'customer_gateway_policy.reordered');
|
||||
});
|
||||
|
||||
return this.list(gatewayId);
|
||||
}
|
||||
|
||||
private async ensureGateway(gatewayId: string): Promise<{ id: string; customerId: string }> {
|
||||
const gateway = await this.prisma.customerGateway.findUnique({
|
||||
where: { id: gatewayId },
|
||||
select: { id: true, customerId: true, deletedAt: true }
|
||||
});
|
||||
if (!gateway || gateway.deletedAt) {
|
||||
throw new NotFoundException({ code: 'CUSTOMER_GATEWAY_NOT_FOUND', message: 'Customer gateway not found.' });
|
||||
}
|
||||
return gateway;
|
||||
}
|
||||
|
||||
private async ensureLineGroup(lineGroupId: string): Promise<void> {
|
||||
const lineGroup = await this.prisma.landingLineGroup.findUnique({
|
||||
where: { id: lineGroupId },
|
||||
select: { id: true, deletedAt: true }
|
||||
});
|
||||
if (!lineGroup || lineGroup.deletedAt) {
|
||||
throw new NotFoundException({ code: 'LINE_GROUP_NOT_FOUND', message: 'Landing line group not found.' });
|
||||
}
|
||||
}
|
||||
|
||||
private async findActiveOrThrow(policyIdValue: string) {
|
||||
const policy = await this.prisma.customerGatewayPolicy.findUnique({ where: { id: policyIdValue } });
|
||||
if (!policy || policy.deletedAt) {
|
||||
throw new NotFoundException({ code: 'CUSTOMER_GATEWAY_POLICY_NOT_FOUND', message: 'Customer gateway policy not found.' });
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
private async nextPriority(gatewayId: string): Promise<number> {
|
||||
const aggregate = await this.prisma.customerGatewayPolicy.aggregate({
|
||||
where: { gatewayId, deletedAt: null },
|
||||
_max: { priority: true }
|
||||
});
|
||||
return (aggregate._max.priority ?? 0) + 1;
|
||||
}
|
||||
|
||||
private async enqueueConfigOutbox(tx: Prisma.TransactionClient, gatewayId: string, eventType: string): Promise<void> {
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
id: outboxId(),
|
||||
aggregateType: 'customer_gateway_config',
|
||||
aggregateId: gatewayId,
|
||||
eventType,
|
||||
payload: { gatewayId, eventType }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private toSummary(policy: {
|
||||
id: string;
|
||||
customerId: string;
|
||||
gatewayId: string;
|
||||
lineGroupId: string;
|
||||
name: string;
|
||||
priority: number;
|
||||
callerMode: PolicyMatchMode;
|
||||
callerValue: string | null;
|
||||
calleeMode: PolicyMatchMode;
|
||||
calleeValue: string | null;
|
||||
status: PolicyStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): CustomerGatewayPolicySummary {
|
||||
return {
|
||||
id: policy.id,
|
||||
customerId: policy.customerId,
|
||||
gatewayId: policy.gatewayId,
|
||||
lineGroupId: policy.lineGroupId,
|
||||
name: policy.name,
|
||||
priority: policy.priority,
|
||||
callerMode: policy.callerMode,
|
||||
callerValue: policy.callerValue,
|
||||
calleeMode: policy.calleeMode,
|
||||
calleeValue: policy.calleeValue,
|
||||
status: policy.status,
|
||||
createdAt: policy.createdAt,
|
||||
updatedAt: policy.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private handleUniqueConflict(error: unknown): void {
|
||||
if (error && typeof error === 'object' && 'code' in error && (error as { code?: unknown }).code === 'P2002') {
|
||||
throw new ConflictException({ code: 'CUSTOMER_GATEWAY_POLICY_CONFLICT', message: 'Customer gateway policy priority already exists.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
CUSTOMER_GATEWAY_POLICIES_REPOSITORY,
|
||||
type CreatePolicyInput,
|
||||
type CustomerGatewayPoliciesRepository,
|
||||
type CustomerGatewayPolicySummary,
|
||||
type PolicyMatchMode,
|
||||
type PolicyStatus,
|
||||
type UpdatePolicyInput
|
||||
} from './customer-gateway-policies.repository.js';
|
||||
|
||||
interface CreatePolicyDto {
|
||||
lineGroupId?: unknown;
|
||||
name?: unknown;
|
||||
priority?: unknown;
|
||||
callerMode?: unknown;
|
||||
callerValue?: unknown;
|
||||
calleeMode?: unknown;
|
||||
calleeValue?: unknown;
|
||||
status?: unknown;
|
||||
}
|
||||
|
||||
interface UpdatePolicyDto {
|
||||
lineGroupId?: unknown;
|
||||
name?: unknown;
|
||||
priority?: unknown;
|
||||
callerMode?: unknown;
|
||||
callerValue?: unknown;
|
||||
calleeMode?: unknown;
|
||||
calleeValue?: unknown;
|
||||
status?: unknown;
|
||||
}
|
||||
|
||||
interface ReorderDto {
|
||||
policyIds?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CustomerGatewayPoliciesService {
|
||||
constructor(@Inject(CUSTOMER_GATEWAY_POLICIES_REPOSITORY) private readonly policies: CustomerGatewayPoliciesRepository) {}
|
||||
|
||||
list(gatewayId: string): Promise<CustomerGatewayPolicySummary[]> {
|
||||
return this.policies.list(gatewayId);
|
||||
}
|
||||
|
||||
create(gatewayId: string, body: CreatePolicyDto, actorId?: string): Promise<CustomerGatewayPolicySummary> {
|
||||
const callerMode = body.callerMode === undefined ? 'ANY' : this.matchMode(body.callerMode, 'callerMode');
|
||||
const calleeMode = body.calleeMode === undefined ? 'ANY' : this.matchMode(body.calleeMode, 'calleeMode');
|
||||
const input: CreatePolicyInput = {
|
||||
gatewayId,
|
||||
lineGroupId: this.limitedString(body.lineGroupId, 'lineGroupId', 32),
|
||||
name: this.limitedString(body.name, 'name', 120),
|
||||
priority: body.priority === undefined ? undefined : this.priority(body.priority),
|
||||
callerMode,
|
||||
callerValue: this.matchValue(callerMode, body.callerValue, 'callerValue'),
|
||||
calleeMode,
|
||||
calleeValue: this.matchValue(calleeMode, body.calleeValue, 'calleeValue'),
|
||||
status: body.status === undefined ? 'ENABLED' : this.status(body.status),
|
||||
actorId
|
||||
};
|
||||
return this.policies.create(input);
|
||||
}
|
||||
|
||||
update(policyId: string, body: UpdatePolicyDto, actorId?: string): Promise<CustomerGatewayPolicySummary> {
|
||||
const callerMode = body.callerMode === undefined ? undefined : this.matchMode(body.callerMode, 'callerMode');
|
||||
const calleeMode = body.calleeMode === undefined ? undefined : this.matchMode(body.calleeMode, 'calleeMode');
|
||||
const input: UpdatePolicyInput = {
|
||||
lineGroupId: body.lineGroupId === undefined ? undefined : this.limitedString(body.lineGroupId, 'lineGroupId', 32),
|
||||
name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120),
|
||||
priority: body.priority === undefined ? undefined : this.priority(body.priority),
|
||||
callerMode,
|
||||
callerValue: callerMode === undefined ? (body.callerValue === undefined ? undefined : this.nullableString(body.callerValue, 'callerValue', 64)) : this.matchValue(callerMode, body.callerValue, 'callerValue'),
|
||||
calleeMode,
|
||||
calleeValue: calleeMode === undefined ? (body.calleeValue === undefined ? undefined : this.nullableString(body.calleeValue, 'calleeValue', 64)) : this.matchValue(calleeMode, body.calleeValue, 'calleeValue'),
|
||||
status: body.status === undefined ? undefined : this.status(body.status),
|
||||
actorId
|
||||
};
|
||||
return this.policies.update(policyId, input);
|
||||
}
|
||||
|
||||
remove(policyId: string, actorId?: string): Promise<CustomerGatewayPolicySummary> {
|
||||
return this.policies.softDelete(policyId, actorId);
|
||||
}
|
||||
|
||||
reorder(gatewayId: string, body: ReorderDto, actorId?: string): Promise<CustomerGatewayPolicySummary[]> {
|
||||
if (!Array.isArray(body.policyIds) || body.policyIds.length === 0) {
|
||||
throw new BadRequestException({ code: 'POLICY_IDS_INVALID', message: 'policyIds must be a non-empty array.' });
|
||||
}
|
||||
return this.policies.reorder(
|
||||
gatewayId,
|
||||
body.policyIds.map((id) => this.limitedString(id, 'policyIds', 32)),
|
||||
actorId
|
||||
);
|
||||
}
|
||||
|
||||
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 nullableString(value: unknown, field: string, maxLength: number): string | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
return this.limitedString(value, field, maxLength);
|
||||
}
|
||||
|
||||
private matchMode(value: unknown, field: string): PolicyMatchMode {
|
||||
if (value !== 'ANY' && value !== 'EQUALS' && value !== 'PREFIX') {
|
||||
throw new BadRequestException({ code: 'MATCH_MODE_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private matchValue(mode: PolicyMatchMode, value: unknown, field: string): string | null {
|
||||
if (mode === 'ANY') {
|
||||
return null;
|
||||
}
|
||||
const text = this.limitedString(value, field, 64);
|
||||
if (!/^[0-9A-Za-z+*#.-]+$/.test(text)) {
|
||||
throw new BadRequestException({ code: 'MATCH_VALUE_INVALID', message: `${field} contains invalid characters.` });
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private priority(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 10000) {
|
||||
throw new BadRequestException({ code: 'PRIORITY_INVALID', message: 'priority must be an integer from 1 to 10000.' });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private status(value: unknown): PolicyStatus {
|
||||
if (value !== 'ENABLED' && value !== 'DISABLED') {
|
||||
throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Body, Controller, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
import { CustomerGatewaysService } from './customer-gateways.service.js';
|
||||
|
||||
@ApiTags('customer-gateways')
|
||||
@Controller('customer-gateways')
|
||||
export class CustomerGatewaysController {
|
||||
constructor(@Inject(CustomerGatewaysService) private readonly customerGatewaysService: CustomerGatewaysService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('customer_gateways.view')
|
||||
list(@Query() query: unknown) {
|
||||
return this.customerGatewaysService.list(query as never);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('customer_gateways.view')
|
||||
get(@Param('id') id: string) {
|
||||
return this.customerGatewaysService.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'create', objectType: 'customer_gateway' })
|
||||
create(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.customerGatewaysService.create(body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'update', objectType: 'customer_gateway', objectIdParam: 'id' })
|
||||
update(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.customerGatewaysService.update(id, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/enable')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'enable', objectType: 'customer_gateway', objectIdParam: 'id' })
|
||||
enable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.customerGatewaysService.enable(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/disable')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'disable', objectType: 'customer_gateway', objectIdParam: 'id' })
|
||||
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.customerGatewaysService.disable(id, currentUser?.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
|
||||
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js';
|
||||
import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js';
|
||||
import type { CurrentUser } from '../security/security.metadata.js';
|
||||
import {
|
||||
CUSTOMER_GATEWAYS_REPOSITORY,
|
||||
type CreateCustomerGatewayInput,
|
||||
type CustomerGatewayStatus,
|
||||
type CustomerGatewaySummary,
|
||||
type CustomerGatewaysRepository,
|
||||
type UpdateCustomerGatewayInput
|
||||
} from './customer-gateways.repository.js';
|
||||
|
||||
class MemoryIdentityRepository implements IdentityRepository {
|
||||
users = new Map<string, CurrentUser>();
|
||||
|
||||
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||
return this.users.get(userId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAuditRepository implements AuditRepository {
|
||||
entries: AuditEntryInput[] = [];
|
||||
|
||||
async write(input: AuditEntryInput): Promise<void> {
|
||||
this.entries.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
|
||||
private readonly gateways = new Map<string, CustomerGatewaySummary>();
|
||||
|
||||
constructor() {
|
||||
this.gateways.set(
|
||||
'cgw_seed',
|
||||
this.summary({
|
||||
id: 'cgw_seed',
|
||||
customerId: 'cus_seed',
|
||||
name: 'Seed IP Gateway',
|
||||
authMode: 'IP',
|
||||
sourceIp: '100.93.185.30',
|
||||
hasSipCredential: false,
|
||||
policyCount: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async list(customerId?: string): Promise<CustomerGatewaySummary[]> {
|
||||
return [...this.gateways.values()].filter((gateway) => !customerId || gateway.customerId === customerId);
|
||||
}
|
||||
|
||||
async get(gatewayId: string): Promise<CustomerGatewaySummary> {
|
||||
return this.gateways.get(gatewayId) ?? this.summary({ id: gatewayId, name: 'Missing Gateway' });
|
||||
}
|
||||
|
||||
async create(input: CreateCustomerGatewayInput): Promise<CustomerGatewaySummary> {
|
||||
const gateway = this.summary({
|
||||
id: 'cgw_created',
|
||||
customerId: input.customerId,
|
||||
name: input.name,
|
||||
authMode: input.authMode,
|
||||
sourceIp: input.sourceIp ?? null,
|
||||
sipUsername: input.sipUsername ?? null,
|
||||
sipDomain: input.sipDomain ?? null,
|
||||
hasSipCredential: Boolean(input.sipHa1)
|
||||
});
|
||||
this.gateways.set(gateway.id, gateway);
|
||||
return gateway;
|
||||
}
|
||||
|
||||
async update(gatewayId: string, input: UpdateCustomerGatewayInput): Promise<CustomerGatewaySummary> {
|
||||
const current = await this.get(gatewayId);
|
||||
const updated: CustomerGatewaySummary = {
|
||||
...current,
|
||||
customerId: input.customerId ?? current.customerId,
|
||||
name: input.name ?? current.name,
|
||||
authMode: input.authMode ?? current.authMode,
|
||||
sourceIp: input.sourceIp === undefined ? current.sourceIp : input.sourceIp,
|
||||
sipUsername: input.sipUsername === undefined ? current.sipUsername : input.sipUsername,
|
||||
sipDomain: input.sipDomain === undefined ? current.sipDomain : input.sipDomain,
|
||||
hasSipCredential: input.sipHa1 === undefined ? current.hasSipCredential : Boolean(input.sipHa1),
|
||||
updatedAt: new Date('2026-06-21T03:00:00.000Z')
|
||||
};
|
||||
this.gateways.set(gatewayId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async setStatus(gatewayId: string, status: CustomerGatewayStatus): Promise<CustomerGatewaySummary> {
|
||||
const current = await this.get(gatewayId);
|
||||
const updated = { ...current, status };
|
||||
this.gateways.set(gatewayId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private summary(input: {
|
||||
id: string;
|
||||
customerId?: string;
|
||||
name: string;
|
||||
authMode?: 'IP' | 'SIP_DIGEST' | 'MIXED';
|
||||
sourceIp?: string | null;
|
||||
sipUsername?: string | null;
|
||||
sipDomain?: string | null;
|
||||
hasSipCredential?: boolean;
|
||||
status?: CustomerGatewayStatus;
|
||||
policyCount?: number;
|
||||
}): CustomerGatewaySummary {
|
||||
return {
|
||||
id: input.id,
|
||||
customerId: input.customerId ?? 'cus_seed',
|
||||
customerName: 'Seed Customer',
|
||||
name: input.name,
|
||||
authMode: input.authMode ?? 'IP',
|
||||
sourceIp: input.sourceIp ?? null,
|
||||
sipUsername: input.sipUsername ?? null,
|
||||
sipDomain: input.sipDomain ?? null,
|
||||
hasSipCredential: input.hasSipCredential ?? false,
|
||||
status: input.status ?? 'ENABLED',
|
||||
policyCount: input.policyCount ?? 0,
|
||||
createdAt: new Date('2026-06-21T02:30:00.000Z'),
|
||||
updatedAt: new Date('2026-06-21T02:30:00.000Z')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('S13 customer gateways API', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let audit: MemoryAuditRepository;
|
||||
|
||||
const tokenFor = (userId: string) =>
|
||||
signAccessToken(
|
||||
{
|
||||
sub: userId,
|
||||
username: userId,
|
||||
roles: ['test'],
|
||||
typ: 'access'
|
||||
},
|
||||
{
|
||||
secret: 'test-only-access-token-secret-min-32-bytes',
|
||||
issuer: 'lisglosips-api',
|
||||
audience: 'lisglosips-web',
|
||||
ttlSeconds: 900
|
||||
}
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
|
||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
|
||||
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
|
||||
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
|
||||
|
||||
const identities = new MemoryIdentityRepository();
|
||||
audit = new MemoryAuditRepository();
|
||||
|
||||
identities.users.set('usr_ops', {
|
||||
id: 'usr_ops',
|
||||
username: 'ops',
|
||||
roles: ['运营管理员'],
|
||||
permissions: ['customer_gateways.view', 'customer_gateways.manage'] as PermissionKey[]
|
||||
});
|
||||
identities.users.set('usr_viewer', {
|
||||
id: 'usr_viewer',
|
||||
username: 'viewer',
|
||||
roles: ['只读'],
|
||||
permissions: ['customer_gateways.view'] as PermissionKey[]
|
||||
});
|
||||
|
||||
const { AppModule } = await import('../app.module.js');
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule]
|
||||
})
|
||||
.overrideProvider(IDENTITY_REPOSITORY)
|
||||
.useValue(identities)
|
||||
.overrideProvider(AUDIT_REPOSITORY)
|
||||
.useValue(audit)
|
||||
.overrideProvider(CUSTOMER_GATEWAYS_REPOSITORY)
|
||||
.useValue(new MemoryCustomerGatewaysRepository())
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('lists gateways without exposing SIP secrets', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v2/customer-gateways?customerId=cus_seed')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body[0]).toMatchObject({
|
||||
id: 'cgw_seed',
|
||||
authMode: 'IP',
|
||||
sourceIp: '100.93.185.30',
|
||||
policyCount: 1
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain('sipPassword');
|
||||
expect(JSON.stringify(response.body)).not.toContain('sipHa1');
|
||||
});
|
||||
|
||||
it('rejects writes without customer_gateways.manage', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/customer-gateways')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.send({ customerId: 'cus_seed', name: 'Denied', authMode: 'IP', sourceIp: '100.93.185.31' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('creates SIP digest gateway, hides the password, and writes audit', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v2/customer-gateways')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({
|
||||
customerId: 'cus_seed',
|
||||
name: 'SIP Digest Gateway',
|
||||
authMode: 'SIP_DIGEST',
|
||||
sipUsername: 'alice-gw',
|
||||
sipDomain: 'SIP.EXAMPLE.LOCAL',
|
||||
sipPassword: 'change-me-very-strong'
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
id: 'cgw_created',
|
||||
authMode: 'SIP_DIGEST',
|
||||
sipUsername: 'alice-gw',
|
||||
sipDomain: 'sip.example.local',
|
||||
sourceIp: null,
|
||||
hasSipCredential: true
|
||||
});
|
||||
expect(response.body.sipPassword).toBeUndefined();
|
||||
expect(response.body.sipHa1).toBeUndefined();
|
||||
expect(audit.entries.some((entry) => entry.module === 'customer_gateways' && entry.action === 'create' && entry.result === 'SUCCESS')).toBe(true);
|
||||
expect(JSON.stringify(audit.entries)).not.toContain('change-me-very-strong');
|
||||
});
|
||||
|
||||
it('requires a new SIP password when SIP identity changes', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v2/customer-gateways/cgw_created')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ sipDomain: 'new.example.local' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('switches to IP auth and supports enable/disable', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v2/customer-gateways/cgw_created')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ authMode: 'IP', sourceIp: '100.93.185.32' })
|
||||
.expect(200)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({
|
||||
authMode: 'IP',
|
||||
sourceIp: '100.93.185.32',
|
||||
sipUsername: null,
|
||||
sipDomain: null,
|
||||
hasSipCredential: false
|
||||
});
|
||||
});
|
||||
|
||||
await request(app.getHttpServer()).post('/api/v2/customer-gateways/cgw_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
await request(app.getHttpServer()).post('/api/v2/customer-gateways/cgw_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CustomerGatewaysController } from './customer-gateways.controller.js';
|
||||
import { CUSTOMER_GATEWAYS_REPOSITORY, PrismaCustomerGatewaysRepository } from './customer-gateways.repository.js';
|
||||
import { CustomerGatewaysService } from './customer-gateways.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [CustomerGatewaysController],
|
||||
providers: [
|
||||
CustomerGatewaysService,
|
||||
{
|
||||
provide: CUSTOMER_GATEWAYS_REPOSITORY,
|
||||
useClass: PrismaCustomerGatewaysRepository
|
||||
}
|
||||
],
|
||||
exports: [CustomerGatewaysService]
|
||||
})
|
||||
export class CustomerGatewaysModule {}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export type CustomerGatewayStatus = 'ENABLED' | 'DISABLED';
|
||||
export type CustomerGatewayAuthMode = 'IP' | 'SIP_DIGEST' | 'MIXED';
|
||||
|
||||
export interface CustomerGatewaySummary {
|
||||
id: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
name: string;
|
||||
authMode: CustomerGatewayAuthMode;
|
||||
sourceIp: string | null;
|
||||
sipUsername: string | null;
|
||||
sipDomain: string | null;
|
||||
hasSipCredential: boolean;
|
||||
status: CustomerGatewayStatus;
|
||||
policyCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateCustomerGatewayInput {
|
||||
customerId: string;
|
||||
name: string;
|
||||
authMode: CustomerGatewayAuthMode;
|
||||
sourceIp?: string | null;
|
||||
sipUsername?: string | null;
|
||||
sipDomain?: string | null;
|
||||
sipHa1?: string;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCustomerGatewayInput {
|
||||
customerId?: string;
|
||||
name?: string;
|
||||
authMode?: CustomerGatewayAuthMode;
|
||||
sourceIp?: string | null;
|
||||
sipUsername?: string | null;
|
||||
sipDomain?: string | null;
|
||||
sipHa1?: string | null;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface CustomerGatewaysRepository {
|
||||
list(customerId?: string): Promise<CustomerGatewaySummary[]>;
|
||||
get(gatewayId: string): Promise<CustomerGatewaySummary>;
|
||||
create(input: CreateCustomerGatewayInput): Promise<CustomerGatewaySummary>;
|
||||
update(gatewayId: string, input: UpdateCustomerGatewayInput): Promise<CustomerGatewaySummary>;
|
||||
setStatus(gatewayId: string, status: CustomerGatewayStatus, actorId?: string): Promise<CustomerGatewaySummary>;
|
||||
}
|
||||
|
||||
export const CUSTOMER_GATEWAYS_REPOSITORY = Symbol('CUSTOMER_GATEWAYS_REPOSITORY');
|
||||
|
||||
function gatewayId(): string {
|
||||
return `cgw_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
|
||||
}
|
||||
|
||||
function outboxId(): string {
|
||||
return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaCustomerGatewaysRepository implements CustomerGatewaysRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(customerId?: string): Promise<CustomerGatewaySummary[]> {
|
||||
const gateways = await this.prisma.customerGateway.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
customerId
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
include: this.includeSummary()
|
||||
});
|
||||
|
||||
return gateways.map((gateway) => this.toSummary(gateway));
|
||||
}
|
||||
|
||||
async get(gatewayIdValue: string): Promise<CustomerGatewaySummary> {
|
||||
return this.toSummary(await this.findActiveOrThrow(gatewayIdValue));
|
||||
}
|
||||
|
||||
async create(input: CreateCustomerGatewayInput): Promise<CustomerGatewaySummary> {
|
||||
await this.ensureCustomerExists(input.customerId);
|
||||
|
||||
try {
|
||||
const gateway = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.customerGateway.create({
|
||||
data: {
|
||||
id: gatewayId(),
|
||||
customerId: input.customerId,
|
||||
name: input.name,
|
||||
authMode: input.authMode,
|
||||
sourceIp: input.sourceIp,
|
||||
sipUsername: input.sipUsername,
|
||||
sipDomain: input.sipDomain,
|
||||
sipHa1: input.sipHa1,
|
||||
createdBy: input.actorId,
|
||||
updatedBy: input.actorId
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, created.id, 'customer_gateway.changed');
|
||||
return created;
|
||||
});
|
||||
|
||||
return this.toSummary(gateway);
|
||||
} catch (error) {
|
||||
this.handleUniqueConflict(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(gatewayIdValue: string, input: UpdateCustomerGatewayInput): Promise<CustomerGatewaySummary> {
|
||||
await this.findActiveOrThrow(gatewayIdValue);
|
||||
if (input.customerId) {
|
||||
await this.ensureCustomerExists(input.customerId);
|
||||
}
|
||||
|
||||
try {
|
||||
const gateway = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.customerGateway.update({
|
||||
where: { id: gatewayIdValue },
|
||||
data: {
|
||||
customerId: input.customerId,
|
||||
name: input.name,
|
||||
authMode: input.authMode,
|
||||
sourceIp: input.sourceIp,
|
||||
sipUsername: input.sipUsername,
|
||||
sipDomain: input.sipDomain,
|
||||
sipHa1: input.sipHa1,
|
||||
updatedBy: input.actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, updated.id, 'customer_gateway.changed');
|
||||
return updated;
|
||||
});
|
||||
|
||||
return this.toSummary(gateway);
|
||||
} catch (error) {
|
||||
this.handleUniqueConflict(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async setStatus(gatewayIdValue: string, status: CustomerGatewayStatus, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
await this.findActiveOrThrow(gatewayIdValue);
|
||||
|
||||
const gateway = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.customerGateway.update({
|
||||
where: { id: gatewayIdValue },
|
||||
data: {
|
||||
status,
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, updated.id, 'customer_gateway.changed');
|
||||
return updated;
|
||||
});
|
||||
|
||||
return this.toSummary(gateway);
|
||||
}
|
||||
|
||||
private async enqueueConfigOutbox(tx: Prisma.TransactionClient, aggregateId: string, eventType: string): Promise<void> {
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
id: outboxId(),
|
||||
aggregateType: 'customer_gateway_config',
|
||||
aggregateId,
|
||||
eventType,
|
||||
payload: {
|
||||
aggregateId,
|
||||
eventType
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureCustomerExists(customerId: string): Promise<void> {
|
||||
const customer = await this.prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: { id: true, deletedAt: true }
|
||||
});
|
||||
|
||||
if (!customer || customer.deletedAt) {
|
||||
throw new NotFoundException({ code: 'CUSTOMER_NOT_FOUND', message: 'Customer not found.' });
|
||||
}
|
||||
}
|
||||
|
||||
private async findActiveOrThrow(gatewayIdValue: string) {
|
||||
const gateway = await this.prisma.customerGateway.findUnique({
|
||||
where: { id: gatewayIdValue },
|
||||
include: this.includeSummary()
|
||||
});
|
||||
|
||||
if (!gateway || gateway.deletedAt) {
|
||||
throw new NotFoundException({ code: 'CUSTOMER_GATEWAY_NOT_FOUND', message: 'Customer gateway not found.' });
|
||||
}
|
||||
|
||||
return gateway;
|
||||
}
|
||||
|
||||
private includeSummary() {
|
||||
return {
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
policies: {
|
||||
where: { deletedAt: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
} satisfies Prisma.CustomerGatewayInclude;
|
||||
}
|
||||
|
||||
private toSummary(gateway: {
|
||||
id: string;
|
||||
customerId: string;
|
||||
name: string;
|
||||
authMode: CustomerGatewayAuthMode;
|
||||
sourceIp: string | null;
|
||||
sipUsername: string | null;
|
||||
sipDomain: string | null;
|
||||
sipHa1: string | null;
|
||||
status: CustomerGatewayStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
customer: { name: string };
|
||||
_count: { policies: number };
|
||||
}): CustomerGatewaySummary {
|
||||
return {
|
||||
id: gateway.id,
|
||||
customerId: gateway.customerId,
|
||||
customerName: gateway.customer.name,
|
||||
name: gateway.name,
|
||||
authMode: gateway.authMode,
|
||||
sourceIp: gateway.sourceIp,
|
||||
sipUsername: gateway.sipUsername,
|
||||
sipDomain: gateway.sipDomain,
|
||||
hasSipCredential: Boolean(gateway.sipHa1),
|
||||
status: gateway.status,
|
||||
policyCount: gateway._count.policies,
|
||||
createdAt: gateway.createdAt,
|
||||
updatedAt: gateway.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private handleUniqueConflict(error: unknown): void {
|
||||
if (error && typeof error === 'object' && 'code' in error && (error as { code?: unknown }).code === 'P2002') {
|
||||
throw new ConflictException({ code: 'CUSTOMER_GATEWAY_CONFLICT', message: 'Customer gateway name or SIP identity already exists.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import {
|
||||
CUSTOMER_GATEWAYS_REPOSITORY,
|
||||
type CreateCustomerGatewayInput,
|
||||
type CustomerGatewayAuthMode,
|
||||
type CustomerGatewaySummary,
|
||||
type CustomerGatewaysRepository,
|
||||
type UpdateCustomerGatewayInput
|
||||
} from './customer-gateways.repository.js';
|
||||
|
||||
interface CreateCustomerGatewayDto {
|
||||
customerId?: unknown;
|
||||
name?: unknown;
|
||||
authMode?: unknown;
|
||||
sourceIp?: unknown;
|
||||
sipUsername?: unknown;
|
||||
sipDomain?: unknown;
|
||||
sipPassword?: unknown;
|
||||
}
|
||||
|
||||
interface UpdateCustomerGatewayDto {
|
||||
customerId?: unknown;
|
||||
name?: unknown;
|
||||
authMode?: unknown;
|
||||
sourceIp?: unknown;
|
||||
sipUsername?: unknown;
|
||||
sipDomain?: unknown;
|
||||
sipPassword?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CustomerGatewaysService {
|
||||
constructor(@Inject(CUSTOMER_GATEWAYS_REPOSITORY) private readonly gateways: CustomerGatewaysRepository) {}
|
||||
|
||||
list(query: { customerId?: unknown } = {}): Promise<CustomerGatewaySummary[]> {
|
||||
const customerId = query.customerId === undefined ? undefined : this.limitedString(query.customerId, 'customerId', 32);
|
||||
return this.gateways.list(customerId);
|
||||
}
|
||||
|
||||
get(gatewayId: string): Promise<CustomerGatewaySummary> {
|
||||
return this.gateways.get(gatewayId);
|
||||
}
|
||||
|
||||
create(body: CreateCustomerGatewayDto, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
const authMode = this.authMode(body.authMode);
|
||||
const sipIdentity = this.normalizeSipIdentity(authMode, body.sipUsername, body.sipDomain);
|
||||
const sipPassword = this.requiredSipPassword(authMode, body.sipPassword);
|
||||
const input: CreateCustomerGatewayInput = {
|
||||
customerId: this.limitedString(body.customerId, 'customerId', 32),
|
||||
name: this.limitedString(body.name, 'name', 120),
|
||||
authMode,
|
||||
sourceIp: this.normalizeSourceIp(authMode, body.sourceIp),
|
||||
sipUsername: sipIdentity.sipUsername,
|
||||
sipDomain: sipIdentity.sipDomain,
|
||||
sipHa1: sipPassword ? this.sipHa1(sipIdentity.sipUsername, sipIdentity.sipDomain, sipPassword) : undefined,
|
||||
actorId
|
||||
};
|
||||
|
||||
return this.gateways.create(input);
|
||||
}
|
||||
|
||||
async update(gatewayId: string, body: UpdateCustomerGatewayDto, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
const current = await this.gateways.get(gatewayId);
|
||||
const authMode = body.authMode === undefined ? current.authMode : this.authMode(body.authMode);
|
||||
const requestedSipUsername = body.sipUsername === undefined ? current.sipUsername : this.nullableString(body.sipUsername, 'sipUsername', 120);
|
||||
const requestedSipDomain = body.sipDomain === undefined ? current.sipDomain : this.nullableString(body.sipDomain, 'sipDomain', 160);
|
||||
const sipIdentity = this.normalizeSipIdentity(authMode, requestedSipUsername, requestedSipDomain);
|
||||
const sourceIp = body.sourceIp === undefined ? current.sourceIp : this.nullableString(body.sourceIp, 'sourceIp', 45);
|
||||
const normalizedSourceIp = this.normalizeSourceIp(authMode, sourceIp);
|
||||
const password = body.sipPassword === undefined ? undefined : this.requiredSipPassword(authMode, body.sipPassword);
|
||||
|
||||
if (this.requiresSip(authMode)) {
|
||||
const identityChanged = sipIdentity.sipUsername !== current.sipUsername || sipIdentity.sipDomain !== current.sipDomain;
|
||||
if (!password && (identityChanged || !current.hasSipCredential)) {
|
||||
throw new BadRequestException({
|
||||
code: 'SIP_PASSWORD_REQUIRED',
|
||||
message: 'sipPassword is required when creating or changing SIP identity.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const input: UpdateCustomerGatewayInput = {
|
||||
customerId: body.customerId === undefined ? undefined : this.limitedString(body.customerId, 'customerId', 32),
|
||||
name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120),
|
||||
authMode,
|
||||
sourceIp: normalizedSourceIp,
|
||||
sipUsername: sipIdentity.sipUsername,
|
||||
sipDomain: sipIdentity.sipDomain,
|
||||
sipHa1: password ? this.sipHa1(sipIdentity.sipUsername, sipIdentity.sipDomain, password) : authMode === 'IP' ? null : undefined,
|
||||
actorId
|
||||
};
|
||||
|
||||
return this.gateways.update(gatewayId, input);
|
||||
}
|
||||
|
||||
enable(gatewayId: string, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
return this.gateways.setStatus(gatewayId, 'ENABLED', actorId);
|
||||
}
|
||||
|
||||
disable(gatewayId: string, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
return this.gateways.setStatus(gatewayId, 'DISABLED', actorId);
|
||||
}
|
||||
|
||||
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 nullableString(value: unknown, field: string, maxLength: number): string | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.limitedString(value, field, maxLength);
|
||||
}
|
||||
|
||||
private authMode(value: unknown): CustomerGatewayAuthMode {
|
||||
if (value !== 'IP' && value !== 'SIP_DIGEST' && value !== 'MIXED') {
|
||||
throw new BadRequestException({ code: 'AUTH_MODE_INVALID', message: 'Auth mode is invalid.' });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private normalizeSourceIp(authMode: CustomerGatewayAuthMode, value: unknown): string | null {
|
||||
if (!this.requiresIp(authMode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceIp = this.limitedString(value, 'sourceIp', 45);
|
||||
if (net.isIP(sourceIp) === 0) {
|
||||
throw new BadRequestException({ code: 'SOURCE_IP_INVALID', message: 'sourceIp must be an IPv4 or IPv6 address.' });
|
||||
}
|
||||
|
||||
return sourceIp;
|
||||
}
|
||||
|
||||
private normalizeSipIdentity(authMode: CustomerGatewayAuthMode, usernameValue: unknown, domainValue: unknown) {
|
||||
if (!this.requiresSip(authMode)) {
|
||||
return {
|
||||
sipUsername: null,
|
||||
sipDomain: null
|
||||
};
|
||||
}
|
||||
|
||||
const sipUsername = this.limitedString(usernameValue, 'sipUsername', 120);
|
||||
const sipDomain = this.limitedString(domainValue, 'sipDomain', 160).toLowerCase();
|
||||
if (!/^[A-Za-z0-9_.:+-]+$/.test(sipUsername)) {
|
||||
throw new BadRequestException({ code: 'SIP_USERNAME_INVALID', message: 'sipUsername contains invalid characters.' });
|
||||
}
|
||||
if (!/^[A-Za-z0-9.-]+$/.test(sipDomain)) {
|
||||
throw new BadRequestException({ code: 'SIP_DOMAIN_INVALID', message: 'sipDomain contains invalid characters.' });
|
||||
}
|
||||
|
||||
return { sipUsername, sipDomain };
|
||||
}
|
||||
|
||||
private requiredSipPassword(authMode: CustomerGatewayAuthMode, value: unknown): string | undefined {
|
||||
if (!this.requiresSip(authMode)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const password = this.limitedString(value, 'sipPassword', 128);
|
||||
if (password.length < 12) {
|
||||
throw new BadRequestException({ code: 'SIP_PASSWORD_WEAK', message: 'sipPassword must be at least 12 characters.' });
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
private sipHa1(username: string | null, domain: string | null, password: string): string {
|
||||
if (!username || !domain) {
|
||||
throw new BadRequestException({ code: 'SIP_IDENTITY_REQUIRED', message: 'SIP identity is required.' });
|
||||
}
|
||||
|
||||
return crypto.createHash('md5').update(`${username}:${domain}:${password}`, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
private requiresIp(authMode: CustomerGatewayAuthMode): boolean {
|
||||
return authMode === 'IP' || authMode === 'MIXED';
|
||||
}
|
||||
|
||||
private requiresSip(authMode: CustomerGatewayAuthMode): boolean {
|
||||
return authMode === 'SIP_DIGEST' || authMode === 'MIXED';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
import { CustomersService } from './customers.service.js';
|
||||
|
||||
@ApiTags('customers')
|
||||
@Controller('customers')
|
||||
export class CustomersController {
|
||||
constructor(@Inject(CustomersService) private readonly customersService: CustomersService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('customers.view')
|
||||
list() {
|
||||
return this.customersService.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('customers.view')
|
||||
get(@Param('id') id: string) {
|
||||
return this.customersService.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('customers.manage')
|
||||
@AuditAction({ module: 'customers', action: 'create', objectType: 'customer' })
|
||||
create(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.customersService.create(body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('customers.manage')
|
||||
@AuditAction({ module: 'customers', action: 'update', objectType: 'customer', objectIdParam: 'id' })
|
||||
update(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.customersService.update(id, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/enable')
|
||||
@RequirePermissions('customers.manage')
|
||||
@AuditAction({ module: 'customers', action: 'enable', objectType: 'customer', objectIdParam: 'id' })
|
||||
enable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.customersService.enable(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/disable')
|
||||
@RequirePermissions('customers.manage')
|
||||
@AuditAction({ module: 'customers', action: 'disable', objectType: 'customer', objectIdParam: 'id' })
|
||||
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.customersService.disable(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions('customers.manage')
|
||||
@AuditAction({ module: 'customers', action: 'delete', objectType: 'customer', objectIdParam: 'id' })
|
||||
remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.customersService.remove(id, currentUser?.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
|
||||
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js';
|
||||
import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js';
|
||||
import type { CurrentUser } from '../security/security.metadata.js';
|
||||
import {
|
||||
CUSTOMERS_REPOSITORY,
|
||||
type CreateCustomerInput,
|
||||
type CustomerStatus,
|
||||
type CustomerSummary,
|
||||
type CustomersRepository,
|
||||
type UpdateCustomerInput
|
||||
} from './customers.repository.js';
|
||||
|
||||
class MemoryIdentityRepository implements IdentityRepository {
|
||||
users = new Map<string, CurrentUser>();
|
||||
|
||||
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||
return this.users.get(userId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAuditRepository implements AuditRepository {
|
||||
entries: AuditEntryInput[] = [];
|
||||
|
||||
async write(input: AuditEntryInput): Promise<void> {
|
||||
this.entries.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryCustomersRepository implements CustomersRepository {
|
||||
private readonly customers = new Map<string, CustomerSummary>();
|
||||
|
||||
constructor() {
|
||||
this.customers.set(
|
||||
'cus_seed',
|
||||
this.summary({
|
||||
id: 'cus_seed',
|
||||
name: 'Seed Customer',
|
||||
status: 'ENABLED',
|
||||
creditLimit: '100.000000',
|
||||
minBalance: '5.000000',
|
||||
gatewayCount: 2
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async list(): Promise<CustomerSummary[]> {
|
||||
return [...this.customers.values()];
|
||||
}
|
||||
|
||||
async get(customerId: string): Promise<CustomerSummary> {
|
||||
return this.customers.get(customerId) ?? this.summary({ id: customerId, name: 'Missing' });
|
||||
}
|
||||
|
||||
async create(input: CreateCustomerInput): Promise<CustomerSummary> {
|
||||
const customer = this.summary({
|
||||
id: 'cus_created',
|
||||
name: input.name,
|
||||
contactName: input.contactName ?? null,
|
||||
phone: input.phone ?? null,
|
||||
email: input.email ?? null,
|
||||
domain: input.domain ?? null,
|
||||
status: input.status ?? 'ENABLED',
|
||||
billingMode: input.billingMode,
|
||||
creditLimit: input.creditLimit,
|
||||
minBalance: input.minBalance,
|
||||
notes: input.notes ?? null
|
||||
});
|
||||
this.customers.set(customer.id, customer);
|
||||
return customer;
|
||||
}
|
||||
|
||||
async update(customerId: string, input: UpdateCustomerInput): Promise<CustomerSummary> {
|
||||
const current = this.customers.get(customerId) ?? this.summary({ id: customerId, name: 'Updated Customer' });
|
||||
const updated: CustomerSummary = {
|
||||
...current,
|
||||
name: input.name ?? current.name,
|
||||
contactName: input.contactName === undefined ? current.contactName : input.contactName,
|
||||
phone: input.phone === undefined ? current.phone : input.phone,
|
||||
email: input.email === undefined ? current.email : input.email,
|
||||
domain: input.domain === undefined ? current.domain : input.domain,
|
||||
status: input.status ?? current.status,
|
||||
billingMode: input.billingMode ?? current.billingMode,
|
||||
creditLimit: input.creditLimit ?? current.creditLimit,
|
||||
minBalance: input.minBalance ?? current.minBalance,
|
||||
notes: input.notes === undefined ? current.notes : input.notes,
|
||||
updatedAt: new Date('2026-06-21T01:00:00.000Z')
|
||||
};
|
||||
this.customers.set(customerId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async setStatus(customerId: string, status: CustomerStatus): Promise<CustomerSummary> {
|
||||
return this.update(customerId, { status });
|
||||
}
|
||||
|
||||
async softDelete(customerId: string): Promise<CustomerSummary> {
|
||||
return this.update(customerId, { status: 'DISABLED' });
|
||||
}
|
||||
|
||||
private summary(input: {
|
||||
id: string;
|
||||
name: string;
|
||||
contactName?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
domain?: string | null;
|
||||
status?: CustomerStatus;
|
||||
billingMode?: 'PREPAID' | 'POSTPAID';
|
||||
creditLimit?: string;
|
||||
minBalance?: string;
|
||||
notes?: string | null;
|
||||
gatewayCount?: number;
|
||||
}): CustomerSummary {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
contactName: input.contactName ?? null,
|
||||
phone: input.phone ?? null,
|
||||
email: input.email ?? null,
|
||||
domain: input.domain ?? null,
|
||||
status: input.status ?? 'ENABLED',
|
||||
billingMode: input.billingMode ?? 'PREPAID',
|
||||
balance: '10.000000',
|
||||
creditLimit: input.creditLimit ?? '0.000000',
|
||||
minBalance: input.minBalance ?? '0.000000',
|
||||
availableBalance: (10 + Number(input.creditLimit ?? '0')).toFixed(6),
|
||||
notes: input.notes ?? null,
|
||||
gatewayCount: input.gatewayCount ?? 0,
|
||||
createdAt: new Date('2026-06-21T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-21T00:00:00.000Z')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('S11 customers API', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let audit: MemoryAuditRepository;
|
||||
|
||||
const tokenFor = (userId: string) =>
|
||||
signAccessToken(
|
||||
{
|
||||
sub: userId,
|
||||
username: userId,
|
||||
roles: ['test'],
|
||||
typ: 'access'
|
||||
},
|
||||
{
|
||||
secret: 'test-only-access-token-secret-min-32-bytes',
|
||||
issuer: 'lisglosips-api',
|
||||
audience: 'lisglosips-web',
|
||||
ttlSeconds: 900
|
||||
}
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
|
||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
|
||||
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
|
||||
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
|
||||
|
||||
const identities = new MemoryIdentityRepository();
|
||||
audit = new MemoryAuditRepository();
|
||||
|
||||
identities.users.set('usr_admin', {
|
||||
id: 'usr_admin',
|
||||
username: 'admin',
|
||||
roles: ['超级管理员'],
|
||||
permissions: ['customers.view', 'customers.manage', 'audit.view'] as PermissionKey[]
|
||||
});
|
||||
identities.users.set('usr_readonly', {
|
||||
id: 'usr_readonly',
|
||||
username: 'readonly',
|
||||
roles: ['只读'],
|
||||
permissions: ['customers.view'] as PermissionKey[]
|
||||
});
|
||||
|
||||
const { AppModule } = await import('../app.module.js');
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule]
|
||||
})
|
||||
.overrideProvider(IDENTITY_REPOSITORY)
|
||||
.useValue(identities)
|
||||
.overrideProvider(AUDIT_REPOSITORY)
|
||||
.useValue(audit)
|
||||
.overrideProvider(CUSTOMERS_REPOSITORY)
|
||||
.useValue(new MemoryCustomersRepository())
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('returns customer balance, credit, and gateway count to viewers', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/v2/customers').set('Authorization', `Bearer ${tokenFor('usr_readonly')}`).expect(200);
|
||||
|
||||
expect(response.body[0]).toMatchObject({
|
||||
id: 'cus_seed',
|
||||
balance: '10.000000',
|
||||
creditLimit: '100.000000',
|
||||
minBalance: '5.000000',
|
||||
availableBalance: '110.000000',
|
||||
gatewayCount: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects write operations without customers.manage', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/customers')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_readonly')}`)
|
||||
.send({ name: 'Rejected Customer' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('creates, disables, enables, and soft deletes customers with audit entries', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/customers')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_admin')}`)
|
||||
.send({
|
||||
name: 'Acme Telecom',
|
||||
contactName: 'Ops',
|
||||
creditLimit: '88.5',
|
||||
minBalance: '2.25',
|
||||
billingMode: 'POSTPAID'
|
||||
})
|
||||
.expect(201)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({
|
||||
id: 'cus_created',
|
||||
name: 'Acme Telecom',
|
||||
creditLimit: '88.500000',
|
||||
minBalance: '2.250000',
|
||||
billingMode: 'POSTPAID'
|
||||
});
|
||||
});
|
||||
|
||||
await request(app.getHttpServer()).post('/api/v2/customers/cus_created/disable').set('Authorization', `Bearer ${tokenFor('usr_admin')}`).expect(201);
|
||||
await request(app.getHttpServer()).post('/api/v2/customers/cus_created/enable').set('Authorization', `Bearer ${tokenFor('usr_admin')}`).expect(201);
|
||||
await request(app.getHttpServer()).delete('/api/v2/customers/cus_created').set('Authorization', `Bearer ${tokenFor('usr_admin')}`).expect(200);
|
||||
|
||||
expect(audit.entries.some((entry) => entry.module === 'customers' && entry.action === 'create' && entry.result === 'SUCCESS')).toBe(true);
|
||||
expect(audit.entries.some((entry) => entry.module === 'customers' && entry.action === 'disable' && entry.objectId === 'cus_created')).toBe(true);
|
||||
expect(audit.entries.some((entry) => entry.module === 'customers' && entry.action === 'delete' && entry.objectId === 'cus_created')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CustomersController } from './customers.controller.js';
|
||||
import { CUSTOMERS_REPOSITORY, PrismaCustomersRepository } from './customers.repository.js';
|
||||
import { CustomersService } from './customers.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [CustomersController],
|
||||
providers: [
|
||||
CustomersService,
|
||||
{
|
||||
provide: CUSTOMERS_REPOSITORY,
|
||||
useClass: PrismaCustomersRepository
|
||||
}
|
||||
],
|
||||
exports: [CustomersService]
|
||||
})
|
||||
export class CustomersModule {}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export type CustomerStatus = 'ENABLED' | 'DISABLED';
|
||||
export type CustomerBillingMode = 'PREPAID' | 'POSTPAID';
|
||||
|
||||
export interface CustomerSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
contactName: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
domain: string | null;
|
||||
status: CustomerStatus;
|
||||
billingMode: CustomerBillingMode;
|
||||
balance: string;
|
||||
creditLimit: string;
|
||||
minBalance: string;
|
||||
availableBalance: string;
|
||||
notes: string | null;
|
||||
gatewayCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateCustomerInput {
|
||||
name: string;
|
||||
contactName?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
domain?: string;
|
||||
status?: CustomerStatus;
|
||||
billingMode: CustomerBillingMode;
|
||||
creditLimit: string;
|
||||
minBalance: string;
|
||||
notes?: string;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCustomerInput {
|
||||
name?: string;
|
||||
contactName?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
domain?: string | null;
|
||||
status?: CustomerStatus;
|
||||
billingMode?: CustomerBillingMode;
|
||||
creditLimit?: string;
|
||||
minBalance?: string;
|
||||
notes?: string | null;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface CustomersRepository {
|
||||
list(): Promise<CustomerSummary[]>;
|
||||
get(customerId: string): Promise<CustomerSummary>;
|
||||
create(input: CreateCustomerInput): Promise<CustomerSummary>;
|
||||
update(customerId: string, input: UpdateCustomerInput): Promise<CustomerSummary>;
|
||||
setStatus(customerId: string, status: CustomerStatus, actorId?: string): Promise<CustomerSummary>;
|
||||
softDelete(customerId: string, actorId?: string): Promise<CustomerSummary>;
|
||||
}
|
||||
|
||||
export const CUSTOMERS_REPOSITORY = Symbol('CUSTOMERS_REPOSITORY');
|
||||
|
||||
function customerId(): string {
|
||||
return `cus_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaCustomersRepository implements CustomersRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<CustomerSummary[]> {
|
||||
const customers = await this.prisma.customer.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
gateways: {
|
||||
where: { deletedAt: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return customers.map((customer) => this.toSummary(customer));
|
||||
}
|
||||
|
||||
async get(customerIdValue: string): Promise<CustomerSummary> {
|
||||
return this.toSummary(await this.findActiveOrThrow(customerIdValue));
|
||||
}
|
||||
|
||||
async create(input: CreateCustomerInput): Promise<CustomerSummary> {
|
||||
const customer = await this.prisma.customer.create({
|
||||
data: {
|
||||
id: customerId(),
|
||||
name: input.name,
|
||||
contactName: input.contactName,
|
||||
phone: input.phone,
|
||||
email: input.email,
|
||||
domain: input.domain,
|
||||
status: input.status ?? 'ENABLED',
|
||||
billingMode: input.billingMode,
|
||||
creditLimit: new Prisma.Decimal(input.creditLimit),
|
||||
minBalance: new Prisma.Decimal(input.minBalance),
|
||||
notes: input.notes,
|
||||
createdBy: input.actorId,
|
||||
updatedBy: input.actorId
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
gateways: {
|
||||
where: { deletedAt: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.toSummary(customer);
|
||||
}
|
||||
|
||||
async update(customerIdValue: string, input: UpdateCustomerInput): Promise<CustomerSummary> {
|
||||
await this.ensureExists(customerIdValue);
|
||||
|
||||
const customer = await this.prisma.customer.update({
|
||||
where: { id: customerIdValue },
|
||||
data: {
|
||||
name: input.name,
|
||||
contactName: input.contactName,
|
||||
phone: input.phone,
|
||||
email: input.email,
|
||||
domain: input.domain,
|
||||
status: input.status,
|
||||
billingMode: input.billingMode,
|
||||
creditLimit: input.creditLimit === undefined ? undefined : new Prisma.Decimal(input.creditLimit),
|
||||
minBalance: input.minBalance === undefined ? undefined : new Prisma.Decimal(input.minBalance),
|
||||
notes: input.notes,
|
||||
updatedBy: input.actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
gateways: {
|
||||
where: { deletedAt: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.toSummary(customer);
|
||||
}
|
||||
|
||||
async setStatus(customerIdValue: string, status: CustomerStatus, actorId?: string): Promise<CustomerSummary> {
|
||||
return this.update(customerIdValue, { status, actorId });
|
||||
}
|
||||
|
||||
async softDelete(customerIdValue: string, actorId?: string): Promise<CustomerSummary> {
|
||||
const existing = await this.findActiveOrThrow(customerIdValue);
|
||||
const linkedGateways = await this.prisma.customerGateway.count({
|
||||
where: {
|
||||
customerId: customerIdValue,
|
||||
deletedAt: null
|
||||
}
|
||||
});
|
||||
|
||||
if (linkedGateways > 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'CUSTOMER_HAS_GATEWAYS',
|
||||
message: 'Customer with active gateways cannot be deleted.'
|
||||
});
|
||||
}
|
||||
|
||||
const customer = await this.prisma.customer.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
gateways: {
|
||||
where: { deletedAt: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.toSummary(customer);
|
||||
}
|
||||
|
||||
private async ensureExists(customerIdValue: string): Promise<void> {
|
||||
await this.findActiveOrThrow(customerIdValue);
|
||||
}
|
||||
|
||||
private async findActiveOrThrow(customerIdValue: string) {
|
||||
const customer = await this.prisma.customer.findUnique({
|
||||
where: { id: customerIdValue },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
gateways: {
|
||||
where: { deletedAt: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!customer || customer.deletedAt) {
|
||||
throw new NotFoundException({ code: 'CUSTOMER_NOT_FOUND', message: 'Customer not found.' });
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
private toSummary(customer: {
|
||||
id: string;
|
||||
name: string;
|
||||
contactName: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
domain: string | null;
|
||||
status: CustomerStatus;
|
||||
billingMode: CustomerBillingMode;
|
||||
balance: Prisma.Decimal;
|
||||
creditLimit: Prisma.Decimal;
|
||||
minBalance: Prisma.Decimal;
|
||||
notes: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
_count: { gateways: number };
|
||||
}): CustomerSummary {
|
||||
return {
|
||||
id: customer.id,
|
||||
name: customer.name,
|
||||
contactName: customer.contactName,
|
||||
phone: customer.phone,
|
||||
email: customer.email,
|
||||
domain: customer.domain,
|
||||
status: customer.status,
|
||||
billingMode: customer.billingMode,
|
||||
balance: customer.balance.toFixed(6),
|
||||
creditLimit: customer.creditLimit.toFixed(6),
|
||||
minBalance: customer.minBalance.toFixed(6),
|
||||
availableBalance: customer.balance.plus(customer.creditLimit).toFixed(6),
|
||||
notes: customer.notes,
|
||||
gatewayCount: customer._count.gateways,
|
||||
createdAt: customer.createdAt,
|
||||
updatedAt: customer.updatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
CUSTOMERS_REPOSITORY,
|
||||
type CreateCustomerInput,
|
||||
type CustomerBillingMode,
|
||||
type CustomerStatus,
|
||||
type CustomerSummary,
|
||||
type CustomersRepository,
|
||||
type UpdateCustomerInput
|
||||
} from './customers.repository.js';
|
||||
|
||||
interface CreateCustomerDto {
|
||||
name?: unknown;
|
||||
contactName?: unknown;
|
||||
phone?: unknown;
|
||||
email?: unknown;
|
||||
domain?: unknown;
|
||||
status?: unknown;
|
||||
billingMode?: unknown;
|
||||
creditLimit?: unknown;
|
||||
minBalance?: unknown;
|
||||
notes?: unknown;
|
||||
}
|
||||
|
||||
interface UpdateCustomerDto {
|
||||
name?: unknown;
|
||||
contactName?: unknown;
|
||||
phone?: unknown;
|
||||
email?: unknown;
|
||||
domain?: unknown;
|
||||
status?: unknown;
|
||||
billingMode?: unknown;
|
||||
creditLimit?: unknown;
|
||||
minBalance?: unknown;
|
||||
notes?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(@Inject(CUSTOMERS_REPOSITORY) private readonly customers: CustomersRepository) {}
|
||||
|
||||
list(): Promise<CustomerSummary[]> {
|
||||
return this.customers.list();
|
||||
}
|
||||
|
||||
get(customerId: string): Promise<CustomerSummary> {
|
||||
return this.customers.get(customerId);
|
||||
}
|
||||
|
||||
create(body: CreateCustomerDto, actorId?: string): Promise<CustomerSummary> {
|
||||
const input: CreateCustomerInput = {
|
||||
name: this.limitedString(body.name, 'name', 120),
|
||||
contactName: this.optionalString(body.contactName, 'contactName', 80),
|
||||
phone: this.optionalString(body.phone, 'phone', 32),
|
||||
email: this.optionalString(body.email, 'email', 160),
|
||||
domain: this.optionalString(body.domain, 'domain', 160),
|
||||
status: body.status === undefined ? 'ENABLED' : this.status(body.status),
|
||||
billingMode: body.billingMode === undefined ? 'PREPAID' : this.billingMode(body.billingMode),
|
||||
creditLimit: body.creditLimit === undefined ? '0.000000' : this.money(body.creditLimit, 'creditLimit'),
|
||||
minBalance: body.minBalance === undefined ? '0.000000' : this.money(body.minBalance, 'minBalance'),
|
||||
notes: this.optionalString(body.notes, 'notes', 500),
|
||||
actorId
|
||||
};
|
||||
|
||||
return this.customers.create(input);
|
||||
}
|
||||
|
||||
update(customerId: string, body: UpdateCustomerDto, actorId?: string): Promise<CustomerSummary> {
|
||||
const input: UpdateCustomerInput = {
|
||||
name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120),
|
||||
contactName: body.contactName === undefined ? undefined : this.nullableString(body.contactName, 'contactName', 80),
|
||||
phone: body.phone === undefined ? undefined : this.nullableString(body.phone, 'phone', 32),
|
||||
email: body.email === undefined ? undefined : this.nullableString(body.email, 'email', 160),
|
||||
domain: body.domain === undefined ? undefined : this.nullableString(body.domain, 'domain', 160),
|
||||
status: body.status === undefined ? undefined : this.status(body.status),
|
||||
billingMode: body.billingMode === undefined ? undefined : this.billingMode(body.billingMode),
|
||||
creditLimit: body.creditLimit === undefined ? undefined : this.money(body.creditLimit, 'creditLimit'),
|
||||
minBalance: body.minBalance === undefined ? undefined : this.money(body.minBalance, 'minBalance'),
|
||||
notes: body.notes === undefined ? undefined : this.nullableString(body.notes, 'notes', 500),
|
||||
actorId
|
||||
};
|
||||
|
||||
return this.customers.update(customerId, input);
|
||||
}
|
||||
|
||||
enable(customerId: string, actorId?: string): Promise<CustomerSummary> {
|
||||
return this.customers.setStatus(customerId, 'ENABLED', actorId);
|
||||
}
|
||||
|
||||
disable(customerId: string, actorId?: string): Promise<CustomerSummary> {
|
||||
return this.customers.setStatus(customerId, 'DISABLED', actorId);
|
||||
}
|
||||
|
||||
remove(customerId: string, actorId?: string): Promise<CustomerSummary> {
|
||||
return this.customers.softDelete(customerId, actorId);
|
||||
}
|
||||
|
||||
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 optionalString(value: unknown, field: string, maxLength: number): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.limitedString(value, field, maxLength);
|
||||
}
|
||||
|
||||
private nullableString(value: unknown, field: string, maxLength: number): string | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.limitedString(value, field, maxLength);
|
||||
}
|
||||
|
||||
private status(value: unknown): CustomerStatus {
|
||||
if (value !== 'ENABLED' && value !== 'DISABLED') {
|
||||
throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private billingMode(value: unknown): CustomerBillingMode {
|
||||
if (value !== 'PREPAID' && value !== 'POSTPAID') {
|
||||
throw new BadRequestException({ code: 'BILLING_MODE_INVALID', message: 'Billing mode is invalid.' });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private money(value: unknown, field: string): string {
|
||||
const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : '';
|
||||
if (!/^\d{1,14}(?:\.\d{1,6})?$/.test(raw)) {
|
||||
throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be a non-negative decimal with up to 6 places.` });
|
||||
}
|
||||
|
||||
const [integerPart, fractionPart = ''] = raw.split('.');
|
||||
return `${integerPart}.${fractionPart.padEnd(6, '0')}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, Inject, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermissions } from '../security/security.metadata.js';
|
||||
import { DashboardService } from './dashboard.service.js';
|
||||
|
||||
@ApiTags('dashboard')
|
||||
@Controller('dashboard')
|
||||
export class DashboardController {
|
||||
constructor(@Inject(DashboardService) private readonly dashboardService: DashboardService) {}
|
||||
|
||||
@Get('summary')
|
||||
@RequirePermissions('dashboard.view')
|
||||
summary() {
|
||||
return this.dashboardService.summary();
|
||||
}
|
||||
|
||||
@Get('trends')
|
||||
@RequirePermissions('dashboard.view')
|
||||
trends(@Query() query: { hours?: string; bucketMinutes?: string }) {
|
||||
return this.dashboardService.trends(query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DashboardController } from './dashboard.controller.js';
|
||||
import { PrismaDashboardRepository, DASHBOARD_REPOSITORY } from './dashboard.repository.js';
|
||||
import { DashboardService } from './dashboard.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [DashboardController],
|
||||
providers: [
|
||||
DashboardService,
|
||||
{
|
||||
provide: DASHBOARD_REPOSITORY,
|
||||
useClass: PrismaDashboardRepository
|
||||
}
|
||||
]
|
||||
})
|
||||
export class DashboardModule {}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export interface DashboardCdrRow {
|
||||
startedAt: Date;
|
||||
durationSec: number;
|
||||
sipCode: number;
|
||||
}
|
||||
|
||||
export interface DashboardRatedRow {
|
||||
startedAt: Date;
|
||||
customerFee: string;
|
||||
vendorCost: string;
|
||||
grossProfit: string;
|
||||
}
|
||||
|
||||
export interface DashboardFailureCodeRow {
|
||||
sipCode: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DashboardGatewayFailureRow {
|
||||
vendorGatewayId: string;
|
||||
vendorGatewayName: string;
|
||||
vendorName: string | null;
|
||||
host: string;
|
||||
port: number;
|
||||
status: string;
|
||||
failedCalls: number;
|
||||
totalCalls: number;
|
||||
}
|
||||
|
||||
export interface DashboardSummarySnapshot {
|
||||
cdrs: DashboardCdrRow[];
|
||||
ratedCdrs: DashboardRatedRow[];
|
||||
failureCodes: DashboardFailureCodeRow[];
|
||||
abnormalGateways: DashboardGatewayFailureRow[];
|
||||
activeCustomers: number;
|
||||
activeCustomerGateways: number;
|
||||
activeVendorGateways: number;
|
||||
pendingQuality: number;
|
||||
}
|
||||
|
||||
export const DASHBOARD_REPOSITORY = Symbol('DASHBOARD_REPOSITORY');
|
||||
|
||||
export interface DashboardRepository {
|
||||
summaryWindow(start: Date, end: Date): Promise<DashboardSummarySnapshot>;
|
||||
trendWindow(start: Date, end: Date): Promise<{ cdrs: DashboardCdrRow[]; ratedCdrs: DashboardRatedRow[] }>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaDashboardRepository implements DashboardRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async summaryWindow(start: Date, end: Date): Promise<DashboardSummarySnapshot> {
|
||||
const [cdrs, ratedCdrs, activeCustomers, activeCustomerGateways, activeVendorGateways, pendingQuality] = await Promise.all([
|
||||
this.prisma.rawCdr.findMany({
|
||||
where: { startedAt: { gte: start, lt: end } },
|
||||
select: { startedAt: true, durationSec: true, sipCode: true }
|
||||
}),
|
||||
this.prisma.ratedCdr.findMany({
|
||||
where: { rawCdr: { startedAt: { gte: start, lt: end } } },
|
||||
select: {
|
||||
customerFee: true,
|
||||
vendorCost: true,
|
||||
grossProfit: true,
|
||||
rawCdr: { select: { startedAt: true } }
|
||||
}
|
||||
}),
|
||||
this.prisma.customer.count({ where: { status: 'ENABLED', deletedAt: null } }),
|
||||
this.prisma.customerGateway.count({ where: { status: 'ENABLED', deletedAt: null } }),
|
||||
this.prisma.vendorGateway.count({ where: { status: 'ENABLED', deletedAt: null } }),
|
||||
this.prisma.recording.count({ where: { status: 'READY', reviews: { none: {} } } })
|
||||
]);
|
||||
|
||||
const failureCodes = failureCodesFrom(cdrs);
|
||||
const abnormalGateways = await this.gatewayFailures(start, end);
|
||||
|
||||
return {
|
||||
cdrs,
|
||||
ratedCdrs: ratedCdrs.map((row) => ({
|
||||
startedAt: row.rawCdr.startedAt,
|
||||
customerFee: decimalString(row.customerFee),
|
||||
vendorCost: decimalString(row.vendorCost),
|
||||
grossProfit: decimalString(row.grossProfit)
|
||||
})),
|
||||
failureCodes,
|
||||
abnormalGateways,
|
||||
activeCustomers,
|
||||
activeCustomerGateways,
|
||||
activeVendorGateways,
|
||||
pendingQuality
|
||||
};
|
||||
}
|
||||
|
||||
async trendWindow(start: Date, end: Date): Promise<{ cdrs: DashboardCdrRow[]; ratedCdrs: DashboardRatedRow[] }> {
|
||||
const [cdrs, ratedCdrs] = await Promise.all([
|
||||
this.prisma.rawCdr.findMany({
|
||||
where: { startedAt: { gte: start, lt: end } },
|
||||
select: { startedAt: true, durationSec: true, sipCode: true }
|
||||
}),
|
||||
this.prisma.ratedCdr.findMany({
|
||||
where: { rawCdr: { startedAt: { gte: start, lt: end } } },
|
||||
select: {
|
||||
customerFee: true,
|
||||
vendorCost: true,
|
||||
grossProfit: true,
|
||||
rawCdr: { select: { startedAt: true } }
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
cdrs,
|
||||
ratedCdrs: ratedCdrs.map((row) => ({
|
||||
startedAt: row.rawCdr.startedAt,
|
||||
customerFee: decimalString(row.customerFee),
|
||||
vendorCost: decimalString(row.vendorCost),
|
||||
grossProfit: decimalString(row.grossProfit)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
private async gatewayFailures(start: Date, end: Date): Promise<DashboardGatewayFailureRow[]> {
|
||||
const cdrs = await this.prisma.rawCdr.findMany({
|
||||
where: { startedAt: { gte: start, lt: end }, vendorGatewayId: { not: null } },
|
||||
select: {
|
||||
vendorGatewayId: true,
|
||||
sipCode: true,
|
||||
vendorGateway: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
host: true,
|
||||
port: true,
|
||||
status: true,
|
||||
vendor: { select: { name: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const grouped = new Map<string, { failedCalls: number; totalCalls: number; gateway: NonNullable<(typeof cdrs)[number]['vendorGateway']> }>();
|
||||
for (const cdr of cdrs) {
|
||||
if (!cdr.vendorGatewayId || !cdr.vendorGateway) {
|
||||
continue;
|
||||
}
|
||||
const current = grouped.get(cdr.vendorGatewayId) ?? { failedCalls: 0, totalCalls: 0, gateway: cdr.vendorGateway };
|
||||
current.totalCalls += 1;
|
||||
if (!isAnswered(cdr.sipCode)) {
|
||||
current.failedCalls += 1;
|
||||
}
|
||||
grouped.set(cdr.vendorGatewayId, current);
|
||||
}
|
||||
|
||||
return [...grouped.entries()]
|
||||
.map(([vendorGatewayId, item]) => ({
|
||||
vendorGatewayId,
|
||||
vendorGatewayName: item.gateway.name,
|
||||
vendorName: item.gateway.vendor?.name ?? null,
|
||||
host: item.gateway.host,
|
||||
port: item.gateway.port,
|
||||
status: item.gateway.status,
|
||||
failedCalls: item.failedCalls,
|
||||
totalCalls: item.totalCalls
|
||||
}))
|
||||
.filter((item) => item.failedCalls > 0)
|
||||
.sort((left, right) => right.failedCalls - left.failedCalls || right.totalCalls - left.totalCalls)
|
||||
.slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
function failureCodesFrom(cdrs: DashboardCdrRow[]): DashboardFailureCodeRow[] {
|
||||
const counts = new Map<number, number>();
|
||||
for (const cdr of cdrs) {
|
||||
if (isAnswered(cdr.sipCode)) {
|
||||
continue;
|
||||
}
|
||||
counts.set(cdr.sipCode, (counts.get(cdr.sipCode) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([sipCode, count]) => ({ sipCode, count }))
|
||||
.sort((left, right) => right.count - left.count || left.sipCode - right.sipCode)
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
function decimalString(value: Prisma.Decimal): string {
|
||||
return value.toFixed(6);
|
||||
}
|
||||
|
||||
function isAnswered(sipCode: number): boolean {
|
||||
return sipCode >= 200 && sipCode < 300;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildTrendBuckets, callMetrics, DashboardService, startOfShanghaiDayUtc } from './dashboard.service.js';
|
||||
import type { DashboardRepository } from './dashboard.repository.js';
|
||||
|
||||
describe('dashboard service', () => {
|
||||
it('uses Asia/Shanghai day boundary for today summary', async () => {
|
||||
const calls: Array<{ start: Date; end: Date }> = [];
|
||||
const repository = {
|
||||
summaryWindow: async (start: Date, end: Date) => {
|
||||
calls.push({ start, end });
|
||||
return {
|
||||
cdrs: [],
|
||||
ratedCdrs: [],
|
||||
failureCodes: [],
|
||||
abnormalGateways: [],
|
||||
activeCustomers: 0,
|
||||
activeCustomerGateways: 0,
|
||||
activeVendorGateways: 0,
|
||||
pendingQuality: 0
|
||||
};
|
||||
}
|
||||
} as unknown as DashboardRepository;
|
||||
const service = new DashboardService(repository);
|
||||
const now = new Date('2026-06-21T15:30:00.000Z');
|
||||
|
||||
await service.summary(now);
|
||||
|
||||
expect(calls[0]?.start.toISOString()).toBe('2026-06-20T16:00:00.000Z');
|
||||
expect(startOfShanghaiDayUtc(now).toISOString()).toBe('2026-06-20T16:00:00.000Z');
|
||||
});
|
||||
|
||||
it('computes call metrics and answer rate without floating point money exposure', () => {
|
||||
expect(
|
||||
callMetrics([
|
||||
{ startedAt: new Date('2026-06-21T00:00:00.000Z'), durationSec: 10, sipCode: 200 },
|
||||
{ startedAt: new Date('2026-06-21T00:01:00.000Z'), durationSec: 0, sipCode: 486 },
|
||||
{ startedAt: new Date('2026-06-21T00:02:00.000Z'), durationSec: 0, sipCode: 503 }
|
||||
])
|
||||
).toEqual({
|
||||
totalCalls: 3,
|
||||
answeredCalls: 1,
|
||||
failedCalls: 2,
|
||||
answerRate: '0.3333',
|
||||
totalDurationSec: 10
|
||||
});
|
||||
});
|
||||
|
||||
it('builds fixed trend buckets', () => {
|
||||
const buckets = buildTrendBuckets(
|
||||
new Date('2026-06-21T00:00:00.000Z'),
|
||||
new Date('2026-06-21T01:00:00.000Z'),
|
||||
30,
|
||||
[
|
||||
{ startedAt: new Date('2026-06-21T00:10:00.000Z'), durationSec: 30, sipCode: 200 },
|
||||
{ startedAt: new Date('2026-06-21T00:45:00.000Z'), durationSec: 0, sipCode: 480 }
|
||||
],
|
||||
[
|
||||
{ startedAt: new Date('2026-06-21T00:10:00.000Z'), customerFee: '0.100000', vendorCost: '0.030000', grossProfit: '0.070000' }
|
||||
]
|
||||
);
|
||||
|
||||
expect(buckets).toHaveLength(2);
|
||||
expect(buckets[0]).toMatchObject({
|
||||
calls: { totalCalls: 1, answeredCalls: 1, failedCalls: 0 },
|
||||
money: { customerFee: '0.100000', vendorCost: '0.030000', grossProfit: '0.070000' }
|
||||
});
|
||||
expect(buckets[1]).toMatchObject({
|
||||
calls: { totalCalls: 1, answeredCalls: 0, failedCalls: 1 }
|
||||
});
|
||||
});
|
||||
|
||||
it('validates trend query bounds', async () => {
|
||||
const service = new DashboardService({} as DashboardRepository);
|
||||
await expect(service.trends({ hours: '0' })).rejects.toThrow(BadRequestException);
|
||||
await expect(service.trends({ bucketMinutes: '10' })).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
DASHBOARD_REPOSITORY,
|
||||
type DashboardCdrRow,
|
||||
type DashboardRatedRow,
|
||||
type DashboardRepository
|
||||
} from './dashboard.repository.js';
|
||||
|
||||
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
const DEFAULT_TREND_HOURS = 24;
|
||||
const DEFAULT_BUCKET_MINUTES = 60;
|
||||
const ALLOWED_BUCKET_MINUTES = new Set([5, 15, 60]);
|
||||
|
||||
export interface DashboardCallMetrics {
|
||||
totalCalls: number;
|
||||
answeredCalls: number;
|
||||
failedCalls: number;
|
||||
answerRate: string;
|
||||
totalDurationSec: number;
|
||||
}
|
||||
|
||||
export interface DashboardMoneyMetrics {
|
||||
customerFee: string;
|
||||
vendorCost: string;
|
||||
grossProfit: string;
|
||||
}
|
||||
|
||||
export interface DashboardTrendBucket {
|
||||
start: string;
|
||||
end: string;
|
||||
calls: DashboardCallMetrics;
|
||||
money: DashboardMoneyMetrics;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(@Inject(DASHBOARD_REPOSITORY) private readonly dashboard: DashboardRepository) {}
|
||||
|
||||
async summary(now = new Date()) {
|
||||
const start = startOfShanghaiDayUtc(now);
|
||||
const snapshot = await this.dashboard.summaryWindow(start, now);
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
window: { start: start.toISOString(), end: now.toISOString(), timezone: 'Asia/Shanghai' },
|
||||
calls: callMetrics(snapshot.cdrs),
|
||||
money: moneyMetrics(snapshot.ratedCdrs),
|
||||
realtime: {
|
||||
onlineCalls: 0,
|
||||
registeredUsers: 0,
|
||||
source: 'not_configured'
|
||||
},
|
||||
entities: {
|
||||
activeCustomers: snapshot.activeCustomers,
|
||||
activeCustomerGateways: snapshot.activeCustomerGateways,
|
||||
activeVendorGateways: snapshot.activeVendorGateways
|
||||
},
|
||||
quality: {
|
||||
pendingReviews: snapshot.pendingQuality
|
||||
},
|
||||
failureCodes: snapshot.failureCodes,
|
||||
abnormalGateways: snapshot.abnormalGateways
|
||||
};
|
||||
}
|
||||
|
||||
async trends(query: { hours?: unknown; bucketMinutes?: unknown } = {}, now = new Date()) {
|
||||
const hours = query.hours === undefined ? DEFAULT_TREND_HOURS : this.integer(query.hours, 'hours', 1, 168);
|
||||
const bucketMinutes =
|
||||
query.bucketMinutes === undefined ? DEFAULT_BUCKET_MINUTES : this.integer(query.bucketMinutes, 'bucketMinutes', 5, 60);
|
||||
if (!ALLOWED_BUCKET_MINUTES.has(bucketMinutes)) {
|
||||
throw new BadRequestException({ code: 'DASHBOARD_BUCKET_INVALID', message: 'bucketMinutes must be one of 5, 15 or 60.' });
|
||||
}
|
||||
|
||||
const bucketMs = bucketMinutes * 60 * 1000;
|
||||
const end = new Date(Math.ceil(now.getTime() / bucketMs) * bucketMs);
|
||||
const start = new Date(end.getTime() - hours * 60 * 60 * 1000);
|
||||
const rows = await this.dashboard.trendWindow(start, end);
|
||||
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
window: { start: start.toISOString(), end: end.toISOString(), hours, bucketMinutes },
|
||||
buckets: buildTrendBuckets(start, end, bucketMinutes, rows.cdrs, rows.ratedCdrs)
|
||||
};
|
||||
}
|
||||
|
||||
private integer(value: unknown, field: string, min: number, max: number): number {
|
||||
const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number.parseInt(value, 10) : NaN;
|
||||
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||
throw new BadRequestException({ code: 'INTEGER_INVALID', message: `${field} must be an integer from ${min} to ${max}.` });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
export function startOfShanghaiDayUtc(now: Date): Date {
|
||||
const shifted = new Date(now.getTime() + SHANGHAI_OFFSET_MS);
|
||||
shifted.setUTCHours(0, 0, 0, 0);
|
||||
return new Date(shifted.getTime() - SHANGHAI_OFFSET_MS);
|
||||
}
|
||||
|
||||
export function buildTrendBuckets(
|
||||
start: Date,
|
||||
end: Date,
|
||||
bucketMinutes: number,
|
||||
cdrs: DashboardCdrRow[],
|
||||
ratedCdrs: DashboardRatedRow[]
|
||||
): DashboardTrendBucket[] {
|
||||
const bucketMs = bucketMinutes * 60 * 1000;
|
||||
const bucketCount = Math.ceil((end.getTime() - start.getTime()) / bucketMs);
|
||||
const buckets = Array.from({ length: bucketCount }, (_, index) => {
|
||||
const bucketStart = new Date(start.getTime() + index * bucketMs);
|
||||
return {
|
||||
start: bucketStart,
|
||||
end: new Date(bucketStart.getTime() + bucketMs),
|
||||
cdrs: [] as DashboardCdrRow[],
|
||||
ratedCdrs: [] as DashboardRatedRow[]
|
||||
};
|
||||
});
|
||||
|
||||
for (const cdr of cdrs) {
|
||||
const index = Math.floor((cdr.startedAt.getTime() - start.getTime()) / bucketMs);
|
||||
if (index >= 0 && index < buckets.length) {
|
||||
buckets[index].cdrs.push(cdr);
|
||||
}
|
||||
}
|
||||
|
||||
for (const ratedCdr of ratedCdrs) {
|
||||
const index = Math.floor((ratedCdr.startedAt.getTime() - start.getTime()) / bucketMs);
|
||||
if (index >= 0 && index < buckets.length) {
|
||||
buckets[index].ratedCdrs.push(ratedCdr);
|
||||
}
|
||||
}
|
||||
|
||||
return buckets.map((bucket) => ({
|
||||
start: bucket.start.toISOString(),
|
||||
end: bucket.end.toISOString(),
|
||||
calls: callMetrics(bucket.cdrs),
|
||||
money: moneyMetrics(bucket.ratedCdrs)
|
||||
}));
|
||||
}
|
||||
|
||||
export function callMetrics(cdrs: DashboardCdrRow[]): DashboardCallMetrics {
|
||||
const answeredCalls = cdrs.filter((cdr) => isAnswered(cdr.sipCode)).length;
|
||||
const totalCalls = cdrs.length;
|
||||
return {
|
||||
totalCalls,
|
||||
answeredCalls,
|
||||
failedCalls: totalCalls - answeredCalls,
|
||||
answerRate: ratioString(answeredCalls, totalCalls),
|
||||
totalDurationSec: cdrs.reduce((sum, cdr) => sum + cdr.durationSec, 0)
|
||||
};
|
||||
}
|
||||
|
||||
export function moneyMetrics(rows: DashboardRatedRow[]): DashboardMoneyMetrics {
|
||||
const customerFee = rows.reduce((sum, row) => sum + Number.parseFloat(row.customerFee), 0);
|
||||
const vendorCost = rows.reduce((sum, row) => sum + Number.parseFloat(row.vendorCost), 0);
|
||||
const grossProfit = rows.reduce((sum, row) => sum + Number.parseFloat(row.grossProfit), 0);
|
||||
return {
|
||||
customerFee: moneyString(customerFee),
|
||||
vendorCost: moneyString(vendorCost),
|
||||
grossProfit: moneyString(grossProfit)
|
||||
};
|
||||
}
|
||||
|
||||
function ratioString(numerator: number, denominator: number): string {
|
||||
if (denominator === 0) {
|
||||
return '0.0000';
|
||||
}
|
||||
return (numerator / denominator).toFixed(4);
|
||||
}
|
||||
|
||||
function moneyString(value: number): string {
|
||||
return value.toFixed(6);
|
||||
}
|
||||
|
||||
function isAnswered(sipCode: number): boolean {
|
||||
return sipCode >= 200 && sipCode < 300;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService]
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@lisglosips/database';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleDestroy {
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Inject } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import type { HealthCheckResponse } from '@lisglosips/contracts';
|
||||
import { Public } from '../security/security.metadata.js';
|
||||
import { HealthService } from './health.service.js';
|
||||
|
||||
@ApiTags('health')
|
||||
@Public()
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(@Inject(HealthService) private readonly healthService: HealthService) {}
|
||||
|
||||
@Get('live')
|
||||
@ApiOkResponse({ description: 'Process liveness check.' })
|
||||
live(): HealthCheckResponse {
|
||||
return this.healthService.live();
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
@ApiOkResponse({ description: 'Configuration and dependency readiness check.' })
|
||||
ready(): HealthCheckResponse {
|
||||
return this.healthService.ready();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller.js';
|
||||
import { HealthService } from './health.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
providers: [HealthService]
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HealthService } from './health.service.js';
|
||||
|
||||
describe('HealthService', () => {
|
||||
it('reports ready when required connection strings are configured', () => {
|
||||
const config = new ConfigService({
|
||||
service: { name: 'api-test' },
|
||||
database: { url: 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips' },
|
||||
redis: { url: 'redis://127.0.0.1:6379/0' }
|
||||
});
|
||||
|
||||
const result = new HealthService(config).ready();
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
expect(result.service).toBe('api-test');
|
||||
expect(result.checks.database).toBe('ok');
|
||||
expect(result.checks.redis).toBe('ok');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { HealthCheckResponse, HealthStatus } from '@lisglosips/contracts';
|
||||
|
||||
@Injectable()
|
||||
export class HealthService {
|
||||
constructor(@Inject(ConfigService) private readonly configService: ConfigService) {}
|
||||
|
||||
live(): HealthCheckResponse {
|
||||
return this.response('ok', {
|
||||
process: 'ok'
|
||||
});
|
||||
}
|
||||
|
||||
ready(): HealthCheckResponse {
|
||||
const hasDatabaseUrl = Boolean(this.configService.get<string>('database.url'));
|
||||
const hasRedisUrl = Boolean(this.configService.get<string>('redis.url'));
|
||||
|
||||
return this.response(hasDatabaseUrl && hasRedisUrl ? 'ok' : 'degraded', {
|
||||
config: 'ok',
|
||||
database: hasDatabaseUrl ? 'ok' : 'degraded',
|
||||
redis: hasRedisUrl ? 'ok' : 'degraded'
|
||||
});
|
||||
}
|
||||
|
||||
private response(status: HealthStatus, checks: Record<string, HealthStatus>): HealthCheckResponse {
|
||||
return {
|
||||
status,
|
||||
service: this.configService.get<string>('service.name') ?? 'api',
|
||||
timestamp: new Date().toISOString(),
|
||||
checks
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
import { LandingLineGroupsService } from './landing-line-groups.service.js';
|
||||
|
||||
@ApiTags('landing-line-groups')
|
||||
@Controller('landing-line-groups')
|
||||
export class LandingLineGroupsController {
|
||||
constructor(@Inject(LandingLineGroupsService) private readonly lineGroupsService: LandingLineGroupsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('line_groups.view')
|
||||
list() {
|
||||
return this.lineGroupsService.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('line_groups.view')
|
||||
get(@Param('id') id: string) {
|
||||
return this.lineGroupsService.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('line_groups.manage')
|
||||
@AuditAction({ module: 'line_groups', action: 'create', objectType: 'landing_line_group' })
|
||||
create(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.lineGroupsService.create(body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('line_groups.manage')
|
||||
@AuditAction({ module: 'line_groups', action: 'update', objectType: 'landing_line_group', objectIdParam: 'id' })
|
||||
update(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.lineGroupsService.update(id, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/enable')
|
||||
@RequirePermissions('line_groups.manage')
|
||||
@AuditAction({ module: 'line_groups', action: 'enable', objectType: 'landing_line_group', objectIdParam: 'id' })
|
||||
enable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.lineGroupsService.enable(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/disable')
|
||||
@RequirePermissions('line_groups.manage')
|
||||
@AuditAction({ module: 'line_groups', action: 'disable', objectType: 'landing_line_group', objectIdParam: 'id' })
|
||||
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.lineGroupsService.disable(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions('line_groups.manage')
|
||||
@AuditAction({ module: 'line_groups', action: 'delete', objectType: 'landing_line_group', objectIdParam: 'id' })
|
||||
remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.lineGroupsService.remove(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/items')
|
||||
@RequirePermissions('line_groups.manage')
|
||||
@AuditAction({ module: 'line_groups', action: 'item_create', objectType: 'landing_line_group', objectIdParam: 'id' })
|
||||
addItem(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.lineGroupsService.addItem(id, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Patch('items/:itemId')
|
||||
@RequirePermissions('line_groups.manage')
|
||||
@AuditAction({ module: 'line_groups', action: 'item_update', objectType: 'landing_line_group_item', objectIdParam: 'itemId' })
|
||||
updateItem(@Param('itemId') itemId: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.lineGroupsService.updateItem(itemId, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Delete(':id/items/:gatewayId')
|
||||
@RequirePermissions('line_groups.manage')
|
||||
@AuditAction({ module: 'line_groups', action: 'item_delete', objectType: 'landing_line_group', objectIdParam: 'id' })
|
||||
removeItem(@Param('id') id: string, @Param('gatewayId') gatewayId: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.lineGroupsService.removeItem(id, gatewayId, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/items/reorder')
|
||||
@RequirePermissions('line_groups.manage')
|
||||
@AuditAction({ module: 'line_groups', action: 'item_reorder', objectType: 'landing_line_group', objectIdParam: 'id' })
|
||||
reorderItems(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.lineGroupsService.reorderItems(id, body as never, currentUser?.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
|
||||
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js';
|
||||
import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js';
|
||||
import type { CurrentUser } from '../security/security.metadata.js';
|
||||
import {
|
||||
LANDING_LINE_GROUPS_REPOSITORY,
|
||||
type AddLineGroupItemInput,
|
||||
type LandingLineGroupsRepository,
|
||||
type LineGroupItemSummary,
|
||||
type LineGroupStatus,
|
||||
type LineGroupSummary,
|
||||
type UpdateLineGroupInput,
|
||||
type UpdateLineGroupItemInput
|
||||
} from './landing-line-groups.repository.js';
|
||||
|
||||
class MemoryIdentityRepository implements IdentityRepository {
|
||||
users = new Map<string, CurrentUser>();
|
||||
|
||||
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||
return this.users.get(userId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAuditRepository implements AuditRepository {
|
||||
entries: AuditEntryInput[] = [];
|
||||
|
||||
async write(input: AuditEntryInput): Promise<void> {
|
||||
this.entries.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryLandingLineGroupsRepository implements LandingLineGroupsRepository {
|
||||
private readonly groups = new Map<string, LineGroupSummary>();
|
||||
outboxEvents = 0;
|
||||
|
||||
constructor() {
|
||||
this.groups.set(
|
||||
'llg_seed',
|
||||
this.summary({
|
||||
id: 'llg_seed',
|
||||
name: 'Seed Group',
|
||||
items: [
|
||||
this.item({ id: 'item_a', lineGroupId: 'llg_seed', vendorGatewayId: 'vgw_a', priority: 1, concurrencyCap: 80 }),
|
||||
this.item({ id: 'item_b', lineGroupId: 'llg_seed', vendorGatewayId: 'vgw_b', priority: 2, concurrencyCap: 20, status: 'DISABLED' })
|
||||
]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async list(): Promise<LineGroupSummary[]> {
|
||||
return [...this.groups.values()];
|
||||
}
|
||||
|
||||
async get(lineGroupId: string): Promise<LineGroupSummary> {
|
||||
return this.groups.get(lineGroupId) ?? this.summary({ id: lineGroupId, name: 'Missing Group' });
|
||||
}
|
||||
|
||||
async create(input: { name: string; status: LineGroupStatus; notes?: string | null }): Promise<LineGroupSummary> {
|
||||
const group = this.summary({
|
||||
id: 'llg_created',
|
||||
name: input.name,
|
||||
status: input.status,
|
||||
notes: input.notes ?? null
|
||||
});
|
||||
this.groups.set(group.id, group);
|
||||
this.outboxEvents += 1;
|
||||
return group;
|
||||
}
|
||||
|
||||
async update(lineGroupId: string, input: UpdateLineGroupInput): Promise<LineGroupSummary> {
|
||||
const current = await this.get(lineGroupId);
|
||||
const updated = this.summary({
|
||||
...current,
|
||||
name: input.name ?? current.name,
|
||||
status: input.status ?? current.status,
|
||||
notes: input.notes === undefined ? current.notes : input.notes,
|
||||
items: current.items
|
||||
});
|
||||
this.groups.set(lineGroupId, updated);
|
||||
this.outboxEvents += 1;
|
||||
return updated;
|
||||
}
|
||||
|
||||
async setStatus(lineGroupId: string, status: LineGroupStatus): Promise<LineGroupSummary> {
|
||||
return this.update(lineGroupId, { status });
|
||||
}
|
||||
|
||||
async softDelete(lineGroupId: string): Promise<LineGroupSummary> {
|
||||
return this.update(lineGroupId, { status: 'DISABLED' });
|
||||
}
|
||||
|
||||
async addItem(input: AddLineGroupItemInput): Promise<LineGroupSummary> {
|
||||
const current = await this.get(input.lineGroupId);
|
||||
const item = this.item({
|
||||
id: `item_${current.items.length + 1}`,
|
||||
lineGroupId: input.lineGroupId,
|
||||
vendorGatewayId: input.vendorGatewayId,
|
||||
priority: input.priority ?? current.items.length + 1,
|
||||
weight: input.weight,
|
||||
concurrencyCap: input.concurrencyCap,
|
||||
status: input.status
|
||||
});
|
||||
const updated = this.summary({ ...current, items: [...current.items, item] });
|
||||
this.groups.set(input.lineGroupId, updated);
|
||||
this.outboxEvents += 1;
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateItem(itemId: string, input: UpdateLineGroupItemInput): Promise<LineGroupItemSummary> {
|
||||
for (const group of this.groups.values()) {
|
||||
const item = group.items.find((candidate) => candidate.id === itemId);
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
const updatedItem = this.item({
|
||||
...item,
|
||||
priority: input.priority ?? item.priority,
|
||||
weight: input.weight ?? item.weight,
|
||||
concurrencyCap: input.concurrencyCap ?? item.concurrencyCap,
|
||||
status: input.status ?? item.status
|
||||
});
|
||||
const updated = this.summary({
|
||||
...group,
|
||||
items: group.items.map((candidate) => (candidate.id === itemId ? updatedItem : candidate))
|
||||
});
|
||||
this.groups.set(group.id, updated);
|
||||
this.outboxEvents += 1;
|
||||
return updatedItem;
|
||||
}
|
||||
return this.item({ id: itemId, lineGroupId: 'missing', vendorGatewayId: 'missing', priority: 1 });
|
||||
}
|
||||
|
||||
async removeItem(lineGroupId: string, vendorGatewayId: string): Promise<LineGroupSummary> {
|
||||
const current = await this.get(lineGroupId);
|
||||
const updated = this.summary({
|
||||
...current,
|
||||
items: current.items.filter((item) => item.vendorGatewayId !== vendorGatewayId)
|
||||
});
|
||||
this.groups.set(lineGroupId, updated);
|
||||
this.outboxEvents += 1;
|
||||
return updated;
|
||||
}
|
||||
|
||||
async reorderItems(lineGroupId: string, itemIds: string[]): Promise<LineGroupSummary> {
|
||||
const current = await this.get(lineGroupId);
|
||||
const currentIds = current.items.map((item) => item.id).sort();
|
||||
const requestedIds = [...itemIds].sort();
|
||||
if (currentIds.length !== requestedIds.length || currentIds.some((id, index) => id !== requestedIds[index])) {
|
||||
throw new Error('test repository reorder mismatch');
|
||||
}
|
||||
const itemsById = new Map(current.items.map((item) => [item.id, item]));
|
||||
const updated = this.summary({
|
||||
...current,
|
||||
items: itemIds.map((id, index) => this.item({ ...itemsById.get(id)!, priority: index + 1 }))
|
||||
});
|
||||
this.groups.set(lineGroupId, updated);
|
||||
this.outboxEvents += 1;
|
||||
return updated;
|
||||
}
|
||||
|
||||
private summary(input: Partial<LineGroupSummary> & { id: string; name: string; items?: LineGroupItemSummary[] }): LineGroupSummary {
|
||||
const items = input.items ?? [];
|
||||
const enabledItems = items.filter((item) => item.status === 'ENABLED');
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
status: input.status ?? 'ENABLED',
|
||||
notes: input.notes ?? null,
|
||||
itemCount: items.length,
|
||||
enabledItemCount: enabledItems.length,
|
||||
concurrencyCapSum: enabledItems.reduce((sum, item) => sum + item.concurrencyCap, 0),
|
||||
gatewayConcurrencyLimitSum: enabledItems.reduce((sum, item) => sum + item.vendorGatewayConcurrencyLimit, 0),
|
||||
policyCount: input.policyCount ?? 0,
|
||||
items,
|
||||
createdAt: new Date('2026-06-21T07:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-21T07:00:00.000Z')
|
||||
};
|
||||
}
|
||||
|
||||
private item(input: Partial<LineGroupItemSummary> & { id: string; lineGroupId: string; vendorGatewayId: string; priority: number }): LineGroupItemSummary {
|
||||
return {
|
||||
id: input.id,
|
||||
lineGroupId: input.lineGroupId,
|
||||
vendorGatewayId: input.vendorGatewayId,
|
||||
vendorGatewayName: input.vendorGatewayName ?? `Gateway ${input.vendorGatewayId}`,
|
||||
vendorId: input.vendorId ?? 'ven_seed',
|
||||
vendorName: input.vendorName ?? 'Seed Vendor',
|
||||
vendorGatewayStatus: input.vendorGatewayStatus ?? 'ENABLED',
|
||||
vendorGatewayConcurrencyLimit: input.vendorGatewayConcurrencyLimit ?? 100,
|
||||
vendorGatewayCpsLimit: input.vendorGatewayCpsLimit ?? 10,
|
||||
priority: input.priority,
|
||||
weight: input.weight ?? 1,
|
||||
concurrencyCap: input.concurrencyCap ?? 0,
|
||||
status: input.status ?? 'ENABLED',
|
||||
createdAt: new Date('2026-06-21T07:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-21T07:00:00.000Z')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('S17 landing line groups API', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let audit: MemoryAuditRepository;
|
||||
let repository: MemoryLandingLineGroupsRepository;
|
||||
|
||||
const tokenFor = (userId: string) =>
|
||||
signAccessToken(
|
||||
{
|
||||
sub: userId,
|
||||
username: userId,
|
||||
roles: ['test'],
|
||||
typ: 'access'
|
||||
},
|
||||
{
|
||||
secret: 'test-only-access-token-secret-min-32-bytes',
|
||||
issuer: 'lisglosips-api',
|
||||
audience: 'lisglosips-web',
|
||||
ttlSeconds: 900
|
||||
}
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
|
||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
|
||||
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
|
||||
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
|
||||
|
||||
const identities = new MemoryIdentityRepository();
|
||||
audit = new MemoryAuditRepository();
|
||||
repository = new MemoryLandingLineGroupsRepository();
|
||||
|
||||
identities.users.set('usr_ops', {
|
||||
id: 'usr_ops',
|
||||
username: 'ops',
|
||||
roles: ['运营管理员'],
|
||||
permissions: ['line_groups.view', 'line_groups.manage'] as PermissionKey[]
|
||||
});
|
||||
identities.users.set('usr_viewer', {
|
||||
id: 'usr_viewer',
|
||||
username: 'viewer',
|
||||
roles: ['只读'],
|
||||
permissions: ['line_groups.view'] as PermissionKey[]
|
||||
});
|
||||
|
||||
const { AppModule } = await import('../app.module.js');
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(IDENTITY_REPOSITORY)
|
||||
.useValue(identities)
|
||||
.overrideProvider(AUDIT_REPOSITORY)
|
||||
.useValue(audit)
|
||||
.overrideProvider(LANDING_LINE_GROUPS_REPOSITORY)
|
||||
.useValue(repository)
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('lists line groups with member counts and concurrency summaries', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v2/landing-line-groups')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body[0]).toMatchObject({
|
||||
id: 'llg_seed',
|
||||
itemCount: 2,
|
||||
enabledItemCount: 1,
|
||||
concurrencyCapSum: 80,
|
||||
gatewayConcurrencyLimitSum: 100
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects writes without line_groups.manage', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/landing-line-groups')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.send({ name: 'Denied Group' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('creates and maintains group members with audit entries', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/landing-line-groups')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ name: 'Primary Routing', notes: 'core vendors' })
|
||||
.expect(201)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({ id: 'llg_created', name: 'Primary Routing', itemCount: 0 });
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/landing-line-groups/llg_created/items')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ vendorGatewayId: 'vgw_primary', priority: 1, weight: 2, concurrencyCap: 60 })
|
||||
.expect(201)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({ itemCount: 1, concurrencyCapSum: 60 });
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v2/landing-line-groups/items/item_1')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ concurrencyCap: 40, status: 'DISABLED' })
|
||||
.expect(200)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({ id: 'item_1', concurrencyCap: 40, status: 'DISABLED' });
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete('/api/v2/landing-line-groups/llg_created/items/vgw_primary')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.expect(200)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({ itemCount: 0 });
|
||||
});
|
||||
|
||||
expect(repository.outboxEvents).toBeGreaterThanOrEqual(4);
|
||||
expect(audit.entries.some((entry) => entry.module === 'line_groups' && entry.action === 'create')).toBe(true);
|
||||
expect(audit.entries.some((entry) => entry.module === 'line_groups' && entry.action === 'item_delete')).toBe(true);
|
||||
});
|
||||
|
||||
it('validates members and supports reorder, enable, disable, and delete', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/landing-line-groups/llg_seed/items')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ vendorGatewayId: 'vgw_bad', weight: 0 })
|
||||
.expect(400);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/landing-line-groups/llg_seed/items/reorder')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ itemIds: ['item_b', 'item_a'] })
|
||||
.expect(201)
|
||||
.expect((response) => {
|
||||
expect(response.body.items.map((item: { id: string }) => item.id)).toEqual(['item_b', 'item_a']);
|
||||
});
|
||||
|
||||
await request(app.getHttpServer()).post('/api/v2/landing-line-groups/llg_seed/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
await request(app.getHttpServer()).post('/api/v2/landing-line-groups/llg_seed/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
await request(app.getHttpServer()).delete('/api/v2/landing-line-groups/llg_seed').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LandingLineGroupsController } from './landing-line-groups.controller.js';
|
||||
import { LANDING_LINE_GROUPS_REPOSITORY, PrismaLandingLineGroupsRepository } from './landing-line-groups.repository.js';
|
||||
import { LandingLineGroupsService } from './landing-line-groups.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [LandingLineGroupsController],
|
||||
providers: [
|
||||
LandingLineGroupsService,
|
||||
{
|
||||
provide: LANDING_LINE_GROUPS_REPOSITORY,
|
||||
useClass: PrismaLandingLineGroupsRepository
|
||||
}
|
||||
],
|
||||
exports: [LandingLineGroupsService]
|
||||
})
|
||||
export class LandingLineGroupsModule {}
|
||||
@@ -0,0 +1,474 @@
|
||||
import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export type LineGroupStatus = 'ENABLED' | 'DISABLED';
|
||||
|
||||
export interface LineGroupItemSummary {
|
||||
id: string;
|
||||
lineGroupId: string;
|
||||
vendorGatewayId: string;
|
||||
vendorGatewayName: string;
|
||||
vendorId: string;
|
||||
vendorName: string;
|
||||
vendorGatewayStatus: LineGroupStatus;
|
||||
vendorGatewayConcurrencyLimit: number;
|
||||
vendorGatewayCpsLimit: number;
|
||||
priority: number;
|
||||
weight: number;
|
||||
concurrencyCap: number;
|
||||
status: LineGroupStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface LineGroupSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
status: LineGroupStatus;
|
||||
notes: string | null;
|
||||
itemCount: number;
|
||||
enabledItemCount: number;
|
||||
concurrencyCapSum: number;
|
||||
gatewayConcurrencyLimitSum: number;
|
||||
policyCount: number;
|
||||
items: LineGroupItemSummary[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateLineGroupInput {
|
||||
name: string;
|
||||
status: LineGroupStatus;
|
||||
notes?: string | null;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLineGroupInput {
|
||||
name?: string;
|
||||
status?: LineGroupStatus;
|
||||
notes?: string | null;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface AddLineGroupItemInput {
|
||||
lineGroupId: string;
|
||||
vendorGatewayId: string;
|
||||
priority?: number;
|
||||
weight: number;
|
||||
concurrencyCap: number;
|
||||
status: LineGroupStatus;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLineGroupItemInput {
|
||||
priority?: number;
|
||||
weight?: number;
|
||||
concurrencyCap?: number;
|
||||
status?: LineGroupStatus;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface LandingLineGroupsRepository {
|
||||
list(): Promise<LineGroupSummary[]>;
|
||||
get(lineGroupId: string): Promise<LineGroupSummary>;
|
||||
create(input: CreateLineGroupInput): Promise<LineGroupSummary>;
|
||||
update(lineGroupId: string, input: UpdateLineGroupInput): Promise<LineGroupSummary>;
|
||||
setStatus(lineGroupId: string, status: LineGroupStatus, actorId?: string): Promise<LineGroupSummary>;
|
||||
softDelete(lineGroupId: string, actorId?: string): Promise<LineGroupSummary>;
|
||||
addItem(input: AddLineGroupItemInput): Promise<LineGroupSummary>;
|
||||
updateItem(itemId: string, input: UpdateLineGroupItemInput): Promise<LineGroupItemSummary>;
|
||||
removeItem(lineGroupId: string, vendorGatewayId: string, actorId?: string): Promise<LineGroupSummary>;
|
||||
reorderItems(lineGroupId: string, itemIds: string[], actorId?: string): Promise<LineGroupSummary>;
|
||||
}
|
||||
|
||||
export const LANDING_LINE_GROUPS_REPOSITORY = Symbol('LANDING_LINE_GROUPS_REPOSITORY');
|
||||
|
||||
type LineGroupItemRecord = {
|
||||
id: string;
|
||||
lineGroupId: string;
|
||||
vendorGatewayId: string;
|
||||
priority: number;
|
||||
weight: number;
|
||||
concurrencyCap: number;
|
||||
status: LineGroupStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
vendorGateway: {
|
||||
id: string;
|
||||
vendorId: string;
|
||||
name: string;
|
||||
status: LineGroupStatus;
|
||||
concurrencyLimit: number;
|
||||
cpsLimit: number;
|
||||
vendor: { name: string };
|
||||
};
|
||||
};
|
||||
|
||||
type LineGroupRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: LineGroupStatus;
|
||||
notes: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
items: LineGroupItemRecord[];
|
||||
_count: { policies: number };
|
||||
};
|
||||
|
||||
function lineGroupId(): string {
|
||||
return `llg_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
|
||||
}
|
||||
|
||||
function lineGroupItemId(): string {
|
||||
return `llgi_${crypto.randomUUID().replaceAll('-', '').slice(0, 27)}`;
|
||||
}
|
||||
|
||||
function outboxId(): string {
|
||||
return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<LineGroupSummary[]> {
|
||||
const groups = await this.prisma.landingLineGroup.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
include: this.includeSummary()
|
||||
});
|
||||
return groups.map((group) => this.toSummary(group));
|
||||
}
|
||||
|
||||
async get(lineGroupIdValue: string): Promise<LineGroupSummary> {
|
||||
return this.toSummary(await this.findActiveOrThrow(lineGroupIdValue));
|
||||
}
|
||||
|
||||
async create(input: CreateLineGroupInput): Promise<LineGroupSummary> {
|
||||
try {
|
||||
const group = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.landingLineGroup.create({
|
||||
data: {
|
||||
id: lineGroupId(),
|
||||
name: input.name,
|
||||
status: input.status,
|
||||
notes: input.notes,
|
||||
createdBy: input.actorId,
|
||||
updatedBy: input.actorId
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, created.id, 'line_group.changed');
|
||||
return created;
|
||||
});
|
||||
return this.toSummary(group);
|
||||
} catch (error) {
|
||||
this.handleUniqueConflict(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(lineGroupIdValue: string, input: UpdateLineGroupInput): Promise<LineGroupSummary> {
|
||||
await this.findActiveOrThrow(lineGroupIdValue);
|
||||
try {
|
||||
const group = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.landingLineGroup.update({
|
||||
where: { id: lineGroupIdValue },
|
||||
data: {
|
||||
name: input.name,
|
||||
status: input.status,
|
||||
notes: input.notes,
|
||||
updatedBy: input.actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, updated.id, 'line_group.changed');
|
||||
return updated;
|
||||
});
|
||||
return this.toSummary(group);
|
||||
} catch (error) {
|
||||
this.handleUniqueConflict(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async setStatus(lineGroupIdValue: string, status: LineGroupStatus, actorId?: string): Promise<LineGroupSummary> {
|
||||
return this.update(lineGroupIdValue, { status, actorId });
|
||||
}
|
||||
|
||||
async softDelete(lineGroupIdValue: string, actorId?: string): Promise<LineGroupSummary> {
|
||||
await this.findActiveOrThrow(lineGroupIdValue);
|
||||
const linkedPolicies = await this.prisma.customerGatewayPolicy.count({
|
||||
where: { lineGroupId: lineGroupIdValue, deletedAt: null }
|
||||
});
|
||||
if (linkedPolicies > 0) {
|
||||
throw new BadRequestException({ code: 'LINE_GROUP_IN_USE', message: 'Line group referenced by active policies cannot be deleted.' });
|
||||
}
|
||||
|
||||
const group = await this.prisma.$transaction(async (tx) => {
|
||||
const deleted = await tx.landingLineGroup.update({
|
||||
where: { id: lineGroupIdValue },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, deleted.id, 'line_group.deleted');
|
||||
return deleted;
|
||||
});
|
||||
return this.toSummary(group);
|
||||
}
|
||||
|
||||
async addItem(input: AddLineGroupItemInput): Promise<LineGroupSummary> {
|
||||
await this.findActiveOrThrow(input.lineGroupId);
|
||||
await this.ensureVendorGateway(input.vendorGatewayId);
|
||||
const priority = input.priority ?? (await this.nextPriority(input.lineGroupId));
|
||||
|
||||
try {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.landingLineGroupItem.create({
|
||||
data: {
|
||||
id: lineGroupItemId(),
|
||||
lineGroupId: input.lineGroupId,
|
||||
vendorGatewayId: input.vendorGatewayId,
|
||||
priority,
|
||||
weight: input.weight,
|
||||
concurrencyCap: input.concurrencyCap,
|
||||
status: input.status,
|
||||
createdBy: input.actorId,
|
||||
updatedBy: input.actorId
|
||||
}
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, input.lineGroupId, 'line_group_item.changed');
|
||||
});
|
||||
return this.get(input.lineGroupId);
|
||||
} catch (error) {
|
||||
this.handleUniqueConflict(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateItem(itemId: string, input: UpdateLineGroupItemInput): Promise<LineGroupItemSummary> {
|
||||
const existing = await this.findItemOrThrow(itemId);
|
||||
try {
|
||||
const item = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.landingLineGroupItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
priority: input.priority,
|
||||
weight: input.weight,
|
||||
concurrencyCap: input.concurrencyCap,
|
||||
status: input.status,
|
||||
updatedBy: input.actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeItem()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, existing.lineGroupId, 'line_group_item.changed');
|
||||
return updated;
|
||||
});
|
||||
return this.toItemSummary(item);
|
||||
} catch (error) {
|
||||
this.handleUniqueConflict(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async removeItem(lineGroupIdValue: string, vendorGatewayId: string, actorId?: string): Promise<LineGroupSummary> {
|
||||
await this.findActiveOrThrow(lineGroupIdValue);
|
||||
const item = await this.prisma.landingLineGroupItem.findUnique({
|
||||
where: { lineGroupId_vendorGatewayId: { lineGroupId: lineGroupIdValue, vendorGatewayId } },
|
||||
select: { id: true }
|
||||
});
|
||||
if (!item) {
|
||||
throw new NotFoundException({ code: 'LINE_GROUP_ITEM_NOT_FOUND', message: 'Line group item not found.' });
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.landingLineGroupItem.delete({ where: { id: item.id } });
|
||||
await tx.landingLineGroup.update({
|
||||
where: { id: lineGroupIdValue },
|
||||
data: { updatedBy: actorId, version: { increment: 1 } }
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, lineGroupIdValue, 'line_group_item.deleted');
|
||||
});
|
||||
return this.get(lineGroupIdValue);
|
||||
}
|
||||
|
||||
async reorderItems(lineGroupIdValue: string, itemIds: string[], actorId?: string): Promise<LineGroupSummary> {
|
||||
await this.findActiveOrThrow(lineGroupIdValue);
|
||||
const current = await this.prisma.landingLineGroupItem.findMany({
|
||||
where: { lineGroupId: lineGroupIdValue },
|
||||
select: { id: true }
|
||||
});
|
||||
const currentIds = current.map((item) => item.id).sort();
|
||||
const requestedIds = [...itemIds].sort();
|
||||
if (currentIds.length !== requestedIds.length || currentIds.some((id, index) => id !== requestedIds[index])) {
|
||||
throw new BadRequestException({ code: 'LINE_GROUP_ITEM_REORDER_SET_MISMATCH', message: 'Reorder must include every item exactly once.' });
|
||||
}
|
||||
if (new Set(itemIds).size !== itemIds.length) {
|
||||
throw new BadRequestException({ code: 'LINE_GROUP_ITEM_REORDER_DUPLICATE', message: 'Item ids must be unique.' });
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (let index = 0; index < itemIds.length; index += 1) {
|
||||
await tx.landingLineGroupItem.update({
|
||||
where: { id: itemIds[index] },
|
||||
data: {
|
||||
priority: -(index + 1),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
}
|
||||
});
|
||||
}
|
||||
for (let index = 0; index < itemIds.length; index += 1) {
|
||||
await tx.landingLineGroupItem.update({
|
||||
where: { id: itemIds[index] },
|
||||
data: { priority: index + 1 }
|
||||
});
|
||||
}
|
||||
await tx.landingLineGroup.update({
|
||||
where: { id: lineGroupIdValue },
|
||||
data: { updatedBy: actorId, version: { increment: 1 } }
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, lineGroupIdValue, 'line_group_items.reordered');
|
||||
});
|
||||
return this.get(lineGroupIdValue);
|
||||
}
|
||||
|
||||
private includeItem() {
|
||||
return {
|
||||
vendorGateway: {
|
||||
include: {
|
||||
vendor: { select: { name: true } }
|
||||
}
|
||||
}
|
||||
} satisfies Prisma.LandingLineGroupItemInclude;
|
||||
}
|
||||
|
||||
private includeSummary() {
|
||||
return {
|
||||
items: {
|
||||
orderBy: [{ priority: 'asc' }],
|
||||
include: this.includeItem()
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
policies: {
|
||||
where: { deletedAt: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
} satisfies Prisma.LandingLineGroupInclude;
|
||||
}
|
||||
|
||||
private async ensureVendorGateway(vendorGatewayId: string): Promise<void> {
|
||||
const gateway = await this.prisma.vendorGateway.findUnique({
|
||||
where: { id: vendorGatewayId },
|
||||
select: { id: true, deletedAt: true }
|
||||
});
|
||||
if (!gateway || gateway.deletedAt) {
|
||||
throw new NotFoundException({ code: 'VENDOR_GATEWAY_NOT_FOUND', message: 'Vendor gateway not found.' });
|
||||
}
|
||||
}
|
||||
|
||||
private async findActiveOrThrow(lineGroupIdValue: string) {
|
||||
const group = await this.prisma.landingLineGroup.findUnique({
|
||||
where: { id: lineGroupIdValue },
|
||||
include: this.includeSummary()
|
||||
});
|
||||
if (!group || group.deletedAt) {
|
||||
throw new NotFoundException({ code: 'LINE_GROUP_NOT_FOUND', message: 'Landing line group not found.' });
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
private async findItemOrThrow(itemId: string) {
|
||||
const item = await this.prisma.landingLineGroupItem.findUnique({
|
||||
where: { id: itemId },
|
||||
include: this.includeItem()
|
||||
});
|
||||
if (!item) {
|
||||
throw new NotFoundException({ code: 'LINE_GROUP_ITEM_NOT_FOUND', message: 'Line group item not found.' });
|
||||
}
|
||||
const group = await this.prisma.landingLineGroup.findUnique({
|
||||
where: { id: item.lineGroupId },
|
||||
select: { deletedAt: true }
|
||||
});
|
||||
if (!group || group.deletedAt) {
|
||||
throw new NotFoundException({ code: 'LINE_GROUP_NOT_FOUND', message: 'Landing line group not found.' });
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
private async nextPriority(lineGroupIdValue: string): Promise<number> {
|
||||
const aggregate = await this.prisma.landingLineGroupItem.aggregate({
|
||||
where: { lineGroupId: lineGroupIdValue },
|
||||
_max: { priority: true }
|
||||
});
|
||||
return (aggregate._max.priority ?? 0) + 1;
|
||||
}
|
||||
|
||||
private async enqueueConfigOutbox(tx: Prisma.TransactionClient, lineGroupIdValue: string, eventType: string): Promise<void> {
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
id: outboxId(),
|
||||
aggregateType: 'line_group_config',
|
||||
aggregateId: lineGroupIdValue,
|
||||
eventType,
|
||||
payload: { lineGroupId: lineGroupIdValue, eventType }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private toSummary(group: LineGroupRecord): LineGroupSummary {
|
||||
const items = group.items.map((item) => this.toItemSummary(item));
|
||||
const enabledItems = items.filter((item) => item.status === 'ENABLED');
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
status: group.status,
|
||||
notes: group.notes,
|
||||
itemCount: items.length,
|
||||
enabledItemCount: enabledItems.length,
|
||||
concurrencyCapSum: enabledItems.reduce((sum, item) => sum + item.concurrencyCap, 0),
|
||||
gatewayConcurrencyLimitSum: enabledItems.reduce((sum, item) => sum + item.vendorGatewayConcurrencyLimit, 0),
|
||||
policyCount: group._count.policies,
|
||||
items,
|
||||
createdAt: group.createdAt,
|
||||
updatedAt: group.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private toItemSummary(item: LineGroupItemRecord): LineGroupItemSummary {
|
||||
return {
|
||||
id: item.id,
|
||||
lineGroupId: item.lineGroupId,
|
||||
vendorGatewayId: item.vendorGatewayId,
|
||||
vendorGatewayName: item.vendorGateway.name,
|
||||
vendorId: item.vendorGateway.vendorId,
|
||||
vendorName: item.vendorGateway.vendor.name,
|
||||
vendorGatewayStatus: item.vendorGateway.status,
|
||||
vendorGatewayConcurrencyLimit: item.vendorGateway.concurrencyLimit,
|
||||
vendorGatewayCpsLimit: item.vendorGateway.cpsLimit,
|
||||
priority: item.priority,
|
||||
weight: item.weight,
|
||||
concurrencyCap: item.concurrencyCap,
|
||||
status: item.status,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private handleUniqueConflict(error: unknown): void {
|
||||
if (error && typeof error === 'object' && 'code' in error && (error as { code?: unknown }).code === 'P2002') {
|
||||
throw new ConflictException({ code: 'LINE_GROUP_CONFLICT', message: 'Line group name, member gateway, or priority already exists.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
LANDING_LINE_GROUPS_REPOSITORY,
|
||||
type AddLineGroupItemInput,
|
||||
type CreateLineGroupInput,
|
||||
type LandingLineGroupsRepository,
|
||||
type LineGroupItemSummary,
|
||||
type LineGroupStatus,
|
||||
type LineGroupSummary,
|
||||
type UpdateLineGroupInput,
|
||||
type UpdateLineGroupItemInput
|
||||
} from './landing-line-groups.repository.js';
|
||||
|
||||
interface CreateLineGroupDto {
|
||||
name?: unknown;
|
||||
status?: unknown;
|
||||
notes?: unknown;
|
||||
}
|
||||
|
||||
interface UpdateLineGroupDto {
|
||||
name?: unknown;
|
||||
status?: unknown;
|
||||
notes?: unknown;
|
||||
}
|
||||
|
||||
interface AddItemDto {
|
||||
vendorGatewayId?: unknown;
|
||||
priority?: unknown;
|
||||
weight?: unknown;
|
||||
concurrencyCap?: unknown;
|
||||
status?: unknown;
|
||||
}
|
||||
|
||||
interface UpdateItemDto {
|
||||
priority?: unknown;
|
||||
weight?: unknown;
|
||||
concurrencyCap?: unknown;
|
||||
status?: unknown;
|
||||
}
|
||||
|
||||
interface ReorderItemsDto {
|
||||
itemIds?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LandingLineGroupsService {
|
||||
constructor(@Inject(LANDING_LINE_GROUPS_REPOSITORY) private readonly lineGroups: LandingLineGroupsRepository) {}
|
||||
|
||||
list(): Promise<LineGroupSummary[]> {
|
||||
return this.lineGroups.list();
|
||||
}
|
||||
|
||||
get(lineGroupId: string): Promise<LineGroupSummary> {
|
||||
return this.lineGroups.get(lineGroupId);
|
||||
}
|
||||
|
||||
create(body: CreateLineGroupDto, actorId?: string): Promise<LineGroupSummary> {
|
||||
const input: CreateLineGroupInput = {
|
||||
name: this.limitedString(body.name, 'name', 120),
|
||||
status: body.status === undefined ? 'ENABLED' : this.status(body.status),
|
||||
notes: this.optionalString(body.notes, 'notes', 500),
|
||||
actorId
|
||||
};
|
||||
return this.lineGroups.create(input);
|
||||
}
|
||||
|
||||
update(lineGroupId: string, body: UpdateLineGroupDto, actorId?: string): Promise<LineGroupSummary> {
|
||||
const input: UpdateLineGroupInput = {
|
||||
name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120),
|
||||
status: body.status === undefined ? undefined : this.status(body.status),
|
||||
notes: body.notes === undefined ? undefined : this.nullableString(body.notes, 'notes', 500),
|
||||
actorId
|
||||
};
|
||||
return this.lineGroups.update(lineGroupId, input);
|
||||
}
|
||||
|
||||
enable(lineGroupId: string, actorId?: string): Promise<LineGroupSummary> {
|
||||
return this.lineGroups.setStatus(lineGroupId, 'ENABLED', actorId);
|
||||
}
|
||||
|
||||
disable(lineGroupId: string, actorId?: string): Promise<LineGroupSummary> {
|
||||
return this.lineGroups.setStatus(lineGroupId, 'DISABLED', actorId);
|
||||
}
|
||||
|
||||
remove(lineGroupId: string, actorId?: string): Promise<LineGroupSummary> {
|
||||
return this.lineGroups.softDelete(lineGroupId, actorId);
|
||||
}
|
||||
|
||||
addItem(lineGroupId: string, body: AddItemDto, actorId?: string): Promise<LineGroupSummary> {
|
||||
const input: AddLineGroupItemInput = {
|
||||
lineGroupId,
|
||||
vendorGatewayId: this.limitedString(body.vendorGatewayId, 'vendorGatewayId', 32),
|
||||
priority: body.priority === undefined ? undefined : this.positiveInteger(body.priority, 'priority', 1, 10000),
|
||||
weight: body.weight === undefined ? 1 : this.positiveInteger(body.weight, 'weight', 1, 1000),
|
||||
concurrencyCap: body.concurrencyCap === undefined ? 0 : this.positiveInteger(body.concurrencyCap, 'concurrencyCap', 0, 100000),
|
||||
status: body.status === undefined ? 'ENABLED' : this.status(body.status),
|
||||
actorId
|
||||
};
|
||||
return this.lineGroups.addItem(input);
|
||||
}
|
||||
|
||||
updateItem(itemId: string, body: UpdateItemDto, actorId?: string): Promise<LineGroupItemSummary> {
|
||||
const input: UpdateLineGroupItemInput = {
|
||||
priority: body.priority === undefined ? undefined : this.positiveInteger(body.priority, 'priority', 1, 10000),
|
||||
weight: body.weight === undefined ? undefined : this.positiveInteger(body.weight, 'weight', 1, 1000),
|
||||
concurrencyCap: body.concurrencyCap === undefined ? undefined : this.positiveInteger(body.concurrencyCap, 'concurrencyCap', 0, 100000),
|
||||
status: body.status === undefined ? undefined : this.status(body.status),
|
||||
actorId
|
||||
};
|
||||
return this.lineGroups.updateItem(itemId, input);
|
||||
}
|
||||
|
||||
removeItem(lineGroupId: string, vendorGatewayId: string, actorId?: string): Promise<LineGroupSummary> {
|
||||
return this.lineGroups.removeItem(lineGroupId, vendorGatewayId, actorId);
|
||||
}
|
||||
|
||||
reorderItems(lineGroupId: string, body: ReorderItemsDto, actorId?: string): Promise<LineGroupSummary> {
|
||||
if (!Array.isArray(body.itemIds) || body.itemIds.length === 0) {
|
||||
throw new BadRequestException({ code: 'LINE_GROUP_ITEM_IDS_INVALID', message: 'itemIds must be a non-empty array.' });
|
||||
}
|
||||
return this.lineGroups.reorderItems(
|
||||
lineGroupId,
|
||||
body.itemIds.map((id) => this.limitedString(id, 'itemIds', 32)),
|
||||
actorId
|
||||
);
|
||||
}
|
||||
|
||||
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 optionalString(value: unknown, field: string, maxLength: number): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return this.limitedString(value, field, maxLength);
|
||||
}
|
||||
|
||||
private nullableString(value: unknown, field: string, maxLength: number): string | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
return this.limitedString(value, field, maxLength);
|
||||
}
|
||||
|
||||
private positiveInteger(value: unknown, field: string, min: number, max: number): number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) {
|
||||
throw new BadRequestException({ code: 'INTEGER_INVALID', message: `${field} must be an integer from ${min} to ${max}.` });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private status(value: unknown): LineGroupStatus {
|
||||
if (value !== 'ENABLED' && value !== 'DISABLED') {
|
||||
throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
import { QualityService } from './quality.service.js';
|
||||
|
||||
@ApiTags('quality')
|
||||
@Controller('quality')
|
||||
export class QualityController {
|
||||
constructor(@Inject(QualityService) private readonly qualityService: QualityService) {}
|
||||
|
||||
@Get('rules')
|
||||
@RequirePermissions('quality.view')
|
||||
listRules() {
|
||||
return this.qualityService.listRules();
|
||||
}
|
||||
|
||||
@Get('rules/:id')
|
||||
@RequirePermissions('quality.view')
|
||||
getRule(@Param('id') id: string) {
|
||||
return this.qualityService.getRule(id);
|
||||
}
|
||||
|
||||
@Post('rules')
|
||||
@RequirePermissions('quality.manage')
|
||||
@AuditAction({ module: 'quality', action: 'rule_create', objectType: 'quality_sampling_rule' })
|
||||
createRule(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.qualityService.createRule(body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Patch('rules/:id')
|
||||
@RequirePermissions('quality.manage')
|
||||
@AuditAction({ module: 'quality', action: 'rule_update', objectType: 'quality_sampling_rule', objectIdParam: 'id' })
|
||||
updateRule(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.qualityService.updateRule(id, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post('rules/:id/enable')
|
||||
@RequirePermissions('quality.manage')
|
||||
@AuditAction({ module: 'quality', action: 'rule_enable', objectType: 'quality_sampling_rule', objectIdParam: 'id' })
|
||||
enableRule(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.qualityService.enableRule(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post('rules/:id/disable')
|
||||
@RequirePermissions('quality.manage')
|
||||
@AuditAction({ module: 'quality', action: 'rule_disable', objectType: 'quality_sampling_rule', objectIdParam: 'id' })
|
||||
disableRule(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.qualityService.disableRule(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Delete('rules/:id')
|
||||
@RequirePermissions('quality.manage')
|
||||
@AuditAction({ module: 'quality', action: 'rule_delete', objectType: 'quality_sampling_rule', objectIdParam: 'id' })
|
||||
deleteRule(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.qualityService.deleteRule(id, currentUser?.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { QualityController } from './quality.controller.js';
|
||||
import { PrismaQualityRepository, QUALITY_REPOSITORY } from './quality.repository.js';
|
||||
import { QualityService } from './quality.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [QualityController],
|
||||
providers: [
|
||||
QualityService,
|
||||
{
|
||||
provide: QUALITY_REPOSITORY,
|
||||
useClass: PrismaQualityRepository
|
||||
}
|
||||
],
|
||||
exports: [QualityService]
|
||||
})
|
||||
export class QualityModule {}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export type QualityRuleStatus = 'ENABLED' | 'DISABLED';
|
||||
|
||||
export interface QualityRuleSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
lineGroupId: string | null;
|
||||
lineGroupName: string | null;
|
||||
ratio: string;
|
||||
status: QualityRuleStatus;
|
||||
effectiveAt: Date;
|
||||
expiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateQualityRuleInput {
|
||||
name: string;
|
||||
customerId?: string | null;
|
||||
lineGroupId?: string | null;
|
||||
ratio: string;
|
||||
status: QualityRuleStatus;
|
||||
effectiveAt?: Date;
|
||||
expiresAt?: Date | null;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateQualityRuleInput {
|
||||
name?: string;
|
||||
customerId?: string | null;
|
||||
lineGroupId?: string | null;
|
||||
ratio?: string;
|
||||
status?: QualityRuleStatus;
|
||||
effectiveAt?: Date;
|
||||
expiresAt?: Date | null;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface ActiveQualityRule {
|
||||
id: string;
|
||||
name: string;
|
||||
customerId: string | null;
|
||||
lineGroupId: string | null;
|
||||
ratio: string;
|
||||
}
|
||||
|
||||
export const QUALITY_REPOSITORY = Symbol('QUALITY_REPOSITORY');
|
||||
|
||||
export interface QualityRepository {
|
||||
listRules(): Promise<QualityRuleSummary[]>;
|
||||
getRule(ruleId: string): Promise<QualityRuleSummary>;
|
||||
createRule(input: CreateQualityRuleInput): Promise<QualityRuleSummary>;
|
||||
updateRule(ruleId: string, input: UpdateQualityRuleInput): Promise<QualityRuleSummary>;
|
||||
setRuleStatus(ruleId: string, status: QualityRuleStatus, actorId?: string): Promise<QualityRuleSummary>;
|
||||
softDeleteRule(ruleId: string, actorId?: string): Promise<QualityRuleSummary>;
|
||||
listActiveRules(now: Date): Promise<ActiveQualityRule[]>;
|
||||
}
|
||||
|
||||
type QualityRuleRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
customerId: string | null;
|
||||
lineGroupId: string | null;
|
||||
ratio: Prisma.Decimal;
|
||||
status: QualityRuleStatus;
|
||||
effectiveAt: Date;
|
||||
expiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
customer: { name: string } | null;
|
||||
lineGroup: { name: string } | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PrismaQualityRepository implements QualityRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listRules(): Promise<QualityRuleSummary[]> {
|
||||
const rules = await this.prisma.qualitySamplingRule.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
include: this.includeRule()
|
||||
});
|
||||
return rules.map((rule) => this.toRuleSummary(rule));
|
||||
}
|
||||
|
||||
async getRule(ruleId: string): Promise<QualityRuleSummary> {
|
||||
return this.toRuleSummary(await this.findRuleOrThrow(ruleId));
|
||||
}
|
||||
|
||||
async createRule(input: CreateQualityRuleInput): Promise<QualityRuleSummary> {
|
||||
try {
|
||||
const created = await this.prisma.qualitySamplingRule.create({
|
||||
data: {
|
||||
id: qualityRuleId(),
|
||||
name: input.name,
|
||||
customerId: input.customerId,
|
||||
lineGroupId: input.lineGroupId,
|
||||
ratio: input.ratio,
|
||||
status: input.status,
|
||||
effectiveAt: input.effectiveAt,
|
||||
expiresAt: input.expiresAt,
|
||||
createdBy: input.actorId,
|
||||
updatedBy: input.actorId
|
||||
},
|
||||
include: this.includeRule()
|
||||
});
|
||||
return this.toRuleSummary(created);
|
||||
} catch (error) {
|
||||
this.handleForeignKey(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateRule(ruleId: string, input: UpdateQualityRuleInput): Promise<QualityRuleSummary> {
|
||||
await this.findRuleOrThrow(ruleId);
|
||||
try {
|
||||
const updated = await this.prisma.qualitySamplingRule.update({
|
||||
where: { id: ruleId },
|
||||
data: {
|
||||
name: input.name,
|
||||
customerId: input.customerId,
|
||||
lineGroupId: input.lineGroupId,
|
||||
ratio: input.ratio,
|
||||
status: input.status,
|
||||
effectiveAt: input.effectiveAt,
|
||||
expiresAt: input.expiresAt,
|
||||
updatedBy: input.actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeRule()
|
||||
});
|
||||
return this.toRuleSummary(updated);
|
||||
} catch (error) {
|
||||
this.handleForeignKey(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
setRuleStatus(ruleId: string, status: QualityRuleStatus, actorId?: string): Promise<QualityRuleSummary> {
|
||||
return this.updateRule(ruleId, { status, actorId });
|
||||
}
|
||||
|
||||
async softDeleteRule(ruleId: string, actorId?: string): Promise<QualityRuleSummary> {
|
||||
await this.findRuleOrThrow(ruleId);
|
||||
const deleted = await this.prisma.qualitySamplingRule.update({
|
||||
where: { id: ruleId },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeRule()
|
||||
});
|
||||
return this.toRuleSummary(deleted);
|
||||
}
|
||||
|
||||
async listActiveRules(now: Date): Promise<ActiveQualityRule[]> {
|
||||
const rules = await this.prisma.qualitySamplingRule.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
status: 'ENABLED',
|
||||
effectiveAt: { lte: now },
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }]
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
customerId: true,
|
||||
lineGroupId: true,
|
||||
ratio: true
|
||||
}
|
||||
});
|
||||
return rules.map((rule) => ({
|
||||
...rule,
|
||||
ratio: rule.ratio.toFixed(2)
|
||||
}));
|
||||
}
|
||||
|
||||
private includeRule() {
|
||||
return {
|
||||
customer: { select: { name: true } },
|
||||
lineGroup: { select: { name: true } }
|
||||
} satisfies Prisma.QualitySamplingRuleInclude;
|
||||
}
|
||||
|
||||
private async findRuleOrThrow(ruleId: string): Promise<QualityRuleRecord> {
|
||||
const rule = await this.prisma.qualitySamplingRule.findUnique({
|
||||
where: { id: ruleId },
|
||||
include: this.includeRule()
|
||||
});
|
||||
if (!rule || rule.deletedAt) {
|
||||
throw new NotFoundException({ code: 'QUALITY_RULE_NOT_FOUND', message: 'Quality sampling rule not found.' });
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
private toRuleSummary(rule: QualityRuleRecord): QualityRuleSummary {
|
||||
return {
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
customerId: rule.customerId,
|
||||
customerName: rule.customer?.name ?? null,
|
||||
lineGroupId: rule.lineGroupId,
|
||||
lineGroupName: rule.lineGroup?.name ?? null,
|
||||
ratio: rule.ratio.toFixed(2),
|
||||
status: rule.status,
|
||||
effectiveAt: rule.effectiveAt,
|
||||
expiresAt: rule.expiresAt,
|
||||
createdAt: rule.createdAt,
|
||||
updatedAt: rule.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private handleForeignKey(error: unknown): void {
|
||||
if (error && typeof error === 'object' && 'code' in error && (error as { code?: unknown }).code === 'P2003') {
|
||||
throw new ConflictException({ code: 'QUALITY_RULE_REFERENCE_INVALID', message: 'Referenced customer or line group does not exist.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function qualityRuleId(): string {
|
||||
return `qsr_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { stableSamplingDecision, stableSamplingScore } from './sampling.js';
|
||||
|
||||
describe('stable quality sampling', () => {
|
||||
it('returns a stable score for the same rule and recording', () => {
|
||||
const first = stableSamplingScore('rule-a', 'rec-a');
|
||||
const second = stableSamplingScore('rule-a', 'rec-a');
|
||||
expect(first).toBe(second);
|
||||
expect(first).toBeGreaterThanOrEqual(0);
|
||||
expect(first).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('honors ratio boundaries', () => {
|
||||
expect(stableSamplingDecision('rule-a', 'rec-a', '0.00').selected).toBe(false);
|
||||
expect(stableSamplingDecision('rule-a', 'rec-a', '100.00').selected).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export interface SamplingDecision {
|
||||
score: number;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export function stableSamplingDecision(ruleId: string, recordingId: string, ratio: string | number): SamplingDecision {
|
||||
const normalizedRatio = typeof ratio === 'number' ? ratio : Number.parseFloat(ratio);
|
||||
if (!Number.isFinite(normalizedRatio) || normalizedRatio <= 0) {
|
||||
return { score: stableSamplingScore(ruleId, recordingId), selected: false };
|
||||
}
|
||||
if (normalizedRatio >= 100) {
|
||||
return { score: stableSamplingScore(ruleId, recordingId), selected: true };
|
||||
}
|
||||
const score = stableSamplingScore(ruleId, recordingId);
|
||||
return { score, selected: score < normalizedRatio };
|
||||
}
|
||||
|
||||
export function stableSamplingScore(ruleId: string, recordingId: string): number {
|
||||
const digest = crypto.createHash('sha256').update(`${ruleId}:${recordingId}`, 'utf8').digest();
|
||||
const value = digest.readUInt32BE(0);
|
||||
return Number(((value / 0x1_0000_0000) * 100).toFixed(4));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Body, Controller, Get, Inject, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
import { RechargesService } from './recharges.service.js';
|
||||
|
||||
@ApiTags('recharges')
|
||||
@Controller()
|
||||
export class RechargesController {
|
||||
constructor(@Inject(RechargesService) private readonly rechargesService: RechargesService) {}
|
||||
|
||||
@Get('recharges')
|
||||
@RequirePermissions('recharges.view')
|
||||
list(@Query() query: unknown) {
|
||||
return this.rechargesService.list(query as never);
|
||||
}
|
||||
|
||||
@Post('customers/:id/recharges')
|
||||
@RequirePermissions('recharges.manage')
|
||||
@AuditAction({ module: 'recharges', action: 'customer_recharge', objectType: 'customer', objectIdParam: 'id' })
|
||||
rechargeCustomer(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.rechargesService.rechargeCustomer(id, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post('vendors/:id/recharges')
|
||||
@RequirePermissions('recharges.manage')
|
||||
@AuditAction({ module: 'recharges', action: 'vendor_recharge', objectType: 'vendor', objectIdParam: 'id' })
|
||||
rechargeVendor(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.rechargesService.rechargeVendor(id, body as never, currentUser?.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
|
||||
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js';
|
||||
import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js';
|
||||
import type { CurrentUser } from '../security/security.metadata.js';
|
||||
import {
|
||||
RECHARGES_REPOSITORY,
|
||||
type RechargeInput,
|
||||
type RechargeListQuery,
|
||||
type RechargeListResult,
|
||||
type RechargeSummary,
|
||||
type RechargesRepository
|
||||
} from './recharges.repository.js';
|
||||
|
||||
class MemoryIdentityRepository implements IdentityRepository {
|
||||
users = new Map<string, CurrentUser>();
|
||||
|
||||
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||
return this.users.get(userId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAuditRepository implements AuditRepository {
|
||||
entries: AuditEntryInput[] = [];
|
||||
|
||||
async write(input: AuditEntryInput): Promise<void> {
|
||||
this.entries.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryRechargesRepository implements RechargesRepository {
|
||||
private readonly items: RechargeSummary[] = [];
|
||||
private readonly balances = new Map<string, number>([
|
||||
['CUSTOMER:cus_seed', 10],
|
||||
['VENDOR:ven_seed', 20]
|
||||
]);
|
||||
private readonly idempotency = new Map<string, { scope: string; hash: string; response: RechargeSummary }>();
|
||||
|
||||
async list(query: RechargeListQuery): Promise<RechargeListResult> {
|
||||
const items = this.items.filter((item) => {
|
||||
if (query.accountType && item.accountType !== query.accountType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !query.accountId || item.accountId === query.accountId;
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.slice(query.skip, query.skip + query.take),
|
||||
total: items.length
|
||||
};
|
||||
}
|
||||
|
||||
async rechargeCustomer(input: RechargeInput): Promise<RechargeSummary> {
|
||||
return this.recharge('CUSTOMER', 'customer_recharge', input, 'Seed Customer');
|
||||
}
|
||||
|
||||
async rechargeVendor(input: RechargeInput): Promise<RechargeSummary> {
|
||||
return this.recharge('VENDOR', 'vendor_recharge', input, 'Seed Vendor');
|
||||
}
|
||||
|
||||
private async recharge(accountType: 'CUSTOMER' | 'VENDOR', scope: string, input: RechargeInput, accountName: string): Promise<RechargeSummary> {
|
||||
const existing = this.idempotency.get(input.idempotencyKey);
|
||||
if (existing) {
|
||||
if (existing.scope !== scope || existing.hash !== input.requestHash) {
|
||||
throw new ConflictException({ code: 'IDEMPOTENCY_KEY_CONFLICT', message: 'Idempotency key was used by another request.' });
|
||||
}
|
||||
|
||||
return existing.response;
|
||||
}
|
||||
|
||||
const key = `${accountType}:${input.accountId}`;
|
||||
const before = this.balances.get(key) ?? 0;
|
||||
const amount = Number(input.amount);
|
||||
const after = before + amount;
|
||||
this.balances.set(key, after);
|
||||
|
||||
const response: RechargeSummary = {
|
||||
id: `rch_${this.items.length + 1}`,
|
||||
accountType,
|
||||
accountId: input.accountId,
|
||||
accountName,
|
||||
amount: amount.toFixed(6),
|
||||
beforeBalance: before.toFixed(6),
|
||||
afterBalance: after.toFixed(6),
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
remark: input.remark ?? null,
|
||||
status: 'SUCCEEDED',
|
||||
occurredAt: new Date('2026-06-21T02:00:00.000Z'),
|
||||
createdAt: new Date('2026-06-21T02:00:00.000Z'),
|
||||
createdBy: input.actorId ?? null
|
||||
};
|
||||
this.items.unshift(response);
|
||||
this.idempotency.set(input.idempotencyKey, { scope, hash: input.requestHash, response });
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
describe('S12 recharges API', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let audit: MemoryAuditRepository;
|
||||
|
||||
const tokenFor = (userId: string) =>
|
||||
signAccessToken(
|
||||
{
|
||||
sub: userId,
|
||||
username: userId,
|
||||
roles: ['test'],
|
||||
typ: 'access'
|
||||
},
|
||||
{
|
||||
secret: 'test-only-access-token-secret-min-32-bytes',
|
||||
issuer: 'lisglosips-api',
|
||||
audience: 'lisglosips-web',
|
||||
ttlSeconds: 900
|
||||
}
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
|
||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
|
||||
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
|
||||
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
|
||||
|
||||
const identities = new MemoryIdentityRepository();
|
||||
audit = new MemoryAuditRepository();
|
||||
|
||||
identities.users.set('usr_finance', {
|
||||
id: 'usr_finance',
|
||||
username: 'finance',
|
||||
roles: ['财务'],
|
||||
permissions: ['recharges.view', 'recharges.manage', 'customers.view', 'vendors.view'] as PermissionKey[]
|
||||
});
|
||||
identities.users.set('usr_viewer', {
|
||||
id: 'usr_viewer',
|
||||
username: 'viewer',
|
||||
roles: ['只读'],
|
||||
permissions: ['recharges.view'] as PermissionKey[]
|
||||
});
|
||||
|
||||
const { AppModule } = await import('../app.module.js');
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule]
|
||||
})
|
||||
.overrideProvider(IDENTITY_REPOSITORY)
|
||||
.useValue(identities)
|
||||
.overrideProvider(AUDIT_REPOSITORY)
|
||||
.useValue(audit)
|
||||
.overrideProvider(RECHARGES_REPOSITORY)
|
||||
.useValue(new MemoryRechargesRepository())
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('rejects recharge without recharges.manage', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/customers/cus_seed/recharges')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.send({ amount: '10', idempotencyKey: 'viewer-denied-001' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('creates customer recharge once and replays idempotent duplicate', async () => {
|
||||
const body = { amount: '15.25', idempotencyKey: 'customer-rch-001', remark: 'manual top up' };
|
||||
const first = await request(app.getHttpServer())
|
||||
.post('/api/v2/customers/cus_seed/recharges')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_finance')}`)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
|
||||
const duplicate = await request(app.getHttpServer())
|
||||
.post('/api/v2/customers/cus_seed/recharges')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_finance')}`)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
|
||||
expect(first.body).toMatchObject({
|
||||
accountType: 'CUSTOMER',
|
||||
accountId: 'cus_seed',
|
||||
amount: '15.250000',
|
||||
beforeBalance: '10.000000',
|
||||
afterBalance: '25.250000'
|
||||
});
|
||||
expect(duplicate.body.id).toBe(first.body.id);
|
||||
expect(audit.entries.some((entry) => entry.module === 'recharges' && entry.action === 'customer_recharge' && entry.result === 'SUCCESS')).toBe(true);
|
||||
});
|
||||
|
||||
it('creates vendor recharge, lists ledgers, and rejects idempotency conflicts', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/vendors/ven_seed/recharges')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_finance')}`)
|
||||
.send({ amount: '3.5', idempotencyKey: 'vendor-rch-001' })
|
||||
.expect(201)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({
|
||||
accountType: 'VENDOR',
|
||||
accountId: 'ven_seed',
|
||||
beforeBalance: '20.000000',
|
||||
afterBalance: '23.500000'
|
||||
});
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/vendors/ven_seed/recharges')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_finance')}`)
|
||||
.send({ amount: '4.5', idempotencyKey: 'vendor-rch-001' })
|
||||
.expect(409);
|
||||
|
||||
const list = await request(app.getHttpServer()).get('/api/v2/recharges?take=10').set('Authorization', `Bearer ${tokenFor('usr_viewer')}`).expect(200);
|
||||
|
||||
expect(list.body.total).toBe(2);
|
||||
expect(list.body.items.some((item: { accountType: string }) => item.accountType === 'CUSTOMER')).toBe(true);
|
||||
expect(list.body.items.some((item: { accountType: string }) => item.accountType === 'VENDOR')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RechargesController } from './recharges.controller.js';
|
||||
import { RECHARGES_REPOSITORY, PrismaRechargesRepository } from './recharges.repository.js';
|
||||
import { RechargesService } from './recharges.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [RechargesController],
|
||||
providers: [
|
||||
RechargesService,
|
||||
{
|
||||
provide: RECHARGES_REPOSITORY,
|
||||
useClass: PrismaRechargesRepository
|
||||
}
|
||||
],
|
||||
exports: [RechargesService]
|
||||
})
|
||||
export class RechargesModule {}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export type RechargeAccountType = 'CUSTOMER' | 'VENDOR';
|
||||
|
||||
export interface RechargeInput {
|
||||
accountId: string;
|
||||
amount: string;
|
||||
idempotencyKey: string;
|
||||
requestHash: string;
|
||||
remark?: string;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface RechargeSummary {
|
||||
id: string;
|
||||
accountType: RechargeAccountType;
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
amount: string;
|
||||
beforeBalance: string;
|
||||
afterBalance: string;
|
||||
idempotencyKey: string;
|
||||
remark: string | null;
|
||||
status: 'SUCCEEDED' | 'FAILED' | 'REVERSED';
|
||||
occurredAt: Date | string;
|
||||
createdAt: Date | string;
|
||||
createdBy: string | null;
|
||||
}
|
||||
|
||||
export interface RechargeListQuery {
|
||||
accountType?: RechargeAccountType;
|
||||
accountId?: string;
|
||||
take: number;
|
||||
skip: number;
|
||||
}
|
||||
|
||||
export interface RechargeListResult {
|
||||
items: RechargeSummary[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface RechargesRepository {
|
||||
list(query: RechargeListQuery): Promise<RechargeListResult>;
|
||||
rechargeCustomer(input: RechargeInput): Promise<RechargeSummary>;
|
||||
rechargeVendor(input: RechargeInput): Promise<RechargeSummary>;
|
||||
}
|
||||
|
||||
export const RECHARGES_REPOSITORY = Symbol('RECHARGES_REPOSITORY');
|
||||
|
||||
function rechargeId(): string {
|
||||
return `rch_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`;
|
||||
}
|
||||
|
||||
function idempotencyId(): string {
|
||||
return `idem_${crypto.randomUUID().replaceAll('-', '').slice(0, 35)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaRechargesRepository implements RechargesRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: RechargeListQuery): Promise<RechargeListResult> {
|
||||
const [customerItems, customerTotal] =
|
||||
query.accountType === 'VENDOR' ? [[], 0] : await this.listCustomerRecharges(query.accountId, query.take, query.skip);
|
||||
const [vendorItems, vendorTotal] =
|
||||
query.accountType === 'CUSTOMER' ? [[], 0] : await this.listVendorRecharges(query.accountId, query.take, query.skip);
|
||||
|
||||
const items = [...customerItems, ...vendorItems]
|
||||
.sort((left, right) => this.timeOf(right.occurredAt) - this.timeOf(left.occurredAt))
|
||||
.slice(0, query.take);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: customerTotal + vendorTotal
|
||||
};
|
||||
}
|
||||
|
||||
async rechargeCustomer(input: RechargeInput): Promise<RechargeSummary> {
|
||||
return this.runIdempotent('customer_recharge', input, async (tx) => {
|
||||
const [locked] = await tx.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
balance: Prisma.Decimal;
|
||||
deleted_at: Date | null;
|
||||
}[]
|
||||
>`SELECT id, name, balance, deleted_at FROM customers WHERE id = ${input.accountId} FOR UPDATE`;
|
||||
|
||||
if (!locked || locked.deleted_at) {
|
||||
throw new NotFoundException({ code: 'CUSTOMER_NOT_FOUND', message: 'Customer not found.' });
|
||||
}
|
||||
|
||||
const amount = new Prisma.Decimal(input.amount);
|
||||
const afterBalance = locked.balance.plus(amount);
|
||||
const created = await tx.customerRecharge.create({
|
||||
data: {
|
||||
id: rechargeId(),
|
||||
customerId: locked.id,
|
||||
amount,
|
||||
beforeBalance: locked.balance,
|
||||
afterBalance,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
remark: input.remark,
|
||||
createdBy: input.actorId
|
||||
}
|
||||
});
|
||||
|
||||
await tx.customer.update({
|
||||
where: { id: locked.id },
|
||||
data: {
|
||||
balance: afterBalance,
|
||||
updatedBy: input.actorId,
|
||||
version: { increment: 1 }
|
||||
}
|
||||
});
|
||||
|
||||
return this.customerSummary(created, locked.name);
|
||||
});
|
||||
}
|
||||
|
||||
async rechargeVendor(input: RechargeInput): Promise<RechargeSummary> {
|
||||
return this.runIdempotent('vendor_recharge', input, async (tx) => {
|
||||
const [locked] = await tx.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
balance: Prisma.Decimal;
|
||||
deleted_at: Date | null;
|
||||
}[]
|
||||
>`SELECT id, name, balance, deleted_at FROM vendors WHERE id = ${input.accountId} FOR UPDATE`;
|
||||
|
||||
if (!locked || locked.deleted_at) {
|
||||
throw new NotFoundException({ code: 'VENDOR_NOT_FOUND', message: 'Vendor not found.' });
|
||||
}
|
||||
|
||||
const amount = new Prisma.Decimal(input.amount);
|
||||
const afterBalance = locked.balance.plus(amount);
|
||||
const created = await tx.vendorRecharge.create({
|
||||
data: {
|
||||
id: rechargeId(),
|
||||
vendorId: locked.id,
|
||||
amount,
|
||||
beforeBalance: locked.balance,
|
||||
afterBalance,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
remark: input.remark,
|
||||
createdBy: input.actorId
|
||||
}
|
||||
});
|
||||
|
||||
await tx.vendor.update({
|
||||
where: { id: locked.id },
|
||||
data: {
|
||||
balance: afterBalance,
|
||||
updatedBy: input.actorId,
|
||||
version: { increment: 1 }
|
||||
}
|
||||
});
|
||||
|
||||
return this.vendorSummary(created, locked.name);
|
||||
});
|
||||
}
|
||||
|
||||
private async runIdempotent(
|
||||
scope: string,
|
||||
input: RechargeInput,
|
||||
operation: (tx: Prisma.TransactionClient) => Promise<RechargeSummary>
|
||||
): Promise<RechargeSummary> {
|
||||
for (let attempt = 1; attempt <= 8; attempt += 1) {
|
||||
try {
|
||||
return await this.prisma.$transaction(
|
||||
async (tx) => {
|
||||
const existing = await tx.idempotencyKey.findUnique({
|
||||
where: { key: input.idempotencyKey }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing.scope !== scope || existing.requestHash !== input.requestHash) {
|
||||
throw new ConflictException({ code: 'IDEMPOTENCY_KEY_CONFLICT', message: 'Idempotency key was used by another request.' });
|
||||
}
|
||||
|
||||
if (existing.status === 'SUCCEEDED' && existing.responseBody) {
|
||||
return existing.responseBody as unknown as RechargeSummary;
|
||||
}
|
||||
|
||||
throw new ConflictException({ code: 'IDEMPOTENCY_IN_PROGRESS', message: 'Idempotent request is still in progress.' });
|
||||
}
|
||||
|
||||
await tx.idempotencyKey.create({
|
||||
data: {
|
||||
id: idempotencyId(),
|
||||
key: input.idempotencyKey,
|
||||
scope,
|
||||
requestHash: input.requestHash,
|
||||
status: 'IN_PROGRESS',
|
||||
lockedUntil: new Date(Date.now() + 60_000),
|
||||
expiresAt: new Date(Date.now() + 86_400_000)
|
||||
}
|
||||
});
|
||||
|
||||
const response = await operation(tx);
|
||||
|
||||
await tx.idempotencyKey.update({
|
||||
where: { key: input.idempotencyKey },
|
||||
data: {
|
||||
status: 'SUCCEEDED',
|
||||
responseStatus: 201,
|
||||
responseBody: this.toJson(response),
|
||||
lockedUntil: null
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
{
|
||||
isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (attempt < 8 && this.isRetryableTransactionError(error)) {
|
||||
await this.delay(50 * attempt);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ConflictException({ code: 'RECHARGE_RETRY_EXHAUSTED', message: 'Recharge transaction retry exhausted.' });
|
||||
}
|
||||
|
||||
private async listCustomerRecharges(accountId: string | undefined, take: number, skip: number): Promise<[RechargeSummary[], number]> {
|
||||
const where = accountId ? { customerId: accountId } : {};
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.customerRecharge.findMany({
|
||||
where,
|
||||
take,
|
||||
skip,
|
||||
orderBy: [{ occurredAt: 'desc' }],
|
||||
include: { customer: true }
|
||||
}),
|
||||
this.prisma.customerRecharge.count({ where })
|
||||
]);
|
||||
|
||||
return [items.map((item) => this.customerSummary(item, item.customer.name)), total];
|
||||
}
|
||||
|
||||
private async listVendorRecharges(accountId: string | undefined, take: number, skip: number): Promise<[RechargeSummary[], number]> {
|
||||
const where = accountId ? { vendorId: accountId } : {};
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.vendorRecharge.findMany({
|
||||
where,
|
||||
take,
|
||||
skip,
|
||||
orderBy: [{ occurredAt: 'desc' }],
|
||||
include: { vendor: true }
|
||||
}),
|
||||
this.prisma.vendorRecharge.count({ where })
|
||||
]);
|
||||
|
||||
return [items.map((item) => this.vendorSummary(item, item.vendor.name)), total];
|
||||
}
|
||||
|
||||
private customerSummary(
|
||||
recharge: {
|
||||
id: string;
|
||||
customerId: string;
|
||||
amount: Prisma.Decimal;
|
||||
beforeBalance: Prisma.Decimal;
|
||||
afterBalance: Prisma.Decimal;
|
||||
idempotencyKey: string;
|
||||
remark: string | null;
|
||||
status: 'SUCCEEDED' | 'FAILED' | 'REVERSED';
|
||||
occurredAt: Date;
|
||||
createdAt: Date;
|
||||
createdBy: string | null;
|
||||
},
|
||||
accountName: string
|
||||
): RechargeSummary {
|
||||
return {
|
||||
id: recharge.id,
|
||||
accountType: 'CUSTOMER',
|
||||
accountId: recharge.customerId,
|
||||
accountName,
|
||||
amount: recharge.amount.toFixed(6),
|
||||
beforeBalance: recharge.beforeBalance.toFixed(6),
|
||||
afterBalance: recharge.afterBalance.toFixed(6),
|
||||
idempotencyKey: recharge.idempotencyKey,
|
||||
remark: recharge.remark,
|
||||
status: recharge.status,
|
||||
occurredAt: recharge.occurredAt,
|
||||
createdAt: recharge.createdAt,
|
||||
createdBy: recharge.createdBy
|
||||
};
|
||||
}
|
||||
|
||||
private vendorSummary(
|
||||
recharge: {
|
||||
id: string;
|
||||
vendorId: string;
|
||||
amount: Prisma.Decimal;
|
||||
beforeBalance: Prisma.Decimal;
|
||||
afterBalance: Prisma.Decimal;
|
||||
idempotencyKey: string;
|
||||
remark: string | null;
|
||||
status: 'SUCCEEDED' | 'FAILED' | 'REVERSED';
|
||||
occurredAt: Date;
|
||||
createdAt: Date;
|
||||
createdBy: string | null;
|
||||
},
|
||||
accountName: string
|
||||
): RechargeSummary {
|
||||
return {
|
||||
id: recharge.id,
|
||||
accountType: 'VENDOR',
|
||||
accountId: recharge.vendorId,
|
||||
accountName,
|
||||
amount: recharge.amount.toFixed(6),
|
||||
beforeBalance: recharge.beforeBalance.toFixed(6),
|
||||
afterBalance: recharge.afterBalance.toFixed(6),
|
||||
idempotencyKey: recharge.idempotencyKey,
|
||||
remark: recharge.remark,
|
||||
status: recharge.status,
|
||||
occurredAt: recharge.occurredAt,
|
||||
createdAt: recharge.createdAt,
|
||||
createdBy: recharge.createdBy
|
||||
};
|
||||
}
|
||||
|
||||
private toJson(response: RechargeSummary): Prisma.InputJsonValue {
|
||||
return {
|
||||
...response,
|
||||
occurredAt: response.occurredAt instanceof Date ? response.occurredAt.toISOString() : response.occurredAt,
|
||||
createdAt: response.createdAt instanceof Date ? response.createdAt.toISOString() : response.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
private timeOf(value: Date | string): number {
|
||||
return value instanceof Date ? value.getTime() : Date.parse(value);
|
||||
}
|
||||
|
||||
private isRetryableTransactionError(error: unknown): boolean {
|
||||
return Boolean(error && typeof error === 'object' && 'code' in error && ((error as { code?: unknown }).code === 'P2034' || (error as { code?: unknown }).code === 'P2002'));
|
||||
}
|
||||
|
||||
private delay(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, milliseconds);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import {
|
||||
RECHARGES_REPOSITORY,
|
||||
type RechargeAccountType,
|
||||
type RechargeListResult,
|
||||
type RechargeSummary,
|
||||
type RechargesRepository
|
||||
} from './recharges.repository.js';
|
||||
|
||||
interface RechargeDto {
|
||||
amount?: unknown;
|
||||
idempotencyKey?: unknown;
|
||||
remark?: unknown;
|
||||
}
|
||||
|
||||
interface ListRechargesQueryDto {
|
||||
accountType?: unknown;
|
||||
accountId?: unknown;
|
||||
take?: unknown;
|
||||
skip?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RechargesService {
|
||||
constructor(@Inject(RECHARGES_REPOSITORY) private readonly recharges: RechargesRepository) {}
|
||||
|
||||
list(query: ListRechargesQueryDto): Promise<RechargeListResult> {
|
||||
return this.recharges.list({
|
||||
accountType: query.accountType === undefined ? undefined : this.accountType(query.accountType),
|
||||
accountId: query.accountId === undefined ? undefined : this.limitedString(query.accountId, 'accountId', 40),
|
||||
take: this.pageNumber(query.take, 50, 100),
|
||||
skip: this.pageNumber(query.skip, 0, 10000)
|
||||
});
|
||||
}
|
||||
|
||||
rechargeCustomer(customerId: string, body: RechargeDto, actorId?: string): Promise<RechargeSummary> {
|
||||
const amount = this.money(body.amount, 'amount');
|
||||
const idempotencyKey = this.idempotencyKey(body.idempotencyKey);
|
||||
const remark = this.optionalString(body.remark, 'remark', 500);
|
||||
|
||||
return this.recharges.rechargeCustomer({
|
||||
accountId: customerId,
|
||||
amount,
|
||||
idempotencyKey,
|
||||
requestHash: this.requestHash({ accountType: 'CUSTOMER', accountId: customerId, amount, remark }),
|
||||
remark,
|
||||
actorId
|
||||
});
|
||||
}
|
||||
|
||||
rechargeVendor(vendorId: string, body: RechargeDto, actorId?: string): Promise<RechargeSummary> {
|
||||
const amount = this.money(body.amount, 'amount');
|
||||
const idempotencyKey = this.idempotencyKey(body.idempotencyKey);
|
||||
const remark = this.optionalString(body.remark, 'remark', 500);
|
||||
|
||||
return this.recharges.rechargeVendor({
|
||||
accountId: vendorId,
|
||||
amount,
|
||||
idempotencyKey,
|
||||
requestHash: this.requestHash({ accountType: 'VENDOR', accountId: vendorId, amount, remark }),
|
||||
remark,
|
||||
actorId
|
||||
});
|
||||
}
|
||||
|
||||
private accountType(value: unknown): RechargeAccountType {
|
||||
if (value !== 'CUSTOMER' && value !== 'VENDOR') {
|
||||
throw new BadRequestException({ code: 'ACCOUNT_TYPE_INVALID', message: 'Account type is invalid.' });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private idempotencyKey(value: unknown): string {
|
||||
const key = this.limitedString(value, 'idempotencyKey', 128);
|
||||
if (key.length < 8 || !/^[A-Za-z0-9._:-]+$/.test(key)) {
|
||||
throw new BadRequestException({
|
||||
code: 'IDEMPOTENCY_KEY_INVALID',
|
||||
message: 'Idempotency key must be 8-128 safe characters.'
|
||||
});
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
private optionalString(value: unknown, field: string, maxLength: number): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.limitedString(value, field, maxLength);
|
||||
}
|
||||
|
||||
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 money(value: unknown, field: string): string {
|
||||
const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : '';
|
||||
if (!/^(?:0|[1-9]\d{0,13})(?:\.\d{1,6})?$/.test(raw)) {
|
||||
throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be a positive decimal with up to 6 places.` });
|
||||
}
|
||||
|
||||
const [integerPart, fractionPart = ''] = raw.split('.');
|
||||
const normalized = `${integerPart}.${fractionPart.padEnd(6, '0')}`;
|
||||
if (normalized === '0.000000') {
|
||||
throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be greater than zero.` });
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private pageNumber(value: unknown, defaultValue: number, max: number): number {
|
||||
if (value === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const parsed = typeof value === 'string' ? Number(value) : typeof value === 'number' ? value : Number.NaN;
|
||||
if (!Number.isInteger(parsed) || parsed < 0 || parsed > max) {
|
||||
throw new BadRequestException({ code: 'PAGINATION_INVALID', message: 'Pagination is invalid.' });
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private requestHash(value: Record<string, unknown>): string {
|
||||
return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Body, Controller, Get, Header, Inject, Param, Put, Query, Res } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
import { RecordingsService } from './recordings.service.js';
|
||||
|
||||
@ApiTags('recordings')
|
||||
@Controller('recordings')
|
||||
export class RecordingsController {
|
||||
constructor(@Inject(RecordingsService) private readonly recordingsService: RecordingsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('quality.view')
|
||||
list(@Query() query: Record<string, unknown>) {
|
||||
return this.recordingsService.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('quality.view')
|
||||
get(@Param('id') id: string) {
|
||||
return this.recordingsService.get(id);
|
||||
}
|
||||
|
||||
@Get(':id/play')
|
||||
@RequirePermissions('recordings.play')
|
||||
@AuditAction({ module: 'recordings', action: 'play', objectType: 'recording', objectIdParam: 'id' })
|
||||
@Header('Cache-Control', 'private, no-store')
|
||||
async play(@Param('id') id: string, @Res() reply: FastifyReply): Promise<void> {
|
||||
const playback = await this.recordingsService.playback(id);
|
||||
reply
|
||||
.header('X-Accel-Redirect', playback.internalPath)
|
||||
.header('Content-Type', playback.contentType)
|
||||
.header('Content-Length', playback.recording.bytes.toString())
|
||||
.header('X-Recording-Sha256', playback.recording.sha256 ?? '')
|
||||
.header('Content-Disposition', `inline; filename="${sanitizeFileName(playback.fileName)}"`)
|
||||
.send();
|
||||
}
|
||||
|
||||
@Put(':id/review')
|
||||
@RequirePermissions('quality.manage')
|
||||
@AuditAction({ module: 'quality', action: 'review_save', objectType: 'recording', objectIdParam: 'id' })
|
||||
saveReview(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.recordingsService.saveReview(id, body, currentUser?.id);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeFileName(fileName: string): string {
|
||||
return fileName.replaceAll(/[^A-Za-z0-9._-]/g, '_');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { QualityModule } from '../quality/quality.module.js';
|
||||
import { RecordingsController } from './recordings.controller.js';
|
||||
import { PrismaRecordingsRepository, RECORDINGS_REPOSITORY } from './recordings.repository.js';
|
||||
import { RecordingsService } from './recordings.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [QualityModule],
|
||||
controllers: [RecordingsController],
|
||||
providers: [
|
||||
RecordingsService,
|
||||
{
|
||||
provide: RECORDINGS_REPOSITORY,
|
||||
useClass: PrismaRecordingsRepository
|
||||
}
|
||||
]
|
||||
})
|
||||
export class RecordingsModule {}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export interface RecordingPlayback {
|
||||
id: string;
|
||||
storageKey: string;
|
||||
storagePath: string;
|
||||
sha256: string | null;
|
||||
bytes: bigint;
|
||||
durationSec: number;
|
||||
status: 'PENDING' | 'READY' | 'FAILED' | 'DELETED';
|
||||
}
|
||||
|
||||
export type ReviewResult = 'PASS' | 'ISSUE' | 'ESCALATED';
|
||||
|
||||
export interface RecordingReviewSummary {
|
||||
id: string;
|
||||
reviewerId: string;
|
||||
reviewerName: string;
|
||||
score: number | null;
|
||||
result: ReviewResult;
|
||||
issueTags: Prisma.JsonValue | null;
|
||||
notes: string | null;
|
||||
reviewedAt: Date;
|
||||
}
|
||||
|
||||
export interface RecordingListItem {
|
||||
id: string;
|
||||
rawCdrId: string | null;
|
||||
callId: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
lineGroupId: string | null;
|
||||
lineGroupName: string | null;
|
||||
caller: string | null;
|
||||
callee: string | null;
|
||||
startedAt: Date | null;
|
||||
durationSec: number;
|
||||
storageKey: string;
|
||||
sha256: string | null;
|
||||
bytes: string;
|
||||
status: string;
|
||||
movedAt: Date | null;
|
||||
createdAt: Date;
|
||||
latestReview: RecordingReviewSummary | null;
|
||||
}
|
||||
|
||||
export interface RecordingDetail extends RecordingListItem {
|
||||
storagePath: string;
|
||||
reviews: RecordingReviewSummary[];
|
||||
previousId: string | null;
|
||||
nextId: string | null;
|
||||
}
|
||||
|
||||
export interface SaveReviewInput {
|
||||
recordingId: string;
|
||||
reviewerId: string;
|
||||
score?: number | null;
|
||||
result: ReviewResult;
|
||||
issueTags?: Prisma.InputJsonValue | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export const RECORDINGS_REPOSITORY = Symbol('RECORDINGS_REPOSITORY');
|
||||
|
||||
export interface RecordingsRepository {
|
||||
list(query?: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number }): Promise<RecordingListItem[]>;
|
||||
getDetail(id: string): Promise<RecordingDetail>;
|
||||
getReadyForPlayback(id: string): Promise<RecordingPlayback>;
|
||||
saveReview(input: SaveReviewInput): Promise<RecordingReviewSummary>;
|
||||
}
|
||||
|
||||
type RecordingRecord = Prisma.RecordingGetPayload<{
|
||||
include: {
|
||||
rawCdr: {
|
||||
include: {
|
||||
customer: { select: { name: true } };
|
||||
lineGroup: { select: { name: true } };
|
||||
};
|
||||
};
|
||||
reviews: {
|
||||
orderBy: [{ reviewedAt: 'desc' }];
|
||||
include: {
|
||||
reviewer: { select: { displayName: true; username: true } };
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
type ReviewRecord = Prisma.QualityReviewGetPayload<{
|
||||
include: {
|
||||
reviewer: { select: { displayName: true; username: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class PrismaRecordingsRepository implements RecordingsRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number } = {}): Promise<RecordingListItem[]> {
|
||||
const take = Math.min(query.limit ?? 100, 500);
|
||||
const recordings = await this.prisma.recording.findMany({
|
||||
where: {
|
||||
status: query.status ? (query.status as never) : 'READY',
|
||||
reviews: query.reviewStatus === 'PENDING' ? { none: {} } : query.reviewStatus === 'REVIEWED' ? { some: {} } : undefined
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
take,
|
||||
include: this.includeRecording()
|
||||
});
|
||||
return recordings.map((recording) => this.toListItem(recording));
|
||||
}
|
||||
|
||||
async getDetail(id: string): Promise<RecordingDetail> {
|
||||
const recording = await this.findRecordingOrThrow(id);
|
||||
const [previous, next] = await Promise.all([
|
||||
this.prisma.recording.findFirst({
|
||||
where: { status: 'READY', createdAt: { gt: recording.createdAt } },
|
||||
orderBy: [{ createdAt: 'asc' }],
|
||||
select: { id: true }
|
||||
}),
|
||||
this.prisma.recording.findFirst({
|
||||
where: { status: 'READY', createdAt: { lt: recording.createdAt } },
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
select: { id: true }
|
||||
})
|
||||
]);
|
||||
const item = this.toListItem(recording);
|
||||
return {
|
||||
...item,
|
||||
storagePath: recording.storagePath,
|
||||
reviews: recording.reviews.map((review) => this.toReviewSummary(review)),
|
||||
previousId: previous?.id ?? null,
|
||||
nextId: next?.id ?? null
|
||||
};
|
||||
}
|
||||
|
||||
async getReadyForPlayback(id: string): Promise<RecordingPlayback> {
|
||||
const recording = await this.prisma.recording.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
storageKey: true,
|
||||
storagePath: true,
|
||||
sha256: true,
|
||||
bytes: true,
|
||||
durationSec: true,
|
||||
status: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!recording || recording.status !== 'READY') {
|
||||
throw new NotFoundException({
|
||||
code: 'RECORDING_NOT_READY',
|
||||
message: 'Recording is not available for playback.'
|
||||
});
|
||||
}
|
||||
|
||||
return recording;
|
||||
}
|
||||
|
||||
async saveReview(input: SaveReviewInput): Promise<RecordingReviewSummary> {
|
||||
const recording = await this.prisma.recording.findUnique({
|
||||
where: { id: input.recordingId },
|
||||
select: { id: true, status: true }
|
||||
});
|
||||
if (!recording || recording.status !== 'READY') {
|
||||
throw new NotFoundException({
|
||||
code: 'RECORDING_NOT_READY',
|
||||
message: 'Recording is not available for review.'
|
||||
});
|
||||
}
|
||||
|
||||
const review = await this.prisma.qualityReview.create({
|
||||
data: {
|
||||
id: qualityReviewId(),
|
||||
recordingId: input.recordingId,
|
||||
reviewerId: input.reviewerId,
|
||||
score: input.score,
|
||||
result: input.result,
|
||||
issueTags: input.issueTags === null ? Prisma.JsonNull : input.issueTags,
|
||||
notes: input.notes
|
||||
},
|
||||
include: {
|
||||
reviewer: { select: { displayName: true, username: true } }
|
||||
}
|
||||
});
|
||||
return this.toReviewSummary(review as ReviewRecord);
|
||||
}
|
||||
|
||||
private includeRecording() {
|
||||
return {
|
||||
rawCdr: {
|
||||
include: {
|
||||
customer: { select: { name: true } },
|
||||
lineGroup: { select: { name: true } }
|
||||
}
|
||||
},
|
||||
reviews: {
|
||||
orderBy: [{ reviewedAt: 'desc' }],
|
||||
include: {
|
||||
reviewer: { select: { displayName: true, username: true } }
|
||||
}
|
||||
}
|
||||
} satisfies Prisma.RecordingInclude;
|
||||
}
|
||||
|
||||
private async findRecordingOrThrow(id: string): Promise<RecordingRecord> {
|
||||
const recording = await this.prisma.recording.findUnique({
|
||||
where: { id },
|
||||
include: this.includeRecording()
|
||||
});
|
||||
if (!recording) {
|
||||
throw new NotFoundException({ code: 'RECORDING_NOT_FOUND', message: 'Recording not found.' });
|
||||
}
|
||||
return recording;
|
||||
}
|
||||
|
||||
private toListItem(recording: RecordingRecord): RecordingListItem {
|
||||
return {
|
||||
id: recording.id,
|
||||
rawCdrId: recording.rawCdrId,
|
||||
callId: recording.rawCdr?.callId ?? null,
|
||||
customerId: recording.rawCdr?.customerId ?? null,
|
||||
customerName: recording.rawCdr?.customer?.name ?? null,
|
||||
lineGroupId: recording.rawCdr?.lineGroupId ?? null,
|
||||
lineGroupName: recording.rawCdr?.lineGroup?.name ?? null,
|
||||
caller: recording.rawCdr?.caller ?? null,
|
||||
callee: recording.rawCdr?.callee ?? null,
|
||||
startedAt: recording.rawCdr?.startedAt ?? null,
|
||||
durationSec: recording.durationSec,
|
||||
storageKey: recording.storageKey,
|
||||
sha256: recording.sha256,
|
||||
bytes: recording.bytes.toString(),
|
||||
status: recording.status,
|
||||
movedAt: recording.movedAt,
|
||||
createdAt: recording.createdAt,
|
||||
latestReview: recording.reviews[0] ? this.toReviewSummary(recording.reviews[0]) : null
|
||||
};
|
||||
}
|
||||
|
||||
private toReviewSummary(review: ReviewRecord): RecordingReviewSummary {
|
||||
return {
|
||||
id: review.id,
|
||||
reviewerId: review.reviewerId,
|
||||
reviewerName: review.reviewer.displayName || review.reviewer.username,
|
||||
score: review.score,
|
||||
result: review.result,
|
||||
issueTags: review.issueTags,
|
||||
notes: review.notes,
|
||||
reviewedAt: review.reviewedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function qualityReviewId(): string {
|
||||
return `qrev_${crypto.randomUUID().replaceAll('-', '').slice(0, 32)}`;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { internalRecordingPath, RecordingsService } from './recordings.service.js';
|
||||
import type { RecordingsRepository } from './recordings.repository.js';
|
||||
import type { QualityService } from '../quality/quality.service.js';
|
||||
|
||||
describe('recordings service', () => {
|
||||
it('builds nginx internal recording paths', () => {
|
||||
expect(internalRecordingPath('2026/06/21/call one.wav')).toBe('/_recordings/2026/06/21/call%20one.wav');
|
||||
});
|
||||
|
||||
it('rejects unsafe storage keys', () => {
|
||||
expect(() => internalRecordingPath('../secret.wav')).toThrow(BadRequestException);
|
||||
expect(() => internalRecordingPath('/../../secret.wav')).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('adds stable sampling and review status to recording list rows', async () => {
|
||||
const recordings = {
|
||||
list: async () => [
|
||||
{
|
||||
id: 'rec_1',
|
||||
rawCdrId: 'raw_1',
|
||||
callId: 'call-1',
|
||||
customerId: 'cust_1',
|
||||
customerName: 'Customer',
|
||||
lineGroupId: 'lg_1',
|
||||
lineGroupName: 'Line group',
|
||||
caller: '1001',
|
||||
callee: '1002',
|
||||
startedAt: new Date('2026-06-21T00:00:00.000Z'),
|
||||
durationSec: 12,
|
||||
storageKey: '2026/06/21/call.wav',
|
||||
sha256: null,
|
||||
bytes: '12',
|
||||
status: 'READY',
|
||||
movedAt: null,
|
||||
createdAt: new Date('2026-06-21T00:00:00.000Z'),
|
||||
latestReview: null
|
||||
}
|
||||
]
|
||||
} as RecordingsRepository;
|
||||
const quality = {
|
||||
samplingFor: async () => ({
|
||||
selected: true,
|
||||
matches: [{ ruleId: 'rule_1', ruleName: 'All', ratio: '100.00', score: 1, selected: true }]
|
||||
})
|
||||
} as unknown as QualityService;
|
||||
const service = new RecordingsService(recordings, quality);
|
||||
|
||||
await expect(service.list()).resolves.toMatchObject([
|
||||
{
|
||||
id: 'rec_1',
|
||||
reviewStatus: 'PENDING',
|
||||
sampling: { selected: true }
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('validates quality review payloads', async () => {
|
||||
const service = new RecordingsService({} as RecordingsRepository, {} as QualityService);
|
||||
await expect(service.saveReview('rec_1', { result: 'BAD' }, 'user_1')).rejects.toThrow(BadRequestException);
|
||||
await expect(service.saveReview('rec_1', { result: 'PASS', score: 101 }, 'user_1')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import path from 'node:path';
|
||||
import { QualityService } from '../quality/quality.service.js';
|
||||
import {
|
||||
RECORDINGS_REPOSITORY,
|
||||
type RecordingDetail,
|
||||
type RecordingListItem,
|
||||
type RecordingPlayback,
|
||||
type RecordingReviewSummary,
|
||||
type RecordingsRepository,
|
||||
type ReviewResult
|
||||
} from './recordings.repository.js';
|
||||
|
||||
export interface RecordingPlaybackResponse {
|
||||
recording: RecordingPlayback;
|
||||
internalPath: string;
|
||||
contentType: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RecordingsService {
|
||||
constructor(
|
||||
@Inject(RECORDINGS_REPOSITORY) private readonly recordings: RecordingsRepository,
|
||||
@Inject(QualityService) private readonly qualityService: QualityService
|
||||
) {}
|
||||
|
||||
async list(query: { status?: unknown; reviewStatus?: unknown; limit?: unknown } = {}) {
|
||||
const recordings = await this.recordings.list({
|
||||
status: query.status === undefined ? undefined : this.recordingStatus(query.status),
|
||||
reviewStatus: query.reviewStatus === undefined ? undefined : this.reviewStatus(query.reviewStatus),
|
||||
limit: query.limit === undefined ? undefined : this.integer(query.limit, 'limit', 1, 500)
|
||||
});
|
||||
return Promise.all(recordings.map((recording) => this.withSampling(recording)));
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
return this.withSampling(await this.recordings.getDetail(id));
|
||||
}
|
||||
|
||||
async playback(id: string): Promise<RecordingPlaybackResponse> {
|
||||
const recording = await this.recordings.getReadyForPlayback(id);
|
||||
return {
|
||||
recording,
|
||||
internalPath: internalRecordingPath(recording.storageKey),
|
||||
contentType: contentTypeFor(recording.storageKey),
|
||||
fileName: path.posix.basename(recording.storageKey)
|
||||
};
|
||||
}
|
||||
|
||||
async saveReview(id: string, body: unknown, reviewerId?: string): Promise<RecordingReviewSummary> {
|
||||
if (!reviewerId) {
|
||||
throw new BadRequestException({ code: 'REVIEWER_REQUIRED', message: 'Current user is required to save review.' });
|
||||
}
|
||||
const data = body as Record<string, unknown>;
|
||||
return this.recordings.saveReview({
|
||||
recordingId: id,
|
||||
reviewerId,
|
||||
score: data.score === undefined || data.score === null ? null : this.integer(data.score, 'score', 0, 100),
|
||||
result: this.reviewResult(data.result),
|
||||
issueTags: data.issueTags === undefined ? null : this.issueTags(data.issueTags),
|
||||
notes: data.notes === undefined || data.notes === null ? null : this.optionalString(data.notes, 'notes', 1000)
|
||||
});
|
||||
}
|
||||
|
||||
private async withSampling<T extends RecordingListItem | RecordingDetail>(recording: T) {
|
||||
const sampling = await this.qualityService.samplingFor({
|
||||
recordingId: recording.id,
|
||||
customerId: recording.customerId,
|
||||
lineGroupId: recording.lineGroupId
|
||||
});
|
||||
return {
|
||||
...recording,
|
||||
reviewStatus: recording.latestReview ? 'REVIEWED' : 'PENDING',
|
||||
sampling
|
||||
};
|
||||
}
|
||||
|
||||
private recordingStatus(value: unknown): string {
|
||||
if (value !== 'PENDING' && value !== 'READY' && value !== 'FAILED' && value !== 'DELETED') {
|
||||
throw new BadRequestException({ code: 'RECORDING_STATUS_INVALID', message: 'status is invalid.' });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private reviewStatus(value: unknown): 'PENDING' | 'REVIEWED' {
|
||||
if (value !== 'PENDING' && value !== 'REVIEWED') {
|
||||
throw new BadRequestException({ code: 'REVIEW_STATUS_INVALID', message: 'reviewStatus is invalid.' });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private reviewResult(value: unknown): ReviewResult {
|
||||
if (value !== 'PASS' && value !== 'ISSUE' && value !== 'ESCALATED') {
|
||||
throw new BadRequestException({ code: 'QUALITY_REVIEW_RESULT_INVALID', message: 'result is invalid.' });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private issueTags(value: unknown): Prisma.InputJsonValue {
|
||||
if (!Array.isArray(value) || value.some((tag) => typeof tag !== 'string' || tag.trim().length === 0 || tag.length > 60)) {
|
||||
throw new BadRequestException({ code: 'QUALITY_ISSUE_TAGS_INVALID', message: 'issueTags must be an array of short strings.' });
|
||||
}
|
||||
return value.map((tag) => tag.trim());
|
||||
}
|
||||
|
||||
private optionalString(value: unknown, field: string, maxLength: number): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} must be a string.` });
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > maxLength) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` });
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private integer(value: unknown, field: string, min: number, max: number): number {
|
||||
const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number.parseInt(value, 10) : NaN;
|
||||
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||
throw new BadRequestException({ code: 'INTEGER_INVALID', message: `${field} must be an integer from ${min} to ${max}.` });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
export function internalRecordingPath(storageKey: string): string {
|
||||
const normalized = storageKey.replaceAll('\\', '/').replace(/^\/+/, '');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
if (parts.length === 0 || parts.some((part) => part === '.' || part === '..' || part.includes('\0'))) {
|
||||
throw new BadRequestException({
|
||||
code: 'RECORDING_STORAGE_KEY_INVALID',
|
||||
message: 'Recording storage key is invalid.'
|
||||
});
|
||||
}
|
||||
return `/_recordings/${parts.map(encodeURIComponent).join('/')}`;
|
||||
}
|
||||
|
||||
function contentTypeFor(storageKey: string): string {
|
||||
const extension = path.posix.extname(storageKey).toLowerCase();
|
||||
if (extension === '.wav') {
|
||||
return 'audio/wav';
|
||||
}
|
||||
if (extension === '.mp3') {
|
||||
return 'audio/mpeg';
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
import { RolesService } from './roles.service.js';
|
||||
|
||||
@ApiTags('roles')
|
||||
@Controller()
|
||||
export class RolesController {
|
||||
constructor(@Inject(RolesService) private readonly rolesService: RolesService) {}
|
||||
|
||||
@Get('roles')
|
||||
@RequirePermissions('roles.view')
|
||||
listRoles() {
|
||||
return this.rolesService.listRoles();
|
||||
}
|
||||
|
||||
@Post('roles')
|
||||
@RequirePermissions('roles.manage')
|
||||
@AuditAction({ module: 'roles', action: 'create', objectType: 'role' })
|
||||
createRole(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.rolesService.createRole(body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Patch('roles/:id')
|
||||
@RequirePermissions('roles.manage')
|
||||
@AuditAction({ module: 'roles', action: 'update', objectType: 'role', objectIdParam: 'id' })
|
||||
updateRole(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.rolesService.updateRole(id, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Get('permissions')
|
||||
@RequirePermissions('roles.view')
|
||||
listPermissions() {
|
||||
return this.rolesService.listPermissions();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RolesController } from './roles.controller.js';
|
||||
import { PrismaRolesRepository, ROLES_REPOSITORY } from './roles.repository.js';
|
||||
import { RolesService } from './roles.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [RolesController],
|
||||
providers: [
|
||||
RolesService,
|
||||
PrismaRolesRepository,
|
||||
{
|
||||
provide: ROLES_REPOSITORY,
|
||||
useExisting: PrismaRolesRepository
|
||||
}
|
||||
]
|
||||
})
|
||||
export class RolesModule {}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export interface PermissionSummary {
|
||||
id: string;
|
||||
module: string;
|
||||
action: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface RoleSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
builtIn: boolean;
|
||||
status: 'ENABLED' | 'DISABLED';
|
||||
permissionIds: string[];
|
||||
userCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateRoleInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
permissionIds: string[];
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRoleInput {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
status?: 'ENABLED' | 'DISABLED';
|
||||
permissionIds?: string[];
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface RolesRepository {
|
||||
listRoles(): Promise<RoleSummary[]>;
|
||||
listPermissions(): Promise<PermissionSummary[]>;
|
||||
createRole(input: CreateRoleInput): Promise<RoleSummary>;
|
||||
updateRole(roleId: string, input: UpdateRoleInput): Promise<RoleSummary>;
|
||||
}
|
||||
|
||||
export const ROLES_REPOSITORY = Symbol('ROLES_REPOSITORY');
|
||||
|
||||
function roleId(): string {
|
||||
return `rol_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaRolesRepository implements RolesRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async listRoles(): Promise<RoleSummary[]> {
|
||||
const roles = await this.prisma.role.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ builtIn: 'desc' }, { name: 'asc' }],
|
||||
include: {
|
||||
permissions: true,
|
||||
_count: {
|
||||
select: { userRoles: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return roles.map((role) => this.toSummary(role));
|
||||
}
|
||||
|
||||
async listPermissions(): Promise<PermissionSummary[]> {
|
||||
return this.prisma.permission.findMany({
|
||||
orderBy: [{ module: 'asc' }, { action: 'asc' }]
|
||||
});
|
||||
}
|
||||
|
||||
async createRole(input: CreateRoleInput): Promise<RoleSummary> {
|
||||
await this.ensurePermissions(input.permissionIds);
|
||||
|
||||
const role = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.role.create({
|
||||
data: {
|
||||
id: roleId(),
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
builtIn: false,
|
||||
createdBy: input.actorId,
|
||||
updatedBy: input.actorId
|
||||
}
|
||||
});
|
||||
|
||||
for (const permissionId of input.permissionIds) {
|
||||
await tx.rolePermission.create({
|
||||
data: {
|
||||
roleId: created.id,
|
||||
permissionId,
|
||||
createdBy: input.actorId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return tx.role.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: {
|
||||
permissions: true,
|
||||
_count: {
|
||||
select: { userRoles: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return this.toSummary(role);
|
||||
}
|
||||
|
||||
async updateRole(roleIdValue: string, input: UpdateRoleInput): Promise<RoleSummary> {
|
||||
if (input.permissionIds) {
|
||||
await this.ensurePermissions(input.permissionIds);
|
||||
}
|
||||
|
||||
const role = await this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.role.findUnique({ where: { id: roleIdValue } });
|
||||
if (!existing || existing.deletedAt) {
|
||||
throw new NotFoundException({ code: 'ROLE_NOT_FOUND', message: 'Role not found.' });
|
||||
}
|
||||
|
||||
if (existing.builtIn && (input.name !== undefined || input.status !== undefined || input.permissionIds !== undefined)) {
|
||||
throw new ForbiddenException({
|
||||
code: 'BUILT_IN_ROLE_PROTECTED',
|
||||
message: 'Built-in role name, status, and permissions are protected.'
|
||||
});
|
||||
}
|
||||
|
||||
await tx.role.update({
|
||||
where: { id: roleIdValue },
|
||||
data: {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
status: input.status,
|
||||
updatedBy: input.actorId,
|
||||
version: { increment: 1 }
|
||||
}
|
||||
});
|
||||
|
||||
if (input.permissionIds) {
|
||||
await tx.rolePermission.deleteMany({ where: { roleId: roleIdValue } });
|
||||
for (const permissionId of input.permissionIds) {
|
||||
await tx.rolePermission.create({
|
||||
data: {
|
||||
roleId: roleIdValue,
|
||||
permissionId,
|
||||
createdBy: input.actorId
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tx.role.findUniqueOrThrow({
|
||||
where: { id: roleIdValue },
|
||||
include: {
|
||||
permissions: true,
|
||||
_count: {
|
||||
select: { userRoles: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return this.toSummary(role);
|
||||
}
|
||||
|
||||
private async ensurePermissions(permissionIds: string[]): Promise<void> {
|
||||
const uniquePermissionIds = [...new Set(permissionIds)];
|
||||
const count = await this.prisma.permission.count({
|
||||
where: {
|
||||
id: { in: uniquePermissionIds }
|
||||
}
|
||||
});
|
||||
|
||||
if (count !== uniquePermissionIds.length) {
|
||||
throw new BadRequestException({
|
||||
code: 'PERMISSION_INVALID',
|
||||
message: 'One or more permissions are invalid.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private toSummary(role: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
builtIn: boolean;
|
||||
status: 'ENABLED' | 'DISABLED';
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
permissions: { permissionId: string }[];
|
||||
_count: { userRoles: number };
|
||||
}): RoleSummary {
|
||||
return {
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
builtIn: role.builtIn,
|
||||
status: role.status,
|
||||
permissionIds: role.permissions.map((permission) => permission.permissionId).sort(),
|
||||
userCount: role._count.userRoles,
|
||||
createdAt: role.createdAt,
|
||||
updatedAt: role.updatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { ROLES_REPOSITORY, type CreateRoleInput, type RolesRepository, type UpdateRoleInput } from './roles.repository.js';
|
||||
|
||||
interface CreateRoleDto {
|
||||
name?: unknown;
|
||||
description?: unknown;
|
||||
permissionIds?: unknown;
|
||||
}
|
||||
|
||||
interface UpdateRoleDto {
|
||||
name?: unknown;
|
||||
description?: unknown;
|
||||
status?: unknown;
|
||||
permissionIds?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RolesService {
|
||||
constructor(@Inject(ROLES_REPOSITORY) private readonly roles: RolesRepository) {}
|
||||
|
||||
listRoles() {
|
||||
return this.roles.listRoles();
|
||||
}
|
||||
|
||||
listPermissions() {
|
||||
return this.roles.listPermissions();
|
||||
}
|
||||
|
||||
createRole(body: CreateRoleDto, actorId?: string) {
|
||||
const input: CreateRoleInput = {
|
||||
name: this.requiredString(body.name, 'name').trim(),
|
||||
description: this.optionalString(body.description),
|
||||
permissionIds: this.permissionIds(body.permissionIds),
|
||||
actorId
|
||||
};
|
||||
|
||||
return this.roles.createRole(input);
|
||||
}
|
||||
|
||||
updateRole(roleId: string, body: UpdateRoleDto, actorId?: string) {
|
||||
const input: UpdateRoleInput = {
|
||||
name: body.name === undefined ? undefined : this.requiredString(body.name, 'name').trim(),
|
||||
description: body.description === undefined ? undefined : this.nullableString(body.description),
|
||||
status: body.status === undefined ? undefined : this.status(body.status),
|
||||
permissionIds: body.permissionIds === undefined ? undefined : this.permissionIds(body.permissionIds),
|
||||
actorId
|
||||
};
|
||||
|
||||
return this.roles.updateRole(roleId, input);
|
||||
}
|
||||
|
||||
private requiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private optionalString(value: unknown): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.requiredString(value, 'description').trim();
|
||||
}
|
||||
|
||||
private nullableString(value: unknown): string | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.requiredString(value, 'description').trim();
|
||||
}
|
||||
|
||||
private permissionIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value) || !value.every((permissionId) => typeof permissionId === 'string' && permissionId.length > 0)) {
|
||||
throw new BadRequestException({ code: 'PERMISSION_IDS_INVALID', message: 'Permission ids are invalid.' });
|
||||
}
|
||||
|
||||
return [...new Set(value as string[])];
|
||||
}
|
||||
|
||||
private status(value: unknown): 'ENABLED' | 'DISABLED' {
|
||||
if (value !== 'ENABLED' && value !== 'DISABLED') {
|
||||
throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { verifyAccessToken } from '@lisglosips/auth';
|
||||
import type { RuntimeConfig } from '../../shared/config.js';
|
||||
import { IDENTITY_REPOSITORY, type IdentityRepository } from './identity.repository.js';
|
||||
import { IS_PUBLIC_KEY, type AuthenticatedRequest } from './security.metadata.js';
|
||||
|
||||
@Injectable()
|
||||
export class AccessTokenGuard implements CanActivate {
|
||||
constructor(
|
||||
@Inject(Reflector) private readonly reflector: Reflector,
|
||||
@Inject(ConfigService) private readonly config: ConfigService<RuntimeConfig, true>,
|
||||
@Inject(IDENTITY_REPOSITORY) private readonly identities: IdentityRepository
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]);
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const token = this.extractBearerToken(request.headers.authorization);
|
||||
|
||||
if (!token) {
|
||||
throw this.unauthorized();
|
||||
}
|
||||
|
||||
const payload = verifyAccessToken(token, {
|
||||
secret: this.config.get('auth.accessTokenSecret', { infer: true }),
|
||||
issuer: this.config.get('auth.tokenIssuer', { infer: true }),
|
||||
audience: this.config.get('auth.tokenAudience', { infer: true })
|
||||
});
|
||||
|
||||
if (!payload) {
|
||||
throw this.unauthorized();
|
||||
}
|
||||
|
||||
const user = await this.identities.findCurrentUserById(payload.sub);
|
||||
if (!user) {
|
||||
throw this.unauthorized();
|
||||
}
|
||||
|
||||
request.currentUser = user;
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractBearerToken(header: string | undefined): string | null {
|
||||
if (!header) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [type, token] = header.split(' ');
|
||||
return type === 'Bearer' && token ? token : null;
|
||||
}
|
||||
|
||||
private unauthorized(): UnauthorizedException {
|
||||
return new UnauthorizedException({
|
||||
code: 'AUTH_REQUIRED',
|
||||
message: 'Authentication is required.'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { PermissionKey } from '@lisglosips/auth';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
import type { CurrentUser } from './security.metadata.js';
|
||||
|
||||
export interface IdentityRepository {
|
||||
findCurrentUserById(userId: string): Promise<CurrentUser | null>;
|
||||
}
|
||||
|
||||
export const IDENTITY_REPOSITORY = Symbol('IDENTITY_REPOSITORY');
|
||||
|
||||
@Injectable()
|
||||
export class PrismaIdentityRepository implements IdentityRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!user || user.deletedAt || user.status !== 'ENABLED') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enabledRoles = user.userRoles.map((userRole) => userRole.role).filter((role) => role.status === 'ENABLED' && !role.deletedAt);
|
||||
const permissions = new Set<PermissionKey>();
|
||||
|
||||
for (const role of enabledRoles) {
|
||||
for (const rolePermission of role.permissions) {
|
||||
permissions.add(rolePermission.permissionId as PermissionKey);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
roles: enabledRoles.map((role) => role.name),
|
||||
permissions: [...permissions].sort()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { PermissionKey } from '@lisglosips/auth';
|
||||
import { IS_PUBLIC_KEY, REQUIRED_PERMISSIONS_KEY, type AuthenticatedRequest } from './security.metadata.js';
|
||||
|
||||
@Injectable()
|
||||
export class RbacGuard implements CanActivate {
|
||||
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]);
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const required = this.reflector.getAllAndOverride<PermissionKey[]>(REQUIRED_PERMISSIONS_KEY, [context.getHandler(), context.getClass()]) ?? [];
|
||||
if (required.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const granted = new Set(request.currentUser?.permissions ?? []);
|
||||
const allowed = required.every((permission) => granted.has(permission));
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException({
|
||||
code: 'RBAC_FORBIDDEN',
|
||||
message: 'Permission denied.'
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import crypto from 'node:crypto';
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
|
||||
import { AUDIT_LOGS_REPOSITORY, type AuditLogDetail, type AuditLogsRepository } from '../audit-logs/audit-logs.repository.js';
|
||||
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js';
|
||||
import { IDENTITY_REPOSITORY, type IdentityRepository } from './identity.repository.js';
|
||||
import type { CurrentUser } from './security.metadata.js';
|
||||
import { ROLES_REPOSITORY, type CreateRoleInput, type RoleSummary, type RolesRepository, type UpdateRoleInput } from '../roles/roles.repository.js';
|
||||
import { USERS_REPOSITORY, type CreateUserInput, type UpdateUserInput, type UserSummary, type UsersRepository } from '../users/users.repository.js';
|
||||
|
||||
class MemoryIdentityRepository implements IdentityRepository {
|
||||
users = new Map<string, CurrentUser>();
|
||||
|
||||
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||
return this.users.get(userId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAuditRepository implements AuditRepository {
|
||||
entries: AuditEntryInput[] = [];
|
||||
|
||||
async write(input: AuditEntryInput): Promise<void> {
|
||||
this.entries.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAuditLogsRepository implements AuditLogsRepository {
|
||||
constructor(private readonly audit: MemoryAuditRepository) {}
|
||||
|
||||
async list() {
|
||||
return {
|
||||
items: this.audit.entries.map((entry, index) => this.toDetail(entry, index)),
|
||||
total: this.audit.entries.length
|
||||
};
|
||||
}
|
||||
|
||||
async get(id: string): Promise<AuditLogDetail> {
|
||||
const index = Number(id.replace('aud_test_', ''));
|
||||
return this.toDetail(this.audit.entries[index], index);
|
||||
}
|
||||
|
||||
private toDetail(entry: AuditEntryInput, index: number): AuditLogDetail {
|
||||
return {
|
||||
id: `aud_test_${index}`,
|
||||
requestId: entry.requestId,
|
||||
userId: entry.userId ?? null,
|
||||
username: entry.username ?? null,
|
||||
roleNames: entry.roleNames ?? null,
|
||||
ip: entry.ip ?? null,
|
||||
userAgent: entry.userAgent ?? null,
|
||||
module: entry.module,
|
||||
action: entry.action,
|
||||
objectType: entry.objectType,
|
||||
objectId: entry.objectId ?? null,
|
||||
beforeSummary: entry.beforeSummary,
|
||||
afterSummary: entry.afterSummary,
|
||||
result: entry.result,
|
||||
errorCode: entry.errorCode ?? null,
|
||||
createdAt: new Date('2026-06-21T00:00:00.000Z')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryUsersRepository implements UsersRepository {
|
||||
async list(): Promise<UserSummary[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async create(input: CreateUserInput): Promise<UserSummary> {
|
||||
return this.summary('usr_created', input.username, input.roleIds);
|
||||
}
|
||||
|
||||
async update(userId: string, input: UpdateUserInput): Promise<UserSummary> {
|
||||
return this.summary(userId, 'operator', input.roleIds ?? ['ROLE_SUPER_ADMIN']);
|
||||
}
|
||||
|
||||
async resetPassword(userId: string): Promise<UserSummary> {
|
||||
return this.summary(userId, 'operator', ['ROLE_SUPER_ADMIN'], true);
|
||||
}
|
||||
|
||||
private summary(id: string, username: string, roleIds: string[], requirePasswordChange = false): UserSummary {
|
||||
return {
|
||||
id,
|
||||
username,
|
||||
displayName: 'Operator',
|
||||
phone: null,
|
||||
email: null,
|
||||
status: 'ENABLED',
|
||||
requirePasswordChange,
|
||||
lastLoginAt: null,
|
||||
roles: roleIds,
|
||||
roleIds,
|
||||
createdAt: new Date('2026-06-21T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-21T00:00:00.000Z')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryRolesRepository implements RolesRepository {
|
||||
async listRoles(): Promise<RoleSummary[]> {
|
||||
return [this.role('ROLE_SUPER_ADMIN', true, ['users.manage', 'roles.manage'])];
|
||||
}
|
||||
|
||||
async listPermissions() {
|
||||
return [{ id: 'users.manage', module: 'users', action: 'manage', description: 'Manage users' }];
|
||||
}
|
||||
|
||||
async createRole(input: CreateRoleInput): Promise<RoleSummary> {
|
||||
return this.role('rol_created', false, input.permissionIds, input.name);
|
||||
}
|
||||
|
||||
async updateRole(roleId: string, input: UpdateRoleInput): Promise<RoleSummary> {
|
||||
if (roleId === 'ROLE_SUPER_ADMIN' && (input.name !== undefined || input.status !== undefined || input.permissionIds !== undefined)) {
|
||||
throw new ForbiddenException({
|
||||
code: 'BUILT_IN_ROLE_PROTECTED',
|
||||
message: 'Built-in role name, status, and permissions are protected.'
|
||||
});
|
||||
}
|
||||
|
||||
return this.role(roleId, roleId === 'ROLE_SUPER_ADMIN', input.permissionIds ?? ['users.manage']);
|
||||
}
|
||||
|
||||
private role(id: string, builtIn: boolean, permissionIds: string[], name = 'role'): RoleSummary {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: null,
|
||||
builtIn,
|
||||
status: 'ENABLED',
|
||||
permissionIds,
|
||||
userCount: 0,
|
||||
createdAt: new Date('2026-06-21T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-21T00:00:00.000Z')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('S10 security, RBAC, and audit API', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let identities: MemoryIdentityRepository;
|
||||
let audit: MemoryAuditRepository;
|
||||
|
||||
const tokenFor = (userId: string) =>
|
||||
signAccessToken(
|
||||
{
|
||||
sub: userId,
|
||||
username: userId,
|
||||
roles: ['test'],
|
||||
typ: 'access'
|
||||
},
|
||||
{
|
||||
secret: 'test-only-access-token-secret-min-32-bytes',
|
||||
issuer: 'lisglosips-api',
|
||||
audience: 'lisglosips-web',
|
||||
ttlSeconds: 900
|
||||
}
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
|
||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
|
||||
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
|
||||
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
|
||||
|
||||
identities = new MemoryIdentityRepository();
|
||||
audit = new MemoryAuditRepository();
|
||||
const auditLogs = new MemoryAuditLogsRepository(audit);
|
||||
|
||||
identities.users.set('usr_admin', {
|
||||
id: 'usr_admin',
|
||||
username: 'admin',
|
||||
roles: ['超级管理员'],
|
||||
permissions: ['users.view', 'users.manage', 'roles.view', 'roles.manage', 'audit.view'] as PermissionKey[]
|
||||
});
|
||||
identities.users.set('usr_readonly', {
|
||||
id: 'usr_readonly',
|
||||
username: 'readonly',
|
||||
roles: ['只读'],
|
||||
permissions: ['users.view'] as PermissionKey[]
|
||||
});
|
||||
|
||||
const { AppModule } = await import('../app.module.js');
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule]
|
||||
})
|
||||
.overrideProvider(IDENTITY_REPOSITORY)
|
||||
.useValue(identities)
|
||||
.overrideProvider(AUDIT_REPOSITORY)
|
||||
.useValue(audit)
|
||||
.overrideProvider(AUDIT_LOGS_REPOSITORY)
|
||||
.useValue(auditLogs)
|
||||
.overrideProvider(USERS_REPOSITORY)
|
||||
.useValue(new MemoryUsersRepository())
|
||||
.overrideProvider(ROLES_REPOSITORY)
|
||||
.useValue(new MemoryRolesRepository())
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('rejects missing and insufficient permissions', async () => {
|
||||
await request(app.getHttpServer()).get('/api/v2/users').expect(401);
|
||||
await request(app.getHttpServer()).post('/api/v2/users/usr_target/reset-password').set('Authorization', `Bearer ${tokenFor('usr_readonly')}`).send({
|
||||
password: crypto.randomUUID() + crypto.randomUUID()
|
||||
}).expect(403);
|
||||
});
|
||||
|
||||
it('audits reset password with sensitive body redacted', async () => {
|
||||
const generatedPassword = crypto.randomUUID() + crypto.randomUUID();
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/users/usr_target/reset-password')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_admin')}`)
|
||||
.send({ password: generatedPassword })
|
||||
.expect(201);
|
||||
|
||||
const entry = audit.entries.find((item) => item.action === 'reset_password');
|
||||
|
||||
expect(entry?.userId).toBe('usr_admin');
|
||||
expect(entry?.result).toBe('SUCCESS');
|
||||
expect(JSON.stringify(entry?.beforeSummary)).not.toContain(generatedPassword);
|
||||
expect(JSON.stringify(entry?.beforeSummary)).toContain('[REDACTED]');
|
||||
});
|
||||
|
||||
it('protects built-in role permissions and exposes audit details', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v2/roles/ROLE_SUPER_ADMIN')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_admin')}`)
|
||||
.send({ permissionIds: ['users.view'] })
|
||||
.expect(403);
|
||||
|
||||
const logs = await request(app.getHttpServer()).get('/api/v2/audit-logs').set('Authorization', `Bearer ${tokenFor('usr_admin')}`).expect(200);
|
||||
|
||||
expect(logs.body.total).toBeGreaterThanOrEqual(1);
|
||||
expect(logs.body.items.some((item: { action: string }) => item.action === 'reset_password')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SetMetadata, createParamDecorator, type ExecutionContext } from '@nestjs/common';
|
||||
import type { PermissionKey } from '@lisglosips/auth';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'lisglosips:is_public';
|
||||
export const REQUIRED_PERMISSIONS_KEY = 'lisglosips:required_permissions';
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
username: string;
|
||||
roles: string[];
|
||||
permissions: PermissionKey[];
|
||||
}
|
||||
|
||||
export type AuthenticatedRequest = FastifyRequest & {
|
||||
currentUser?: CurrentUser;
|
||||
};
|
||||
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
export const RequirePermissions = (...permissions: PermissionKey[]) => SetMetadata(REQUIRED_PERMISSIONS_KEY, permissions);
|
||||
|
||||
export const CurrentUserParam = createParamDecorator((_data: unknown, context: ExecutionContext): CurrentUser | undefined => {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
return request.currentUser;
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AccessTokenGuard } from './auth.guard.js';
|
||||
import { PrismaIdentityRepository, IDENTITY_REPOSITORY } from './identity.repository.js';
|
||||
import { RbacGuard } from './rbac.guard.js';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
PrismaIdentityRepository,
|
||||
{
|
||||
provide: IDENTITY_REPOSITORY,
|
||||
useExisting: PrismaIdentityRepository
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: AccessTokenGuard
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: RbacGuard
|
||||
}
|
||||
],
|
||||
exports: [IDENTITY_REPOSITORY]
|
||||
})
|
||||
export class SecurityModule {}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
import { UsersService } from './users.service.js';
|
||||
|
||||
@ApiTags('users')
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(@Inject(UsersService) private readonly usersService: UsersService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('users.view')
|
||||
list() {
|
||||
return this.usersService.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('users.manage')
|
||||
@AuditAction({ module: 'users', action: 'create', objectType: 'user' })
|
||||
create(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.usersService.create(body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('users.manage')
|
||||
@AuditAction({ module: 'users', action: 'update', objectType: 'user', objectIdParam: 'id' })
|
||||
update(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.usersService.update(id, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/reset-password')
|
||||
@RequirePermissions('users.manage')
|
||||
@AuditAction({ module: 'users', action: 'reset_password', objectType: 'user', objectIdParam: 'id' })
|
||||
resetPassword(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.usersService.resetPassword(id, body as never, currentUser?.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaUsersRepository, USERS_REPOSITORY } from './users.repository.js';
|
||||
import { UsersController } from './users.controller.js';
|
||||
import { UsersService } from './users.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [
|
||||
UsersService,
|
||||
PrismaUsersRepository,
|
||||
{
|
||||
provide: USERS_REPOSITORY,
|
||||
useExisting: PrismaUsersRepository
|
||||
}
|
||||
]
|
||||
})
|
||||
export class UsersModule {}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export interface UserSummary {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
status: 'ENABLED' | 'DISABLED';
|
||||
requirePasswordChange: boolean;
|
||||
lastLoginAt: Date | null;
|
||||
roles: string[];
|
||||
roleIds: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateUserInput {
|
||||
username: string;
|
||||
displayName: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
passwordHash: string;
|
||||
passwordAlgo: string;
|
||||
requirePasswordChange: boolean;
|
||||
roleIds: string[];
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserInput {
|
||||
displayName?: string;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
status?: 'ENABLED' | 'DISABLED';
|
||||
roleIds?: string[];
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface UsersRepository {
|
||||
list(): Promise<UserSummary[]>;
|
||||
create(input: CreateUserInput): Promise<UserSummary>;
|
||||
update(userId: string, input: UpdateUserInput): Promise<UserSummary>;
|
||||
resetPassword(userId: string, passwordHash: string, passwordAlgo: string, actorId?: string): Promise<UserSummary>;
|
||||
}
|
||||
|
||||
export const USERS_REPOSITORY = Symbol('USERS_REPOSITORY');
|
||||
|
||||
function userId(): string {
|
||||
return `usr_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaUsersRepository implements UsersRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<UserSummary[]> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return users.map((user) => this.toSummary(user));
|
||||
}
|
||||
|
||||
async create(input: CreateUserInput): Promise<UserSummary> {
|
||||
await this.ensureRoles(input.roleIds);
|
||||
|
||||
const user = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
id: userId(),
|
||||
username: input.username,
|
||||
displayName: input.displayName,
|
||||
phone: input.phone,
|
||||
email: input.email,
|
||||
passwordHash: input.passwordHash,
|
||||
passwordAlgo: input.passwordAlgo,
|
||||
requirePasswordChange: input.requirePasswordChange,
|
||||
createdBy: input.actorId,
|
||||
updatedBy: input.actorId
|
||||
}
|
||||
});
|
||||
|
||||
for (const roleId of input.roleIds) {
|
||||
await tx.userRole.create({
|
||||
data: {
|
||||
userId: created.id,
|
||||
roleId,
|
||||
createdBy: input.actorId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return tx.user.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return this.toSummary(user);
|
||||
}
|
||||
|
||||
async update(userIdValue: string, input: UpdateUserInput): Promise<UserSummary> {
|
||||
if (input.roleIds) {
|
||||
await this.ensureRoles(input.roleIds);
|
||||
}
|
||||
|
||||
const user = await this.prisma.$transaction(async (tx) => {
|
||||
const exists = await tx.user.findUnique({ where: { id: userIdValue } });
|
||||
if (!exists || exists.deletedAt) {
|
||||
throw new NotFoundException({ code: 'USER_NOT_FOUND', message: 'User not found.' });
|
||||
}
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: userIdValue },
|
||||
data: {
|
||||
displayName: input.displayName,
|
||||
phone: input.phone,
|
||||
email: input.email,
|
||||
status: input.status,
|
||||
updatedBy: input.actorId,
|
||||
version: { increment: 1 }
|
||||
}
|
||||
});
|
||||
|
||||
if (input.roleIds) {
|
||||
await tx.userRole.deleteMany({ where: { userId: userIdValue } });
|
||||
for (const roleId of input.roleIds) {
|
||||
await tx.userRole.create({
|
||||
data: {
|
||||
userId: userIdValue,
|
||||
roleId,
|
||||
createdBy: input.actorId
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tx.user.findUniqueOrThrow({
|
||||
where: { id: userIdValue },
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return this.toSummary(user);
|
||||
}
|
||||
|
||||
async resetPassword(userIdValue: string, passwordHash: string, passwordAlgo: string, actorId?: string): Promise<UserSummary> {
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userIdValue },
|
||||
data: {
|
||||
passwordHash,
|
||||
passwordAlgo,
|
||||
requirePasswordChange: true,
|
||||
failedLoginCount: 0,
|
||||
lockedUntil: null,
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.toSummary(user);
|
||||
}
|
||||
|
||||
private async ensureRoles(roleIds: string[]): Promise<void> {
|
||||
const uniqueRoleIds = [...new Set(roleIds)];
|
||||
const count = await this.prisma.role.count({
|
||||
where: {
|
||||
id: { in: uniqueRoleIds },
|
||||
status: 'ENABLED',
|
||||
deletedAt: null
|
||||
}
|
||||
});
|
||||
|
||||
if (count !== uniqueRoleIds.length) {
|
||||
throw new BadRequestException({
|
||||
code: 'ROLE_INVALID',
|
||||
message: 'One or more roles are invalid.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private toSummary(user: {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
status: 'ENABLED' | 'DISABLED';
|
||||
requirePasswordChange: boolean;
|
||||
lastLoginAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
userRoles: { roleId: string; role: { name: string } }[];
|
||||
}): UserSummary {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
phone: user.phone,
|
||||
email: user.email,
|
||||
status: user.status,
|
||||
requirePasswordChange: user.requirePasswordChange,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
roleIds: user.userRoles.map((userRole) => userRole.roleId),
|
||||
roles: user.userRoles.map((userRole) => userRole.role.name),
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { PASSWORD_ALGO_ARGON2ID, hashPasswordArgon2id } from '@lisglosips/auth';
|
||||
import { USERS_REPOSITORY, type UpdateUserInput, type UserSummary, type UsersRepository } from './users.repository.js';
|
||||
|
||||
interface CreateUserDto {
|
||||
username?: unknown;
|
||||
displayName?: unknown;
|
||||
phone?: unknown;
|
||||
email?: unknown;
|
||||
password?: unknown;
|
||||
requirePasswordChange?: unknown;
|
||||
roleIds?: unknown;
|
||||
}
|
||||
|
||||
interface UpdateUserDto {
|
||||
displayName?: unknown;
|
||||
phone?: unknown;
|
||||
email?: unknown;
|
||||
status?: unknown;
|
||||
roleIds?: unknown;
|
||||
}
|
||||
|
||||
interface ResetPasswordDto {
|
||||
password?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(@Inject(USERS_REPOSITORY) private readonly users: UsersRepository) {}
|
||||
|
||||
list(): Promise<UserSummary[]> {
|
||||
return this.users.list();
|
||||
}
|
||||
|
||||
async create(body: CreateUserDto, actorId?: string): Promise<UserSummary> {
|
||||
const username = this.requiredString(body.username, 'username').trim().toLowerCase();
|
||||
const displayName = this.requiredString(body.displayName, 'displayName').trim();
|
||||
const password = this.password(body.password);
|
||||
const roleIds = this.roleIds(body.roleIds);
|
||||
|
||||
return this.users.create({
|
||||
username,
|
||||
displayName,
|
||||
phone: this.optionalString(body.phone),
|
||||
email: this.optionalString(body.email),
|
||||
passwordHash: await hashPasswordArgon2id(password),
|
||||
passwordAlgo: PASSWORD_ALGO_ARGON2ID,
|
||||
requirePasswordChange: body.requirePasswordChange === undefined ? true : body.requirePasswordChange === true,
|
||||
roleIds,
|
||||
actorId
|
||||
});
|
||||
}
|
||||
|
||||
update(userId: string, body: UpdateUserDto, actorId?: string): Promise<UserSummary> {
|
||||
const input: UpdateUserInput = {
|
||||
displayName: body.displayName === undefined ? undefined : this.requiredString(body.displayName, 'displayName').trim(),
|
||||
phone: body.phone === undefined ? undefined : this.nullableString(body.phone),
|
||||
email: body.email === undefined ? undefined : this.nullableString(body.email),
|
||||
status: body.status === undefined ? undefined : this.status(body.status),
|
||||
roleIds: body.roleIds === undefined ? undefined : this.roleIds(body.roleIds),
|
||||
actorId
|
||||
};
|
||||
|
||||
return this.users.update(userId, input);
|
||||
}
|
||||
|
||||
async resetPassword(userId: string, body: ResetPasswordDto, actorId?: string): Promise<UserSummary> {
|
||||
return this.users.resetPassword(userId, await hashPasswordArgon2id(this.password(body.password)), PASSWORD_ALGO_ARGON2ID, actorId);
|
||||
}
|
||||
|
||||
private requiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private optionalString(value: unknown): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.requiredString(value, 'value').trim();
|
||||
}
|
||||
|
||||
private nullableString(value: unknown): string | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.requiredString(value, 'value').trim();
|
||||
}
|
||||
|
||||
private password(value: unknown): string {
|
||||
const password = this.requiredString(value, 'password');
|
||||
if (password.length < 12) {
|
||||
throw new BadRequestException({ code: 'PASSWORD_TOO_SHORT', message: 'Password must be at least 12 characters.' });
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
private roleIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value) || value.length === 0 || !value.every((roleId) => typeof roleId === 'string' && roleId.length > 0)) {
|
||||
throw new BadRequestException({ code: 'ROLE_IDS_REQUIRED', message: 'At least one role is required.' });
|
||||
}
|
||||
|
||||
return [...new Set(value as string[])];
|
||||
}
|
||||
|
||||
private status(value: unknown): 'ENABLED' | 'DISABLED' {
|
||||
if (value !== 'ENABLED' && value !== 'DISABLED') {
|
||||
throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Body, Controller, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
import { VendorGatewaysService } from './vendor-gateways.service.js';
|
||||
|
||||
@ApiTags('vendor-gateways')
|
||||
@Controller('vendor-gateways')
|
||||
export class VendorGatewaysController {
|
||||
constructor(@Inject(VendorGatewaysService) private readonly vendorGatewaysService: VendorGatewaysService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('vendor_gateways.view')
|
||||
list(@Query() query: unknown) {
|
||||
return this.vendorGatewaysService.list(query as never);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('vendor_gateways.view')
|
||||
get(@Param('id') id: string) {
|
||||
return this.vendorGatewaysService.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('vendor_gateways.manage')
|
||||
@AuditAction({ module: 'vendor_gateways', action: 'create', objectType: 'vendor_gateway' })
|
||||
create(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.vendorGatewaysService.create(body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('vendor_gateways.manage')
|
||||
@AuditAction({ module: 'vendor_gateways', action: 'update', objectType: 'vendor_gateway', objectIdParam: 'id' })
|
||||
update(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.vendorGatewaysService.update(id, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/enable')
|
||||
@RequirePermissions('vendor_gateways.manage')
|
||||
@AuditAction({ module: 'vendor_gateways', action: 'enable', objectType: 'vendor_gateway', objectIdParam: 'id' })
|
||||
enable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.vendorGatewaysService.enable(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/disable')
|
||||
@RequirePermissions('vendor_gateways.manage')
|
||||
@AuditAction({ module: 'vendor_gateways', action: 'disable', objectType: 'vendor_gateway', objectIdParam: 'id' })
|
||||
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.vendorGatewaysService.disable(id, currentUser?.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
|
||||
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js';
|
||||
import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js';
|
||||
import type { CurrentUser } from '../security/security.metadata.js';
|
||||
import {
|
||||
VENDOR_GATEWAYS_REPOSITORY,
|
||||
type CreateVendorGatewayInput,
|
||||
type UpdateVendorGatewayInput,
|
||||
type VendorGatewayStatus,
|
||||
type VendorGatewaySummary,
|
||||
type VendorGatewaysRepository
|
||||
} from './vendor-gateways.repository.js';
|
||||
|
||||
class MemoryIdentityRepository implements IdentityRepository {
|
||||
users = new Map<string, CurrentUser>();
|
||||
|
||||
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||
return this.users.get(userId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAuditRepository implements AuditRepository {
|
||||
entries: AuditEntryInput[] = [];
|
||||
|
||||
async write(input: AuditEntryInput): Promise<void> {
|
||||
this.entries.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
|
||||
private readonly gateways = new Map<string, VendorGatewaySummary>();
|
||||
outboxEvents = 0;
|
||||
|
||||
constructor() {
|
||||
this.gateways.set(
|
||||
'vgw_seed',
|
||||
this.summary({
|
||||
id: 'vgw_seed',
|
||||
vendorId: 'ven_seed',
|
||||
name: 'Seed Gateway',
|
||||
host: 'carrier.example.local',
|
||||
cpsLimit: 20,
|
||||
concurrencyLimit: 200,
|
||||
cycleRate: '0.120000',
|
||||
billingCycleSec: 60
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async list(vendorId?: string): Promise<VendorGatewaySummary[]> {
|
||||
return [...this.gateways.values()].filter((gateway) => !vendorId || gateway.vendorId === vendorId);
|
||||
}
|
||||
|
||||
async get(gatewayId: string): Promise<VendorGatewaySummary> {
|
||||
return this.gateways.get(gatewayId) ?? this.summary({ id: gatewayId, name: 'Missing Gateway' });
|
||||
}
|
||||
|
||||
async create(input: CreateVendorGatewayInput): Promise<VendorGatewaySummary> {
|
||||
const gateway = this.summary({
|
||||
id: 'vgw_created',
|
||||
vendorId: input.vendorId,
|
||||
name: input.name,
|
||||
authMode: input.authMode,
|
||||
host: input.host,
|
||||
port: input.port,
|
||||
transport: input.transport,
|
||||
sipUsername: input.sipUsername ?? null,
|
||||
hasSipCredential: Boolean(input.sipHa1),
|
||||
cpsLimit: input.cpsLimit,
|
||||
concurrencyLimit: input.concurrencyLimit,
|
||||
billingCycleSec: input.billingCycleSec,
|
||||
cycleRate: input.cycleRate,
|
||||
forbiddenPeriods: input.forbiddenPeriods.map((period, index) => ({ id: `period_${index}`, ...period })),
|
||||
codecs: input.codecs.map((codec, index) => ({ id: `codec_${index}`, ...codec })),
|
||||
prefixRules: input.prefixRules.map((rule, index) => ({ id: `rule_${index}`, ...rule }))
|
||||
});
|
||||
this.gateways.set(gateway.id, gateway);
|
||||
this.outboxEvents += 1;
|
||||
return gateway;
|
||||
}
|
||||
|
||||
async update(gatewayId: string, input: UpdateVendorGatewayInput): Promise<VendorGatewaySummary> {
|
||||
const current = await this.get(gatewayId);
|
||||
const updated = this.summary({
|
||||
...current,
|
||||
vendorId: input.vendorId ?? current.vendorId,
|
||||
name: input.name ?? current.name,
|
||||
authMode: input.authMode ?? current.authMode,
|
||||
host: input.host ?? current.host,
|
||||
port: input.port ?? current.port,
|
||||
transport: input.transport ?? current.transport,
|
||||
sipUsername: input.sipUsername === undefined ? current.sipUsername : input.sipUsername,
|
||||
hasSipCredential: input.sipHa1 === undefined ? current.hasSipCredential : Boolean(input.sipHa1),
|
||||
cpsLimit: input.cpsLimit ?? current.cpsLimit,
|
||||
concurrencyLimit: input.concurrencyLimit ?? current.concurrencyLimit,
|
||||
billingCycleSec: input.billingCycleSec ?? current.billingCycleSec,
|
||||
cycleRate: input.cycleRate ?? current.cycleRate,
|
||||
status: input.status ?? current.status,
|
||||
forbiddenPeriods: input.forbiddenPeriods === undefined ? current.forbiddenPeriods : input.forbiddenPeriods.map((period, index) => ({ id: `period_updated_${index}`, ...period })),
|
||||
codecs: input.codecs === undefined ? current.codecs : input.codecs.map((codec, index) => ({ id: `codec_updated_${index}`, ...codec })),
|
||||
prefixRules: input.prefixRules === undefined ? current.prefixRules : input.prefixRules.map((rule, index) => ({ id: `rule_updated_${index}`, ...rule }))
|
||||
});
|
||||
this.gateways.set(gatewayId, updated);
|
||||
this.outboxEvents += 1;
|
||||
return updated;
|
||||
}
|
||||
|
||||
async setStatus(gatewayId: string, status: VendorGatewayStatus): Promise<VendorGatewaySummary> {
|
||||
return this.update(gatewayId, { status });
|
||||
}
|
||||
|
||||
private summary(input: Partial<VendorGatewaySummary> & { id: string; name: string }): VendorGatewaySummary {
|
||||
const cycleRate = input.cycleRate ?? '0.000000';
|
||||
const billingCycleSec = input.billingCycleSec ?? 60;
|
||||
return {
|
||||
id: input.id,
|
||||
vendorId: input.vendorId ?? 'ven_seed',
|
||||
vendorName: input.vendorName ?? 'Seed Vendor',
|
||||
name: input.name,
|
||||
authMode: input.authMode ?? 'IP',
|
||||
host: input.host ?? 'carrier.example.local',
|
||||
port: input.port ?? 5060,
|
||||
transport: input.transport ?? 'udp',
|
||||
sipUsername: input.sipUsername ?? null,
|
||||
hasSipCredential: input.hasSipCredential ?? false,
|
||||
cpsLimit: input.cpsLimit ?? 0,
|
||||
concurrencyLimit: input.concurrencyLimit ?? 0,
|
||||
billingCycleSec,
|
||||
cycleRate,
|
||||
minuteRate: (Number(cycleRate) * 60 / billingCycleSec).toFixed(6),
|
||||
status: input.status ?? 'ENABLED',
|
||||
forbiddenPeriods: input.forbiddenPeriods ?? [],
|
||||
codecs: input.codecs ?? [],
|
||||
prefixRules: input.prefixRules ?? [],
|
||||
createdAt: new Date('2026-06-21T06:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-21T06:00:00.000Z')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('S16 vendor gateways API', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let audit: MemoryAuditRepository;
|
||||
let repository: MemoryVendorGatewaysRepository;
|
||||
|
||||
const tokenFor = (userId: string) =>
|
||||
signAccessToken(
|
||||
{
|
||||
sub: userId,
|
||||
username: userId,
|
||||
roles: ['test'],
|
||||
typ: 'access'
|
||||
},
|
||||
{
|
||||
secret: 'test-only-access-token-secret-min-32-bytes',
|
||||
issuer: 'lisglosips-api',
|
||||
audience: 'lisglosips-web',
|
||||
ttlSeconds: 900
|
||||
}
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
|
||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
|
||||
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
|
||||
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
|
||||
|
||||
const identities = new MemoryIdentityRepository();
|
||||
audit = new MemoryAuditRepository();
|
||||
repository = new MemoryVendorGatewaysRepository();
|
||||
|
||||
identities.users.set('usr_ops', {
|
||||
id: 'usr_ops',
|
||||
username: 'ops',
|
||||
roles: ['运营管理员'],
|
||||
permissions: ['vendor_gateways.view', 'vendor_gateways.manage'] as PermissionKey[]
|
||||
});
|
||||
identities.users.set('usr_viewer', {
|
||||
id: 'usr_viewer',
|
||||
username: 'viewer',
|
||||
roles: ['只读'],
|
||||
permissions: ['vendor_gateways.view'] as PermissionKey[]
|
||||
});
|
||||
|
||||
const { AppModule } = await import('../app.module.js');
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(IDENTITY_REPOSITORY)
|
||||
.useValue(identities)
|
||||
.overrideProvider(AUDIT_REPOSITORY)
|
||||
.useValue(audit)
|
||||
.overrideProvider(VENDOR_GATEWAYS_REPOSITORY)
|
||||
.useValue(repository)
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('lists vendor gateways with limits and derived minute rate', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v2/vendor-gateways?vendorId=ven_seed')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body[0]).toMatchObject({
|
||||
id: 'vgw_seed',
|
||||
cpsLimit: 20,
|
||||
concurrencyLimit: 200,
|
||||
cycleRate: '0.120000',
|
||||
minuteRate: '0.120000'
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain('sipHa1');
|
||||
});
|
||||
|
||||
it('rejects writes without vendor_gateways.manage', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/vendor-gateways')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.send({ vendorId: 'ven_seed', name: 'Denied', authMode: 'IP', host: '1.2.3.4' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('creates and updates a full vendor gateway configuration', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/vendor-gateways')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({
|
||||
vendorId: 'ven_seed',
|
||||
name: 'Carrier SIP',
|
||||
authMode: 'SIP_DIGEST',
|
||||
host: 'SIP.CARRIER.LOCAL',
|
||||
port: 5060,
|
||||
transport: 'udp',
|
||||
sipUsername: 'carrier-user',
|
||||
sipPassword: 'change-me-very-strong',
|
||||
cpsLimit: 30,
|
||||
concurrencyLimit: 300,
|
||||
billingCycleSec: 6,
|
||||
cycleRate: '0.012',
|
||||
forbiddenPeriods: [{ weekdayMask: 62, startTime: '23:00:00', endTime: '23:59:59' }],
|
||||
codecs: [
|
||||
{ codec: 'PCMA', priority: 1 },
|
||||
{ codec: 'PCMU', priority: 2 }
|
||||
],
|
||||
prefixRules: [{ direction: 'CALLEE', matchPrefix: '00', replacePrefix: '+', priority: 1 }]
|
||||
})
|
||||
.expect(201)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({
|
||||
id: 'vgw_created',
|
||||
authMode: 'SIP_DIGEST',
|
||||
host: 'sip.carrier.local',
|
||||
hasSipCredential: true,
|
||||
billingCycleSec: 6,
|
||||
cycleRate: '0.012000',
|
||||
minuteRate: '0.120000'
|
||||
});
|
||||
expect(response.body.sipHa1).toBeUndefined();
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v2/vendor-gateways/vgw_created')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({
|
||||
authMode: 'IP',
|
||||
host: '203.0.113.10',
|
||||
codecs: [{ codec: 'G729', priority: 1 }],
|
||||
prefixRules: [{ direction: 'CALLER', matchPrefix: '+86', replacePrefix: '0', priority: 1 }]
|
||||
})
|
||||
.expect(200)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({
|
||||
authMode: 'IP',
|
||||
host: '203.0.113.10',
|
||||
sipUsername: null,
|
||||
hasSipCredential: false
|
||||
});
|
||||
expect(response.body.codecs).toHaveLength(1);
|
||||
expect(response.body.prefixRules[0]).toMatchObject({ direction: 'CALLER', matchPrefix: '+86' });
|
||||
});
|
||||
|
||||
expect(repository.outboxEvents).toBeGreaterThanOrEqual(2);
|
||||
expect(audit.entries.some((entry) => entry.module === 'vendor_gateways' && entry.action === 'create')).toBe(true);
|
||||
expect(JSON.stringify(audit.entries)).not.toContain('change-me-very-strong');
|
||||
});
|
||||
|
||||
it('validates child configuration and supports enable/disable', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/vendor-gateways')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({
|
||||
vendorId: 'ven_seed',
|
||||
name: 'Invalid Codec',
|
||||
authMode: 'IP',
|
||||
host: '203.0.113.20',
|
||||
codecs: [
|
||||
{ codec: 'PCMA', priority: 1 },
|
||||
{ codec: 'PCMU', priority: 1 }
|
||||
]
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
await request(app.getHttpServer()).post('/api/v2/vendor-gateways/vgw_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
await request(app.getHttpServer()).post('/api/v2/vendor-gateways/vgw_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { VENDOR_GATEWAYS_REPOSITORY, PrismaVendorGatewaysRepository } from './vendor-gateways.repository.js';
|
||||
import { VendorGatewaysController } from './vendor-gateways.controller.js';
|
||||
import { VendorGatewaysService } from './vendor-gateways.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [VendorGatewaysController],
|
||||
providers: [
|
||||
VendorGatewaysService,
|
||||
{
|
||||
provide: VENDOR_GATEWAYS_REPOSITORY,
|
||||
useClass: PrismaVendorGatewaysRepository
|
||||
}
|
||||
],
|
||||
exports: [VendorGatewaysService]
|
||||
})
|
||||
export class VendorGatewaysModule {}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export type VendorGatewayStatus = 'ENABLED' | 'DISABLED';
|
||||
export type VendorGatewayAuthMode = 'IP' | 'SIP_DIGEST' | 'MIXED';
|
||||
export type PrefixDirection = 'CALLER' | 'CALLEE';
|
||||
|
||||
export interface ForbiddenPeriodSummary {
|
||||
id: string;
|
||||
weekdayMask: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
export interface CodecSummary {
|
||||
id: string;
|
||||
codec: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface PrefixRuleSummary {
|
||||
id: string;
|
||||
direction: PrefixDirection;
|
||||
matchPrefix: string;
|
||||
replacePrefix: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface VendorGatewaySummary {
|
||||
id: string;
|
||||
vendorId: string;
|
||||
vendorName: string;
|
||||
name: string;
|
||||
authMode: VendorGatewayAuthMode;
|
||||
host: string;
|
||||
port: number;
|
||||
transport: string;
|
||||
sipUsername: string | null;
|
||||
hasSipCredential: boolean;
|
||||
cpsLimit: number;
|
||||
concurrencyLimit: number;
|
||||
billingCycleSec: number;
|
||||
cycleRate: string;
|
||||
minuteRate: string;
|
||||
status: VendorGatewayStatus;
|
||||
forbiddenPeriods: ForbiddenPeriodSummary[];
|
||||
codecs: CodecSummary[];
|
||||
prefixRules: PrefixRuleSummary[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ForbiddenPeriodInput {
|
||||
weekdayMask: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
export interface CodecInput {
|
||||
codec: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface PrefixRuleInput {
|
||||
direction: PrefixDirection;
|
||||
matchPrefix: string;
|
||||
replacePrefix: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface CreateVendorGatewayInput {
|
||||
vendorId: string;
|
||||
name: string;
|
||||
authMode: VendorGatewayAuthMode;
|
||||
host: string;
|
||||
port: number;
|
||||
transport: string;
|
||||
sipUsername?: string | null;
|
||||
sipHa1?: string | null;
|
||||
cpsLimit: number;
|
||||
concurrencyLimit: number;
|
||||
billingCycleSec: number;
|
||||
cycleRate: string;
|
||||
status: VendorGatewayStatus;
|
||||
forbiddenPeriods: ForbiddenPeriodInput[];
|
||||
codecs: CodecInput[];
|
||||
prefixRules: PrefixRuleInput[];
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export type UpdateVendorGatewayInput = Partial<Omit<CreateVendorGatewayInput, 'actorId'>> & {
|
||||
actorId?: string;
|
||||
};
|
||||
|
||||
export interface VendorGatewaysRepository {
|
||||
list(vendorId?: string): Promise<VendorGatewaySummary[]>;
|
||||
get(gatewayId: string): Promise<VendorGatewaySummary>;
|
||||
create(input: CreateVendorGatewayInput): Promise<VendorGatewaySummary>;
|
||||
update(gatewayId: string, input: UpdateVendorGatewayInput): Promise<VendorGatewaySummary>;
|
||||
setStatus(gatewayId: string, status: VendorGatewayStatus, actorId?: string): Promise<VendorGatewaySummary>;
|
||||
}
|
||||
|
||||
export const VENDOR_GATEWAYS_REPOSITORY = Symbol('VENDOR_GATEWAYS_REPOSITORY');
|
||||
|
||||
function gatewayId(): string {
|
||||
return `vgw_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
|
||||
}
|
||||
|
||||
function childId(prefix: string): string {
|
||||
return `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
|
||||
}
|
||||
|
||||
function outboxId(): string {
|
||||
return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(vendorId?: string): Promise<VendorGatewaySummary[]> {
|
||||
const gateways = await this.prisma.vendorGateway.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
vendorId
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
include: this.includeSummary()
|
||||
});
|
||||
return gateways.map((gateway) => this.toSummary(gateway));
|
||||
}
|
||||
|
||||
async get(gatewayIdValue: string): Promise<VendorGatewaySummary> {
|
||||
return this.toSummary(await this.findActiveOrThrow(gatewayIdValue));
|
||||
}
|
||||
|
||||
async create(input: CreateVendorGatewayInput): Promise<VendorGatewaySummary> {
|
||||
await this.ensureVendor(input.vendorId);
|
||||
try {
|
||||
const gateway = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.vendorGateway.create({
|
||||
data: {
|
||||
id: gatewayId(),
|
||||
vendorId: input.vendorId,
|
||||
name: input.name,
|
||||
authMode: input.authMode,
|
||||
host: input.host,
|
||||
port: input.port,
|
||||
transport: input.transport,
|
||||
sipUsername: input.sipUsername,
|
||||
sipHa1: input.sipHa1,
|
||||
cpsLimit: input.cpsLimit,
|
||||
concurrencyLimit: input.concurrencyLimit,
|
||||
billingCycleSec: input.billingCycleSec,
|
||||
cycleRate: new Prisma.Decimal(input.cycleRate),
|
||||
status: input.status,
|
||||
createdBy: input.actorId,
|
||||
updatedBy: input.actorId,
|
||||
forbiddenPeriods: { create: input.forbiddenPeriods.map((period) => this.forbiddenCreate(period, input.actorId)) },
|
||||
codecs: { create: input.codecs.map((codec) => this.codecCreate(codec, input.actorId)) },
|
||||
prefixRules: { create: input.prefixRules.map((rule) => this.prefixRuleCreate(rule, input.actorId)) }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, created.id, 'vendor_gateway.changed');
|
||||
return created;
|
||||
});
|
||||
return this.toSummary(gateway);
|
||||
} catch (error) {
|
||||
this.handleUniqueConflict(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(gatewayIdValue: string, input: UpdateVendorGatewayInput): Promise<VendorGatewaySummary> {
|
||||
const existing = await this.findActiveOrThrow(gatewayIdValue);
|
||||
if (input.vendorId) {
|
||||
await this.ensureVendor(input.vendorId);
|
||||
}
|
||||
|
||||
try {
|
||||
const gateway = await this.prisma.$transaction(async (tx) => {
|
||||
if (input.forbiddenPeriods) {
|
||||
await tx.vendorGatewayForbiddenPeriod.deleteMany({ where: { vendorGatewayId: gatewayIdValue } });
|
||||
}
|
||||
if (input.codecs) {
|
||||
await tx.vendorGatewayCodec.deleteMany({ where: { vendorGatewayId: gatewayIdValue } });
|
||||
}
|
||||
if (input.prefixRules) {
|
||||
await tx.vendorGatewayPrefixRule.deleteMany({ where: { vendorGatewayId: gatewayIdValue } });
|
||||
}
|
||||
|
||||
const updated = await tx.vendorGateway.update({
|
||||
where: { id: gatewayIdValue },
|
||||
data: {
|
||||
vendorId: input.vendorId,
|
||||
name: input.name,
|
||||
authMode: input.authMode,
|
||||
host: input.host,
|
||||
port: input.port,
|
||||
transport: input.transport,
|
||||
sipUsername: input.sipUsername,
|
||||
sipHa1: input.sipHa1,
|
||||
cpsLimit: input.cpsLimit,
|
||||
concurrencyLimit: input.concurrencyLimit,
|
||||
billingCycleSec: input.billingCycleSec,
|
||||
cycleRate: input.cycleRate === undefined ? undefined : new Prisma.Decimal(input.cycleRate),
|
||||
status: input.status,
|
||||
updatedBy: input.actorId,
|
||||
version: { increment: 1 },
|
||||
forbiddenPeriods: input.forbiddenPeriods ? { create: input.forbiddenPeriods.map((period) => this.forbiddenCreate(period, input.actorId)) } : undefined,
|
||||
codecs: input.codecs ? { create: input.codecs.map((codec) => this.codecCreate(codec, input.actorId)) } : undefined,
|
||||
prefixRules: input.prefixRules ? { create: input.prefixRules.map((rule) => this.prefixRuleCreate(rule, input.actorId)) } : undefined
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, existing.id, 'vendor_gateway.changed');
|
||||
return updated;
|
||||
});
|
||||
return this.toSummary(gateway);
|
||||
} catch (error) {
|
||||
this.handleUniqueConflict(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async setStatus(gatewayIdValue: string, status: VendorGatewayStatus, actorId?: string): Promise<VendorGatewaySummary> {
|
||||
await this.findActiveOrThrow(gatewayIdValue);
|
||||
const gateway = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.vendorGateway.update({
|
||||
where: { id: gatewayIdValue },
|
||||
data: {
|
||||
status,
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, updated.id, 'vendor_gateway.changed');
|
||||
return updated;
|
||||
});
|
||||
return this.toSummary(gateway);
|
||||
}
|
||||
|
||||
private async ensureVendor(vendorId: string): Promise<void> {
|
||||
const vendor = await this.prisma.vendor.findUnique({
|
||||
where: { id: vendorId },
|
||||
select: { id: true, deletedAt: true }
|
||||
});
|
||||
if (!vendor || vendor.deletedAt) {
|
||||
throw new NotFoundException({ code: 'VENDOR_NOT_FOUND', message: 'Vendor not found.' });
|
||||
}
|
||||
}
|
||||
|
||||
private async findActiveOrThrow(gatewayIdValue: string) {
|
||||
const gateway = await this.prisma.vendorGateway.findUnique({
|
||||
where: { id: gatewayIdValue },
|
||||
include: this.includeSummary()
|
||||
});
|
||||
if (!gateway || gateway.deletedAt) {
|
||||
throw new NotFoundException({ code: 'VENDOR_GATEWAY_NOT_FOUND', message: 'Vendor gateway not found.' });
|
||||
}
|
||||
return gateway;
|
||||
}
|
||||
|
||||
private forbiddenCreate(period: ForbiddenPeriodInput, actorId?: string) {
|
||||
return {
|
||||
id: childId('vgfp'),
|
||||
weekdayMask: period.weekdayMask,
|
||||
startTime: period.startTime,
|
||||
endTime: period.endTime,
|
||||
createdBy: actorId,
|
||||
updatedBy: actorId
|
||||
};
|
||||
}
|
||||
|
||||
private codecCreate(codec: CodecInput, actorId?: string) {
|
||||
return {
|
||||
id: childId('vgc'),
|
||||
codec: codec.codec,
|
||||
priority: codec.priority,
|
||||
createdBy: actorId,
|
||||
updatedBy: actorId
|
||||
};
|
||||
}
|
||||
|
||||
private prefixRuleCreate(rule: PrefixRuleInput, actorId?: string) {
|
||||
return {
|
||||
id: childId('vgpr'),
|
||||
direction: rule.direction,
|
||||
matchPrefix: rule.matchPrefix,
|
||||
replacePrefix: rule.replacePrefix,
|
||||
priority: rule.priority,
|
||||
createdBy: actorId,
|
||||
updatedBy: actorId
|
||||
};
|
||||
}
|
||||
|
||||
private includeSummary() {
|
||||
return {
|
||||
vendor: { select: { name: true } },
|
||||
forbiddenPeriods: { orderBy: [{ weekdayMask: 'asc' }, { startTime: 'asc' }] },
|
||||
codecs: { orderBy: [{ priority: 'asc' }] },
|
||||
prefixRules: { orderBy: [{ direction: 'asc' }, { priority: 'asc' }] }
|
||||
} satisfies Prisma.VendorGatewayInclude;
|
||||
}
|
||||
|
||||
private async enqueueConfigOutbox(tx: Prisma.TransactionClient, gatewayIdValue: string, eventType: string): Promise<void> {
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
id: outboxId(),
|
||||
aggregateType: 'vendor_gateway_config',
|
||||
aggregateId: gatewayIdValue,
|
||||
eventType,
|
||||
payload: { gatewayId: gatewayIdValue, eventType }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private toSummary(gateway: {
|
||||
id: string;
|
||||
vendorId: string;
|
||||
name: string;
|
||||
authMode: VendorGatewayAuthMode;
|
||||
host: string;
|
||||
port: number;
|
||||
transport: string;
|
||||
sipUsername: string | null;
|
||||
sipHa1: string | null;
|
||||
cpsLimit: number;
|
||||
concurrencyLimit: number;
|
||||
billingCycleSec: number;
|
||||
cycleRate: Prisma.Decimal;
|
||||
status: VendorGatewayStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
vendor: { name: string };
|
||||
forbiddenPeriods: Array<{ id: string; weekdayMask: number; startTime: string; endTime: string }>;
|
||||
codecs: Array<{ id: string; codec: string; priority: number }>;
|
||||
prefixRules: Array<{ id: string; direction: PrefixDirection; matchPrefix: string; replacePrefix: string; priority: number }>;
|
||||
}): VendorGatewaySummary {
|
||||
const minuteRate = gateway.cycleRate.mul(new Prisma.Decimal(60)).div(gateway.billingCycleSec);
|
||||
return {
|
||||
id: gateway.id,
|
||||
vendorId: gateway.vendorId,
|
||||
vendorName: gateway.vendor.name,
|
||||
name: gateway.name,
|
||||
authMode: gateway.authMode,
|
||||
host: gateway.host,
|
||||
port: gateway.port,
|
||||
transport: gateway.transport,
|
||||
sipUsername: gateway.sipUsername,
|
||||
hasSipCredential: Boolean(gateway.sipHa1),
|
||||
cpsLimit: gateway.cpsLimit,
|
||||
concurrencyLimit: gateway.concurrencyLimit,
|
||||
billingCycleSec: gateway.billingCycleSec,
|
||||
cycleRate: gateway.cycleRate.toFixed(6),
|
||||
minuteRate: minuteRate.toFixed(6),
|
||||
status: gateway.status,
|
||||
forbiddenPeriods: gateway.forbiddenPeriods,
|
||||
codecs: gateway.codecs,
|
||||
prefixRules: gateway.prefixRules,
|
||||
createdAt: gateway.createdAt,
|
||||
updatedAt: gateway.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private handleUniqueConflict(error: unknown): void {
|
||||
if (error && typeof error === 'object' && 'code' in error && (error as { code?: unknown }).code === 'P2002') {
|
||||
throw new ConflictException({ code: 'VENDOR_GATEWAY_CONFLICT', message: 'Vendor gateway name or child priority already exists.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import {
|
||||
VENDOR_GATEWAYS_REPOSITORY,
|
||||
type CodecInput,
|
||||
type CreateVendorGatewayInput,
|
||||
type ForbiddenPeriodInput,
|
||||
type PrefixDirection,
|
||||
type PrefixRuleInput,
|
||||
type UpdateVendorGatewayInput,
|
||||
type VendorGatewayAuthMode,
|
||||
type VendorGatewayStatus,
|
||||
type VendorGatewaySummary,
|
||||
type VendorGatewaysRepository
|
||||
} from './vendor-gateways.repository.js';
|
||||
|
||||
interface VendorGatewayDto {
|
||||
vendorId?: unknown;
|
||||
name?: unknown;
|
||||
authMode?: unknown;
|
||||
host?: unknown;
|
||||
port?: unknown;
|
||||
transport?: unknown;
|
||||
sipUsername?: unknown;
|
||||
sipPassword?: unknown;
|
||||
cpsLimit?: unknown;
|
||||
concurrencyLimit?: unknown;
|
||||
billingCycleSec?: unknown;
|
||||
cycleRate?: unknown;
|
||||
status?: unknown;
|
||||
forbiddenPeriods?: unknown;
|
||||
codecs?: unknown;
|
||||
prefixRules?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VendorGatewaysService {
|
||||
constructor(@Inject(VENDOR_GATEWAYS_REPOSITORY) private readonly gateways: VendorGatewaysRepository) {}
|
||||
|
||||
list(query: { vendorId?: unknown } = {}): Promise<VendorGatewaySummary[]> {
|
||||
const vendorId = query.vendorId === undefined ? undefined : this.limitedString(query.vendorId, 'vendorId', 32);
|
||||
return this.gateways.list(vendorId);
|
||||
}
|
||||
|
||||
get(gatewayId: string): Promise<VendorGatewaySummary> {
|
||||
return this.gateways.get(gatewayId);
|
||||
}
|
||||
|
||||
create(body: VendorGatewayDto, actorId?: string): Promise<VendorGatewaySummary> {
|
||||
const authMode = this.authMode(body.authMode);
|
||||
const host = this.host(body.host);
|
||||
const sipUsername = this.normalizeSipUsername(authMode, body.sipUsername);
|
||||
const sipPassword = this.requiredSipPassword(authMode, body.sipPassword);
|
||||
const input: CreateVendorGatewayInput = {
|
||||
vendorId: this.limitedString(body.vendorId, 'vendorId', 32),
|
||||
name: this.limitedString(body.name, 'name', 120),
|
||||
authMode,
|
||||
host,
|
||||
port: body.port === undefined ? 5060 : this.integer(body.port, 'port', 1, 65535),
|
||||
transport: body.transport === undefined ? 'udp' : this.transport(body.transport),
|
||||
sipUsername,
|
||||
sipHa1: sipPassword ? this.sipHa1(sipUsername, host, sipPassword) : undefined,
|
||||
cpsLimit: body.cpsLimit === undefined ? 0 : this.integer(body.cpsLimit, 'cpsLimit', 0, 10000),
|
||||
concurrencyLimit: body.concurrencyLimit === undefined ? 0 : this.integer(body.concurrencyLimit, 'concurrencyLimit', 0, 100000),
|
||||
billingCycleSec: body.billingCycleSec === undefined ? 60 : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 60),
|
||||
cycleRate: body.cycleRate === undefined ? '0.000000' : this.money(body.cycleRate, 'cycleRate'),
|
||||
status: body.status === undefined ? 'ENABLED' : this.status(body.status),
|
||||
forbiddenPeriods: this.forbiddenPeriods(body.forbiddenPeriods),
|
||||
codecs: this.codecs(body.codecs),
|
||||
prefixRules: this.prefixRules(body.prefixRules),
|
||||
actorId
|
||||
};
|
||||
return this.gateways.create(input);
|
||||
}
|
||||
|
||||
async update(gatewayId: string, body: VendorGatewayDto, actorId?: string): Promise<VendorGatewaySummary> {
|
||||
const current = await this.gateways.get(gatewayId);
|
||||
const authMode = body.authMode === undefined ? current.authMode : this.authMode(body.authMode);
|
||||
const host = body.host === undefined ? current.host : this.host(body.host);
|
||||
const sipUsername = body.sipUsername === undefined ? current.sipUsername : this.nullableSipUsername(authMode, body.sipUsername);
|
||||
const password = body.sipPassword === undefined ? undefined : this.requiredSipPassword(authMode, body.sipPassword);
|
||||
|
||||
if (this.requiresSip(authMode)) {
|
||||
const identityChanged = sipUsername !== current.sipUsername || host !== current.host;
|
||||
if (!password && (identityChanged || !current.hasSipCredential)) {
|
||||
throw new BadRequestException({
|
||||
code: 'SIP_PASSWORD_REQUIRED',
|
||||
message: 'sipPassword is required when creating or changing SIP identity.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const input: UpdateVendorGatewayInput = {
|
||||
vendorId: body.vendorId === undefined ? undefined : this.limitedString(body.vendorId, 'vendorId', 32),
|
||||
name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120),
|
||||
authMode,
|
||||
host,
|
||||
port: body.port === undefined ? undefined : this.integer(body.port, 'port', 1, 65535),
|
||||
transport: body.transport === undefined ? undefined : this.transport(body.transport),
|
||||
sipUsername: this.requiresSip(authMode) ? sipUsername : null,
|
||||
sipHa1: password ? this.sipHa1(sipUsername, host, password) : this.requiresSip(authMode) ? undefined : null,
|
||||
cpsLimit: body.cpsLimit === undefined ? undefined : this.integer(body.cpsLimit, 'cpsLimit', 0, 10000),
|
||||
concurrencyLimit: body.concurrencyLimit === undefined ? undefined : this.integer(body.concurrencyLimit, 'concurrencyLimit', 0, 100000),
|
||||
billingCycleSec: body.billingCycleSec === undefined ? undefined : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 60),
|
||||
cycleRate: body.cycleRate === undefined ? undefined : this.money(body.cycleRate, 'cycleRate'),
|
||||
status: body.status === undefined ? undefined : this.status(body.status),
|
||||
forbiddenPeriods: body.forbiddenPeriods === undefined ? undefined : this.forbiddenPeriods(body.forbiddenPeriods),
|
||||
codecs: body.codecs === undefined ? undefined : this.codecs(body.codecs),
|
||||
prefixRules: body.prefixRules === undefined ? undefined : this.prefixRules(body.prefixRules),
|
||||
actorId
|
||||
};
|
||||
return this.gateways.update(gatewayId, input);
|
||||
}
|
||||
|
||||
enable(gatewayId: string, actorId?: string): Promise<VendorGatewaySummary> {
|
||||
return this.gateways.setStatus(gatewayId, 'ENABLED', actorId);
|
||||
}
|
||||
|
||||
disable(gatewayId: string, actorId?: string): Promise<VendorGatewaySummary> {
|
||||
return this.gateways.setStatus(gatewayId, 'DISABLED', actorId);
|
||||
}
|
||||
|
||||
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 authMode(value: unknown): VendorGatewayAuthMode {
|
||||
if (value !== 'IP' && value !== 'SIP_DIGEST' && value !== 'MIXED') {
|
||||
throw new BadRequestException({ code: 'AUTH_MODE_INVALID', message: 'Auth mode is invalid.' });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private status(value: unknown): VendorGatewayStatus {
|
||||
if (value !== 'ENABLED' && value !== 'DISABLED') {
|
||||
throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private host(value: unknown): string {
|
||||
const host = this.limitedString(value, 'host', 160).toLowerCase();
|
||||
if (net.isIP(host) === 0 && !/^[a-z0-9.-]+$/.test(host)) {
|
||||
throw new BadRequestException({ code: 'HOST_INVALID', message: 'host must be an IP address or hostname.' });
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
private transport(value: unknown): string {
|
||||
const transport = this.limitedString(value, 'transport', 16).toLowerCase();
|
||||
if (transport !== 'udp' && transport !== 'tcp' && transport !== 'tls') {
|
||||
throw new BadRequestException({ code: 'TRANSPORT_INVALID', message: 'transport must be udp, tcp, or tls.' });
|
||||
}
|
||||
return transport;
|
||||
}
|
||||
|
||||
private integer(value: unknown, field: string, min: number, max: number): number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) {
|
||||
throw new BadRequestException({ code: 'INTEGER_INVALID', message: `${field} must be an integer from ${min} to ${max}.` });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private money(value: unknown, field: string): string {
|
||||
const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : '';
|
||||
if (!/^\d{1,14}(?:\.\d{1,6})?$/.test(raw)) {
|
||||
throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be a non-negative decimal with up to 6 places.` });
|
||||
}
|
||||
const [integerPart, fractionPart = ''] = raw.split('.');
|
||||
return `${integerPart}.${fractionPart.padEnd(6, '0')}`;
|
||||
}
|
||||
|
||||
private normalizeSipUsername(authMode: VendorGatewayAuthMode, value: unknown): string | null {
|
||||
if (!this.requiresSip(authMode)) {
|
||||
return null;
|
||||
}
|
||||
return this.sipUsername(value);
|
||||
}
|
||||
|
||||
private nullableSipUsername(authMode: VendorGatewayAuthMode, value: unknown): string | null {
|
||||
if (!this.requiresSip(authMode)) {
|
||||
return null;
|
||||
}
|
||||
return this.sipUsername(value);
|
||||
}
|
||||
|
||||
private sipUsername(value: unknown): string {
|
||||
const username = this.limitedString(value, 'sipUsername', 120);
|
||||
if (!/^[A-Za-z0-9_.:+-]+$/.test(username)) {
|
||||
throw new BadRequestException({ code: 'SIP_USERNAME_INVALID', message: 'sipUsername contains invalid characters.' });
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
private requiredSipPassword(authMode: VendorGatewayAuthMode, value: unknown): string | undefined {
|
||||
if (!this.requiresSip(authMode)) {
|
||||
return undefined;
|
||||
}
|
||||
const password = this.limitedString(value, 'sipPassword', 128);
|
||||
if (password.length < 12) {
|
||||
throw new BadRequestException({ code: 'SIP_PASSWORD_WEAK', message: 'sipPassword must be at least 12 characters.' });
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
private sipHa1(username: string | null, host: string, password: string): string {
|
||||
if (!username) {
|
||||
throw new BadRequestException({ code: 'SIP_IDENTITY_REQUIRED', message: 'SIP identity is required.' });
|
||||
}
|
||||
return crypto.createHash('md5').update(`${username}:${host}:${password}`, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
private forbiddenPeriods(value: unknown): ForbiddenPeriodInput[] {
|
||||
if (value === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
throw new BadRequestException({ code: 'FORBIDDEN_PERIODS_INVALID', message: 'forbiddenPeriods must be an array.' });
|
||||
}
|
||||
return value.map((item, index) => {
|
||||
const period = item as Record<string, unknown>;
|
||||
return {
|
||||
weekdayMask: this.integer(period.weekdayMask, `forbiddenPeriods[${index}].weekdayMask`, 1, 127),
|
||||
startTime: this.time(period.startTime, `forbiddenPeriods[${index}].startTime`),
|
||||
endTime: this.time(period.endTime, `forbiddenPeriods[${index}].endTime`)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private codecs(value: unknown): CodecInput[] {
|
||||
if (value === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
throw new BadRequestException({ code: 'CODECS_INVALID', message: 'codecs must be an array.' });
|
||||
}
|
||||
const codecs = value.map((item, index) => {
|
||||
const codec = item as Record<string, unknown>;
|
||||
return {
|
||||
codec: this.codec(codec.codec, `codecs[${index}].codec`),
|
||||
priority: this.integer(codec.priority, `codecs[${index}].priority`, 1, 1000)
|
||||
};
|
||||
});
|
||||
this.ensureUnique(codecs.map((codec) => codec.codec), 'CODEC_DUPLICATE', 'Codec values must be unique.');
|
||||
this.ensureUnique(codecs.map((codec) => String(codec.priority)), 'CODEC_PRIORITY_DUPLICATE', 'Codec priorities must be unique.');
|
||||
return codecs;
|
||||
}
|
||||
|
||||
private prefixRules(value: unknown): PrefixRuleInput[] {
|
||||
if (value === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
throw new BadRequestException({ code: 'PREFIX_RULES_INVALID', message: 'prefixRules must be an array.' });
|
||||
}
|
||||
const rules = value.map((item, index) => {
|
||||
const rule = item as Record<string, unknown>;
|
||||
return {
|
||||
direction: this.direction(rule.direction, `prefixRules[${index}].direction`),
|
||||
matchPrefix: this.prefix(rule.matchPrefix, `prefixRules[${index}].matchPrefix`),
|
||||
replacePrefix: this.prefix(rule.replacePrefix, `prefixRules[${index}].replacePrefix`, true),
|
||||
priority: this.integer(rule.priority, `prefixRules[${index}].priority`, 1, 1000)
|
||||
};
|
||||
});
|
||||
this.ensureUnique(
|
||||
rules.map((rule) => `${rule.direction}:${rule.priority}`),
|
||||
'PREFIX_RULE_PRIORITY_DUPLICATE',
|
||||
'Prefix rule priorities must be unique per direction.'
|
||||
);
|
||||
return rules;
|
||||
}
|
||||
|
||||
private time(value: unknown, field: string): string {
|
||||
const text = this.limitedString(value, field, 8);
|
||||
if (!/^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$/.test(text)) {
|
||||
throw new BadRequestException({ code: 'TIME_INVALID', message: `${field} must be HH:mm:ss.` });
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private codec(value: unknown, field: string): string {
|
||||
const text = this.limitedString(value, field, 32).toUpperCase();
|
||||
if (!/^[A-Z0-9_-]+$/.test(text)) {
|
||||
throw new BadRequestException({ code: 'CODEC_INVALID', message: `${field} contains invalid characters.` });
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private direction(value: unknown, field: string): PrefixDirection {
|
||||
if (value !== 'CALLER' && value !== 'CALLEE') {
|
||||
throw new BadRequestException({ code: 'PREFIX_DIRECTION_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private prefix(value: unknown, field: string, allowEmpty = false): string {
|
||||
if (allowEmpty && value === '') {
|
||||
return '';
|
||||
}
|
||||
const text = this.limitedString(value, field, 32);
|
||||
if (!/^[0-9+*#.-]*$/.test(text)) {
|
||||
throw new BadRequestException({ code: 'PREFIX_INVALID', message: `${field} contains invalid characters.` });
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private ensureUnique(values: string[], code: string, message: string): void {
|
||||
if (new Set(values).size !== values.length) {
|
||||
throw new BadRequestException({ code, message });
|
||||
}
|
||||
}
|
||||
|
||||
private requiresSip(authMode: VendorGatewayAuthMode): boolean {
|
||||
return authMode === 'SIP_DIGEST' || authMode === 'MIXED';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
import { VendorsService } from './vendors.service.js';
|
||||
|
||||
@ApiTags('vendors')
|
||||
@Controller('vendors')
|
||||
export class VendorsController {
|
||||
constructor(@Inject(VendorsService) private readonly vendorsService: VendorsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('vendors.view')
|
||||
list() {
|
||||
return this.vendorsService.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('vendors.view')
|
||||
get(@Param('id') id: string) {
|
||||
return this.vendorsService.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('vendors.manage')
|
||||
@AuditAction({ module: 'vendors', action: 'create', objectType: 'vendor' })
|
||||
create(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.vendorsService.create(body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('vendors.manage')
|
||||
@AuditAction({ module: 'vendors', action: 'update', objectType: 'vendor', objectIdParam: 'id' })
|
||||
update(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.vendorsService.update(id, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/enable')
|
||||
@RequirePermissions('vendors.manage')
|
||||
@AuditAction({ module: 'vendors', action: 'enable', objectType: 'vendor', objectIdParam: 'id' })
|
||||
enable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.vendorsService.enable(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/disable')
|
||||
@RequirePermissions('vendors.manage')
|
||||
@AuditAction({ module: 'vendors', action: 'disable', objectType: 'vendor', objectIdParam: 'id' })
|
||||
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.vendorsService.disable(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions('vendors.manage')
|
||||
@AuditAction({ module: 'vendors', action: 'delete', objectType: 'vendor', objectIdParam: 'id' })
|
||||
remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.vendorsService.remove(id, currentUser?.id);
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
|
||||
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js';
|
||||
import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js';
|
||||
import type { CurrentUser } from '../security/security.metadata.js';
|
||||
import {
|
||||
VENDORS_REPOSITORY,
|
||||
type CreateVendorInput,
|
||||
type UpdateVendorInput,
|
||||
type VendorStatus,
|
||||
type VendorSummary,
|
||||
type VendorsRepository
|
||||
} from './vendors.repository.js';
|
||||
|
||||
class MemoryIdentityRepository implements IdentityRepository {
|
||||
users = new Map<string, CurrentUser>();
|
||||
|
||||
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||
return this.users.get(userId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAuditRepository implements AuditRepository {
|
||||
entries: AuditEntryInput[] = [];
|
||||
|
||||
async write(input: AuditEntryInput): Promise<void> {
|
||||
this.entries.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryVendorsRepository implements VendorsRepository {
|
||||
private readonly vendors = new Map<string, VendorSummary>();
|
||||
|
||||
constructor() {
|
||||
this.vendors.set(
|
||||
'ven_seed',
|
||||
this.summary({
|
||||
id: 'ven_seed',
|
||||
name: 'Seed Vendor',
|
||||
creditLimit: '200.000000',
|
||||
gatewayCount: 2
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async list(): Promise<VendorSummary[]> {
|
||||
return [...this.vendors.values()];
|
||||
}
|
||||
|
||||
async get(vendorId: string): Promise<VendorSummary> {
|
||||
return this.vendors.get(vendorId) ?? this.summary({ id: vendorId, name: 'Missing Vendor' });
|
||||
}
|
||||
|
||||
async create(input: CreateVendorInput): Promise<VendorSummary> {
|
||||
const vendor = this.summary({
|
||||
id: 'ven_created',
|
||||
name: input.name,
|
||||
contactName: input.contactName ?? null,
|
||||
phone: input.phone ?? null,
|
||||
email: input.email ?? null,
|
||||
status: input.status ?? 'ENABLED',
|
||||
creditLimit: input.creditLimit,
|
||||
settlement: input.settlement ?? null,
|
||||
notes: input.notes ?? null
|
||||
});
|
||||
this.vendors.set(vendor.id, vendor);
|
||||
return vendor;
|
||||
}
|
||||
|
||||
async update(vendorId: string, input: UpdateVendorInput): Promise<VendorSummary> {
|
||||
const current = this.vendors.get(vendorId) ?? this.summary({ id: vendorId, name: 'Updated Vendor' });
|
||||
const updated: VendorSummary = {
|
||||
...current,
|
||||
name: input.name ?? current.name,
|
||||
contactName: input.contactName === undefined ? current.contactName : input.contactName,
|
||||
phone: input.phone === undefined ? current.phone : input.phone,
|
||||
email: input.email === undefined ? current.email : input.email,
|
||||
status: input.status ?? current.status,
|
||||
creditLimit: input.creditLimit ?? current.creditLimit,
|
||||
availableBalance: (Number(current.balance) + Number(input.creditLimit ?? current.creditLimit)).toFixed(6),
|
||||
settlement: input.settlement === undefined ? current.settlement : input.settlement,
|
||||
notes: input.notes === undefined ? current.notes : input.notes,
|
||||
updatedAt: new Date('2026-06-21T05:00:00.000Z')
|
||||
};
|
||||
this.vendors.set(vendorId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async setStatus(vendorId: string, status: VendorStatus): Promise<VendorSummary> {
|
||||
return this.update(vendorId, { status });
|
||||
}
|
||||
|
||||
async softDelete(vendorId: string): Promise<VendorSummary> {
|
||||
return this.update(vendorId, { status: 'DISABLED' });
|
||||
}
|
||||
|
||||
private summary(input: {
|
||||
id: string;
|
||||
name: string;
|
||||
contactName?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
status?: VendorStatus;
|
||||
creditLimit?: string;
|
||||
settlement?: string | null;
|
||||
notes?: string | null;
|
||||
gatewayCount?: number;
|
||||
}): VendorSummary {
|
||||
const balance = '20.000000';
|
||||
const creditLimit = input.creditLimit ?? '0.000000';
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
contactName: input.contactName ?? null,
|
||||
phone: input.phone ?? null,
|
||||
email: input.email ?? null,
|
||||
status: input.status ?? 'ENABLED',
|
||||
balance,
|
||||
creditLimit,
|
||||
availableBalance: (Number(balance) + Number(creditLimit)).toFixed(6),
|
||||
settlement: input.settlement ?? null,
|
||||
notes: input.notes ?? null,
|
||||
gatewayCount: input.gatewayCount ?? 0,
|
||||
createdAt: new Date('2026-06-21T05:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-21T05:00:00.000Z')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('S15 vendors API', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let audit: MemoryAuditRepository;
|
||||
|
||||
const tokenFor = (userId: string) =>
|
||||
signAccessToken(
|
||||
{
|
||||
sub: userId,
|
||||
username: userId,
|
||||
roles: ['test'],
|
||||
typ: 'access'
|
||||
},
|
||||
{
|
||||
secret: 'test-only-access-token-secret-min-32-bytes',
|
||||
issuer: 'lisglosips-api',
|
||||
audience: 'lisglosips-web',
|
||||
ttlSeconds: 900
|
||||
}
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
|
||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
|
||||
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
|
||||
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
|
||||
|
||||
const identities = new MemoryIdentityRepository();
|
||||
audit = new MemoryAuditRepository();
|
||||
|
||||
identities.users.set('usr_ops', {
|
||||
id: 'usr_ops',
|
||||
username: 'ops',
|
||||
roles: ['运营管理员'],
|
||||
permissions: ['vendors.view', 'vendors.manage', 'recharges.view'] as PermissionKey[]
|
||||
});
|
||||
identities.users.set('usr_viewer', {
|
||||
id: 'usr_viewer',
|
||||
username: 'viewer',
|
||||
roles: ['只读'],
|
||||
permissions: ['vendors.view'] as PermissionKey[]
|
||||
});
|
||||
|
||||
const { AppModule } = await import('../app.module.js');
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule]
|
||||
})
|
||||
.overrideProvider(IDENTITY_REPOSITORY)
|
||||
.useValue(identities)
|
||||
.overrideProvider(AUDIT_REPOSITORY)
|
||||
.useValue(audit)
|
||||
.overrideProvider(VENDORS_REPOSITORY)
|
||||
.useValue(new MemoryVendorsRepository())
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('returns vendor balance, credit, and gateway count to viewers', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/v2/vendors').set('Authorization', `Bearer ${tokenFor('usr_viewer')}`).expect(200);
|
||||
|
||||
expect(response.body[0]).toMatchObject({
|
||||
id: 'ven_seed',
|
||||
balance: '20.000000',
|
||||
creditLimit: '200.000000',
|
||||
availableBalance: '220.000000',
|
||||
gatewayCount: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects write operations without vendors.manage', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/vendors')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.send({ name: 'Denied Vendor' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('creates, updates, disables, enables, and soft deletes vendors with audit entries', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/vendors')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({
|
||||
name: 'Carrier One',
|
||||
contactName: 'NOC',
|
||||
creditLimit: '188.5',
|
||||
settlement: 'monthly'
|
||||
})
|
||||
.expect(201)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({
|
||||
id: 'ven_created',
|
||||
name: 'Carrier One',
|
||||
creditLimit: '188.500000',
|
||||
availableBalance: '208.500000',
|
||||
settlement: 'monthly'
|
||||
});
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v2/vendors/ven_created')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ notes: 'primary carrier', creditLimit: '200' })
|
||||
.expect(200)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({
|
||||
notes: 'primary carrier',
|
||||
creditLimit: '200.000000'
|
||||
});
|
||||
});
|
||||
|
||||
await request(app.getHttpServer()).post('/api/v2/vendors/ven_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
await request(app.getHttpServer()).post('/api/v2/vendors/ven_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
await request(app.getHttpServer()).delete('/api/v2/vendors/ven_created').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200);
|
||||
|
||||
expect(audit.entries.some((entry) => entry.module === 'vendors' && entry.action === 'create' && entry.result === 'SUCCESS')).toBe(true);
|
||||
expect(audit.entries.some((entry) => entry.module === 'vendors' && entry.action === 'disable' && entry.objectId === 'ven_created')).toBe(true);
|
||||
expect(audit.entries.some((entry) => entry.module === 'vendors' && entry.action === 'delete' && entry.objectId === 'ven_created')).toBe(true);
|
||||
});
|
||||
});
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { VENDORS_REPOSITORY, PrismaVendorsRepository } from './vendors.repository.js';
|
||||
import { VendorsController } from './vendors.controller.js';
|
||||
import { VendorsService } from './vendors.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [VendorsController],
|
||||
providers: [
|
||||
VendorsService,
|
||||
{
|
||||
provide: VENDORS_REPOSITORY,
|
||||
useClass: PrismaVendorsRepository
|
||||
}
|
||||
],
|
||||
exports: [VendorsService]
|
||||
})
|
||||
export class VendorsModule {}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export type VendorStatus = 'ENABLED' | 'DISABLED';
|
||||
|
||||
export interface VendorSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
contactName: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
status: VendorStatus;
|
||||
balance: string;
|
||||
creditLimit: string;
|
||||
availableBalance: string;
|
||||
settlement: string | null;
|
||||
notes: string | null;
|
||||
gatewayCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateVendorInput {
|
||||
name: string;
|
||||
contactName?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
status?: VendorStatus;
|
||||
creditLimit: string;
|
||||
settlement?: string;
|
||||
notes?: string;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateVendorInput {
|
||||
name?: string;
|
||||
contactName?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
status?: VendorStatus;
|
||||
creditLimit?: string;
|
||||
settlement?: string | null;
|
||||
notes?: string | null;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface VendorsRepository {
|
||||
list(): Promise<VendorSummary[]>;
|
||||
get(vendorId: string): Promise<VendorSummary>;
|
||||
create(input: CreateVendorInput): Promise<VendorSummary>;
|
||||
update(vendorId: string, input: UpdateVendorInput): Promise<VendorSummary>;
|
||||
setStatus(vendorId: string, status: VendorStatus, actorId?: string): Promise<VendorSummary>;
|
||||
softDelete(vendorId: string, actorId?: string): Promise<VendorSummary>;
|
||||
}
|
||||
|
||||
export const VENDORS_REPOSITORY = Symbol('VENDORS_REPOSITORY');
|
||||
|
||||
function vendorId(): string {
|
||||
return `ven_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaVendorsRepository implements VendorsRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<VendorSummary[]> {
|
||||
const vendors = await this.prisma.vendor.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
include: this.includeSummary()
|
||||
});
|
||||
|
||||
return vendors.map((vendor) => this.toSummary(vendor));
|
||||
}
|
||||
|
||||
async get(vendorIdValue: string): Promise<VendorSummary> {
|
||||
return this.toSummary(await this.findActiveOrThrow(vendorIdValue));
|
||||
}
|
||||
|
||||
async create(input: CreateVendorInput): Promise<VendorSummary> {
|
||||
const vendor = await this.prisma.vendor.create({
|
||||
data: {
|
||||
id: vendorId(),
|
||||
name: input.name,
|
||||
contactName: input.contactName,
|
||||
phone: input.phone,
|
||||
email: input.email,
|
||||
status: input.status ?? 'ENABLED',
|
||||
creditLimit: new Prisma.Decimal(input.creditLimit),
|
||||
settlement: input.settlement,
|
||||
notes: input.notes,
|
||||
createdBy: input.actorId,
|
||||
updatedBy: input.actorId
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
|
||||
return this.toSummary(vendor);
|
||||
}
|
||||
|
||||
async update(vendorIdValue: string, input: UpdateVendorInput): Promise<VendorSummary> {
|
||||
await this.findActiveOrThrow(vendorIdValue);
|
||||
|
||||
const vendor = await this.prisma.vendor.update({
|
||||
where: { id: vendorIdValue },
|
||||
data: {
|
||||
name: input.name,
|
||||
contactName: input.contactName,
|
||||
phone: input.phone,
|
||||
email: input.email,
|
||||
status: input.status,
|
||||
creditLimit: input.creditLimit === undefined ? undefined : new Prisma.Decimal(input.creditLimit),
|
||||
settlement: input.settlement,
|
||||
notes: input.notes,
|
||||
updatedBy: input.actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
|
||||
return this.toSummary(vendor);
|
||||
}
|
||||
|
||||
async setStatus(vendorIdValue: string, status: VendorStatus, actorId?: string): Promise<VendorSummary> {
|
||||
return this.update(vendorIdValue, { status, actorId });
|
||||
}
|
||||
|
||||
async softDelete(vendorIdValue: string, actorId?: string): Promise<VendorSummary> {
|
||||
const existing = await this.findActiveOrThrow(vendorIdValue);
|
||||
const linkedGateways = await this.prisma.vendorGateway.count({
|
||||
where: {
|
||||
vendorId: vendorIdValue,
|
||||
deletedAt: null
|
||||
}
|
||||
});
|
||||
|
||||
if (linkedGateways > 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'VENDOR_HAS_GATEWAYS',
|
||||
message: 'Vendor with active gateways cannot be deleted.'
|
||||
});
|
||||
}
|
||||
|
||||
const vendor = await this.prisma.vendor.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
|
||||
return this.toSummary(vendor);
|
||||
}
|
||||
|
||||
private includeSummary() {
|
||||
return {
|
||||
_count: {
|
||||
select: {
|
||||
gateways: {
|
||||
where: { deletedAt: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
} satisfies Prisma.VendorInclude;
|
||||
}
|
||||
|
||||
private async findActiveOrThrow(vendorIdValue: string) {
|
||||
const vendor = await this.prisma.vendor.findUnique({
|
||||
where: { id: vendorIdValue },
|
||||
include: this.includeSummary()
|
||||
});
|
||||
|
||||
if (!vendor || vendor.deletedAt) {
|
||||
throw new NotFoundException({ code: 'VENDOR_NOT_FOUND', message: 'Vendor not found.' });
|
||||
}
|
||||
|
||||
return vendor;
|
||||
}
|
||||
|
||||
private toSummary(vendor: {
|
||||
id: string;
|
||||
name: string;
|
||||
contactName: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
status: VendorStatus;
|
||||
balance: Prisma.Decimal;
|
||||
creditLimit: Prisma.Decimal;
|
||||
settlement: string | null;
|
||||
notes: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
_count: { gateways: number };
|
||||
}): VendorSummary {
|
||||
return {
|
||||
id: vendor.id,
|
||||
name: vendor.name,
|
||||
contactName: vendor.contactName,
|
||||
phone: vendor.phone,
|
||||
email: vendor.email,
|
||||
status: vendor.status,
|
||||
balance: vendor.balance.toFixed(6),
|
||||
creditLimit: vendor.creditLimit.toFixed(6),
|
||||
availableBalance: vendor.balance.plus(vendor.creditLimit).toFixed(6),
|
||||
settlement: vendor.settlement,
|
||||
notes: vendor.notes,
|
||||
gatewayCount: vendor._count.gateways,
|
||||
createdAt: vendor.createdAt,
|
||||
updatedAt: vendor.updatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
VENDORS_REPOSITORY,
|
||||
type CreateVendorInput,
|
||||
type UpdateVendorInput,
|
||||
type VendorStatus,
|
||||
type VendorSummary,
|
||||
type VendorsRepository
|
||||
} from './vendors.repository.js';
|
||||
|
||||
interface CreateVendorDto {
|
||||
name?: unknown;
|
||||
contactName?: unknown;
|
||||
phone?: unknown;
|
||||
email?: unknown;
|
||||
status?: unknown;
|
||||
creditLimit?: unknown;
|
||||
settlement?: unknown;
|
||||
notes?: unknown;
|
||||
}
|
||||
|
||||
interface UpdateVendorDto {
|
||||
name?: unknown;
|
||||
contactName?: unknown;
|
||||
phone?: unknown;
|
||||
email?: unknown;
|
||||
status?: unknown;
|
||||
creditLimit?: unknown;
|
||||
settlement?: unknown;
|
||||
notes?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VendorsService {
|
||||
constructor(@Inject(VENDORS_REPOSITORY) private readonly vendors: VendorsRepository) {}
|
||||
|
||||
list(): Promise<VendorSummary[]> {
|
||||
return this.vendors.list();
|
||||
}
|
||||
|
||||
get(vendorId: string): Promise<VendorSummary> {
|
||||
return this.vendors.get(vendorId);
|
||||
}
|
||||
|
||||
create(body: CreateVendorDto, actorId?: string): Promise<VendorSummary> {
|
||||
const input: CreateVendorInput = {
|
||||
name: this.limitedString(body.name, 'name', 120),
|
||||
contactName: this.optionalString(body.contactName, 'contactName', 80),
|
||||
phone: this.optionalString(body.phone, 'phone', 32),
|
||||
email: this.optionalString(body.email, 'email', 160),
|
||||
status: body.status === undefined ? 'ENABLED' : this.status(body.status),
|
||||
creditLimit: body.creditLimit === undefined ? '0.000000' : this.money(body.creditLimit, 'creditLimit'),
|
||||
settlement: this.optionalString(body.settlement, 'settlement', 80),
|
||||
notes: this.optionalString(body.notes, 'notes', 500),
|
||||
actorId
|
||||
};
|
||||
|
||||
return this.vendors.create(input);
|
||||
}
|
||||
|
||||
update(vendorId: string, body: UpdateVendorDto, actorId?: string): Promise<VendorSummary> {
|
||||
const input: UpdateVendorInput = {
|
||||
name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120),
|
||||
contactName: body.contactName === undefined ? undefined : this.nullableString(body.contactName, 'contactName', 80),
|
||||
phone: body.phone === undefined ? undefined : this.nullableString(body.phone, 'phone', 32),
|
||||
email: body.email === undefined ? undefined : this.nullableString(body.email, 'email', 160),
|
||||
status: body.status === undefined ? undefined : this.status(body.status),
|
||||
creditLimit: body.creditLimit === undefined ? undefined : this.money(body.creditLimit, 'creditLimit'),
|
||||
settlement: body.settlement === undefined ? undefined : this.nullableString(body.settlement, 'settlement', 80),
|
||||
notes: body.notes === undefined ? undefined : this.nullableString(body.notes, 'notes', 500),
|
||||
actorId
|
||||
};
|
||||
|
||||
return this.vendors.update(vendorId, input);
|
||||
}
|
||||
|
||||
enable(vendorId: string, actorId?: string): Promise<VendorSummary> {
|
||||
return this.vendors.setStatus(vendorId, 'ENABLED', actorId);
|
||||
}
|
||||
|
||||
disable(vendorId: string, actorId?: string): Promise<VendorSummary> {
|
||||
return this.vendors.setStatus(vendorId, 'DISABLED', actorId);
|
||||
}
|
||||
|
||||
remove(vendorId: string, actorId?: string): Promise<VendorSummary> {
|
||||
return this.vendors.softDelete(vendorId, actorId);
|
||||
}
|
||||
|
||||
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 optionalString(value: unknown, field: string, maxLength: number): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.limitedString(value, field, maxLength);
|
||||
}
|
||||
|
||||
private nullableString(value: unknown, field: string, maxLength: number): string | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.limitedString(value, field, maxLength);
|
||||
}
|
||||
|
||||
private status(value: unknown): VendorStatus {
|
||||
if (value !== 'ENABLED' && value !== 'DISABLED') {
|
||||
throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private money(value: unknown, field: string): string {
|
||||
const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : '';
|
||||
if (!/^\d{1,14}(?:\.\d{1,6})?$/.test(raw)) {
|
||||
throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be a non-negative decimal with up to 6 places.` });
|
||||
}
|
||||
|
||||
const [integerPart, fractionPart = ''] = raw.split('.');
|
||||
return `${integerPart}.${fractionPart.padEnd(6, '0')}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
|
||||
export function buildOpenApiDocument(app: NestFastifyApplication): void {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('LisgloSIPS API')
|
||||
.setDescription('LisgloSIPS V2 management platform API contract.')
|
||||
.setVersion('0.2.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/v2/docs', app, document, {
|
||||
jsonDocumentUrl: 'api/v2/openapi.json'
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Joi from 'joi';
|
||||
|
||||
export interface RuntimeConfig {
|
||||
service: {
|
||||
name: string;
|
||||
};
|
||||
http: {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
logging: {
|
||||
level: string;
|
||||
requestIdHeader: string;
|
||||
};
|
||||
database: {
|
||||
url: string;
|
||||
};
|
||||
redis: {
|
||||
url: string;
|
||||
};
|
||||
auth: {
|
||||
accessTokenSecret: string;
|
||||
accessTokenTtlSeconds: number;
|
||||
refreshTokenTtlDays: number;
|
||||
lockMaxFailures: number;
|
||||
lockWindowSeconds: number;
|
||||
cookieSecure: boolean;
|
||||
tokenIssuer: string;
|
||||
tokenAudience: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const validationSchema = Joi.object({
|
||||
NODE_ENV: Joi.string().valid('development', 'test', 'production').default('development'),
|
||||
LISGLOSIPS_SERVICE_NAME: Joi.string().default('api'),
|
||||
LISGLOSIPS_HOST: Joi.string().hostname().default('127.0.0.1'),
|
||||
LISGLOSIPS_PORT: Joi.number().port().default(3000),
|
||||
LISGLOSIPS_LOG_LEVEL: Joi.string().valid('trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent').default('info'),
|
||||
LISGLOSIPS_REQUEST_ID_HEADER: Joi.string().default('x-request-id'),
|
||||
DATABASE_URL: Joi.string().uri({ scheme: ['mysql'] }).required(),
|
||||
REDIS_URL: Joi.string().uri({ scheme: ['redis', 'rediss'] }).required(),
|
||||
AUTH_ACCESS_TOKEN_SECRET: Joi.string().min(32).when('NODE_ENV', {
|
||||
is: 'production',
|
||||
then: Joi.required(),
|
||||
otherwise: Joi.string().default('dev-only-change-this-auth-secret-32-bytes-min')
|
||||
}),
|
||||
AUTH_ACCESS_TOKEN_TTL_SECONDS: Joi.number().integer().min(60).max(86_400).default(900),
|
||||
AUTH_REFRESH_TOKEN_TTL_DAYS: Joi.number().integer().min(1).max(30).default(7),
|
||||
AUTH_LOCK_MAX_FAILURES: Joi.number().integer().min(3).max(20).default(5),
|
||||
AUTH_LOCK_WINDOW_SECONDS: Joi.number().integer().min(60).max(86_400).default(900),
|
||||
AUTH_COOKIE_SECURE: Joi.boolean().truthy('true').falsy('false').default(true),
|
||||
AUTH_TOKEN_ISSUER: Joi.string().default('lisglosips-api'),
|
||||
AUTH_TOKEN_AUDIENCE: Joi.string().default('lisglosips-web')
|
||||
});
|
||||
|
||||
export function appConfig(): RuntimeConfig {
|
||||
return {
|
||||
service: {
|
||||
name: process.env.LISGLOSIPS_SERVICE_NAME ?? 'api'
|
||||
},
|
||||
http: {
|
||||
host: process.env.LISGLOSIPS_HOST ?? '127.0.0.1',
|
||||
port: Number(process.env.LISGLOSIPS_PORT ?? 3000)
|
||||
},
|
||||
logging: {
|
||||
level: process.env.LISGLOSIPS_LOG_LEVEL ?? 'info',
|
||||
requestIdHeader: process.env.LISGLOSIPS_REQUEST_ID_HEADER ?? 'x-request-id'
|
||||
},
|
||||
database: {
|
||||
url: process.env.DATABASE_URL ?? ''
|
||||
},
|
||||
redis: {
|
||||
url: process.env.REDIS_URL ?? ''
|
||||
},
|
||||
auth: {
|
||||
accessTokenSecret: process.env.AUTH_ACCESS_TOKEN_SECRET ?? 'dev-only-change-this-auth-secret-32-bytes-min',
|
||||
accessTokenTtlSeconds: Number(process.env.AUTH_ACCESS_TOKEN_TTL_SECONDS ?? 900),
|
||||
refreshTokenTtlDays: Number(process.env.AUTH_REFRESH_TOKEN_TTL_DAYS ?? 7),
|
||||
lockMaxFailures: Number(process.env.AUTH_LOCK_MAX_FAILURES ?? 5),
|
||||
lockWindowSeconds: Number(process.env.AUTH_LOCK_WINDOW_SECONDS ?? 900),
|
||||
cookieSecure: (process.env.AUTH_COOKIE_SECURE ?? 'true') !== 'false',
|
||||
tokenIssuer: process.env.AUTH_TOKEN_ISSUER ?? 'lisglosips-api',
|
||||
tokenAudience: process.env.AUTH_TOKEN_AUDIENCE ?? 'lisglosips-web'
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../../packages/auth" },
|
||||
{ "path": "../../packages/contracts" },
|
||||
{ "path": "../../packages/database" },
|
||||
{ "path": "../../packages/observability" }
|
||||
],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user