Files
hope-events/backend/prisma/migrations/20261023154520_popia_privacy_centre/migration.sql
T
joshuaandClaude Opus 5.5 594cb35a85 POPIA toolkit part 1: privacy page, unsubscribe, WhatsApp STOP, data requests
- Privacy page: news opt-in per channel with history, download my data
  (JSON + PDF), correction and deletion requests
- Bulk messages are an Event update or News & announcements; news only
  reaches people who opted in, with unsubscribe links, one-click
  List-Unsubscribe headers and "Reply STOP" on WhatsApp
- WhatsApp STOP via WAWP's message webhook, checked against a secret in
  the webhook URL
- Sign-up opt-in box; existing accounts kept opted in and asked once
- Admin -> Data requests with 30-day due dates and a daily reminder job
- Deleting an account is a request; anonymising now covers attendees,
  form answers, consent details, payer details in provider payloads, etc.
- Unit tests (privacy.test.js) and e2e (privacy.spec.ts); docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-23 18:13:47 +02:00

103 lines
3.8 KiB
SQL

-- CreateEnum
CREATE TYPE "DataRequestType" AS ENUM ('access', 'correction', 'deletion');
-- CreateEnum
CREATE TYPE "DataRequestStatus" AS ENUM ('open', 'completed', 'rejected', 'cancelled');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'data_request_completed';
ALTER TYPE "AdminAuditAction" ADD VALUE 'data_request_rejected';
ALTER TYPE "AdminAuditAction" ADD VALUE 'user_anonymised';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "newsAskedAt" TIMESTAMP(3),
ADD COLUMN "newsEmail" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "newsWhatsApp" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "CommunicationConsent" (
"id" TEXT NOT NULL,
"userId" TEXT,
"address" TEXT,
"channel" TEXT NOT NULL,
"granted" BOOLEAN NOT NULL,
"source" TEXT NOT NULL,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CommunicationConsent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MessagingOptOut" (
"id" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"address" TEXT NOT NULL,
"source" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MessagingOptOut_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DataRequest" (
"id" TEXT NOT NULL,
"type" "DataRequestType" NOT NULL,
"status" "DataRequestStatus" NOT NULL DEFAULT 'open',
"userId" TEXT,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"details" TEXT,
"response" TEXT,
"selfService" BOOLEAN NOT NULL DEFAULT false,
"dueAt" TIMESTAMP(3) NOT NULL,
"closedAt" TIMESTAMP(3),
"handledById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DataRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CommunicationConsent_userId_createdAt_idx" ON "CommunicationConsent"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "CommunicationConsent_address_idx" ON "CommunicationConsent"("address");
-- CreateIndex
CREATE UNIQUE INDEX "MessagingOptOut_channel_address_key" ON "MessagingOptOut"("channel", "address");
-- CreateIndex
CREATE INDEX "DataRequest_status_dueAt_idx" ON "DataRequest"("status", "dueAt");
-- CreateIndex
CREATE INDEX "DataRequest_userId_idx" ON "DataRequest"("userId");
-- AddForeignKey
ALTER TABLE "CommunicationConsent" ADD CONSTRAINT "CommunicationConsent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DataRequest" ADD CONSTRAINT "DataRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DataRequest" ADD CONSTRAINT "DataRequest_handledById_fkey" FOREIGN KEY ("handledById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Existing accounts: kept opted in to news (WhatsApp only when they have a phone number),
-- logged as "existing_account", and asked once at their next login (newsAskedAt stays null).
UPDATE "User" SET "newsEmail" = true, "newsWhatsApp" = ("phoneNumber" IS NOT NULL)
WHERE "isActive" = true AND "email" NOT LIKE '%@deleted.invalid' AND "email" NOT LIKE '%@deleted.local' AND "email" NOT LIKE '%@guest.local';
INSERT INTO "CommunicationConsent" ("id", "userId", "channel", "granted", "source")
SELECT gen_random_uuid()::text, "id", 'email', true, 'existing_account' FROM "User" WHERE "newsEmail" = true;
INSERT INTO "CommunicationConsent" ("id", "userId", "channel", "granted", "source")
SELECT gen_random_uuid()::text, "id", 'whatsapp', true, 'existing_account' FROM "User" WHERE "newsWhatsApp" = true;