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>
This commit is contained in:
2026-06-17 17:49:57 +05:30
parent 3e86e0ffc0
commit 39ca91f07a
24 changed files with 989 additions and 11 deletions
+2
View File
@@ -23,6 +23,7 @@ import { SevaModule } from './modules/seva/seva.module';
import { CirclesModule } from './modules/circles/circles.module';
import { KnowledgeModule } from './modules/knowledge/knowledge.module';
import { GalleryModule } from './modules/gallery/gallery.module';
import { DraftsModule } from './modules/drafts/drafts.module';
@Module({
imports: [
@@ -50,6 +51,7 @@ import { GalleryModule } from './modules/gallery/gallery.module';
CirclesModule,
KnowledgeModule,
GalleryModule,
DraftsModule,
],
})
export class AppModule {}
@@ -46,6 +46,8 @@ export const AuditAction = {
SEVA_COMPLETED: 'SEVA_COMPLETED',
CIRCLE_CREATED: 'CIRCLE_CREATED',
CIRCLE_DELETED: 'CIRCLE_DELETED',
DRAFT_APPROVED: 'DRAFT_APPROVED',
DRAFT_REJECTED: 'DRAFT_REJECTED',
} as const;
export type AuditActionValue = (typeof AuditAction)[keyof typeof AuditAction];
@@ -0,0 +1,29 @@
import { Controller, Get, Post, Param, Query, UseGuards, Request } from '@nestjs/common';
import { DraftsService } from './drafts.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { DraftStatus, DraftType } from '@prisma/client';
@Controller('admin/drafts')
@UseGuards(JwtAuthGuard)
export class DraftsController {
constructor(private readonly service: DraftsService) {}
@Get()
list(
@Request() req: any,
@Query('type') type?: DraftType,
@Query('status') status?: DraftStatus,
) {
return this.service.list(req.user.tenantId, type, status ?? 'PENDING_REVIEW');
}
@Post(':id/approve')
approve(@Param('id') id: string, @Request() req: any) {
return this.service.approve(id, req.user.tenantId, req.user.sub);
}
@Post(':id/reject')
reject(@Param('id') id: string, @Request() req: any) {
return this.service.reject(id, req.user.tenantId, req.user.sub);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DraftsController } from './drafts.controller';
import { DraftsService } from './drafts.service';
import { AuthModule } from '../auth/auth.module';
import { AuditModule } from '../audit/audit.module';
@Module({
imports: [AuthModule, AuditModule],
controllers: [DraftsController],
providers: [DraftsService],
})
export class DraftsModule {}
@@ -0,0 +1,151 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { DraftStatus, DraftType } from '@prisma/client';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
export interface ApproveDraftResult {
publishedId: string;
type: DraftType;
}
@Injectable()
export class DraftsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
list(tenantId: string, type?: DraftType, status: DraftStatus = 'PENDING_REVIEW') {
return this.prisma.contentDraft.findMany({
where: { tenantId, ...(type ? { type } : {}), status },
include: {
sourceMessage: {
select: { id: true, content: true, senderName: true, createdAt: true },
},
},
orderBy: { createdAt: 'desc' },
take: 100,
});
}
async approve(draftId: string, tenantId: string, adminId: string): Promise<ApproveDraftResult> {
const draft = await this.prisma.contentDraft.findUnique({ where: { id: draftId } });
if (!draft || draft.tenantId !== tenantId) throw new NotFoundException('Draft not found');
if (draft.status !== 'PENDING_REVIEW') {
throw new BadRequestException('Draft has already been reviewed');
}
const proposed = draft.proposedJson as Record<string, any>;
let publishedId: string;
if (draft.type === 'EVENT') {
const startsAt = proposed.date
? new Date(`${proposed.date}T${proposed.time ?? '00:00'}:00`)
: new Date();
const event = await this.prisma.event.create({
data: {
tenantId,
title: proposed.title ?? 'Untitled Event',
description: proposed.description ?? null,
location: proposed.location ?? null,
startsAt,
createdBy: adminId,
isPublished: true,
},
});
publishedId = event.id;
} else if (draft.type === 'SEVA_TASK') {
const startsAt = proposed.date ? new Date(`${proposed.date}T00:00:00`) : undefined;
const seva = await this.prisma.sevaOpportunity.create({
data: {
tenantId,
title: proposed.title ?? 'Seva Opportunity',
description: proposed.parentEventTitle
? `Part of: ${proposed.parentEventTitle}`
: (proposed.description ?? null),
slots: proposed.slots ?? null,
startsAt: startsAt ?? null,
createdBy: adminId,
isPublished: true,
},
});
publishedId = seva.id;
} else {
// FAQ_ITEM — find or create a default board
let board = await this.prisma.knowledgeBoard.findFirst({
where: { tenantId },
orderBy: { sortOrder: 'asc' },
});
if (!board) {
board = await this.prisma.knowledgeBoard.create({
data: {
tenantId,
name: 'Community FAQs',
slug: 'community-faqs',
},
});
}
const item = await this.prisma.knowledgeItem.create({
data: {
boardId: board.id,
tenantId,
question: proposed.question ?? 'Untitled question',
answer: proposed.answer ?? '',
tags: proposed.tags ?? [],
status: 'PUBLISHED',
createdBy: adminId,
},
});
publishedId = item.id;
}
await this.prisma.contentDraft.update({
where: { id: draftId },
data: {
status: 'APPROVED',
reviewedBy: adminId,
reviewedAt: new Date(),
publishedId,
},
});
await this.audit.log({
tenantId,
actorType: 'ADMIN',
actorId: adminId,
action: AuditAction.DRAFT_APPROVED,
resourceType: 'ContentDraft',
resourceId: draftId,
payload: { type: draft.type, publishedId },
});
return { publishedId, type: draft.type };
}
async reject(draftId: string, tenantId: string, adminId: string): Promise<void> {
const draft = await this.prisma.contentDraft.findUnique({ where: { id: draftId } });
if (!draft || draft.tenantId !== tenantId) throw new NotFoundException('Draft not found');
if (draft.status !== 'PENDING_REVIEW') {
throw new BadRequestException('Draft has already been reviewed');
}
await this.prisma.contentDraft.update({
where: { id: draftId },
data: { status: 'REJECTED', reviewedBy: adminId, reviewedAt: new Date() },
});
await this.audit.log({
tenantId,
actorType: 'ADMIN',
actorId: adminId,
action: AuditAction.DRAFT_REJECTED,
resourceType: 'ContentDraft',
resourceId: draftId,
payload: { type: draft.type },
});
}
}