P0 Critical (7): - STOP/UNSUBSCRIBE keyword → CANCEL (PECR compliance) - Rate limiting on pledge creation (10/IP/5min) - Terms of Service + Privacy Policy pages - WhatsApp onboarding gate (persistent dashboard banner) - Demo account seeding (demo@pnpl.app) - Footer legal links - Basic accessibility (aria labels on donor flow) P1 Within 2 Weeks (8): - Pledge editing by staff (PATCH amount, name, email, phone, rail) - Donor self-cancel page (/p/cancel) + API - Donor 'My Pledges' lookup page (/p/my-pledges) - Bulk QR code download (print-ready HTML) - Public event progress bar (/e/[slug]/progress) - Email-only donor handling (honest status + WhatsApp fallback) - Email verification (format + disposable domain blocking) - Organisations page rewrite (multi-campaign, not multi-org) P2 Within First Month (10): - Event cloning with QR sources - Account deletion (GDPR Article 17) - Daily digest cron via WhatsApp - AI-6 Smart reminder timing (due date anchoring, cultural sensitivity) - H1 Duplicate donor detection (email, phone, Jaro-Winkler name) - H5 Bank CSV format presets (10 UK banks) - H16 Partial payment matching (underpay, overpay, instalment) - H10 Activity logging (audit trail for staff actions) - AI nudge endpoint + AI column mapping + AI event setup wizard - AI anomaly detection wired into daily digest AI Features (11): smart reconciliation, social proof, auto column mapper, daily digest, impact storyteller, smart timing, nudge composer, event wizard, NLU concierge, anomaly detection, bank presets 22 new files, 15 modified files, 0 TypeScript errors, clean build.
97 lines
2.4 KiB
TypeScript
97 lines
2.4 KiB
TypeScript
/**
|
|
* Activity log utility — records staff actions for audit trail (H10)
|
|
*
|
|
* Uses Prisma's AnalyticsEvent table as a lightweight activity store.
|
|
* Each entry records WHO did WHAT to WHICH entity and WHEN.
|
|
*/
|
|
|
|
import prisma from "@/lib/prisma"
|
|
import type { ActivityAction } from "@/lib/ai"
|
|
|
|
interface LogActivityInput {
|
|
action: ActivityAction
|
|
entityType: string
|
|
entityId?: string
|
|
orgId: string
|
|
userId?: string
|
|
userName?: string
|
|
metadata?: Record<string, unknown>
|
|
}
|
|
|
|
/**
|
|
* Log an activity entry. Non-blocking — errors are silently swallowed.
|
|
*/
|
|
export async function logActivity(input: LogActivityInput): Promise<void> {
|
|
if (!prisma) return
|
|
|
|
try {
|
|
await prisma.analyticsEvent.create({
|
|
data: {
|
|
eventType: `activity.${input.action}`,
|
|
eventId: input.entityType === "event" ? input.entityId : undefined,
|
|
pledgeId: input.entityType === "pledge" ? input.entityId : undefined,
|
|
metadata: {
|
|
action: input.action,
|
|
entityType: input.entityType,
|
|
entityId: input.entityId,
|
|
orgId: input.orgId,
|
|
userId: input.userId,
|
|
userName: input.userName,
|
|
...input.metadata,
|
|
},
|
|
},
|
|
})
|
|
} catch (err) {
|
|
// Activity logging should never break the main flow
|
|
console.error("[activity-log] Failed to write:", err)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Query activity log for an org. Returns most recent entries.
|
|
*/
|
|
export async function getActivityLog(
|
|
orgId: string,
|
|
options: { limit?: number; entityType?: string; entityId?: string } = {}
|
|
) {
|
|
if (!prisma) return []
|
|
|
|
const { limit = 50, entityType, entityId } = options
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const where: any = {
|
|
eventType: { startsWith: "activity." },
|
|
}
|
|
|
|
if (entityId) {
|
|
where.OR = [
|
|
{ eventId: entityId },
|
|
{ pledgeId: entityId },
|
|
]
|
|
}
|
|
|
|
if (entityType) {
|
|
where.eventType = { startsWith: `activity.${entityType}.` }
|
|
}
|
|
|
|
const entries = await prisma.analyticsEvent.findMany({
|
|
where,
|
|
orderBy: { createdAt: "desc" },
|
|
take: limit,
|
|
})
|
|
|
|
return entries.map(e => {
|
|
const meta = e.metadata as Record<string, unknown> || {}
|
|
return {
|
|
id: e.id,
|
|
action: meta.action as string,
|
|
entityType: meta.entityType as string,
|
|
entityId: meta.entityId as string,
|
|
userId: meta.userId as string,
|
|
userName: meta.userName as string,
|
|
metadata: meta,
|
|
timestamp: e.createdAt,
|
|
}
|
|
})
|
|
}
|