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
@@ -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>
);
}