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:
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user