From d49a9e6ca211a43498292de71940fec4927216e1 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 29 Oct 2026 11:57:20 -0400 Subject: [PATCH] fix(devices): device deletion detaches reading history instead of 500ing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting any device whose stats rows still referenced it failed with 500: reading_history.device_id is a NO-ACTION FK by design (the rows are real captured reading stats — Users reads them), so a blind DELETE violated the constraint. Surfaced by the Android app's new logout deregister call, but the admin Devices page Deletes hit the same stone. Fix: DetachReadingHistoryFromDevice — NULL the device association on the user's history rows before the device DELETE (single handler pass). device_id is nullable; the stats survive, the device frees its identifier. sqlc regenerated. --- internal/database/querier.go | 6 ++++++ internal/database/queries.sql.go | 14 ++++++++++++++ internal/database/queries/queries.sql | 8 ++++++++ internal/handlers/devices.go | 6 ++++++ 4 files changed, 34 insertions(+) diff --git a/internal/database/querier.go b/internal/database/querier.go index f6d174d4..23a2be96 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -171,6 +171,12 @@ type Querier interface { DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error DeleteUser(ctx context.Context, id pgtype.UUID) error DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error + // Device deletion (deregister on logout): detach the device's reading + // history instead of deleting it — these rows are real captured stats + // (the Users section reads them); reading_history.device_id is NO ACTION + // so a blind device delete 500s once any history anchors to the device. + // device_id is nullable: NULL keeps the session, loses the association. + DetachReadingHistoryFromDevice(ctx context.Context, deviceID pgtype.UUID) error // Device existence probe for capture: the web/app progress paths // historically stuff the user id into DeviceID (it only fed a broadcast // label before stats); a capture row must only reference a REAL device. diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 2aba58bd..e332857e 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -2362,6 +2362,20 @@ func (q *Queries) DeleteUserSystemCollection(ctx context.Context, arg DeleteUser return err } +const DetachReadingHistoryFromDevice = `-- name: DetachReadingHistoryFromDevice :exec +UPDATE reading_history SET device_id = NULL WHERE device_id = $1 +` + +// Device deletion (deregister on logout): detach the device's reading +// history instead of deleting it — these rows are real captured stats +// (the Users section reads them); reading_history.device_id is NO ACTION +// so a blind device delete 500s once any history anchors to the device. +// device_id is nullable: NULL keeps the session, loses the association. +func (q *Queries) DetachReadingHistoryFromDevice(ctx context.Context, deviceID pgtype.UUID) error { + _, err := q.db.Exec(ctx, DetachReadingHistoryFromDevice, deviceID) + return err +} + const DeviceExistsForUser = `-- name: DeviceExistsForUser :one SELECT EXISTS( SELECT 1 FROM devices diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index cb2cd98d..19baf96e 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -2809,6 +2809,14 @@ SET updated_at = NOW() WHERE id = $1; +-- name: DetachReadingHistoryFromDevice :exec +-- Device deletion (deregister on logout): detach the device's reading +-- history instead of deleting it — these rows are real captured stats +-- (the Users section reads them); reading_history.device_id is NO ACTION +-- so a blind device delete 500s once any history anchors to the device. +-- device_id is nullable: NULL keeps the session, loses the association. +UPDATE reading_history SET device_id = NULL WHERE device_id = $1; + -- name: DeleteDevice :exec DELETE FROM devices WHERE id = $1; diff --git a/internal/handlers/devices.go b/internal/handlers/devices.go index 5d803e81..bd793b72 100644 --- a/internal/handlers/devices.go +++ b/internal/handlers/devices.go @@ -433,6 +433,12 @@ func (h *DeviceHandler) DeleteDevice(c *echo.Context) error { return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"}) } + // Detach reading history first: the history FK to devices is NO + // ACTION on purpose (rows are real captured stats); NULLing the + // device association keeps the stats while freeing the device. + if err := h.db.DetachReadingHistoryFromDevice(c.Request().Context(), pgDeviceID); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to detach device history"}) + } if err := h.db.DeleteDevice(c.Request().Context(), pgDeviceID); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete device"}) }