Skip to content

fix: notify on expired anonymisation requests via a daily sweep - #415

Merged
haksungjang merged 1 commit into
mainfrom
worktree-agent-ae3342dda56b6391e
Sep 6, 2026
Merged

fix: notify on expired anonymisation requests via a daily sweep#415
haksungjang merged 1 commit into
mainfrom
worktree-agent-ae3342dda56b6391e

Conversation

@haksungjang

@haksungjang haksungjang commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #383.

  • services.user_anonymisation_service._expire_stale_for only expires a stale pending anonymisation request lazily, from inside open_request/approve, scoped to the one subject that call touches. A subject nobody revisits stays pending past its window forever, with nobody told.
  • Adds a daily Celery beat (tasks.anonymisation_expiry_sweep, 04:00 UTC) that sweeps every subject, expires stale pending rows, and notifies the requester, the approver if the row carries one, and every active super-admin.
  • The sweep and the lazy path now share one sync query/mutate core (_select_and_expire_due) so the expiry predicate cannot drift between them. The lazy per-subject wrapper (_expire_stale_for) is unchanged in behavior.
  • The beat owns its own session and explicitly commits the expiry before enqueuing notifications (CLAUDE.md hardening rule 6 — this is the same defect class as ER32).

Notification kind

Notification.kind is a closed Postgres enum mirrored by a schema Literal, a shared JSON fixture, and a frontend array, with contract tests pinning all four together. Adding a fifth value needs an additive migration and a frontend PR to keep both contract-test suites green. Since this fix is scoped to the backend, the sweep reuses the existing approval_pending kind, which has been valid since the first notification migration but has never had a producer — the frontend already ships a label ("Approval pending" / "승인 대기"), icon and tone for it, unexercised until now. The recipient-facing title/body (not the kind column) say "expired" explicitly. See the module docstring in tasks/anonymisation_expiry_sweep.py for the full reasoning; a follow-up PR can give this its own kind end-to-end (model + schema + fixture + frontend together).

Test plan

  • ruff check apps/backend — clean
  • mypy apps/backend — clean (947 source files)
  • New unit tests (tests/unit/tasks/test_anonymisation_expiry_sweep.py) — recipient dedup, notification content, enqueue fan-out, task entry point delegation and never-raises behavior
  • New integration tests (tests/integration/test_anonymisation_expiry_sweep.py, real Postgres) — the task actually commits the expiry (re-read from a separate session), notifies requester + super-admins, dedupes a requester who is also a super-admin, never touches an approved request, leaves a fresh pending request alone, is idempotent on immediate rerun, and a regression guard that the lazy per-subject path still uses the shared predicate
  • Existing test_anonymise_user_command.py / test_user_anonymisation_api.py / test_catalog_contracts.py / test_celery_app.py — all still pass unchanged
  • Combined coverage on the touched modules: 98% (gate is 80%)
  • node tools/em-dash/lint.mjs — clean

@haksungjang
haksungjang force-pushed the worktree-agent-ae3342dda56b6391e branch from 3e161c3 to dd640b8 Compare September 6, 2026 16:18
A pending two-person erasure request expires past its seven-day window,
but the only code that applied that expiry ran lazily from inside
open_request/approve, scoped to one subject. A subject nobody revisited
stayed pending forever with nobody told.

Add a daily Celery beat that expires every stale pending request
regardless of subject and notifies the requester, the approver if any,
and every active super-admin. The query/mutate core is shared with the
existing lazy per-subject path (services.user_anonymisation_service
._select_and_expire_due) so the two predicates cannot drift apart. The
beat owns and commits its own session before enqueuing notifications,
so a broker hiccup can only cost a notification, never the expiry.

The notification reuses the existing "approval_pending" enum value
(never emitted until now) rather than adding a new notification_kind:
that enum is mirrored across a schema Literal, a shared JSON fixture and
a frontend array with contract tests pinning all of them together, and
this fix is scoped to the backend.
@haksungjang
haksungjang force-pushed the worktree-agent-ae3342dda56b6391e branch from dd640b8 to 52d04ff Compare September 6, 2026 21:06
@haksungjang
haksungjang merged commit c14bb8e into main Sep 6, 2026
25 checks passed
@haksungjang
haksungjang deleted the worktree-agent-ae3342dda56b6391e branch September 6, 2026 21:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expired anonymisation requests notify no one

1 participant