import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { AuditAction } from '../audit/audit.types'; export interface BatchCreateResult { created: Array<{ id: string; sourceGroupId: string; targetGroupId: string; sourceGroup: { name: string }; targetGroup: { name: string } }>; skipped: string[]; } const routeInclude = { sourceGroup: { select: { name: true, tenantId: true } }, targetGroup: { select: { name: true, tenantId: true } }, } as const; @Injectable() export class RoutesService { constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, ) {} list(tenantId: string, sourceGroupId?: string) { return this.prisma.syncRoute.findMany({ where: { tenantId, ...(sourceGroupId ? { sourceGroupId } : {}), }, include: { sourceGroup: { select: { name: true } }, targetGroup: { select: { name: true } }, }, orderBy: { createdAt: 'desc' }, }); } async create(tenantId: string, sourceGroupId: string, targetGroupId: string) { if (!sourceGroupId || !targetGroupId) { throw new BadRequestException('sourceGroupId and targetGroupId are required'); } if (sourceGroupId === targetGroupId) { throw new BadRequestException('Source and target groups cannot be the same'); } // Source must be owned by this tenant AND active; target can be owned OR shared AND active const [source, target] = await Promise.all([ this.prisma.group.findFirst({ where: { id: sourceGroupId, tenantId, isActive: true }, select: { id: true } }), this.prisma.group.findFirst({ where: { id: targetGroupId, isActive: true, OR: [ { tenantId }, { groupAccesses: { some: { tenantId } } }, ], }, select: { id: true }, }), ]); if (!source) throw new BadRequestException('Source group is not available or the bot has been removed from it'); if (!target) throw new BadRequestException('Target group is not available or the bot has been removed from it'); try { const route = await this.prisma.syncRoute.create({ data: { tenantId, sourceGroupId, targetGroupId }, include: { sourceGroup: { select: { name: true } }, targetGroup: { select: { name: true } }, }, }); await this.audit.log({ tenantId, action: AuditAction.ROUTE_CREATED, resourceType: 'SyncRoute', resourceId: route.id, payload: { sourceGroupId, targetGroupId }, }); return route; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { if (e.code === 'P2002') { throw new ConflictException('A route between these groups already exists'); } } throw e; } } async createBatch(tenantId: string, sourceGroupId: string, targetGroupIds: string[]) { if (!sourceGroupId || !targetGroupIds.length) { throw new BadRequestException('sourceGroupId and at least one targetGroupId are required'); } if (targetGroupIds.includes(sourceGroupId)) { throw new BadRequestException('Source and target groups cannot be the same'); } // Source group must exist in this tenant AND be active const source = await this.prisma.group.findFirst({ where: { id: sourceGroupId, tenantId, isActive: true }, select: { id: true } }); if (!source) { throw new BadRequestException('Source group is not available or the bot has been removed from it'); } // All target groups must be owned OR shared AND active const targets = await this.prisma.group.findMany({ where: { id: { in: targetGroupIds }, isActive: true, OR: [ { tenantId }, { groupAccesses: { some: { tenantId } } }, ], }, select: { id: true, name: true }, }); if (targets.length !== targetGroupIds.length) { const found = new Set(targets.map((t) => t.id)); const missing = targetGroupIds.filter((id) => !found.has(id)); throw new BadRequestException(`Target groups not found or not shared: ${missing.join(', ')}`); } // Check for existing conflicts — reject the whole batch const existing = await this.prisma.syncRoute.findMany({ where: { tenantId, sourceGroupId, targetGroupId: { in: targetGroupIds } }, select: { targetGroupId: true }, }); if (existing.length > 0) { const names = existing.map((e) => { const t = targets.find((t) => t.id === e.targetGroupId); return t?.name ?? e.targetGroupId; }); throw new ConflictException(`Routes already exist for: ${names.join(', ')}`); } const created = await this.prisma.$transaction( targetGroupIds.map((targetGroupId) => this.prisma.syncRoute.create({ data: { tenantId, sourceGroupId, targetGroupId }, include: { sourceGroup: { select: { name: true } }, targetGroup: { select: { name: true } }, }, }), ), ); await this.audit.log({ tenantId, action: AuditAction.ROUTE_CREATED, resourceType: 'SyncRoute', resourceId: created.map((r) => r.id).join(','), payload: { sourceGroupId, targetGroupIds, count: created.length }, }); return created; } async remove(tenantId: string, id: string) { // Verify ownership before delete const existing = await this.prisma.syncRoute.findFirst({ where: { id, tenantId } }); if (!existing) throw new NotFoundException(`Route ${id} not found`); try { await this.prisma.syncRoute.delete({ where: { id } }); await this.audit.log({ tenantId, action: AuditAction.ROUTE_DELETED, resourceType: 'SyncRoute', resourceId: id, }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') { throw new NotFoundException(`Route ${id} not found`); } throw e; } } }