Skip to content

Commit 505f451

Browse files
cpcloudcursoragent
andcommitted
feat(data): FK guards prevent soft-deleting entities with active dependents
Soft-delete (GORM's deleted_at) doesn't trigger SQLite FK constraints, so application-level checks are needed. Added countDependents() helper and pre-delete guards: - DeleteProject refuses if non-deleted quotes reference the project - DeleteMaintenance refuses if non-deleted service log entries exist - DeleteAppliance remains unrestricted (SET NULL FK semantics) - Vendor deletion already blocked (no soft-delete support) Error messages are actionable ("has N active quote(s) -- delete them first") and bubble up to the status bar via the existing error path. 3 new tests: blocked project, blocked maintenance, allowed appliance. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3af707d commit 505f451

File tree

3 files changed

+125
-4
lines changed

3 files changed

+125
-4
lines changed

AGENTS.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,6 +1013,7 @@ in case things crash or otherwise go haywire, be diligent about this.
10131013
- [NAV-CLAMP] column navigation clamped at edges instead of wrapping
10141014
- [DASH-RESIZE] dashboard dynamically resizes for terminal height (2b322cd)
10151015
- [DASH-NO-ACTIVITY] removed recent activity from dashboard summary (a818e44)
1016+
- [SAFE-DELETE] FK guards on soft-delete: projects with quotes and maintenance with service logs are refused with actionable error messages
10161017

10171018
# Remaining work
10181019

@@ -1071,10 +1072,6 @@ in case things crash or otherwise go haywire, be diligent about this.
10711072
HOAFeeCents and PropertyTaxCents. Why aren't those just plain int64s?
10721073

10731074
## Moar
1074-
- [SAFE-DELETE] make sure that deleting, even soft deleting doesn't break the
1075-
model, e.g., if i try to delete a quote that's linked to a project, i get
1076-
a reasonable error message, probably in the status bar but open to thoughts
1077-
on where to show it
10781075
- [HIDE-COMPLETED] would be nice to have a way to hide completed projects
10791076
easily. we'll get to the generic way to do that when we implement filter,
10801077
but i think it will still be useful as a standalone feature

internal/data/store.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,13 @@ func (s *Store) CountMaintenanceByAppliance(applianceIDs []uint) (map[uint]int,
841841
}
842842

843843
func (s *Store) DeleteProject(id uint) error {
844+
n, err := s.countDependents(&Quote{}, "project_id", id)
845+
if err != nil {
846+
return err
847+
}
848+
if n > 0 {
849+
return fmt.Errorf("project has %d active quote(s) -- delete them first", n)
850+
}
844851
return s.softDelete(&Project{}, DeletionEntityProject, id)
845852
}
846853

@@ -849,6 +856,13 @@ func (s *Store) DeleteQuote(id uint) error {
849856
}
850857

851858
func (s *Store) DeleteMaintenance(id uint) error {
859+
n, err := s.countDependents(&ServiceLogEntry{}, "maintenance_item_id", id)
860+
if err != nil {
861+
return err
862+
}
863+
if n > 0 {
864+
return fmt.Errorf("maintenance item has %d service log(s) -- delete them first", n)
865+
}
852866
return s.softDelete(&MaintenanceItem{}, DeletionEntityMaintenance, id)
853867
}
854868

@@ -872,6 +886,14 @@ func (s *Store) RestoreAppliance(id uint) error {
872886
return s.restoreEntity(&Appliance{}, DeletionEntityAppliance, id)
873887
}
874888

889+
// countDependents counts non-deleted rows in model where fkColumn equals id.
890+
// GORM's soft-delete scope automatically excludes deleted rows.
891+
func (s *Store) countDependents(model any, fkColumn string, id uint) (int64, error) {
892+
var count int64
893+
err := s.db.Model(model).Where(fkColumn+" = ?", id).Count(&count).Error
894+
return count, err
895+
}
896+
875897
func (s *Store) softDelete(model any, entity string, id uint) error {
876898
result := s.db.Delete(model, id)
877899
if result.Error != nil {

internal/data/store_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package data
66
import (
77
"errors"
88
"path/filepath"
9+
"strings"
910
"testing"
1011
"time"
1112

@@ -622,6 +623,107 @@ func TestCountServiceLogsByVendor(t *testing.T) {
622623
}
623624
}
624625

626+
func TestDeleteProjectBlockedByQuotes(t *testing.T) {
627+
store := newTestStore(t)
628+
types, _ := store.ProjectTypes()
629+
project := Project{
630+
Title: "Blocked Project", ProjectTypeID: types[0].ID,
631+
Status: ProjectStatusPlanned,
632+
}
633+
if err := store.CreateProject(project); err != nil {
634+
t.Fatalf("CreateProject: %v", err)
635+
}
636+
projects, _ := store.ListProjects(false)
637+
projID := projects[0].ID
638+
639+
// Attach a quote.
640+
quote := Quote{ProjectID: projID, TotalCents: 1000}
641+
if err := store.CreateQuote(quote, Vendor{Name: "V1"}); err != nil {
642+
t.Fatalf("CreateQuote: %v", err)
643+
}
644+
645+
// Delete should be refused.
646+
err := store.DeleteProject(projID)
647+
if err == nil {
648+
t.Fatal("expected error deleting project with active quotes")
649+
}
650+
if !strings.Contains(err.Error(), "active quote") {
651+
t.Fatalf("unexpected error: %v", err)
652+
}
653+
654+
// Soft-delete the quote, then project deletion should succeed.
655+
quotes, _ := store.ListQuotes(false)
656+
if err := store.DeleteQuote(quotes[0].ID); err != nil {
657+
t.Fatalf("DeleteQuote: %v", err)
658+
}
659+
if err := store.DeleteProject(projID); err != nil {
660+
t.Fatalf("DeleteProject after quote removed: %v", err)
661+
}
662+
}
663+
664+
func TestDeleteMaintenanceBlockedByServiceLogs(t *testing.T) {
665+
store := newTestStore(t)
666+
cats, _ := store.MaintenanceCategories()
667+
item := MaintenanceItem{
668+
Name: "Blocked Maint", CategoryID: cats[0].ID, IntervalMonths: 3,
669+
}
670+
if err := store.CreateMaintenance(item); err != nil {
671+
t.Fatalf("CreateMaintenance: %v", err)
672+
}
673+
items, _ := store.ListMaintenance(false)
674+
maintID := items[0].ID
675+
676+
// Attach a service log.
677+
now := time.Now()
678+
entry := ServiceLogEntry{MaintenanceItemID: maintID, ServicedAt: now}
679+
if err := store.CreateServiceLog(entry, Vendor{Name: "SL Vendor"}); err != nil {
680+
t.Fatalf("CreateServiceLog: %v", err)
681+
}
682+
683+
// Delete should be refused.
684+
err := store.DeleteMaintenance(maintID)
685+
if err == nil {
686+
t.Fatal("expected error deleting maintenance with active service logs")
687+
}
688+
if !strings.Contains(err.Error(), "service log") {
689+
t.Fatalf("unexpected error: %v", err)
690+
}
691+
692+
// Soft-delete the service log, then maintenance deletion should succeed.
693+
logs, _ := store.ListServiceLog(maintID, false)
694+
if err := store.DeleteServiceLog(logs[0].ID); err != nil {
695+
t.Fatalf("DeleteServiceLog: %v", err)
696+
}
697+
if err := store.DeleteMaintenance(maintID); err != nil {
698+
t.Fatalf("DeleteMaintenance after logs removed: %v", err)
699+
}
700+
}
701+
702+
func TestDeleteApplianceAllowedWithMaintenance(t *testing.T) {
703+
store := newTestStore(t)
704+
appliance := Appliance{Name: "Deletable Fridge"}
705+
if err := store.CreateAppliance(appliance); err != nil {
706+
t.Fatalf("CreateAppliance: %v", err)
707+
}
708+
appliances, _ := store.ListAppliances(false)
709+
appID := appliances[0].ID
710+
711+
// Attach a maintenance item.
712+
cats, _ := store.MaintenanceCategories()
713+
item := MaintenanceItem{
714+
Name: "Filter", CategoryID: cats[0].ID, IntervalMonths: 6,
715+
ApplianceID: &appID,
716+
}
717+
if err := store.CreateMaintenance(item); err != nil {
718+
t.Fatalf("CreateMaintenance: %v", err)
719+
}
720+
721+
// Appliance deletion should succeed (SET NULL semantics).
722+
if err := store.DeleteAppliance(appID); err != nil {
723+
t.Fatalf("DeleteAppliance should be allowed: %v", err)
724+
}
725+
}
726+
625727
func newTestStore(t *testing.T) *Store {
626728
t.Helper()
627729
path := filepath.Join(t.TempDir(), "test.db")

0 commit comments

Comments
 (0)