Location soft-delete: archiving replaces destructive deletion (#134) #169

Merged
claude-bot merged 3 commits from feat/location-soft-delete into main 2026-08-01 04:24:58 +00:00
Contributor

Closes #134. Decision and rationale recorded on the issue: soft-delete over detach-history — owner_id and location_members survive, so the three-tier visibility model keeps working unchanged for historical events (the part the issue flagged as easiest to get wrong under detach).

Behavior

  • DELETE /api/locations/{id} now archives: sets deleted_at, forces enabled/public_enabled off, revokes live public tokens, keeps every history row. Idempotent (double-archive is a no-op 204).
  • ?purge=true (admin-only, API-only) keeps the old hard delete + cascade for genuine full removal.
  • GET /api/locations/{id}/delete-preview returns retained-history counts; the locations page delete button now shows them in the confirm dialog ("history stays available — N alerts, N events, N snapshots").
  • Config: export excludes archived; replace=true archives instead of destroying; re-importing an archived location revives it (the only revival path — see below).
  • Historical reads keep working for archived locations with unchanged visibility (alert history API now documents this; the Explorer will rely on it). Migration 0035 adds the column.

The audit

Every location-query site in the codebase got an explicit disposition (full table in the implementation commit): active surfaces are covered either by existing enabled == True filters (which archive forces off) or new deleted_at guards; per-location interactive/fetching endpoints 404 on archived; the correlation full rebuild and the AI-summary admin page deliberately include archived locations (history must keep deriving/rendering); plain metadata GET /api/locations/{id} stays readable so the Explorer can resolve an archived location's name/coords.

Review pass (three fixes over the initial implementation)

  1. PATCH and snooze/unsnooze now 404 on archived locations. An edit setting enabled=True on an archived row would create a zombie — polled by background jobs (which filter on enabled) while hidden from every list (which filter on deleted_at). Revival stays deliberate-only via config import.
  2. Archiving a public location revokes its live /p/{token} links (mirrors the existing PATCH public→private transition) instead of leaving them serving until retention expiry.
  3. Delete-button name escaping: the name was interpolated into a single-quoted JS string; Jinja renders ' as ', which the HTML parser decodes back into the JS source — any location name containing an apostrophe ("Aleesha's Home") broke the handler. Now passed via data attributes.

Verification

  • Full bulk suite on the dev server: 900 passed, including 15 new soft-delete tests (archive preserves history, list exclusion, preview counts, history visibility after archive, interactive-endpoint 404s, purge admin/non-admin, import replace/revival, PATCH/snooze guards, token revocation).
  • One pre-existing test updated: the replace-import test asserted the removed location's row was gone — precisely the data-loss behavior this PR removes.
  • A @pytest.mark.postgres test verifies purge's FK cascade under real enforcement (SQLite doesn't enforce ON DELETE CASCADE); CI's Postgres tier runs it plus migration 0035.
  • ruff clean; alembic heads0035 sole head.

🤖 Generated with Claude Code

Closes #134. Decision and rationale recorded on the issue: **soft-delete** over detach-history — `owner_id` and `location_members` survive, so the three-tier visibility model keeps working unchanged for historical events (the part the issue flagged as easiest to get wrong under detach). ## Behavior - `DELETE /api/locations/{id}` now **archives**: sets `deleted_at`, forces `enabled`/`public_enabled` off, revokes live public tokens, keeps every history row. Idempotent (double-archive is a no-op 204). - `?purge=true` (admin-only, API-only) keeps the old hard delete + cascade for genuine full removal. - `GET /api/locations/{id}/delete-preview` returns retained-history counts; the locations page delete button now shows them in the confirm dialog ("history stays available — N alerts, N events, N snapshots"). - Config: export excludes archived; `replace=true` archives instead of destroying; re-importing an archived location revives it (the only revival path — see below). - Historical reads keep working for archived locations with unchanged visibility (alert history API now documents this; the Explorer will rely on it). Migration `0035` adds the column. ## The audit Every location-query site in the codebase got an explicit disposition (full table in the implementation commit): active surfaces are covered either by existing `enabled == True` filters (which archive forces off) or new `deleted_at` guards; per-location interactive/fetching endpoints 404 on archived; the correlation full rebuild and the AI-summary admin page **deliberately include** archived locations (history must keep deriving/rendering); plain metadata `GET /api/locations/{id}` stays readable so the Explorer can resolve an archived location's name/coords. ## Review pass (three fixes over the initial implementation) 1. **`PATCH` and snooze/unsnooze now 404 on archived locations.** An edit setting `enabled=True` on an archived row would create a zombie — polled by background jobs (which filter on `enabled`) while hidden from every list (which filter on `deleted_at`). Revival stays deliberate-only via config import. 2. **Archiving a public location revokes its live `/p/{token}` links** (mirrors the existing PATCH public→private transition) instead of leaving them serving until retention expiry. 3. **Delete-button name escaping**: the name was interpolated into a single-quoted JS string; Jinja renders `'` as `'`, which the HTML parser decodes back into the JS source — any location name containing an apostrophe ("Aleesha's Home") broke the handler. Now passed via data attributes. ## Verification - Full bulk suite on the dev server: **900 passed**, including 15 new soft-delete tests (archive preserves history, list exclusion, preview counts, history visibility after archive, interactive-endpoint 404s, purge admin/non-admin, import replace/revival, PATCH/snooze guards, token revocation). - One pre-existing test updated: the replace-import test asserted the removed location's row was *gone* — precisely the data-loss behavior this PR removes. - A `@pytest.mark.postgres` test verifies purge's FK cascade under real enforcement (SQLite doesn't enforce `ON DELETE CASCADE`); CI's Postgres tier runs it plus migration 0035. - `ruff` clean; `alembic heads` → `0035` sole head. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
DELETE /api/locations/{id} now archives (sets deleted_at, forces
enabled/public_enabled False) instead of hard-deleting: Location.sent_alerts
cascades all,delete-orphan and the history tables (weather_events,
nws_alert_snapshots, ...) cascade on location_id, so a hard delete silently
destroyed the whole history the v2.0.0 Explorer exists to show. A privacy-style
hard delete is still available via admin-only purge=true. Active-surface
queries (list, dashboard, scheduler polling) exclude soft-deleted locations;
history reads (alert history, event-correlation rebuild) deliberately do not.
Config export/import now excludes/revives soft-deleted locations and
replace=true soft-deletes rather than hard-deletes absent locations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- PATCH and snooze/unsnooze now 404 on an archived location: an edit
  setting enabled=True would create a zombie — polled by background jobs
  (which filter on enabled) while hidden from every list (which filter on
  deleted_at). Revival stays deliberate-only, via config import.
- Soft-delete of a public location revokes its live /p/{token} links
  (mirrors the PATCH public->private transition) instead of leaving them
  serving until retention expiry.
- The delete button passed the location name through a single-quoted JS
  string; Jinja renders an apostrophe as &#39; which the HTML parser
  decodes back into the JS source — any name containing one broke the
  handler. Now passed via data attributes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Update replace-import test for soft-delete semantics (#134)
All checks were successful
CI / test (pull_request) Successful in 4m27s
3ebcd0cd0e
replace=true now archives absent locations instead of hard-deleting them;
the test asserted the row was gone, which is precisely the data-loss
behavior #134 removes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
claude-bot deleted branch feat/location-soft-delete 2026-08-01 04:24:58 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/WeatherBot!169
No description provided.