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:
2026-06-25 13:58:02 +05:30
parent e598073dc7
commit 73956fd1e3
24 changed files with 1277 additions and 225 deletions
@@ -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;
+68 -19
View File
@@ -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');
+19 -32
View File
@@ -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> {