fix(devices): device deletion detaches reading history instead of 500ing
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.
This commit is contained in:
1 parent
313268a7ae
commit
d49a9e6ca2
4 files changed
+34
No files matched your search
@@ -171,6 +171,12 @@ type Querier interface {
|
|||||||
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) 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
|
// Device existence probe for capture: the web/app progress paths
|
||||||
// historically stuff the user id into DeviceID (it only fed a broadcast
|
// historically stuff the user id into DeviceID (it only fed a broadcast
|
||||||
// label before stats); a capture row must only reference a REAL device.
|
// label before stats); a capture row must only reference a REAL device.
|
||||||
|
|||||||
@@ -2362,6 +2362,20 @@ func (q *Queries) DeleteUserSystemCollection(ctx context.Context, arg DeleteUser
|
|||||||
return err
|
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
|
const DeviceExistsForUser = `-- name: DeviceExistsForUser :one
|
||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
SELECT 1 FROM devices
|
SELECT 1 FROM devices
|
||||||
|
|||||||
@@ -2809,6 +2809,14 @@ SET
|
|||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1;
|
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
|
-- name: DeleteDevice :exec
|
||||||
DELETE FROM devices WHERE id = $1;
|
DELETE FROM devices WHERE id = $1;
|
||||||
|
|
||||||
|
|||||||
@@ -433,6 +433,12 @@ func (h *DeviceHandler) DeleteDevice(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"})
|
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 {
|
if err := h.db.DeleteDevice(c.Request().Context(), pgDeviceID); err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete device"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete device"})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user