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,158 @@
'use client';
import { useEffect, useState } from 'react';
interface RouteForMessage {
routeId: string;
targetGroupId: string;
targetGroupName: string;
approvalMode: 'MANUAL' | 'AUTO';
forwardFormat: 'THREADED' | 'DIGEST' | 'SUMMARY';
withAttachment: boolean;
frequency: string;
ruleDecision: 'FORWARD' | 'BLOCK' | 'NONE';
}
export function ForwardTargetDialog({
messageId,
onApproved,
onClose,
}: {
messageId: string;
onApproved: () => void;
onClose: () => void;
}) {
const [routes, setRoutes] = useState<RouteForMessage[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [search, setSearch] = useState('');
useEffect(() => {
fetch(`/api/messages/${messageId}/routes`)
.then((r) => r.json())
.then((data: RouteForMessage[]) => {
setRoutes(Array.isArray(data) ? data : []);
// Pre-select: FORWARD decision, or AUTO approvalMode with no BLOCK
const preSelected = new Set(
(Array.isArray(data) ? data : [])
.filter((r) => r.ruleDecision === 'FORWARD' || (r.ruleDecision === 'NONE' && r.approvalMode === 'AUTO'))
.map((r) => r.targetGroupId),
);
setSelected(preSelected);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [messageId]);
function toggleGroup(groupId: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(groupId)) next.delete(groupId); else next.add(groupId);
return next;
});
}
async function confirm() {
setBusy(true);
try {
const res = await fetch(`/api/messages/${messageId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetGroupIds: [...selected] }),
});
if (res.ok) {
onApproved();
onClose();
}
} finally {
setBusy(false);
}
}
const filtered = routes.filter((r) =>
r.targetGroupName.toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 flex flex-col max-h-[80vh]">
<div className="flex items-center justify-between px-5 pt-5 pb-3 border-b border-gray-100">
<h2 className="text-base font-semibold">Approve &amp; Forward</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
<div className="px-5 py-3 border-b border-gray-100">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search groups…"
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div className="flex-1 overflow-y-auto px-5 py-3">
{loading ? (
<p className="text-sm text-gray-400">Loading routes</p>
) : filtered.length === 0 ? (
<p className="text-sm text-gray-400">No target groups found.</p>
) : (
<ul className="flex flex-col gap-1.5">
{filtered.map((r) => {
const isBlocked = r.ruleDecision === 'BLOCK';
const isChecked = selected.has(r.targetGroupId);
return (
<li key={r.targetGroupId}>
<label
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition-colors ${
isBlocked
? 'opacity-50 cursor-default'
: isChecked
? 'bg-blue-50'
: 'hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => !isBlocked && toggleGroup(r.targetGroupId)}
disabled={isBlocked}
className="rounded border-gray-300"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-800 truncate">{r.targetGroupName}</div>
{isBlocked && (
<div className="text-[10px] text-red-500">Blocked by assigned rule</div>
)}
{r.ruleDecision === 'FORWARD' && (
<div className="text-[10px] text-green-600">Auto-forward by rule</div>
)}
</div>
</label>
</li>
);
})}
</ul>
)}
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-gray-100">
<span className="text-xs text-gray-500">{selected.size} group{selected.size !== 1 ? 's' : ''} selected</span>
<div className="flex gap-2">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
Cancel
</button>
<button
onClick={() => void confirm()}
disabled={busy || selected.size === 0}
className="px-4 py-2 rounded bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{busy ? 'Approving…' : 'Approve & Forward'}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -1,4 +1,7 @@
import { apiFetch } from '@/app/_lib/api';
'use client';
import { useCallback, useEffect, useState } from 'react';
import { ForwardTargetDialog } from '../ForwardTargetDialog';
interface PendingMessage {
id: string;
@@ -12,32 +15,30 @@ interface PendingMessage {
sourceGroupPlatformId: string;
}
type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string };
export default function PendingMessagesPage() {
const [messages, setMessages] = useState<PendingMessage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
async function fetchPending(): Promise<FetchResult<PendingMessage[]>> {
try {
const res = await apiFetch('/admin/messages/pending');
if (!res.ok) {
const body = await res.text().catch(() => '');
return { ok: false, status: res.status, error: body.slice(0, 200) || res.statusText };
}
return { ok: true, data: (await res.json()) as PendingMessage[] };
} catch (err) {
return { ok: false, status: 0, error: `API unreachable: ${(err as Error).message}` };
}
}
const load = useCallback(() => {
setLoading(true);
fetch('/api/messages/pending')
.then(async (res) => {
if (!res.ok) throw new Error(`API ${res.status}`);
return res.json() as Promise<PendingMessage[]>;
})
.then((data) => { setMessages(data); setError(null); })
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, []);
export default async function PendingMessagesPage() {
const result = await fetchPending();
const messages = result.ok ? result.data : [];
const error = !result.ok ? (result.status === 0 ? 'API unreachable' : `API ${result.status}: ${result.error}`) : null;
useEffect(() => { load(); }, [load]);
return (
<div className="max-w-3xl">
<h1 className="text-xl font-semibold mb-2">Pending messages</h1>
<p className="text-sm text-gray-500 mb-6">
Flagged messages waiting for an admin to approve. Approving forwards them to every active
route from the source group and indexes them in search.
Messages waiting for admin approval. Click "Approve &amp; Forward" to choose which groups to send to.
</p>
{error && (
@@ -46,60 +47,69 @@ export default async function PendingMessagesPage() {
</div>
)}
{!error && messages.length === 0 && (
{!loading && !error && messages.length === 0 && (
<div className="rounded-xl border border-gray-200 bg-white p-6 text-sm text-gray-500">
No pending messages right now. New flagged messages will appear here as they arrive.
No pending messages right now.
</div>
)}
<ul className="space-y-3">
{messages.map((m) => (
<PendingMessageRow key={m.id} message={m} />
<PendingMessageRow
key={m.id}
message={m}
onApproved={() => setMessages((prev) => prev.filter((x) => x.id !== m.id))}
/>
))}
</ul>
</div>
);
}
function PendingMessageRow({ message }: { message: PendingMessage }) {
function PendingMessageRow({
message,
onApproved,
}: {
message: PendingMessage;
onApproved: () => void;
}) {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<li className="rounded-xl border border-gray-200 bg-white p-4 flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-2">
<div className="text-sm font-medium text-gray-900">{message.sourceGroupName}</div>
<div className="text-xs text-gray-500">
{new Date(message.createdAt).toLocaleString()}
</div>
<div className="text-xs text-gray-500">{new Date(message.createdAt).toLocaleString()}</div>
</div>
<div className="text-xs text-gray-500">
From <span className="font-mono">{message.senderName ?? message.senderJid}</span>
{message.tags.length > 0 && (
<span className="ml-2 inline-flex flex-wrap gap-1">
{message.tags.map((t) => (
<span
key={t}
className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700"
>
<span key={t} className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700">
{t}
</span>
))}
</span>
)}
</div>
<div className="text-sm text-gray-800 whitespace-pre-wrap break-words">
{message.content}
</div>
<form
action={`/api/messages/${message.id}/approve`}
method="post"
className="flex justify-end pt-1"
>
<div className="text-sm text-gray-800 whitespace-pre-wrap break-words">{message.content}</div>
<div className="flex justify-end pt-1">
<button
type="submit"
onClick={() => setDialogOpen(true)}
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Approve &amp; forward
Approve &amp; Forward
</button>
</form>
</div>
{dialogOpen && (
<ForwardTargetDialog
messageId={message.id}
onApproved={onApproved}
onClose={() => setDialogOpen(false)}
/>
)}
</li>
);
}