Files
tower/apps/web/app/(chapter)/messages/ForwardTargetDialog.tsx
T
maaz519 73956fd1e3 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>
2026-06-25 13:58:02 +05:30

159 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}