Skip to content

Commit ef05eef

Browse files
naibanaiba/CloudCode
andcommitted
fix: cascade-delete all vault child tables to prevent PostgreSQL FK violations
DeleteVault only deleted UserVault, Contact, and Vault — missing 30+ related tables (seed data, contact children, pivot tables). SQLite silently ignored the orphan references, but PostgreSQL enforced FK constraints and rejected the deletion. Now deleteVaultCascade properly cleans up everything in FK-safe order. Also fixed AccountCancelService.Cancel which had the same bug. Closes Discussion #68 Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
1 parent 0b5b6e6 commit ef05eef

File tree

3 files changed

+297
-11
lines changed

3 files changed

+297
-11
lines changed

server/internal/services/account_cancel.go

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,12 @@ func (s *AccountCancelService) Cancel(userID, accountID, password string) error
3737
if err := tx.Where("account_id = ?", accountID).Find(&vaults).Error; err != nil {
3838
return err
3939
}
40+
// Reuse deleteVaultCascade (same package) to properly clean up all
41+
// vault child tables — fixes the same incomplete-deletion bug as DeleteVault.
4042
for _, v := range vaults {
41-
if err := tx.Where("vault_id = ?", v.ID).Delete(&models.UserVault{}).Error; err != nil {
43+
if err := deleteVaultCascade(tx, v.ID); err != nil {
4244
return err
4345
}
44-
if err := tx.Where("vault_id = ?", v.ID).Delete(&models.Contact{}).Error; err != nil {
45-
return err
46-
}
47-
}
48-
if err := tx.Where("account_id = ?", accountID).Delete(&models.Vault{}).Error; err != nil {
49-
return err
5046
}
5147
if err := tx.Where("account_id = ?", accountID).Delete(&models.User{}).Error; err != nil {
5248
return err

server/internal/services/vault.go

Lines changed: 193 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,14 +141,203 @@ func (s *VaultService) DeleteVault(vaultID string) error {
141141
}
142142

143143
return s.db.Transaction(func(tx *gorm.DB) error {
144-
if err := tx.Where("vault_id = ?", vaultID).Delete(&models.UserVault{}).Error; err != nil {
144+
return deleteVaultCascade(tx, vaultID)
145+
})
146+
}
147+
148+
// deleteVaultCascade deletes ALL data associated with a vault in the correct order
149+
// to respect foreign key constraints. Discussion #68.
150+
//
151+
// Deletion order rationale:
152+
// 1. Collect all contact IDs in the vault (including soft-deleted contacts)
153+
// 2. Delete deepest children first: ContactReminderScheduled, Streaks, LifeEvents,
154+
// PostMetrics, PostSections, PostTags, etc.
155+
// 3. Delete contact-level children: reminders, goals, notes, tasks, pivots, etc.
156+
// 4. Delete vault-level children: journals, timeline events, labels, etc.
157+
// 5. Delete contacts themselves
158+
// 6. Delete the vault
159+
func deleteVaultCascade(tx *gorm.DB, vaultID string) error {
160+
// Step 1: Collect ALL contact IDs in this vault (including soft-deleted ones,
161+
// since child tables still reference them).
162+
var contactIDs []string
163+
if err := tx.Model(&models.Contact{}).Unscoped().
164+
Where("vault_id = ?", vaultID).
165+
Pluck("id", &contactIDs).Error; err != nil {
166+
return err
167+
}
168+
169+
// Step 2: Delete contact-level children (deepest grandchildren first).
170+
if len(contactIDs) > 0 {
171+
// --- Grandchildren (depend on contact children) ---
172+
173+
// ContactReminderScheduled → depends on ContactReminder
174+
if err := tx.Where("contact_reminder_id IN (?)",
175+
tx.Model(&models.ContactReminder{}).Select("id").Where("contact_id IN ?", contactIDs),
176+
).Delete(&models.ContactReminderScheduled{}).Error; err != nil {
145177
return err
146178
}
147-
if err := tx.Where("vault_id = ?", vaultID).Delete(&models.Contact{}).Error; err != nil {
179+
180+
// Streak → depends on Goal
181+
if err := tx.Where("goal_id IN (?)",
182+
tx.Model(&models.Goal{}).Select("id").Where("contact_id IN ?", contactIDs),
183+
).Delete(&models.Streak{}).Error; err != nil {
148184
return err
149185
}
150-
return tx.Delete(&vault).Error
151-
})
186+
187+
// --- Contact-level children (direct FK to contact_id) ---
188+
189+
contactChildModels := []interface{}{
190+
&models.ContactInformation{},
191+
&models.ContactImportantDate{},
192+
&models.ContactReminder{},
193+
&models.ContactTask{},
194+
&models.ContactFeedItem{},
195+
&models.Call{},
196+
&models.Pet{},
197+
&models.Goal{},
198+
&models.Gift{},
199+
&models.Relationship{},
200+
&models.MoodTrackingEvent{},
201+
&models.QuickFact{},
202+
&models.Note{}, // Note has both contact_id and vault_id; delete by contact_id here
203+
}
204+
for _, m := range contactChildModels {
205+
if err := tx.Where("contact_id IN ?", contactIDs).Delete(m).Error; err != nil {
206+
return err
207+
}
208+
}
209+
210+
// Also delete Relationships where this vault's contacts are the "related" side
211+
if err := tx.Where("related_contact_id IN ?", contactIDs).Delete(&models.Relationship{}).Error; err != nil {
212+
return err
213+
}
214+
215+
// --- Pivot tables (contact_id FK) ---
216+
217+
contactPivotModels := []interface{}{
218+
&models.ContactLabel{},
219+
&models.ContactGroup{},
220+
&models.ContactAddress{},
221+
&models.ContactPost{},
222+
&models.ContactCompany{},
223+
&models.ContactLifeMetric{},
224+
&models.LifeEventParticipant{},
225+
&models.TimelineEventParticipant{},
226+
&models.ContactSubscriptionState{},
227+
}
228+
for _, m := range contactPivotModels {
229+
if err := tx.Where("contact_id IN ?", contactIDs).Delete(m).Error; err != nil {
230+
return err
231+
}
232+
}
233+
234+
// ContactLoan pivot uses loaner_id / loanee_id instead of contact_id
235+
if err := tx.Where("loaner_id IN ? OR loanee_id IN ?", contactIDs, contactIDs).
236+
Delete(&models.ContactLoan{}).Error; err != nil {
237+
return err
238+
}
239+
240+
// ContactGift pivot uses loaner_id / loanee_id (same pattern as ContactLoan)
241+
if err := tx.Where("loaner_id IN ? OR loanee_id IN ?", contactIDs, contactIDs).
242+
Delete(&models.ContactGift{}).Error; err != nil {
243+
return err
244+
}
245+
246+
// DavSyncLog has nullable contact_id
247+
if err := tx.Where("contact_id IN ?", contactIDs).Delete(&models.DavSyncLog{}).Error; err != nil {
248+
return err
249+
}
250+
}
251+
252+
// Step 3: Delete vault-level children (grandchildren of vault-scoped tables first).
253+
254+
// Journal cascade: PostMetric → PostSection → PostTag → ContactPost → Post → SliceOfLife → JournalMetric → Journal
255+
var journalIDs []uint
256+
tx.Model(&models.Journal{}).Where("vault_id = ?", vaultID).Pluck("id", &journalIDs)
257+
if len(journalIDs) > 0 {
258+
var postIDs []uint
259+
tx.Model(&models.Post{}).Where("journal_id IN ?", journalIDs).Pluck("id", &postIDs)
260+
if len(postIDs) > 0 {
261+
tx.Where("post_id IN ?", postIDs).Delete(&models.PostMetric{})
262+
tx.Where("post_id IN ?", postIDs).Delete(&models.PostSection{})
263+
tx.Where("post_id IN ?", postIDs).Delete(&models.PostTag{})
264+
tx.Where("post_id IN ?", postIDs).Delete(&models.ContactPost{})
265+
tx.Where("id IN ?", postIDs).Delete(&models.Post{})
266+
}
267+
268+
var journalMetricIDs []uint
269+
tx.Model(&models.JournalMetric{}).Where("journal_id IN ?", journalIDs).Pluck("id", &journalMetricIDs)
270+
if len(journalMetricIDs) > 0 {
271+
// PostMetric references JournalMetricID — already deleted above via postIDs
272+
tx.Where("id IN ?", journalMetricIDs).Delete(&models.JournalMetric{})
273+
}
274+
275+
tx.Where("journal_id IN ?", journalIDs).Delete(&models.SliceOfLife{})
276+
tx.Where("id IN ?", journalIDs).Delete(&models.Journal{})
277+
}
278+
279+
// LifeEventCategory cascade: LifeEvent → LifeEventType → LifeEventCategory
280+
var categoryIDs []uint
281+
tx.Model(&models.LifeEventCategory{}).Where("vault_id = ?", vaultID).Pluck("id", &categoryIDs)
282+
if len(categoryIDs) > 0 {
283+
var typeIDs []uint
284+
tx.Model(&models.LifeEventType{}).Where("life_event_category_id IN ?", categoryIDs).Pluck("id", &typeIDs)
285+
if len(typeIDs) > 0 {
286+
tx.Where("life_event_type_id IN ?", typeIDs).Delete(&models.LifeEvent{})
287+
tx.Where("id IN ?", typeIDs).Delete(&models.LifeEventType{})
288+
}
289+
tx.Where("id IN ?", categoryIDs).Delete(&models.LifeEventCategory{})
290+
}
291+
292+
// TimelineEvent cascade: LifeEvent (by timeline_event_id) → TimelineEventParticipant → TimelineEvent
293+
var timelineIDs []uint
294+
tx.Model(&models.TimelineEvent{}).Where("vault_id = ?", vaultID).Pluck("id", &timelineIDs)
295+
if len(timelineIDs) > 0 {
296+
tx.Where("timeline_event_id IN ?", timelineIDs).Delete(&models.LifeEvent{})
297+
tx.Where("timeline_event_id IN ?", timelineIDs).Delete(&models.TimelineEventParticipant{})
298+
tx.Where("id IN ?", timelineIDs).Delete(&models.TimelineEvent{})
299+
}
300+
301+
// AddressBookSubscription cascade: DavSyncLog + ContactSubscriptionState → AddressBookSubscription
302+
var subIDs []string
303+
tx.Model(&models.AddressBookSubscription{}).Where("vault_id = ?", vaultID).Pluck("id", &subIDs)
304+
if len(subIDs) > 0 {
305+
tx.Where("address_book_subscription_id IN ?", subIDs).Delete(&models.DavSyncLog{})
306+
tx.Where("address_book_subscription_id IN ?", subIDs).Delete(&models.ContactSubscriptionState{})
307+
tx.Where("id IN ?", subIDs).Delete(&models.AddressBookSubscription{})
308+
}
309+
310+
// --- Simple vault-level tables (no children of their own, or children already deleted) ---
311+
312+
vaultChildModels := []interface{}{
313+
&models.ContactImportantDateType{},
314+
&models.MoodTrackingParameter{},
315+
&models.VaultQuickFactsTemplate{},
316+
&models.Label{},
317+
&models.Company{},
318+
&models.Group{},
319+
&models.Tag{},
320+
&models.Loan{},
321+
&models.File{},
322+
&models.Address{},
323+
&models.LifeMetric{},
324+
&models.Note{}, // Notes with vault_id but no contact (edge case safety)
325+
&models.ContactVaultUser{},
326+
&models.UserVault{},
327+
}
328+
for _, m := range vaultChildModels {
329+
if err := tx.Where("vault_id = ?", vaultID).Delete(m).Error; err != nil {
330+
return err
331+
}
332+
}
333+
334+
// Step 4: Delete contacts (including soft-deleted ones via Unscoped).
335+
if err := tx.Unscoped().Where("vault_id = ?", vaultID).Delete(&models.Contact{}).Error; err != nil {
336+
return err
337+
}
338+
339+
// Step 5: Delete the vault itself.
340+
return tx.Where("id = ?", vaultID).Delete(&models.Vault{}).Error
152341
}
153342

154343
func (s *VaultService) CheckUserVaultAccess(userID, vaultID string, requiredPerm int) error {

server/internal/services/vault_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,3 +315,104 @@ func TestCheckUserVaultAccess(t *testing.T) {
315315
}
316316
})
317317
}
318+
319+
func TestDeleteVault_CleanupCompleteness(t *testing.T) {
320+
svc, accountID, userID := setupVaultTest(t)
321+
322+
vault, err := svc.CreateVault(accountID, userID, dto.CreateVaultRequest{
323+
Name: "Cleanup Test",
324+
Description: "vault to be fully cleaned",
325+
}, "en")
326+
if err != nil {
327+
t.Fatalf("CreateVault failed: %v", err)
328+
}
329+
vaultID := vault.ID
330+
db := svc.db
331+
332+
if err := svc.DeleteVault(vaultID); err != nil {
333+
t.Fatalf("DeleteVault failed: %v", err)
334+
}
335+
336+
type tableCheck struct {
337+
name string
338+
model interface{}
339+
}
340+
vaultTables := []tableCheck{
341+
{"ContactImportantDateType", &models.ContactImportantDateType{}},
342+
{"MoodTrackingParameter", &models.MoodTrackingParameter{}},
343+
{"LifeEventCategory", &models.LifeEventCategory{}},
344+
{"VaultQuickFactsTemplate", &models.VaultQuickFactsTemplate{}},
345+
{"Label", &models.Label{}},
346+
{"Company", &models.Company{}},
347+
{"Group", &models.Group{}},
348+
{"Tag", &models.Tag{}},
349+
{"Loan", &models.Loan{}},
350+
{"File", &models.File{}},
351+
{"Address", &models.Address{}},
352+
{"LifeMetric", &models.LifeMetric{}},
353+
{"Note", &models.Note{}},
354+
{"Journal", &models.Journal{}},
355+
{"TimelineEvent", &models.TimelineEvent{}},
356+
{"ContactVaultUser", &models.ContactVaultUser{}},
357+
{"UserVault", &models.UserVault{}},
358+
}
359+
for _, tc := range vaultTables {
360+
var count int64
361+
db.Model(tc.model).Where("vault_id = ?", vaultID).Count(&count)
362+
if count != 0 {
363+
t.Errorf("%s: expected 0 records for vault_id=%s, got %d", tc.name, vaultID, count)
364+
}
365+
}
366+
367+
var contactCount int64
368+
db.Model(&models.Contact{}).Unscoped().Where("vault_id = ?", vaultID).Count(&contactCount)
369+
if contactCount != 0 {
370+
t.Errorf("Contact: expected 0 records for vault_id=%s, got %d", vaultID, contactCount)
371+
}
372+
373+
var vaultCount int64
374+
db.Model(&models.Vault{}).Where("id = ?", vaultID).Count(&vaultCount)
375+
if vaultCount != 0 {
376+
t.Errorf("Vault: expected 0 records for id=%s, got %d", vaultID, vaultCount)
377+
}
378+
}
379+
380+
func TestDeleteVault_WithForeignKeysEnabled(t *testing.T) {
381+
db := testutil.SetupTestDB(t)
382+
// Enable foreign key enforcement to simulate PostgreSQL behavior.
383+
// With the old incomplete deletion, this would fail due to dangling references.
384+
if err := db.Exec("PRAGMA foreign_keys = ON").Error; err != nil {
385+
t.Fatalf("Failed to enable foreign keys: %v", err)
386+
}
387+
388+
cfg := testutil.TestJWTConfig()
389+
authSvc := NewAuthService(db, cfg)
390+
391+
regReq := dto.RegisterRequest{
392+
FirstName: "FK",
393+
LastName: "Test",
394+
Email: "fk-test@example.com",
395+
Password: "password123",
396+
}
397+
resp, err := authSvc.Register(regReq, "en")
398+
if err != nil {
399+
t.Fatalf("Register failed: %v", err)
400+
}
401+
402+
vaultSvc := NewVaultService(db)
403+
vault, err := vaultSvc.CreateVault(resp.User.AccountID, resp.User.ID, dto.CreateVaultRequest{
404+
Name: "FK Vault",
405+
}, "en")
406+
if err != nil {
407+
t.Fatalf("CreateVault failed: %v", err)
408+
}
409+
410+
if err := vaultSvc.DeleteVault(vault.ID); err != nil {
411+
t.Fatalf("DeleteVault with foreign_keys=ON failed: %v", err)
412+
}
413+
414+
_, err = vaultSvc.GetVault(vault.ID, resp.User.ID)
415+
if err != ErrVaultNotFound {
416+
t.Errorf("Expected ErrVaultNotFound after deletion, got %v", err)
417+
}
418+
}

0 commit comments

Comments
 (0)