@@ -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
154343func (s * VaultService ) CheckUserVaultAccess (userID , vaultID string , requiredPerm int ) error {
0 commit comments