Initial LisgloSIPS V2 implementation

This commit is contained in:
hectorzhao
2026-06-22 10:56:38 +08:00
commit 5fa1bd35e9
303 changed files with 35644 additions and 0 deletions
+74
View File
@@ -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']
};
}
}
+23
View File
@@ -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';
}
}
+132
View File
@@ -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);
});
});
+19
View File
@@ -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 });
});
});
+184
View File
@@ -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.'
});
}
}
+41
View File
@@ -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');
+42
View File
@@ -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('; ');
}
@@ -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 {}
@@ -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);
});
});
+24
View File
@@ -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
};
}
}
+119
View File
@@ -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';
}
}
+58
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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')}`;
}
}