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
+142 -71
View File
@@ -1,12 +1,18 @@
'use client';
import { useState } from 'react';
import { RouteSettingsDialog } from './RouteSettingsDialog';
import { RouteRulesDialog } from './RouteRulesDialog';
interface Group {
type ForwardFormat = 'THREADED' | 'DIGEST' | 'SUMMARY';
type ForwardFrequency = 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
type ApprovalMode = 'MANUAL' | 'AUTO';
interface AssignedRule {
id: string;
name: string;
platform: string;
isActive: boolean;
matchType: string;
matchValue: string;
ruleIntent: 'POSITIVE' | 'NEGATIVE';
}
interface Route {
@@ -15,8 +21,29 @@ interface Route {
targetGroupId: string;
sourceGroup: { name: string };
targetGroup: { name: string };
forwardFormat: ForwardFormat;
withAttachment: boolean;
frequency: ForwardFrequency;
approvalMode: ApprovalMode;
isActive: boolean;
assignedRules?: Array<{ tenantRule: AssignedRule }>;
}
interface Group {
id: string;
name: string;
platform: string;
isActive: boolean;
}
const FREQ_LABELS: Record<ForwardFrequency, string> = {
INSTANT: 'Instant', FIVE_MIN: '5 min', FIFTEEN_MIN: '15 min',
ONE_HOUR: '1h', ONE_DAY: '1d', SEVEN_DAYS: '7d',
};
const FORMAT_LABELS: Record<ForwardFormat, string> = {
THREADED: 'Threaded', DIGEST: 'Digest', SUMMARY: 'Summary',
};
export function RouteManager({
groups,
initialRoutes,
@@ -29,8 +56,9 @@ export function RouteManager({
const [targetIds, setTargetIds] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [settingsRoute, setSettingsRoute] = useState<Route | null>(null);
const [rulesRoute, setRulesRoute] = useState<Route | null>(null);
// Group routes by source group name
const grouped = new Map<string, { sourceId: string; targets: Route[] }>();
for (const r of routes) {
const key = r.sourceGroup.name;
@@ -41,8 +69,7 @@ export function RouteManager({
function toggleTarget(id: string) {
setTargetIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
setError(null);
@@ -50,35 +77,24 @@ export function RouteManager({
async function addRoutes() {
if (!sourceId || targetIds.size === 0) return;
setBusy(true);
setError(null);
setBusy(true); setError(null);
try {
const res = await fetch('/api/routes/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceGroupId: sourceId, targetGroupIds: [...targetIds] }),
});
if (res.status === 409) {
const errBody = await res.json();
setError(errBody.message ?? 'Some routes already exist');
return;
}
if (!res.ok) {
setError('Failed to create routes');
return;
}
if (res.status === 409) { setError((await res.json()).message ?? 'Routes already exist'); return; }
if (!res.ok) { setError('Failed to create routes'); return; }
const created: Route[] = await res.json();
setRoutes((prev) => [...created, ...prev]);
setSourceId('');
setTargetIds(new Set());
} finally {
setBusy(false);
}
setSourceId(''); setTargetIds(new Set());
} finally { setBusy(false); }
}
async function deleteRoute(id: string) {
const res = await fetch(`/api/routes/${id}`, { method: 'DELETE' });
if (res.ok) setRoutes((prev) => prev.filter((r) => r.id !== id));
if (res.ok || res.status === 204) setRoutes((prev) => prev.filter((r) => r.id !== id));
}
const eligibleTargets = groups.filter((g) => g.id !== sourceId && g.platform === 'whatsapp' && g.isActive);
@@ -90,29 +106,72 @@ export function RouteManager({
{routes.length === 0 ? (
<p className="text-sm text-gray-400">No routes configured.</p>
) : (
<ul className="flex flex-col gap-3">
{[...grouped.entries()].map(([sourceName, { sourceId: sId, targets }]) => (
<li key={sId} className="rounded-lg border border-gray-200 bg-white px-4 py-3">
<div className="flex items-start gap-3">
<span className="text-sm font-semibold text-gray-700 whitespace-nowrap mt-1 min-w-[120px]">{sourceName}</span>
<div className="flex flex-col gap-1.5 flex-1">
{targets.map((r) => (
<div key={r.id} className="flex items-center justify-between group">
<span className="text-sm text-gray-600">{r.targetGroup.name}</span>
<button
onClick={() => deleteRoute(r.id)}
className="text-xs text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={`Delete route to ${r.targetGroup.name}`}
>
Delete
</button>
</div>
))}
</div>
</div>
</li>
))}
</ul>
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50 text-left text-xs text-gray-500">
<th className="px-4 py-2.5">Source</th>
<th className="px-4 py-2.5">Target</th>
<th className="px-4 py-2.5">Rules</th>
<th className="px-4 py-2.5">Frequency</th>
<th className="px-4 py-2.5">Format</th>
<th className="px-4 py-2.5">Approval</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{routes.map((r) => {
const ruleCount = r.assignedRules?.length ?? 0;
return (
<tr key={r.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50">
<td className="px-4 py-2.5 font-medium text-gray-700">{r.sourceGroup.name}</td>
<td className="px-4 py-2.5 text-gray-600">{r.targetGroup.name}</td>
<td className="px-4 py-2.5">
<span className={`text-xs rounded px-2 py-0.5 ${ruleCount > 0 ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-400'}`}>
{ruleCount} rule{ruleCount !== 1 ? 's' : ''}
</span>
</td>
<td className="px-4 py-2.5 text-gray-600">{FREQ_LABELS[r.frequency] ?? r.frequency}</td>
<td className="px-4 py-2.5 text-gray-600">
{FORMAT_LABELS[r.forwardFormat] ?? r.forwardFormat}
{r.withAttachment && <span className="ml-1 text-[10px] text-gray-400">+attach</span>}
</td>
<td className="px-4 py-2.5">
<span className={`text-xs rounded px-2 py-0.5 ${r.approvalMode === 'AUTO' ? 'bg-green-100 text-green-700' : 'bg-yellow-50 text-yellow-700'}`}>
{r.approvalMode === 'AUTO' ? 'Auto' : 'Manual'}
</span>
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<button
onClick={() => setSettingsRoute(r)}
className="text-xs text-gray-400 hover:text-blue-600"
title="Edit settings"
>
</button>
<button
onClick={() => setRulesRoute(r)}
className="text-xs text-gray-400 hover:text-blue-600"
title="Manage rules"
>
📋
</button>
<button
onClick={() => void deleteRoute(r.id)}
className="text-xs text-gray-400 hover:text-red-600"
title="Delete route"
>
🗑
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
@@ -124,7 +183,6 @@ export function RouteManager({
value={sourceId}
onChange={(e) => { setSourceId(e.target.value); setTargetIds(new Set()); setError(null); }}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm"
aria-label="Source group"
>
<option value="">Source group</option>
{groups.filter((g) => g.platform === 'whatsapp' && g.isActive).map((g) => (
@@ -132,32 +190,27 @@ export function RouteManager({
))}
</select>
<span className="text-gray-400 text-sm"></span>
<span className="text-xs text-gray-500">
{targetIds.size} target{targetIds.size !== 1 ? 's' : ''} selected
</span>
<span className="text-xs text-gray-500">{targetIds.size} target{targetIds.size !== 1 ? 's' : ''} selected</span>
</div>
{sourceId && eligibleTargets.length > 0 && (
<div className="rounded-lg border border-gray-200 bg-white p-3 max-h-48 overflow-y-auto">
{eligibleTargets.map((g) => {
const checked = targetIds.has(g.id);
return (
<label
key={g.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-sm transition-colors ${
checked ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleTarget(g.id)}
className="rounded border-gray-300"
/>
{g.name}
</label>
);
})}
{eligibleTargets.map((g) => (
<label
key={g.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-sm transition-colors ${
targetIds.has(g.id) ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={targetIds.has(g.id)}
onChange={() => toggleTarget(g.id)}
className="rounded border-gray-300"
/>
{g.name}
</label>
))}
</div>
)}
@@ -166,7 +219,7 @@ export function RouteManager({
)}
<button
onClick={addRoutes}
onClick={() => void addRoutes()}
disabled={!sourceId || targetIds.size === 0 || busy}
className="self-start rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
@@ -174,6 +227,24 @@ export function RouteManager({
</button>
</div>
</section>
{settingsRoute && (
<RouteSettingsDialog
route={settingsRoute}
onSaved={(updates) => {
setRoutes((prev) => prev.map((r) => r.id === settingsRoute.id ? { ...r, ...updates } : r));
}}
onClose={() => setSettingsRoute(null)}
/>
)}
{rulesRoute && (
<RouteRulesDialog
routeId={rulesRoute.id}
targetGroupName={rulesRoute.targetGroup.name}
onClose={() => setRulesRoute(null)}
/>
)}
</div>
);
}