Files
accounted/app/api/extensions/whatsapp-inbox/retention/cron/route.ts
T
bf5ca2c615 feat(whatsapp-inbox): GDPR retention cron and RoPA entry (#1341)
PR5b, the final code piece of the WhatsApp intake track. A daily cron
(04:15) enforces the channel's retention table; the receipt itself stays
7-year WORM under BFL and is never touched.

Retention actions (lib/retention.ts, each isolated and idempotent):
- whatsapp_messages transcripts past 90 days: body_text + raw_payload
  cleared in id batches under a wall-clock budget; the row skeleton
  (wamid, direction, timestamps, status, inbox_item_id) survives for
  audit. Only rows still carrying content match.
- Rows with phone_link_id IS NULL (unknown senders, orphans) past
  30 days: deleted.
- Link codes expired more than 24h ago: deleted, used or not.
- Sender rate counters idle 2+ days: deleted (minute/day window keys
  are dead weight after that).
- Links revoked 90+ days ago: phone_enc crypto-shredded to '' (column
  is NOT NULL), one-shot via neq guard; phone_hash and phone_masked
  kept for uniqueness history and audit display.

Route mirrors the sweep cron exactly: withCronContext + registry gate
(503 EXTENSION_DISABLED when the extension is off). vercel.json gets
the schedule and both Docker crontabs are regenerated.

Compliance: new whatsapp.receipt_intake activity in .compliance/ropa.yaml
covering purpose, Art 6(1)(b)/(c)/(f) bases with the Art 14(5)(b) note
for third-party attendee names, Meta Platforms Ireland as processor
(Cloud API, EU SCC addendum, Local Storage region DE), the differentiated
retention table, and security measures.

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:35:41 +02:00

49 lines
2.1 KiB
TypeScript

import { NextResponse } from 'next/server'
import { loadExtensions } from '@/lib/extensions/loader'
import { extensionRegistry } from '@/lib/extensions/registry'
import { withCronContext } from '@/lib/api/with-cron-context'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { runRetention } from '@/extensions/general/whatsapp-inbox/lib/retention'
/**
* GET /api/extensions/whatsapp-inbox/retention/cron: daily GDPR purge for
* the WhatsApp channel. Enforces the retention table in .compliance/ropa.yaml
* (whatsapp.receipt_intake): 90-day chat transcripts, 30-day unknown-sender
* rows, expired link codes, dead rate counters, and the 90-day crypto-shred
* of revoked phone bindings. Receipts themselves are 7-year WORM under BFL
* and are never touched here. Scheduled in vercel.json (and the generated
* Docker crontabs).
*
* Overlap with a slow previous run is safe: every predicate only matches
* rows still carrying the data, so a second pass is a no-op.
*/
// The transcript purge loops over a possibly large first-run backlog.
export const maxDuration = 300
export const GET = withCronContext('cron.whatsapp_retention', async (_request, ctx) => {
// Load the registry so it reflects extensions.config.json.
loadExtensions()
// Physical routes under app/api/extensions/<id>/ compile into EVERY build,
// including the core-with-zero-extensions one: the registry (generated from
// extensions.config.json) is what actually switches an extension on. Mirror
// the ext/[...path] dispatcher: a disabled extension must not expose a live
// surface, and a scheduled-but-disabled cron must fail visibly (503)
// instead of quietly doing the work anyway.
if (!extensionRegistry.get('whatsapp-inbox')) {
ctx.log.warn('whatsapp-inbox extension is not enabled; cron refused')
return NextResponse.json(
{ error: 'WhatsApp inbox extension is not enabled', code: 'EXTENSION_DISABLED' },
{ status: 503 },
)
}
const supabase = createServiceClientNoCookies()
const summary = await runRetention(supabase)
ctx.log.info('whatsapp retention complete', { ...summary })
return NextResponse.json({ data: summary })
})