Initial LisgloSIPS V2 implementation
This commit is contained in:
+45
@@ -0,0 +1,45 @@
|
||||
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
import { CustomerGatewayPoliciesService } from './customer-gateway-policies.service.js';
|
||||
|
||||
@ApiTags('customer-gateway-policies')
|
||||
@Controller()
|
||||
export class CustomerGatewayPoliciesController {
|
||||
constructor(@Inject(CustomerGatewayPoliciesService) private readonly policiesService: CustomerGatewayPoliciesService) {}
|
||||
|
||||
@Get('customer-gateways/:id/policies')
|
||||
@RequirePermissions('customer_gateways.view')
|
||||
list(@Param('id') gatewayId: string) {
|
||||
return this.policiesService.list(gatewayId);
|
||||
}
|
||||
|
||||
@Post('customer-gateways/:id/policies')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'policy_create', objectType: 'customer_gateway', objectIdParam: 'id' })
|
||||
create(@Param('id') gatewayId: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.policiesService.create(gatewayId, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Patch('customer-gateway-policies/:id')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'policy_update', objectType: 'customer_gateway_policy', objectIdParam: 'id' })
|
||||
update(@Param('id') policyId: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.policiesService.update(policyId, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Delete('customer-gateway-policies/:id')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'policy_delete', objectType: 'customer_gateway_policy', objectIdParam: 'id' })
|
||||
remove(@Param('id') policyId: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.policiesService.remove(policyId, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post('customer-gateways/:id/policies/reorder')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'policy_reorder', objectType: 'customer_gateway', objectIdParam: 'id' })
|
||||
reorder(@Param('id') gatewayId: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.policiesService.reorder(gatewayId, body as never, currentUser?.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
|
||||
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js';
|
||||
import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js';
|
||||
import type { CurrentUser } from '../security/security.metadata.js';
|
||||
import {
|
||||
CUSTOMER_GATEWAY_POLICIES_REPOSITORY,
|
||||
type CreatePolicyInput,
|
||||
type CustomerGatewayPoliciesRepository,
|
||||
type CustomerGatewayPolicySummary,
|
||||
type UpdatePolicyInput
|
||||
} from './customer-gateway-policies.repository.js';
|
||||
|
||||
class MemoryIdentityRepository implements IdentityRepository {
|
||||
users = new Map<string, CurrentUser>();
|
||||
|
||||
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||
return this.users.get(userId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAuditRepository implements AuditRepository {
|
||||
entries: AuditEntryInput[] = [];
|
||||
|
||||
async write(input: AuditEntryInput): Promise<void> {
|
||||
this.entries.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryPoliciesRepository implements CustomerGatewayPoliciesRepository {
|
||||
policies: CustomerGatewayPolicySummary[] = [
|
||||
this.summary({ id: 'cgp_1', name: 'Mobile prefix', priority: 1, callerMode: 'PREFIX', callerValue: '021' }),
|
||||
this.summary({ id: 'cgp_2', name: 'Fallback', priority: 2 })
|
||||
];
|
||||
outboxEvents = 0;
|
||||
|
||||
async list(gatewayId: string): Promise<CustomerGatewayPolicySummary[]> {
|
||||
return this.policies.filter((policy) => policy.gatewayId === gatewayId).sort((left, right) => left.priority - right.priority);
|
||||
}
|
||||
|
||||
async create(input: CreatePolicyInput): Promise<CustomerGatewayPolicySummary> {
|
||||
const policy = this.summary({
|
||||
id: 'cgp_created',
|
||||
gatewayId: input.gatewayId,
|
||||
lineGroupId: input.lineGroupId,
|
||||
name: input.name,
|
||||
priority: input.priority ?? 3,
|
||||
callerMode: input.callerMode,
|
||||
callerValue: input.callerValue ?? null,
|
||||
calleeMode: input.calleeMode,
|
||||
calleeValue: input.calleeValue ?? null,
|
||||
status: input.status
|
||||
});
|
||||
this.policies.push(policy);
|
||||
this.outboxEvents += 1;
|
||||
return policy;
|
||||
}
|
||||
|
||||
async update(policyId: string, input: UpdatePolicyInput): Promise<CustomerGatewayPolicySummary> {
|
||||
const index = this.policies.findIndex((policy) => policy.id === policyId);
|
||||
const current = this.policies[index] ?? this.summary({ id: policyId, name: 'Missing', priority: 999 });
|
||||
const updated = {
|
||||
...current,
|
||||
lineGroupId: input.lineGroupId ?? current.lineGroupId,
|
||||
name: input.name ?? current.name,
|
||||
priority: input.priority ?? current.priority,
|
||||
callerMode: input.callerMode ?? current.callerMode,
|
||||
callerValue: input.callerValue === undefined ? current.callerValue : input.callerValue,
|
||||
calleeMode: input.calleeMode ?? current.calleeMode,
|
||||
calleeValue: input.calleeValue === undefined ? current.calleeValue : input.calleeValue,
|
||||
status: input.status ?? current.status
|
||||
};
|
||||
this.policies[index] = updated;
|
||||
this.outboxEvents += 1;
|
||||
return updated;
|
||||
}
|
||||
|
||||
async softDelete(policyId: string): Promise<CustomerGatewayPolicySummary> {
|
||||
const policy = await this.update(policyId, { status: 'DISABLED' });
|
||||
this.policies = this.policies.filter((item) => item.id !== policyId);
|
||||
return policy;
|
||||
}
|
||||
|
||||
async reorder(gatewayId: string, policyIds: string[]): Promise<CustomerGatewayPolicySummary[]> {
|
||||
this.policies = this.policies.map((policy) => {
|
||||
const index = policyIds.indexOf(policy.id);
|
||||
return policy.gatewayId === gatewayId && index >= 0 ? { ...policy, priority: index + 1 } : policy;
|
||||
});
|
||||
this.outboxEvents += 1;
|
||||
return this.list(gatewayId);
|
||||
}
|
||||
|
||||
private summary(input: {
|
||||
id: string;
|
||||
gatewayId?: string;
|
||||
lineGroupId?: string;
|
||||
name: string;
|
||||
priority: number;
|
||||
callerMode?: 'ANY' | 'EQUALS' | 'PREFIX';
|
||||
callerValue?: string | null;
|
||||
calleeMode?: 'ANY' | 'EQUALS' | 'PREFIX';
|
||||
calleeValue?: string | null;
|
||||
status?: 'ENABLED' | 'DISABLED';
|
||||
}): CustomerGatewayPolicySummary {
|
||||
return {
|
||||
id: input.id,
|
||||
customerId: 'cus_seed',
|
||||
gatewayId: input.gatewayId ?? 'cgw_seed',
|
||||
lineGroupId: input.lineGroupId ?? 'lg_seed',
|
||||
name: input.name,
|
||||
priority: input.priority,
|
||||
callerMode: input.callerMode ?? 'ANY',
|
||||
callerValue: input.callerValue ?? null,
|
||||
calleeMode: input.calleeMode ?? 'ANY',
|
||||
calleeValue: input.calleeValue ?? null,
|
||||
status: input.status ?? 'ENABLED',
|
||||
createdAt: new Date('2026-06-21T04:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-21T04:00:00.000Z')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('S14 customer gateway policies API', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let audit: MemoryAuditRepository;
|
||||
let repository: MemoryPoliciesRepository;
|
||||
|
||||
const tokenFor = (userId: string) =>
|
||||
signAccessToken(
|
||||
{
|
||||
sub: userId,
|
||||
username: userId,
|
||||
roles: ['test'],
|
||||
typ: 'access'
|
||||
},
|
||||
{
|
||||
secret: 'test-only-access-token-secret-min-32-bytes',
|
||||
issuer: 'lisglosips-api',
|
||||
audience: 'lisglosips-web',
|
||||
ttlSeconds: 900
|
||||
}
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
|
||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
|
||||
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
|
||||
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
|
||||
|
||||
const identities = new MemoryIdentityRepository();
|
||||
audit = new MemoryAuditRepository();
|
||||
repository = new MemoryPoliciesRepository();
|
||||
|
||||
identities.users.set('usr_ops', {
|
||||
id: 'usr_ops',
|
||||
username: 'ops',
|
||||
roles: ['运营管理员'],
|
||||
permissions: ['customer_gateways.view', 'customer_gateways.manage'] as PermissionKey[]
|
||||
});
|
||||
identities.users.set('usr_viewer', {
|
||||
id: 'usr_viewer',
|
||||
username: 'viewer',
|
||||
roles: ['只读'],
|
||||
permissions: ['customer_gateways.view'] as PermissionKey[]
|
||||
});
|
||||
|
||||
const { AppModule } = await import('../app.module.js');
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(IDENTITY_REPOSITORY)
|
||||
.useValue(identities)
|
||||
.overrideProvider(AUDIT_REPOSITORY)
|
||||
.useValue(audit)
|
||||
.overrideProvider(CUSTOMER_GATEWAY_POLICIES_REPOSITORY)
|
||||
.useValue(repository)
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('lists policies ordered by priority for viewers', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v2/customer-gateways/cgw_seed/policies')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.map((policy: { id: string }) => policy.id)).toEqual(['cgp_1', 'cgp_2']);
|
||||
});
|
||||
|
||||
it('rejects policy writes without manage permission', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/customer-gateways/cgw_seed/policies')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
||||
.send({ lineGroupId: 'lg_seed', name: 'Denied' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('creates, updates, reorders, and deletes policies with audit and outbox intent', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/customer-gateways/cgw_seed/policies')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({
|
||||
lineGroupId: 'lg_seed',
|
||||
name: 'Callee exact',
|
||||
priority: 3,
|
||||
callerMode: 'ANY',
|
||||
calleeMode: 'EQUALS',
|
||||
calleeValue: '13800138000'
|
||||
})
|
||||
.expect(201)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({
|
||||
id: 'cgp_created',
|
||||
calleeMode: 'EQUALS',
|
||||
calleeValue: '13800138000'
|
||||
});
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v2/customer-gateway-policies/cgp_created')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ callerMode: 'PREFIX', callerValue: '010' })
|
||||
.expect(200);
|
||||
|
||||
const reordered = await request(app.getHttpServer())
|
||||
.post('/api/v2/customer-gateways/cgw_seed/policies/reorder')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ policyIds: ['cgp_created', 'cgp_2', 'cgp_1'] })
|
||||
.expect(201);
|
||||
expect(reordered.body.map((policy: { id: string }) => policy.id)).toEqual(['cgp_created', 'cgp_2', 'cgp_1']);
|
||||
|
||||
await request(app.getHttpServer()).delete('/api/v2/customer-gateway-policies/cgp_2').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200);
|
||||
|
||||
expect(repository.outboxEvents).toBeGreaterThanOrEqual(4);
|
||||
expect(audit.entries.some((entry) => entry.module === 'customer_gateways' && entry.action === 'policy_create')).toBe(true);
|
||||
expect(audit.entries.some((entry) => entry.module === 'customer_gateways' && entry.action === 'policy_reorder')).toBe(true);
|
||||
});
|
||||
|
||||
it('validates match values for non-ANY modes', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/customer-gateways/cgw_seed/policies')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ lineGroupId: 'lg_seed', name: 'Invalid', callerMode: 'PREFIX' })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CustomerGatewayPoliciesController } from './customer-gateway-policies.controller.js';
|
||||
import { CUSTOMER_GATEWAY_POLICIES_REPOSITORY, PrismaCustomerGatewayPoliciesRepository } from './customer-gateway-policies.repository.js';
|
||||
import { CustomerGatewayPoliciesService } from './customer-gateway-policies.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [CustomerGatewayPoliciesController],
|
||||
providers: [
|
||||
CustomerGatewayPoliciesService,
|
||||
{
|
||||
provide: CUSTOMER_GATEWAY_POLICIES_REPOSITORY,
|
||||
useClass: PrismaCustomerGatewayPoliciesRepository
|
||||
}
|
||||
],
|
||||
exports: [CustomerGatewayPoliciesService]
|
||||
})
|
||||
export class CustomerGatewayPoliciesModule {}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export type PolicyStatus = 'ENABLED' | 'DISABLED';
|
||||
export type PolicyMatchMode = 'ANY' | 'EQUALS' | 'PREFIX';
|
||||
|
||||
export interface CustomerGatewayPolicySummary {
|
||||
id: string;
|
||||
customerId: string;
|
||||
gatewayId: string;
|
||||
lineGroupId: string;
|
||||
name: string;
|
||||
priority: number;
|
||||
callerMode: PolicyMatchMode;
|
||||
callerValue: string | null;
|
||||
calleeMode: PolicyMatchMode;
|
||||
calleeValue: string | null;
|
||||
status: PolicyStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreatePolicyInput {
|
||||
gatewayId: string;
|
||||
lineGroupId: string;
|
||||
name: string;
|
||||
priority?: number;
|
||||
callerMode: PolicyMatchMode;
|
||||
callerValue?: string | null;
|
||||
calleeMode: PolicyMatchMode;
|
||||
calleeValue?: string | null;
|
||||
status: PolicyStatus;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePolicyInput {
|
||||
lineGroupId?: string;
|
||||
name?: string;
|
||||
priority?: number;
|
||||
callerMode?: PolicyMatchMode;
|
||||
callerValue?: string | null;
|
||||
calleeMode?: PolicyMatchMode;
|
||||
calleeValue?: string | null;
|
||||
status?: PolicyStatus;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface CustomerGatewayPoliciesRepository {
|
||||
list(gatewayId: string): Promise<CustomerGatewayPolicySummary[]>;
|
||||
create(input: CreatePolicyInput): Promise<CustomerGatewayPolicySummary>;
|
||||
update(policyId: string, input: UpdatePolicyInput): Promise<CustomerGatewayPolicySummary>;
|
||||
softDelete(policyId: string, actorId?: string): Promise<CustomerGatewayPolicySummary>;
|
||||
reorder(gatewayId: string, policyIds: string[], actorId?: string): Promise<CustomerGatewayPolicySummary[]>;
|
||||
}
|
||||
|
||||
export const CUSTOMER_GATEWAY_POLICIES_REPOSITORY = Symbol('CUSTOMER_GATEWAY_POLICIES_REPOSITORY');
|
||||
|
||||
function policyId(): string {
|
||||
return `cgp_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
|
||||
}
|
||||
|
||||
function outboxId(): string {
|
||||
return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaCustomerGatewayPoliciesRepository implements CustomerGatewayPoliciesRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(gatewayId: string): Promise<CustomerGatewayPolicySummary[]> {
|
||||
await this.ensureGateway(gatewayId);
|
||||
const policies = await this.prisma.customerGatewayPolicy.findMany({
|
||||
where: { gatewayId, deletedAt: null },
|
||||
orderBy: [{ priority: 'asc' }]
|
||||
});
|
||||
return policies.map((policy) => this.toSummary(policy));
|
||||
}
|
||||
|
||||
async create(input: CreatePolicyInput): Promise<CustomerGatewayPolicySummary> {
|
||||
const gateway = await this.ensureGateway(input.gatewayId);
|
||||
await this.ensureLineGroup(input.lineGroupId);
|
||||
const priority = input.priority ?? (await this.nextPriority(input.gatewayId));
|
||||
|
||||
try {
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const policy = await tx.customerGatewayPolicy.create({
|
||||
data: {
|
||||
id: policyId(),
|
||||
customerId: gateway.customerId,
|
||||
gatewayId: input.gatewayId,
|
||||
lineGroupId: input.lineGroupId,
|
||||
name: input.name,
|
||||
priority,
|
||||
callerMode: input.callerMode,
|
||||
callerValue: input.callerValue,
|
||||
calleeMode: input.calleeMode,
|
||||
calleeValue: input.calleeValue,
|
||||
status: input.status,
|
||||
createdBy: input.actorId,
|
||||
updatedBy: input.actorId
|
||||
}
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, input.gatewayId, 'customer_gateway_policy.changed');
|
||||
return policy;
|
||||
});
|
||||
return this.toSummary(created);
|
||||
} catch (error) {
|
||||
this.handleUniqueConflict(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(policyIdValue: string, input: UpdatePolicyInput): Promise<CustomerGatewayPolicySummary> {
|
||||
const existing = await this.findActiveOrThrow(policyIdValue);
|
||||
if (input.lineGroupId) {
|
||||
await this.ensureLineGroup(input.lineGroupId);
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const policy = await tx.customerGatewayPolicy.update({
|
||||
where: { id: policyIdValue },
|
||||
data: {
|
||||
lineGroupId: input.lineGroupId,
|
||||
name: input.name,
|
||||
priority: input.priority,
|
||||
callerMode: input.callerMode,
|
||||
callerValue: input.callerValue,
|
||||
calleeMode: input.calleeMode,
|
||||
calleeValue: input.calleeValue,
|
||||
status: input.status,
|
||||
updatedBy: input.actorId,
|
||||
version: { increment: 1 }
|
||||
}
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, existing.gatewayId, 'customer_gateway_policy.changed');
|
||||
return policy;
|
||||
});
|
||||
return this.toSummary(updated);
|
||||
} catch (error) {
|
||||
this.handleUniqueConflict(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async softDelete(policyIdValue: string, actorId?: string): Promise<CustomerGatewayPolicySummary> {
|
||||
const existing = await this.findActiveOrThrow(policyIdValue);
|
||||
const deleted = await this.prisma.$transaction(async (tx) => {
|
||||
const policy = await tx.customerGatewayPolicy.update({
|
||||
where: { id: policyIdValue },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
}
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, existing.gatewayId, 'customer_gateway_policy.changed');
|
||||
return policy;
|
||||
});
|
||||
return this.toSummary(deleted);
|
||||
}
|
||||
|
||||
async reorder(gatewayId: string, policyIds: string[], actorId?: string): Promise<CustomerGatewayPolicySummary[]> {
|
||||
await this.ensureGateway(gatewayId);
|
||||
const current = await this.prisma.customerGatewayPolicy.findMany({
|
||||
where: { gatewayId, deletedAt: null },
|
||||
select: { id: true }
|
||||
});
|
||||
const currentIds = current.map((policy) => policy.id).sort();
|
||||
const requestedIds = [...policyIds].sort();
|
||||
if (currentIds.length !== requestedIds.length || currentIds.some((id, index) => id !== requestedIds[index])) {
|
||||
throw new BadRequestException({ code: 'POLICY_REORDER_SET_MISMATCH', message: 'Reorder must include every active policy exactly once.' });
|
||||
}
|
||||
if (new Set(policyIds).size !== policyIds.length) {
|
||||
throw new BadRequestException({ code: 'POLICY_REORDER_DUPLICATE', message: 'Policy ids must be unique.' });
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (let index = 0; index < policyIds.length; index += 1) {
|
||||
await tx.customerGatewayPolicy.update({
|
||||
where: { id: policyIds[index] },
|
||||
data: {
|
||||
priority: -(index + 1),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
}
|
||||
});
|
||||
}
|
||||
for (let index = 0; index < policyIds.length; index += 1) {
|
||||
await tx.customerGatewayPolicy.update({
|
||||
where: { id: policyIds[index] },
|
||||
data: { priority: index + 1 }
|
||||
});
|
||||
}
|
||||
await this.enqueueConfigOutbox(tx, gatewayId, 'customer_gateway_policy.reordered');
|
||||
});
|
||||
|
||||
return this.list(gatewayId);
|
||||
}
|
||||
|
||||
private async ensureGateway(gatewayId: string): Promise<{ id: string; customerId: string }> {
|
||||
const gateway = await this.prisma.customerGateway.findUnique({
|
||||
where: { id: gatewayId },
|
||||
select: { id: true, customerId: true, deletedAt: true }
|
||||
});
|
||||
if (!gateway || gateway.deletedAt) {
|
||||
throw new NotFoundException({ code: 'CUSTOMER_GATEWAY_NOT_FOUND', message: 'Customer gateway not found.' });
|
||||
}
|
||||
return gateway;
|
||||
}
|
||||
|
||||
private async ensureLineGroup(lineGroupId: string): Promise<void> {
|
||||
const lineGroup = await this.prisma.landingLineGroup.findUnique({
|
||||
where: { id: lineGroupId },
|
||||
select: { id: true, deletedAt: true }
|
||||
});
|
||||
if (!lineGroup || lineGroup.deletedAt) {
|
||||
throw new NotFoundException({ code: 'LINE_GROUP_NOT_FOUND', message: 'Landing line group not found.' });
|
||||
}
|
||||
}
|
||||
|
||||
private async findActiveOrThrow(policyIdValue: string) {
|
||||
const policy = await this.prisma.customerGatewayPolicy.findUnique({ where: { id: policyIdValue } });
|
||||
if (!policy || policy.deletedAt) {
|
||||
throw new NotFoundException({ code: 'CUSTOMER_GATEWAY_POLICY_NOT_FOUND', message: 'Customer gateway policy not found.' });
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
private async nextPriority(gatewayId: string): Promise<number> {
|
||||
const aggregate = await this.prisma.customerGatewayPolicy.aggregate({
|
||||
where: { gatewayId, deletedAt: null },
|
||||
_max: { priority: true }
|
||||
});
|
||||
return (aggregate._max.priority ?? 0) + 1;
|
||||
}
|
||||
|
||||
private async enqueueConfigOutbox(tx: Prisma.TransactionClient, gatewayId: string, eventType: string): Promise<void> {
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
id: outboxId(),
|
||||
aggregateType: 'customer_gateway_config',
|
||||
aggregateId: gatewayId,
|
||||
eventType,
|
||||
payload: { gatewayId, eventType }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private toSummary(policy: {
|
||||
id: string;
|
||||
customerId: string;
|
||||
gatewayId: string;
|
||||
lineGroupId: string;
|
||||
name: string;
|
||||
priority: number;
|
||||
callerMode: PolicyMatchMode;
|
||||
callerValue: string | null;
|
||||
calleeMode: PolicyMatchMode;
|
||||
calleeValue: string | null;
|
||||
status: PolicyStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): CustomerGatewayPolicySummary {
|
||||
return {
|
||||
id: policy.id,
|
||||
customerId: policy.customerId,
|
||||
gatewayId: policy.gatewayId,
|
||||
lineGroupId: policy.lineGroupId,
|
||||
name: policy.name,
|
||||
priority: policy.priority,
|
||||
callerMode: policy.callerMode,
|
||||
callerValue: policy.callerValue,
|
||||
calleeMode: policy.calleeMode,
|
||||
calleeValue: policy.calleeValue,
|
||||
status: policy.status,
|
||||
createdAt: policy.createdAt,
|
||||
updatedAt: policy.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private handleUniqueConflict(error: unknown): void {
|
||||
if (error && typeof error === 'object' && 'code' in error && (error as { code?: unknown }).code === 'P2002') {
|
||||
throw new ConflictException({ code: 'CUSTOMER_GATEWAY_POLICY_CONFLICT', message: 'Customer gateway policy priority already exists.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
CUSTOMER_GATEWAY_POLICIES_REPOSITORY,
|
||||
type CreatePolicyInput,
|
||||
type CustomerGatewayPoliciesRepository,
|
||||
type CustomerGatewayPolicySummary,
|
||||
type PolicyMatchMode,
|
||||
type PolicyStatus,
|
||||
type UpdatePolicyInput
|
||||
} from './customer-gateway-policies.repository.js';
|
||||
|
||||
interface CreatePolicyDto {
|
||||
lineGroupId?: unknown;
|
||||
name?: unknown;
|
||||
priority?: unknown;
|
||||
callerMode?: unknown;
|
||||
callerValue?: unknown;
|
||||
calleeMode?: unknown;
|
||||
calleeValue?: unknown;
|
||||
status?: unknown;
|
||||
}
|
||||
|
||||
interface UpdatePolicyDto {
|
||||
lineGroupId?: unknown;
|
||||
name?: unknown;
|
||||
priority?: unknown;
|
||||
callerMode?: unknown;
|
||||
callerValue?: unknown;
|
||||
calleeMode?: unknown;
|
||||
calleeValue?: unknown;
|
||||
status?: unknown;
|
||||
}
|
||||
|
||||
interface ReorderDto {
|
||||
policyIds?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CustomerGatewayPoliciesService {
|
||||
constructor(@Inject(CUSTOMER_GATEWAY_POLICIES_REPOSITORY) private readonly policies: CustomerGatewayPoliciesRepository) {}
|
||||
|
||||
list(gatewayId: string): Promise<CustomerGatewayPolicySummary[]> {
|
||||
return this.policies.list(gatewayId);
|
||||
}
|
||||
|
||||
create(gatewayId: string, body: CreatePolicyDto, actorId?: string): Promise<CustomerGatewayPolicySummary> {
|
||||
const callerMode = body.callerMode === undefined ? 'ANY' : this.matchMode(body.callerMode, 'callerMode');
|
||||
const calleeMode = body.calleeMode === undefined ? 'ANY' : this.matchMode(body.calleeMode, 'calleeMode');
|
||||
const input: CreatePolicyInput = {
|
||||
gatewayId,
|
||||
lineGroupId: this.limitedString(body.lineGroupId, 'lineGroupId', 32),
|
||||
name: this.limitedString(body.name, 'name', 120),
|
||||
priority: body.priority === undefined ? undefined : this.priority(body.priority),
|
||||
callerMode,
|
||||
callerValue: this.matchValue(callerMode, body.callerValue, 'callerValue'),
|
||||
calleeMode,
|
||||
calleeValue: this.matchValue(calleeMode, body.calleeValue, 'calleeValue'),
|
||||
status: body.status === undefined ? 'ENABLED' : this.status(body.status),
|
||||
actorId
|
||||
};
|
||||
return this.policies.create(input);
|
||||
}
|
||||
|
||||
update(policyId: string, body: UpdatePolicyDto, actorId?: string): Promise<CustomerGatewayPolicySummary> {
|
||||
const callerMode = body.callerMode === undefined ? undefined : this.matchMode(body.callerMode, 'callerMode');
|
||||
const calleeMode = body.calleeMode === undefined ? undefined : this.matchMode(body.calleeMode, 'calleeMode');
|
||||
const input: UpdatePolicyInput = {
|
||||
lineGroupId: body.lineGroupId === undefined ? undefined : this.limitedString(body.lineGroupId, 'lineGroupId', 32),
|
||||
name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120),
|
||||
priority: body.priority === undefined ? undefined : this.priority(body.priority),
|
||||
callerMode,
|
||||
callerValue: callerMode === undefined ? (body.callerValue === undefined ? undefined : this.nullableString(body.callerValue, 'callerValue', 64)) : this.matchValue(callerMode, body.callerValue, 'callerValue'),
|
||||
calleeMode,
|
||||
calleeValue: calleeMode === undefined ? (body.calleeValue === undefined ? undefined : this.nullableString(body.calleeValue, 'calleeValue', 64)) : this.matchValue(calleeMode, body.calleeValue, 'calleeValue'),
|
||||
status: body.status === undefined ? undefined : this.status(body.status),
|
||||
actorId
|
||||
};
|
||||
return this.policies.update(policyId, input);
|
||||
}
|
||||
|
||||
remove(policyId: string, actorId?: string): Promise<CustomerGatewayPolicySummary> {
|
||||
return this.policies.softDelete(policyId, actorId);
|
||||
}
|
||||
|
||||
reorder(gatewayId: string, body: ReorderDto, actorId?: string): Promise<CustomerGatewayPolicySummary[]> {
|
||||
if (!Array.isArray(body.policyIds) || body.policyIds.length === 0) {
|
||||
throw new BadRequestException({ code: 'POLICY_IDS_INVALID', message: 'policyIds must be a non-empty array.' });
|
||||
}
|
||||
return this.policies.reorder(
|
||||
gatewayId,
|
||||
body.policyIds.map((id) => this.limitedString(id, 'policyIds', 32)),
|
||||
actorId
|
||||
);
|
||||
}
|
||||
|
||||
private limitedString(value: unknown, field: string, maxLength: number): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > maxLength) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` });
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private nullableString(value: unknown, field: string, maxLength: number): string | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
return this.limitedString(value, field, maxLength);
|
||||
}
|
||||
|
||||
private matchMode(value: unknown, field: string): PolicyMatchMode {
|
||||
if (value !== 'ANY' && value !== 'EQUALS' && value !== 'PREFIX') {
|
||||
throw new BadRequestException({ code: 'MATCH_MODE_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private matchValue(mode: PolicyMatchMode, value: unknown, field: string): string | null {
|
||||
if (mode === 'ANY') {
|
||||
return null;
|
||||
}
|
||||
const text = this.limitedString(value, field, 64);
|
||||
if (!/^[0-9A-Za-z+*#.-]+$/.test(text)) {
|
||||
throw new BadRequestException({ code: 'MATCH_VALUE_INVALID', message: `${field} contains invalid characters.` });
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private priority(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 10000) {
|
||||
throw new BadRequestException({ code: 'PRIORITY_INVALID', message: 'priority must be an integer from 1 to 10000.' });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private status(value: unknown): PolicyStatus {
|
||||
if (value !== 'ENABLED' && value !== 'DISABLED') {
|
||||
throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user