Files
tower/apps/web/app/_lib/sidebar.tsx
T
maaz519 39ca91f07a feat: auto-detect events, seva tasks, and FAQs from WhatsApp messages
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>
2026-06-17 17:49:57 +05:30

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