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>
112 lines
4.0 KiB
TypeScript
112 lines
4.0 KiB
TypeScript
import { RouteManager } from './RouteManager';
|
|
import { GroupsTabs } from './GroupsTabs';
|
|
import { apiFetch } from '@/app/_lib/api';
|
|
|
|
interface Group {
|
|
id: string;
|
|
name: string;
|
|
platform: string;
|
|
platformId: string;
|
|
isActive: boolean;
|
|
accountId: string | null;
|
|
tenantId: string | null;
|
|
}
|
|
|
|
interface SharedGroup extends Group {
|
|
sharedByTenantName: string;
|
|
}
|
|
|
|
interface Route {
|
|
id: string;
|
|
sourceGroupId: string;
|
|
targetGroupId: string;
|
|
sourceGroup: { name: string };
|
|
targetGroup: { name: string };
|
|
forwardFormat: 'THREADED' | 'DIGEST' | 'SUMMARY';
|
|
withAttachment: boolean;
|
|
frequency: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
|
|
approvalMode: 'MANUAL' | 'AUTO';
|
|
isActive: boolean;
|
|
assignedRules?: Array<{ tenantRule: { id: string; matchType: string; matchValue: string; ruleIntent: 'POSITIVE' | 'NEGATIVE' } }>;
|
|
}
|
|
|
|
type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string };
|
|
|
|
async function fetchJson<T>(path: string): Promise<FetchResult<T>> {
|
|
let res: Response;
|
|
try {
|
|
res = await apiFetch(path);
|
|
} catch (err) {
|
|
return { ok: false, status: 0, error: `API unreachable: ${(err as Error).message}` };
|
|
}
|
|
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 T };
|
|
}
|
|
|
|
export default async function GroupsPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ tab?: 'mine' | 'shared' }>;
|
|
}) {
|
|
const { tab: rawTab } = await searchParams;
|
|
const tab: 'mine' | 'shared' = rawTab === 'shared' ? 'shared' : 'mine';
|
|
const [groupsR, sharedR, routesR] = await Promise.all([
|
|
fetchJson<Group[]>('/groups'),
|
|
tab === 'shared' ? fetchJson<SharedGroup[]>('/groups/shared') : Promise.resolve(null),
|
|
fetchJson<Route[]>('/routes'),
|
|
]);
|
|
|
|
const groups = groupsR?.ok ? groupsR.data : [];
|
|
const shared = sharedR && sharedR.ok ? sharedR.data : [];
|
|
const routes = routesR?.ok ? routesR.data : [];
|
|
const errors = [groupsR, sharedR, routesR]
|
|
.filter((r): r is { ok: false; status: number; error: string } => !!r && !r.ok && r.status !== 401)
|
|
.map((e) => `${e.status === 0 ? 'API unreachable' : `API ${e.status}`}: ${e.error}`);
|
|
|
|
return (
|
|
<div className="max-w-3xl">
|
|
<h1 className="text-xl font-semibold mb-6">Groups & Routes</h1>
|
|
{errors.length > 0 && (
|
|
<div className="mb-4 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
|
<div className="font-medium mb-1">Failed to load some data</div>
|
|
<ul className="list-disc pl-5 space-y-0.5">
|
|
{errors.map((e) => <li key={e}>{e}</li>)}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
<GroupsTabs current={tab} counts={{ mine: groups.length, shared: shared.length }} />
|
|
{tab === 'shared' && (
|
|
<div className="mt-4">
|
|
<h2 className="text-sm font-medium mb-2">Shared with me</h2>
|
|
<p className="text-xs text-gray-500 mb-3">
|
|
Groups other tenants have shared with you. You can use them as TARGET groups in your routes.
|
|
</p>
|
|
{shared.length === 0 ? (
|
|
<p className="text-sm text-gray-400 italic">No groups shared with you yet.</p>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{shared.map((g) => (
|
|
<li key={g.id} className="border border-gray-200 rounded bg-white px-4 py-3 flex items-center justify-between">
|
|
<div>
|
|
<span className="text-sm font-medium">{g.name}</span>
|
|
{!g.isActive && <span className="ml-2 text-xs text-red-500 font-medium">(Bot removed)</span>}
|
|
<span className="text-xs text-gray-400 ml-2">shared by {g.sharedByTenantName}</span>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
{tab === 'mine' && (
|
|
<div className="mt-4">
|
|
<RouteManager groups={groups} initialRoutes={routes} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|