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>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RuleIntent" AS ENUM ('POSITIVE', 'NEGATIVE');
|
||||
CREATE TYPE "ForwardFormat" AS ENUM ('THREADED', 'DIGEST', 'SUMMARY');
|
||||
CREATE TYPE "ForwardFrequency" AS ENUM ('INSTANT', 'FIVE_MIN', 'FIFTEEN_MIN', 'ONE_HOUR', 'ONE_DAY', 'SEVEN_DAYS');
|
||||
CREATE TYPE "ApprovalMode" AS ENUM ('MANUAL', 'AUTO');
|
||||
|
||||
-- AlterEnum: Add INTEREST to RuleMatchType
|
||||
ALTER TYPE "RuleMatchType" ADD VALUE IF NOT EXISTS 'INTEREST';
|
||||
|
||||
-- AlterTable: TenantRule - add ruleIntent
|
||||
ALTER TABLE "TenantRule" ADD COLUMN IF NOT EXISTS "ruleIntent" "RuleIntent" NOT NULL DEFAULT 'POSITIVE';
|
||||
|
||||
-- AlterTable: SyncRoute - add new columns (updatedAt with default NOW() for existing rows)
|
||||
ALTER TABLE "SyncRoute"
|
||||
ADD COLUMN IF NOT EXISTS "forwardFormat" "ForwardFormat" NOT NULL DEFAULT 'THREADED',
|
||||
ADD COLUMN IF NOT EXISTS "withAttachment" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS "frequency" "ForwardFrequency" NOT NULL DEFAULT 'INSTANT',
|
||||
ADD COLUMN IF NOT EXISTS "approvalMode" "ApprovalMode" NOT NULL DEFAULT 'MANUAL',
|
||||
ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT NOW();
|
||||
|
||||
-- CreateTable: SyncRouteRuleMap
|
||||
CREATE TABLE IF NOT EXISTS "SyncRouteRuleMap" (
|
||||
"id" TEXT NOT NULL,
|
||||
"syncRouteId" TEXT NOT NULL,
|
||||
"tenantRuleId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "SyncRouteRuleMap_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "SyncRouteRuleMap_syncRouteId_tenantRuleId_key"
|
||||
ON "SyncRouteRuleMap"("syncRouteId", "tenantRuleId");
|
||||
CREATE INDEX IF NOT EXISTS "SyncRouteRuleMap_syncRouteId_idx"
|
||||
ON "SyncRouteRuleMap"("syncRouteId");
|
||||
CREATE INDEX IF NOT EXISTS "SyncRouteRuleMap_tenantRuleId_idx"
|
||||
ON "SyncRouteRuleMap"("tenantRuleId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SyncRouteRuleMap"
|
||||
ADD CONSTRAINT "SyncRouteRuleMap_syncRouteId_fkey"
|
||||
FOREIGN KEY ("syncRouteId") REFERENCES "SyncRoute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "SyncRouteRuleMap"
|
||||
ADD CONSTRAINT "SyncRouteRuleMap_tenantRuleId_fkey"
|
||||
FOREIGN KEY ("tenantRuleId") REFERENCES "TenantRule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -337,20 +337,40 @@ enum ApprovalDecision {
|
||||
}
|
||||
|
||||
model SyncRoute {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
sourceGroupId String
|
||||
sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id])
|
||||
targetGroupId String
|
||||
targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id])
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
sourceGroupId String
|
||||
sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id])
|
||||
targetGroupId String
|
||||
targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id])
|
||||
isActive Boolean @default(true)
|
||||
forwardFormat ForwardFormat @default(THREADED)
|
||||
withAttachment Boolean @default(true)
|
||||
frequency ForwardFrequency @default(INSTANT)
|
||||
approvalMode ApprovalMode @default(MANUAL)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
assignedRules SyncRouteRuleMap[]
|
||||
|
||||
@@unique([sourceGroupId, targetGroupId])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model SyncRouteRuleMap {
|
||||
id String @id @default(cuid())
|
||||
syncRouteId String
|
||||
syncRoute SyncRoute @relation(fields: [syncRouteId], references: [id], onDelete: Cascade)
|
||||
tenantRuleId String
|
||||
tenantRule TenantRule @relation(fields: [tenantRuleId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([syncRouteId, tenantRuleId])
|
||||
@@index([syncRouteId])
|
||||
@@index([tenantRuleId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Group claiming + sharing
|
||||
// ============================================================================
|
||||
@@ -515,6 +535,7 @@ enum RuleMatchType {
|
||||
HASHTAG
|
||||
PREFIX
|
||||
REACTION_EMOJI
|
||||
INTEREST
|
||||
}
|
||||
|
||||
enum RuleAction {
|
||||
@@ -524,17 +545,45 @@ enum RuleAction {
|
||||
REJECT
|
||||
}
|
||||
|
||||
enum RuleIntent {
|
||||
POSITIVE
|
||||
NEGATIVE
|
||||
}
|
||||
|
||||
enum ForwardFormat {
|
||||
THREADED
|
||||
DIGEST
|
||||
SUMMARY
|
||||
}
|
||||
|
||||
enum ForwardFrequency {
|
||||
INSTANT
|
||||
FIVE_MIN
|
||||
FIFTEEN_MIN
|
||||
ONE_HOUR
|
||||
ONE_DAY
|
||||
SEVEN_DAYS
|
||||
}
|
||||
|
||||
enum ApprovalMode {
|
||||
MANUAL
|
||||
AUTO
|
||||
}
|
||||
|
||||
model TenantRule {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
matchType RuleMatchType
|
||||
matchValue String
|
||||
action RuleAction
|
||||
priority Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
matchType RuleMatchType
|
||||
matchValue String
|
||||
action RuleAction
|
||||
ruleIntent RuleIntent @default(POSITIVE)
|
||||
priority Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
syncRouteRuleMaps SyncRouteRuleMap[]
|
||||
|
||||
@@unique([tenantId, matchType, matchValue])
|
||||
@@index([tenantId, isActive])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { IsArray, IsOptional, IsString } from 'class-validator';
|
||||
import { MessagesService } from './messages.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
@@ -6,6 +7,10 @@ import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||
import { TenantContext } from '../../common/tenant-context';
|
||||
|
||||
class ApproveMessageDto {
|
||||
@IsArray() @IsString({ each: true }) @IsOptional() targetGroupIds?: string[];
|
||||
}
|
||||
|
||||
@Controller('admin/messages')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('OWNER', 'ADMIN')
|
||||
@@ -22,6 +27,11 @@ export class MessagesController {
|
||||
return this.messagesService.pendingCount(ctx.tenantId);
|
||||
}
|
||||
|
||||
@Get(':id/routes')
|
||||
getRoutes(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||
return this.messagesService.getRoutesForMessage(ctx.tenantId, id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@@ -34,8 +44,9 @@ export class MessagesController {
|
||||
approve(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
@Body() body: ApproveMessageDto,
|
||||
) {
|
||||
return this.messagesService.approve(ctx.tenantId, ctx.adminId ?? '', id);
|
||||
return this.messagesService.approve(ctx.tenantId, ctx.adminId ?? '', id, body.targetGroupIds);
|
||||
}
|
||||
|
||||
@Post('reindex')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ConflictException, Inject, Injectable, NotFoundException, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Queue } from 'bullmq';
|
||||
import { ForwardJobData, IndexJobData } from '@tower/types';
|
||||
import { ForwardJobData, IndexJobData, RouteForMessage } from '@tower/types';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AuditAction } from '../audit/audit.types';
|
||||
@@ -112,7 +112,76 @@ export class MessagesService {
|
||||
}));
|
||||
}
|
||||
|
||||
async approve(tenantId: string, adminId: string, messageId: string): Promise<{ id: string; status: string; routesForwarded: number; indexEnqueued: boolean }> {
|
||||
async getRoutesForMessage(tenantId: string, messageId: string): Promise<RouteForMessage[]> {
|
||||
const message = await this.prisma.message.findUnique({
|
||||
where: { id: messageId },
|
||||
select: { id: true, tenantId: true, sourceGroupId: true, content: true },
|
||||
});
|
||||
if (!message) throw new NotFoundException('Message not found');
|
||||
if (message.tenantId !== tenantId) throw new NotFoundException('Message not found');
|
||||
|
||||
const syncRoutes = await this.prisma.syncRoute.findMany({
|
||||
where: { sourceGroupId: message.sourceGroupId, isActive: true },
|
||||
include: {
|
||||
targetGroup: { select: { id: true, name: true, isActive: true } },
|
||||
assignedRules: {
|
||||
include: {
|
||||
tenantRule: {
|
||||
select: { matchType: true, matchValue: true, ruleIntent: true, isActive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return syncRoutes
|
||||
.filter((r: any) => r.targetGroup?.isActive)
|
||||
.map((route: any) => {
|
||||
const activeRules = route.assignedRules
|
||||
.filter((m: any) => m.tenantRule.isActive)
|
||||
.map((m: any) => m.tenantRule);
|
||||
|
||||
let ruleDecision: 'FORWARD' | 'BLOCK' | 'NONE' = 'NONE';
|
||||
|
||||
if (activeRules.length > 0) {
|
||||
const negativeRules = activeRules.filter((r: any) => r.ruleIntent === 'NEGATIVE');
|
||||
const positiveRules = activeRules.filter((r: any) => r.ruleIntent === 'POSITIVE');
|
||||
|
||||
if (negativeRules.length > 0 && this.matchesAny(message.content, negativeRules)) {
|
||||
ruleDecision = 'BLOCK';
|
||||
} else if (positiveRules.length > 0 && this.matchesAny(message.content, positiveRules)) {
|
||||
ruleDecision = 'FORWARD';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
routeId: route.id,
|
||||
targetGroupId: route.targetGroup.id,
|
||||
targetGroupName: route.targetGroup.name,
|
||||
approvalMode: route.approvalMode,
|
||||
forwardFormat: route.forwardFormat,
|
||||
withAttachment: route.withAttachment,
|
||||
frequency: route.frequency,
|
||||
ruleDecision,
|
||||
} as RouteForMessage;
|
||||
});
|
||||
}
|
||||
|
||||
private matchesAny(content: string, rules: Array<{ matchType: string; matchValue: string }>): boolean {
|
||||
for (const rule of rules) {
|
||||
if (rule.matchType === 'HASHTAG') {
|
||||
const escaped = rule.matchValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
if (new RegExp(`(?:^|\\W)${escaped}(?:$|\\W)`, 'i').test(content)) return true;
|
||||
} else if (rule.matchType === 'PREFIX') {
|
||||
if (content.trimStart().startsWith(rule.matchValue)) return true;
|
||||
} else if (rule.matchType === 'REACTION_EMOJI') {
|
||||
if (content.includes(rule.matchValue)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async approve(tenantId: string, adminId: string, messageId: string, targetGroupIds?: string[]): Promise<{ id: string; status: string; routesForwarded: number; indexEnqueued: boolean }> {
|
||||
const message = await this.prisma.message.findUnique({
|
||||
where: { id: messageId },
|
||||
include: {
|
||||
@@ -160,10 +229,14 @@ export class MessagesService {
|
||||
throw new ConflictException('Message could not be approved (concurrent update)');
|
||||
}
|
||||
|
||||
const validRoutes = (message.sourceGroup?.syncRoutesFrom ?? []).filter(
|
||||
const allRoutes = (message.sourceGroup?.syncRoutesFrom ?? []).filter(
|
||||
(r: any) => r.targetGroup != null,
|
||||
);
|
||||
const forwardJobs: ForwardJobData[] = validRoutes.map((route: any) => ({
|
||||
const selectedRoutes = targetGroupIds
|
||||
? allRoutes.filter((r: any) => targetGroupIds.includes(r.targetGroupId))
|
||||
: allRoutes;
|
||||
|
||||
const forwardJobs: ForwardJobData[] = selectedRoutes.map((route: any) => ({
|
||||
tenantId: message.tenantId,
|
||||
messageId: message.id,
|
||||
content: message.content,
|
||||
@@ -204,6 +277,7 @@ export class MessagesService {
|
||||
resourceId: message.id,
|
||||
payload: {
|
||||
routesForwarded: forwardJobs.length,
|
||||
targetGroupIds: targetGroupIds ?? null,
|
||||
contentPreview: message.content.slice(0, 80),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||
import { TenantContext } from '../../common/tenant-context';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
class CreateRouteDto {
|
||||
@IsString() sourceGroupId!: string;
|
||||
@@ -14,6 +14,14 @@ class BatchCreateRouteDto {
|
||||
@IsString({ each: true }) targetGroupIds!: string[];
|
||||
}
|
||||
|
||||
class UpdateRouteDto {
|
||||
@IsEnum(['THREADED', 'DIGEST', 'SUMMARY']) @IsOptional() forwardFormat?: 'THREADED' | 'DIGEST' | 'SUMMARY';
|
||||
@IsBoolean() @IsOptional() withAttachment?: boolean;
|
||||
@IsEnum(['INSTANT', 'FIVE_MIN', 'FIFTEEN_MIN', 'ONE_HOUR', 'ONE_DAY', 'SEVEN_DAYS']) @IsOptional() frequency?: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
|
||||
@IsEnum(['MANUAL', 'AUTO']) @IsOptional() approvalMode?: 'MANUAL' | 'AUTO';
|
||||
@IsBoolean() @IsOptional() isActive?: boolean;
|
||||
}
|
||||
|
||||
@Controller('routes')
|
||||
export class RoutesController {
|
||||
constructor(private readonly routesService: RoutesService) {}
|
||||
@@ -36,6 +44,35 @@ export class RoutesController {
|
||||
return this.routesService.createBatch(ctx.tenantId, body.sourceGroupId, body.targetGroupIds);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string, @Body() body: UpdateRouteDto) {
|
||||
return this.routesService.update(ctx.tenantId, id, body);
|
||||
}
|
||||
|
||||
@Get(':id/rules')
|
||||
listRules(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||
return this.routesService.listAssignedRules(ctx.tenantId, id);
|
||||
}
|
||||
|
||||
@Post(':id/rules/:tenantRuleId')
|
||||
assignRule(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
@Param('tenantRuleId') tenantRuleId: string,
|
||||
) {
|
||||
return this.routesService.assignRule(ctx.tenantId, id, tenantRuleId);
|
||||
}
|
||||
|
||||
@Delete(':id/rules/:tenantRuleId')
|
||||
@HttpCode(204)
|
||||
async unassignRule(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
@Param('tenantRuleId') tenantRuleId: string,
|
||||
) {
|
||||
await this.routesService.unassignRule(ctx.tenantId, id, tenantRuleId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
async remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||
|
||||
@@ -9,6 +9,14 @@ export interface BatchCreateResult {
|
||||
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 } },
|
||||
@@ -30,11 +38,70 @@ export class RoutesService {
|
||||
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');
|
||||
|
||||
@@ -6,27 +6,32 @@ import { PrismaService } from '../../prisma/prisma.service';
|
||||
export class RulesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(tenantId: string): Promise<TenantRuleData[]> {
|
||||
const rows = await this.prisma.tenantRule.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { priority: 'asc' },
|
||||
});
|
||||
return rows.map((r: any) => ({
|
||||
private toData(r: any): TenantRuleData {
|
||||
return {
|
||||
id: r.id,
|
||||
tenantId: r.tenantId,
|
||||
matchType: r.matchType,
|
||||
matchValue: r.matchValue,
|
||||
action: r.action,
|
||||
ruleIntent: r.ruleIntent,
|
||||
priority: r.priority,
|
||||
isActive: r.isActive,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
async list(tenantId: string): Promise<TenantRuleData[]> {
|
||||
const rows = await this.prisma.tenantRule.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { priority: 'asc' },
|
||||
});
|
||||
return rows.map((r: any) => this.toData(r));
|
||||
}
|
||||
|
||||
async create(tenantId: string, req: CreateRuleRequest): Promise<TenantRuleData> {
|
||||
const existing = await this.prisma.tenantRule.findUnique({
|
||||
where: { tenantId_matchType_matchValue: { tenantId, matchType: req.matchType, matchValue: req.matchValue } },
|
||||
where: { tenantId_matchType_matchValue: { tenantId, matchType: req.matchType as any, matchValue: req.matchValue } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('A rule with this matchType + matchValue already exists');
|
||||
@@ -35,25 +40,16 @@ export class RulesService {
|
||||
const row = await this.prisma.tenantRule.create({
|
||||
data: {
|
||||
tenantId,
|
||||
matchType: req.matchType,
|
||||
matchType: req.matchType as any,
|
||||
matchValue: req.matchValue,
|
||||
action: req.action,
|
||||
action: req.action as any,
|
||||
ruleIntent: (req.ruleIntent ?? 'POSITIVE') as any,
|
||||
priority: req.priority ?? 0,
|
||||
isActive: req.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId: row.tenantId,
|
||||
matchType: row.matchType as any,
|
||||
matchValue: row.matchValue,
|
||||
action: row.action as any,
|
||||
priority: row.priority,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
return this.toData(row);
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, req: UpdateRuleRequest): Promise<TenantRuleData> {
|
||||
@@ -64,22 +60,13 @@ export class RulesService {
|
||||
if (req.matchType !== undefined) data.matchType = req.matchType;
|
||||
if (req.matchValue !== undefined) data.matchValue = req.matchValue;
|
||||
if (req.action !== undefined) data.action = req.action;
|
||||
if (req.ruleIntent !== undefined) data.ruleIntent = req.ruleIntent;
|
||||
if (req.priority !== undefined) data.priority = req.priority;
|
||||
if (req.isActive !== undefined) data.isActive = req.isActive;
|
||||
|
||||
const row = await this.prisma.tenantRule.update({ where: { id }, data });
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId: row.tenantId,
|
||||
matchType: row.matchType as any,
|
||||
matchValue: row.matchValue,
|
||||
action: row.action as any,
|
||||
priority: row.priority,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
return this.toData(row);
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user