-- Deletion paths. -- -- Discord's developer policy requires deleting user data on request, and API -- registrants are an international user base whose data protection expectations -- apply regardless. Build this before you need it: retrofitting a deletion path -- across three stores is miserable, and being asked for one you do not have is -- worse. -- -- These functions cover Postgres. The caller must ALSO clear Redis - cached -- callsign lookups, rate-limit counters keyed by hashed Discord ID, and any -- verification challenge in flight. Postgres is not the whole story. -- --------------------------------------------------------------------------- -- Removes a Discord user and everything referencing them. The ON DELETE CASCADE -- chain reaches verifications, subscriptions, imports and qsos, so this is one -- statement rather than a list that can fall out of date as tables are added. -- -- Returns the number of QSOs removed, purely so the caller can tell the user -- what was deleted. CREATE FUNCTION core.forget_user(p_discord_id bigint) RETURNS TABLE (deleted_user boolean, deleted_qsos bigint) LANGUAGE plpgsql -- Pinned: the body qualifies its tables, but now(), count() and friends resolve -- through search_path. Matters more if this ever becomes SECURITY DEFINER. SET search_path = pg_catalog, core, logbook AS $$ DECLARE v_user_id bigint; v_qsos bigint := 0; BEGIN SELECT id INTO v_user_id FROM core.users WHERE discord_id = p_discord_id; IF v_user_id IS NULL THEN RETURN QUERY SELECT false, 0::bigint; RETURN; END IF; SELECT count(*) INTO v_qsos FROM logbook.qsos WHERE user_id = v_user_id; -- Cascades do the rest. DELETE FROM core.users WHERE id = v_user_id; RETURN QUERY SELECT true, v_qsos; END; $$; COMMENT ON FUNCTION core.forget_user(bigint) IS 'Hard-deletes a Discord user and all dependent rows. Caller must also purge Redis keys for this user.'; -- --------------------------------------------------------------------------- -- The API-side equivalent. Keys and usage rows cascade from the owner. CREATE FUNCTION api.forget_owner(p_email core.email) RETURNS TABLE (deleted_owner boolean, deleted_keys bigint) LANGUAGE plpgsql SET search_path = pg_catalog, core, api AS $$ DECLARE v_owner_id bigint; v_keys bigint := 0; BEGIN SELECT id INTO v_owner_id FROM api.owners WHERE email = p_email; IF v_owner_id IS NULL THEN RETURN QUERY SELECT false, 0::bigint; RETURN; END IF; SELECT count(*) INTO v_keys FROM api.keys WHERE owner_id = v_owner_id; DELETE FROM api.owners WHERE id = v_owner_id; RETURN QUERY SELECT true, v_keys; END; $$; -- --------------------------------------------------------------------------- -- Retention. Verification records carry a hashed challenge and a timestamp, and -- are useful for a short while when investigating a failed verification. They -- are not useful a year later. Run this from cron. CREATE FUNCTION core.prune_verifications(p_keep_days integer DEFAULT 90) RETURNS bigint LANGUAGE plpgsql SET search_path = pg_catalog, core AS $$ DECLARE v_removed bigint; BEGIN DELETE FROM core.verifications WHERE created_at < now() - make_interval(days => p_keep_days) AND status <> 'succeeded'; GET DIAGNOSTICS v_removed = ROW_COUNT; RETURN v_removed; END; $$; GRANT EXECUTE ON FUNCTION core.forget_user(bigint) TO hammy_app; GRANT EXECUTE ON FUNCTION api.forget_owner(core.email) TO hammy_app; GRANT EXECUTE ON FUNCTION core.prune_verifications(integer) TO hammy_app;