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
+79 -23
View File
@@ -2,8 +2,8 @@ import { Worker, Queue } from 'bullmq';
import Redis from 'ioredis';
import { IngestJobData, ForwardJobData, IndexJobData } from '@tower/types';
import { parseRedisUrl } from './redis-connection';
import { approveMessage } from '../core/approve-message';
import { publishToMessageStream } from '../streams/message-stream';
import { matchContentRules, TenantRuleRow } from '../whatsapp/match-rules';
import { createLogger } from '@tower/logger';
const logger = createLogger('ingest-processor');
@@ -110,7 +110,7 @@ export async function processIngestJob(
}).catch((err: unknown) => logger.warn({ err, messageId: msg.id }, 'Failed to publish to message stream'));
}
// For REJECT, create an approval record so it's searchable as rejected
// Global gate: REJECT means this message is not acceptable for this tenant
if (job.effectiveAction === 'REJECT') {
await prisma.approval.upsert({
where: { messageId: msg.id },
@@ -122,31 +122,87 @@ export async function processIngestJob(
decision: 'REJECTED',
},
});
logger.info({ messageId: msg.id }, 'Message rejected by rule');
logger.info({ messageId: msg.id }, 'Message rejected by tenant rule');
return;
}
// For AUTO_APPROVE, immediately approve via approveMessage helper
if (job.effectiveAction === 'AUTO_APPROVE') {
const result = await approveMessage(msg.id, job.tenantId, 'system', prisma);
if (result) {
if (forwardQueue) {
for (const fwd of result.forwardJobs) {
await forwardQueue.add('forward', fwd, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
});
}
}
if (indexQueue) {
await indexQueue.add('index', result.indexDoc, {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
});
}
logger.info({ messageId: msg.id, forwardCount: result.forwardJobs.length }, 'Message auto-approved');
// Per-route rule evaluation — determines which routes auto-forward vs stay pending
const syncRoutes = await prisma.syncRoute.findMany({
where: { sourceGroupId: job.sourceGroupId, isActive: true },
include: {
targetGroup: { select: { id: true, platformId: true, accountId: true, isActive: true } },
assignedRules: {
include: {
tenantRule: {
select: { matchType: true, matchValue: true, action: true, ruleIntent: true, priority: true, isActive: true },
},
},
},
},
});
const autoForwardRoutes = syncRoutes.filter((route: any) => {
if (!route.targetGroup?.isActive) return false;
const activeRules: TenantRuleRow[] = route.assignedRules
.filter((m: any) => m.tenantRule.isActive)
.map((m: any) => m.tenantRule);
if (activeRules.length === 0) {
return route.approvalMode === 'AUTO';
}
return;
const negativeRules = activeRules.filter((r) => (r as any).ruleIntent === 'NEGATIVE');
const positiveRules = activeRules.filter((r) => (r as any).ruleIntent === 'POSITIVE');
if (negativeRules.length > 0) {
const { effectiveAction } = matchContentRules(job.content, negativeRules);
if (effectiveAction) return false;
}
if (positiveRules.length > 0) {
const { effectiveAction } = matchContentRules(job.content, positiveRules);
if (effectiveAction) return true;
}
return route.approvalMode === 'AUTO';
});
if (autoForwardRoutes.length > 0 && forwardQueue) {
for (const route of autoForwardRoutes) {
await forwardQueue.add('forward', {
tenantId: job.tenantId,
messageId: msg.id,
content: job.content,
sourceGroupName: group.name,
senderName: job.senderName,
toGroupJid: route.targetGroup.platformId,
fromAccountId: route.targetGroup.accountId ?? '',
} as ForwardJobData, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
}
await prisma.message.update({ where: { id: msg.id }, data: { status: 'APPROVED' } });
await prisma.approval.upsert({
where: { messageId: msg.id },
update: {},
create: { tenantId: job.tenantId, messageId: msg.id, adminId: 'system', decision: 'APPROVED' },
});
if (indexQueue) {
await indexQueue.add('index', {
tenantId: job.tenantId,
messageId: msg.id,
content: job.content,
senderName: job.senderName ?? null,
sourceGroupId: job.sourceGroupId,
sourceGroupName: group.name,
tags: job.tags,
platform: job.platform,
approvedAt: new Date().toISOString(),
}, { attempts: 3, backoff: { type: 'exponential', delay: 1000 } });
}
logger.info({ messageId: msg.id, routeCount: autoForwardRoutes.length }, 'Message auto-forwarded via route rules');
}
}