Files
tower/apps/api/src/modules/routes/routes.service.ts
T
maaz519 73956fd1e3 feat: route-centric rules, approve popup, emoji picker, 5-column sync table
- Schema: SyncRouteRuleMap junction table, ruleIntent on TenantRule,
  ForwardFormat/Frequency/ApprovalMode columns on SyncRoute, INTEREST
  match type
- Worker: remove global AUTO_APPROVE forwarding; per-route rule evaluation
  in ingest processor — POSITIVE rules auto-forward that route, NEGATIVE
  rules block it, fallback to approvalMode
- API: PATCH /routes/:id, rule assign/unassign endpoints, approve now
  accepts targetGroupIds, new GET /messages/:id/routes for popup data
- Web: 5-column route table with settings + rules dialogs, ForwardTarget
  popup with rule-based pre-selection, ruleIntent toggle + emoji dropdown
  (👍/👎 only) + INTEREST type in rules manager

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 13:58:02 +05:30

247 lines
9.1 KiB
TypeScript

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[];
}
export interface UpdateRouteInput {
forwardFormat?: 'THREADED' | 'DIGEST' | 'SUMMARY';
withAttachment?: boolean;
frequency?: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
approvalMode?: 'MANUAL' | 'AUTO';
isActive?: boolean;
}
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 } },
assignedRules: {
include: {
tenantRule: {
select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, isActive: true },
},
},
},
},
orderBy: { createdAt: 'desc' },
});
}
async update(tenantId: string, id: string, dto: UpdateRouteInput) {
const existing = await this.prisma.syncRoute.findFirst({ where: { id, tenantId } });
if (!existing) throw new NotFoundException(`Route ${id} not found`);
return this.prisma.syncRoute.update({ where: { id }, data: dto as any });
}
async assignRule(tenantId: string, routeId: string, tenantRuleId: string) {
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
const rule = await this.prisma.tenantRule.findFirst({ where: { id: tenantRuleId, tenantId } });
if (!rule) throw new NotFoundException(`Rule ${tenantRuleId} not found`);
try {
return await this.prisma.syncRouteRuleMap.create({
data: { id: `${routeId}_${tenantRuleId}`.slice(0, 25) + Math.random().toString(36).slice(2, 8), syncRouteId: routeId, tenantRuleId },
include: { tenantRule: { select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, isActive: true } } },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Rule is already assigned to this route');
}
throw e;
}
}
async unassignRule(tenantId: string, routeId: string, tenantRuleId: string) {
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
const map = await this.prisma.syncRouteRuleMap.findUnique({
where: { syncRouteId_tenantRuleId: { syncRouteId: routeId, tenantRuleId } },
});
if (!map) throw new NotFoundException('Assignment not found');
await this.prisma.syncRouteRuleMap.delete({
where: { syncRouteId_tenantRuleId: { syncRouteId: routeId, tenantRuleId } },
});
}
async listAssignedRules(tenantId: string, routeId: string) {
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
const maps = await this.prisma.syncRouteRuleMap.findMany({
where: { syncRouteId: routeId },
include: {
tenantRule: {
select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, priority: true, isActive: true },
},
},
orderBy: { createdAt: 'asc' },
});
return maps.map((m: any) => ({ ...m.tenantRule, assignedAt: m.createdAt }));
}
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;
}
}
}