39ca91f07a
Adds the AI-driven content pipeline so portal content is never manually created — the system proposes it, admins review and approve. Worker: - `tower.event.extract` queue + Event Extractor processor: LLM extracts event title/date/time/location from messages with #event hashtag, date/time patterns, RSVP phrases, or location terms; seva action items within event messages become separate SEVA_TASK drafts - `tower.faq.candidate` queue + FAQ Curator processor: LLM extracts Q&A pairs from messages with question patterns + meaningful answer length - `classify.processor.ts`: `isEventCandidate()` and `isFaqCandidate()` run after rule matching on all non-spam, non-DNC messages; candidates are enqueued to the extraction lanes in parallel with normal routing - `match-rules.ts`: add P1 to TenantRuleRow action union so P1 rules are fully typed end-to-end - `NormalizedMessage`: add `quotedPlatformMsgId` field - `IngestJobData.effectiveAction`: add `'P1'` to the union Schema: - `ContentDraft` model with `DraftType` (EVENT|SEVA_TASK|FAQ_ITEM) and `DraftStatus` (PENDING_REVIEW|APPROVED|REJECTED); carries `proposedJson` from the LLM, `confidence` score, and `publishedId` after approval - Migration: `20260617270000_add_content_drafts` API: - `DraftsModule`: `GET /admin/drafts`, `POST /admin/drafts/:id/approve`, `POST /admin/drafts/:id/reject`; approve creates the actual Event/SevaOpportunity/KnowledgeItem and marks the draft APPROVED; reject marks it REJECTED — both audit-logged Web: - `/admin/drafts` and `/drafts` — tabbed view by type (All/Events/Seva/FAQ) - `DraftCard` — shows source message excerpt, AI proposed content, and approve/reject buttons; approve/reject immediately refresh the list - "AI Drafts" added to both super-admin and tenant-admin sidebars - "AI Content Drafts" button added to admin dashboard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
131 lines
4.6 KiB
TypeScript
131 lines
4.6 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { usePathname, useRouter } from 'next/navigation';
|
|
import { useEffect, useState } from 'react';
|
|
import { useSuperAdmin } from './super-admin-context';
|
|
import { useAuth } from './auth-context';
|
|
|
|
const NAV_LINKS = [
|
|
{ href: '/search', label: 'Search' },
|
|
{ href: '/groups', label: 'Groups & Routes' },
|
|
{ href: '/messages/pending', label: 'Pending messages' },
|
|
{ href: '/drafts', label: 'AI Drafts' },
|
|
{ href: '/settings/rules', label: 'Rules' },
|
|
{ href: '/settings/bot', label: 'Bot' },
|
|
];
|
|
|
|
const SUPER_ADMIN_LINKS = [
|
|
{ href: '/admin', label: 'Dashboard' },
|
|
{ href: '/admin/tenants', label: 'Tenants' },
|
|
{ href: '/admin/bots', label: 'Bot Pool' },
|
|
{ href: '/admin/drafts', label: 'AI Drafts' },
|
|
];
|
|
|
|
const PUBLIC_PATHS = ['/login', '/signup', '/onboard'];
|
|
const ADMIN_PATHS = ['/admin'];
|
|
const MEMBER_PATHS = ['/my'];
|
|
|
|
export function Sidebar() {
|
|
const { admin, loading, logout } = useAuth();
|
|
const { admin: superAdmin, logout: superLogout } = useSuperAdmin();
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const [pendingCount, setPendingCount] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetch('/api/messages/pending/count')
|
|
.then((r) => r.ok ? r.json() : null)
|
|
.then((data) => setPendingCount(data?.count ?? null))
|
|
.catch(() => setPendingCount(null));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return;
|
|
if (ADMIN_PATHS.some((p) => pathname.startsWith(p))) return;
|
|
if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) return;
|
|
if (!admin) {
|
|
router.replace(`/login?next=${encodeURIComponent(pathname)}`);
|
|
}
|
|
}, [loading, admin, pathname, router]);
|
|
|
|
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
|
|
return (
|
|
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4">
|
|
<span className="font-bold text-base">TOWER</span>
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
if (ADMIN_PATHS.some((p) => pathname.startsWith(p))) {
|
|
return (
|
|
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col">
|
|
<span className="font-bold text-base mb-4">TOWER Admin</span>
|
|
<div className="flex flex-col gap-1 flex-1">
|
|
{SUPER_ADMIN_LINKS.map((link) => (
|
|
<Link key={link.href} href={link.href} className="rounded px-3 py-2 text-sm hover:bg-gray-100">
|
|
{link.label}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
<div className="border-t border-gray-200 pt-3 mt-3 flex flex-col gap-2">
|
|
<div className="px-3 text-xs text-gray-500">
|
|
<div className="font-medium text-gray-700 truncate">{superAdmin?.email}</div>
|
|
<div className="uppercase tracking-wide">Super Admin</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void superLogout()}
|
|
className="text-left rounded px-3 py-2 text-sm hover:bg-gray-100"
|
|
>
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col">
|
|
<span className="font-bold text-base mb-4">TOWER</span>
|
|
<div className="flex flex-col gap-1 flex-1">
|
|
{NAV_LINKS.map((link) => (
|
|
<Link
|
|
key={link.href}
|
|
href={link.href}
|
|
className="rounded px-3 py-2 text-sm hover:bg-gray-100 flex items-center justify-between"
|
|
>
|
|
<span>{link.label}</span>
|
|
{link.href === '/messages/pending' && pendingCount !== null && pendingCount > 0 && (
|
|
<span className="bg-blue-600 text-white text-[11px] font-bold rounded-full w-5 h-5 flex items-center justify-center">
|
|
{pendingCount > 99 ? '99+' : pendingCount}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
{admin && (
|
|
<div className="border-t border-gray-200 pt-3 mt-3 flex flex-col gap-2">
|
|
<div className="px-3 text-xs text-gray-500">
|
|
<div className="font-medium text-gray-700 truncate">{admin.name ?? admin.email}</div>
|
|
<div className="truncate">{admin.tenantName}</div>
|
|
<div className="uppercase tracking-wide">{admin.role}</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void logout()}
|
|
className="text-left rounded px-3 py-2 text-sm hover:bg-gray-100"
|
|
>
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
)}
|
|
</nav>
|
|
);
|
|
}
|