Skip to main content

ADR-004: Patró d'eliminació de recursos amb safeguards (preview + confirm-by-name)

CampValor
Data2026-04-26
EstatAccepted
DecisorsEquip LDP
Relacionat(cap dependència directa)

Context

Eliminar recursos centrals del catàleg (Dance, Song, i futurs Choreographer / Event) toca moltes taules amb FKs amb diferents accions (CASCADE, SET NULL, RESTRICT). Els riscos d'una eliminació mal pensada són grans:

  • Pèrdua silenciosa de dades curades: una DELETE sense informació pot esborrar associacions, links, fitxes tècniques (LineDanceSpec), històric de fusions, etc., en cascada, sense que l'admin sigui conscient.
  • Trencar la integritat de l'historial: si un recurs és el "survivor" d'una fusió (*_merge_history.survivor_id), eliminar-lo trenca la traçabilitat dels merges.
  • Trencar referències d'agenda real: events ja celebrats que tenen un ball al setlist no han de poder perdre la referència ni l'event silenciosament.
  • Eliminacions accidentals: un click descuidat amb un sol botó "Eliminar" pot ser destructiu i no recuperable.

A la sessió del 2026-04-26 es va plantejar la necessitat d'eliminar dades de proves de manera segura, sense renunciar a la integritat del sistema en producció.

Decisió

Tots els recursos centrals que necessitin esborrat dur (hard-delete) implementen el mateix patró de 3 capes:

1. Pre-flight check (preview)

Endpoint admin-only: GET /api/admin/{resource}/{id}/delete-preview.

Retorna un Delete{Resource}PreviewDto amb:

  • blockers: llista de raons que prohibeixen l'esborrat. Si conté algun element, l'esborrat ha de fallar a 409 Conflict.
  • willCascade: resum de files relacionades que es perdran o es desvincularan (counts per categoria).

Tipus de blockers definits actualment:

TipusQuè bloquejaPer què
EVENT_SETLISTDance: present a event_setlist_itemsEls events ja celebrats no han de perdre referències.
MERGE_SURVIVORDance/Song: és survivor_id a *_merge_historyTrencar la història de fusions invalida l'auditoria.
DANCE_USING_SONGSong: alguna fila a dance_songCoherència catalogada; si cal, primer cal arxivar/fusionar.

2. Confirmació pel nom exacte

Endpoint admin-only: DELETE /api/admin/{resource}/{id}?confirmationName={exactValue}.

El servei refusa amb ClaimException (codi *_DELETE_CONFIRMATION_MISMATCH) si el text rebut no coincideix amb el nom canònic del recurs:

  • Dance: dance.name exacte.
  • Song: "{title} — {artist}" (em-dash U+2014 entre títol i artista).
  • (Futurs) Choreographer / Event: definir el nom canònic en el moment d'implementar-se.

3. Safeguards al servei

Dins @Transactional, el servei:

  1. Valida el confirmationName.
  2. Crida internament previewDelete() i refusa si hi ha cap blocker (codi *_IN_USE).
  3. Loggeja INFO amb email admin + cascadeSummary per traçabilitat.
  4. Executa l'esborrat (la majoria de cascades es fan a nivell DB via constraints ON DELETE CASCADE/SET NULL).
  5. Si hi ha decisió específica (e.g. links orfes post-cascade per a Dance), neteja addicional dins la mateixa transacció.

4. Soft-delete com a alternativa preferent

Quan el recurs ho permet (té listing_status o equivalent), la UI sempre ofereix l'opció d'arxivar com a alternativa abans de l'esborrat dur.

  • Dance → soft-delete via PATCH /api/admin/dances/{id}/listing-status amb ARCHIVED. Reversible, preserva tot.
  • Song → soft-delete pendent (no té encara listing_status); a futur, afegir migració.

L'admin tria entre arxivar (preferit, no destructiu) i eliminar permanentment (destructiu, amb 2 passos de confirmació).

5. UX: modal en 2 passos

DeleteDanceModal / DeleteSongModal:

  1. Pas 1: mostra el resum del preview. Si hi ha blockers, només permet "Cancel·lar" / "Arxivar". Si no, ofereix "Continuar amb l'esborrat".
  2. Pas 2: input TextInput amb el nom canònic com a placeholder. El botó vermell "Eliminar permanentment" està desactivat fins que el text exactament coincideix.

Decisions aplicades concretes (Dance, 2026-04-26)

  • Els events que tinguin un dance al setlist mai permeten esborrar el dance (decisió A: dura).
  • Si un dance és survivor de merge, mai es permet esborrar (decisió B: dura).
  • Tant arxivar com eliminar dur estan disponibles a la UI (decisió C: les dues).
  • Els links que quedin orfes a links.dance_song_id post-CASCADE es netegen (decisió D: sí).

Decisions aplicades concretes (Song, 2026-04-26)

  • Songs en ús per algun dance NO es poden eliminar (constraint DB dance_song.song_id NO ACTION a V48 + check al servei).
  • Songs survivor de merge NO es poden eliminar.
  • Soft-delete pendent (Songs no té encara listing_status); s'ha deixat la porta oberta per a una futura migració.

Conseqüències

Positives

  • Sense pèrdua silenciosa: l'admin sempre veu què s'esborrarà abans d'executar.
  • Sense esborrats accidentals: cal un nom exacte per confirmar.
  • Auditoria preservada: la història de merges i events celebrats mai es trenca per error.
  • Patró replicable: els nous recursos eliminables (Choreographer, Event, Venue, Person, Organization...) poden seguir el mateix patró sense reinventar.
  • Frontend coherent: dos modals quasi idèntics (DeleteDanceModal, DeleteSongModal) amb la mateixa UX.

Negatives

  • Cost d'implementació per recurs: cada recurs eliminable necessita endpoint preview, count methods als repos, DTO de preview i modal. ~3-4 hores per recurs.
  • No 100% genèric: Dance i Song tenen DTOs separats (DeleteDancePreviewDto vs DeleteSongPreviewDto) perquè els camps de cascade summary són diferents. Si en algun moment apareix un tercer recurs amb estructura idèntica, refactoritzar a un DTO genèric és viable, però per ara els 2 separats són clars i petits.

Implementació de referència

RecursBackendFrontend
DanceAdminDanceService.previewDelete / deleteDance, AdminDanceController /delete-preview + DELETEDeleteDanceModal
SongSongService.previewDelete / delete, AdminSongController /delete-preview + DELETEDeleteSongModal

Notes per a futurs recursos

Quan s'implementi el patró per a un nou recurs:

  1. Mapejar totes les FKs que apunten a la taula del recurs (grep "REFERENCES <taula>" V*.sql a backend/src/main/resources/db/migration).
  2. Classificar cada FK: BLOCKER (RESTRICT/NO ACTION), CASCADE (silenciós), SET NULL (orfes).
  3. Decidir per cada CASCADE/SET NULL si cal mostrar el count al preview o si és prou irrellevant per amagar-lo.
  4. Afegir count methods als repos corresponents.
  5. Crear Delete{Resource}PreviewDto amb la mateixa estructura (blockers + willCascade).
  6. Crear el modal frontend copiant-ne un d'existent.
  7. Afegir ErrorCodes {RESOURCE}_IN_USE i {RESOURCE}_DELETE_CONFIRMATION_MISMATCH.
  8. Documentar les decisions específiques d'aquest recurs en aquest mateix ADR (afegint una nova secció).