Files
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

260 lines
9.8 KiB
TypeScript
Raw Permalink 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 { useState } from 'react';
type RuleMatchType = 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI' | 'INTEREST';
type RuleAction = 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
type RuleIntent = 'POSITIVE' | 'NEGATIVE';
interface RuleData {
id: string;
matchType: RuleMatchType;
matchValue: string;
action: RuleAction;
ruleIntent: RuleIntent;
priority: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
const MATCH_TYPE_LABELS: Record<RuleMatchType, string> = {
HASHTAG: 'Hashtag',
PREFIX: 'Prefix',
REACTION_EMOJI: 'Reaction Emoji',
INTEREST: 'Interest',
};
const ACTION_LABELS: Record<RuleAction, string> = {
FLAG: 'Flag (Pending)',
AUTO_APPROVE: 'Auto-approve',
SKIP: 'Skip (Silent Drop)',
REJECT: 'Reject (Visible)',
};
const REACTION_OPTIONS = [
{ value: '👍', label: '👍 Thumbs Up' },
{ value: '👎', label: '👎 Thumbs Down' },
];
function getValuePlaceholder(matchType: RuleMatchType): string {
if (matchType === 'HASHTAG') return '#important';
if (matchType === 'PREFIX') return '!!';
if (matchType === 'INTEREST') return '#education';
return '';
}
export function RuleManager({ initial }: { initial: RuleData[] }) {
const [rules, setRules] = useState<RuleData[]>(initial);
const [matchType, setMatchType] = useState<RuleMatchType>('HASHTAG');
const [matchValue, setMatchValue] = useState('');
const [action, setAction] = useState<RuleAction>('FLAG');
const [ruleIntent, setRuleIntent] = useState<RuleIntent>('POSITIVE');
const [priority, setPriority] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
function handleMatchTypeChange(type: RuleMatchType) {
setMatchType(type);
setMatchValue('');
}
async function addRule() {
if (!matchValue.trim()) return;
setBusy(true); setError('');
try {
const res = await fetch('/api/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ matchType, matchValue: matchValue.trim(), action, ruleIntent, priority }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: 'Failed to create rule' }));
setError(err.message ?? 'Failed to create rule');
return;
}
const created: RuleData = await res.json();
setRules((prev) => [...prev, created].sort((a, b) => a.priority - b.priority));
setMatchValue(''); setAction('FLAG'); setRuleIntent('POSITIVE'); setPriority(0);
} finally { setBusy(false); }
}
async function toggleRule(rule: RuleData) {
const res = await fetch(`/api/rules/${rule.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: !rule.isActive }),
});
if (res.ok) {
const updated: RuleData = await res.json();
setRules((prev) => prev.map((r) => (r.id === rule.id ? updated : r)));
}
}
async function deleteRule(id: string) {
const res = await fetch(`/api/rules/${id}`, { method: 'DELETE' });
if (res.ok) setRules((prev) => prev.filter((r) => r.id !== id));
}
return (
<div className="flex flex-col gap-6">
<section className="border border-gray-200 rounded-lg p-4">
<h2 className="text-base font-semibold mb-3">Add Rule</h2>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Type</label>
<select
value={matchType}
onChange={(e) => handleMatchTypeChange(e.target.value as RuleMatchType)}
className="border border-gray-300 rounded px-3 py-2 text-sm"
>
<option value="HASHTAG">Hashtag</option>
<option value="PREFIX">Prefix</option>
<option value="REACTION_EMOJI">Reaction Emoji</option>
<option value="INTEREST">Interest</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Value</label>
{matchType === 'REACTION_EMOJI' ? (
<select
value={matchValue}
onChange={(e) => setMatchValue(e.target.value)}
className="border border-gray-300 rounded px-3 py-2 text-sm w-44"
>
<option value="">Select emoji</option>
{REACTION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
) : (
<input
value={matchValue}
onChange={(e) => setMatchValue(e.target.value)}
placeholder={getValuePlaceholder(matchType)}
className="border border-gray-300 rounded px-3 py-2 text-sm w-40"
/>
)}
{matchType === 'INTEREST' && (
<span className="text-[10px] text-gray-400 mt-0.5">Helps AI classify content into circles</span>
)}
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Intent</label>
<div className="flex gap-1">
{(['POSITIVE', 'NEGATIVE'] as RuleIntent[]).map((i) => (
<button
key={i}
type="button"
onClick={() => setRuleIntent(i)}
className={`px-3 py-2 text-sm rounded border transition-colors ${
ruleIntent === i
? i === 'POSITIVE'
? 'bg-green-600 text-white border-green-600'
: 'bg-red-600 text-white border-red-600'
: 'border-gray-300 text-gray-600 hover:border-gray-400'
}`}
>
{i === 'POSITIVE' ? '+ Pos' : ' Neg'}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Action</label>
<select
value={action}
onChange={(e) => setAction(e.target.value as RuleAction)}
className="border border-gray-300 rounded px-3 py-2 text-sm"
>
<option value="FLAG">Flag (Pending)</option>
<option value="AUTO_APPROVE">Auto-approve</option>
<option value="SKIP">Skip (Silent Drop)</option>
<option value="REJECT">Reject (Visible)</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Priority</label>
<input
type="number"
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
className="border border-gray-300 rounded px-3 py-2 text-sm w-20"
/>
</div>
<button
type="button"
onClick={() => void addRule()}
disabled={busy || !matchValue.trim()}
className="bg-blue-600 text-white rounded px-4 py-2 text-sm hover:bg-blue-700 disabled:opacity-40"
>
{busy ? 'Adding...' : 'Add Rule'}
</button>
</div>
{error && <p className="text-red-600 text-sm mt-2">{error}</p>}
</section>
<section>
<h2 className="text-base font-semibold mb-3">Active Rules</h2>
{rules.length === 0 ? (
<p className="text-sm text-gray-400">No rules configured. Assign rules to sync routes to control auto-forwarding.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-gray-500">
<th className="pb-2 pr-3">Type</th>
<th className="pb-2 pr-3">Value</th>
<th className="pb-2 pr-3">Intent</th>
<th className="pb-2 pr-3">Action</th>
<th className="pb-2 pr-3">Priority</th>
<th className="pb-2 pr-3">Active</th>
<th className="pb-2" />
</tr>
</thead>
<tbody>
{rules.map((rule) => (
<tr key={rule.id} className="border-b border-gray-100">
<td className="py-2 pr-3">{MATCH_TYPE_LABELS[rule.matchType] ?? rule.matchType}</td>
<td className="py-2 pr-3 font-mono">{rule.matchValue}</td>
<td className="py-2 pr-3">
<span className={`text-xs rounded px-2 py-0.5 ${
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{rule.ruleIntent}
</span>
</td>
<td className="py-2 pr-3">{ACTION_LABELS[rule.action] ?? rule.action}</td>
<td className="py-2 pr-3">{rule.priority}</td>
<td className="py-2 pr-3">
<button
type="button"
onClick={() => void toggleRule(rule)}
className={`text-xs rounded px-2 py-0.5 ${rule.isActive ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}
>
{rule.isActive ? 'ON' : 'OFF'}
</button>
</td>
<td className="py-2">
<button
type="button"
onClick={() => void deleteRule(rule.id)}
className="text-red-600 hover:text-red-800 text-xs"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
);
}