73956fd1e3
- 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>
251 lines
9.7 KiB
TypeScript
251 lines
9.7 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { RouteSettingsDialog } from './RouteSettingsDialog';
|
||
import { RouteRulesDialog } from './RouteRulesDialog';
|
||
|
||
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;
|
||
matchType: string;
|
||
matchValue: string;
|
||
ruleIntent: 'POSITIVE' | 'NEGATIVE';
|
||
}
|
||
|
||
interface Route {
|
||
id: string;
|
||
sourceGroupId: string;
|
||
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,
|
||
}: {
|
||
groups: Group[];
|
||
initialRoutes: Route[];
|
||
}) {
|
||
const [routes, setRoutes] = useState<Route[]>(initialRoutes);
|
||
const [sourceId, setSourceId] = useState('');
|
||
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);
|
||
|
||
const grouped = new Map<string, { sourceId: string; targets: Route[] }>();
|
||
for (const r of routes) {
|
||
const key = r.sourceGroup.name;
|
||
if (!grouped.has(key)) grouped.set(key, { sourceId: r.sourceGroupId, targets: [] });
|
||
grouped.get(key)!.targets.push(r);
|
||
}
|
||
|
||
function toggleTarget(id: string) {
|
||
setTargetIds((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id); else next.add(id);
|
||
return next;
|
||
});
|
||
setError(null);
|
||
}
|
||
|
||
async function addRoutes() {
|
||
if (!sourceId || targetIds.size === 0) return;
|
||
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) { 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); }
|
||
}
|
||
|
||
async function deleteRoute(id: string) {
|
||
const res = await fetch(`/api/routes/${id}`, { method: 'DELETE' });
|
||
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);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-6">
|
||
<section>
|
||
<h2 className="text-base font-semibold mb-3">Active sync routes</h2>
|
||
{routes.length === 0 ? (
|
||
<p className="text-sm text-gray-400">No routes configured.</p>
|
||
) : (
|
||
<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>
|
||
|
||
<section>
|
||
<h2 className="text-base font-semibold mb-3">Add routes</h2>
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex gap-2 items-center">
|
||
<select
|
||
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"
|
||
>
|
||
<option value="">Source group…</option>
|
||
{groups.filter((g) => g.platform === 'whatsapp' && g.isActive).map((g) => (
|
||
<option key={g.id} value={g.id}>{g.name}</option>
|
||
))}
|
||
</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>
|
||
</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) => (
|
||
<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>
|
||
)}
|
||
|
||
{error && (
|
||
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{error}</p>
|
||
)}
|
||
|
||
<button
|
||
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"
|
||
>
|
||
{busy ? 'Creating…' : `Create ${targetIds.size} route${targetIds.size !== 1 ? 's' : ''}`}
|
||
</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>
|
||
);
|
||
}
|