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:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user