From fb868dfe40d093b4ecee0eac998cc48a813f6ccc Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 4 Sep 2026 06:20:26 -0700 Subject: [PATCH] feat: delete the storage objects of a step's attachments when the step is deleted, since the attachment rows cascade away with the sequence and left their bytes counted against the organization's storage quota with no row left to reach them --- cmd/backend/main.go | 5 ++++ internal/app/sequence/handler.go | 51 +++++++++++++++++++++++++++++++- internal/app/sequence/service.go | 15 ++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 617c5a9f..c68d9ebe 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -1332,6 +1332,11 @@ func main() { if aware, ok := campaignService.(campaign.AttachmentAware); ok { aware.WireAttachments(attachmentRepoForHandler, s3ForHandler) } + // Deleting a step cascades its attachment rows away, so the sequence + // service needs the same store to drop the objects behind them. + if aware, ok := sequenceService.(sequence.AttachmentAware); ok { + aware.WireAttachments(attachmentRepoForHandler, s3ForHandler) + } // Attaching a lead to a running campaign has to wake that campaign's // parked send chain, or the lead sits queued until the chain's next // tick. Wired here because contactService is built before the scheduler diff --git a/internal/app/sequence/handler.go b/internal/app/sequence/handler.go index 02b127c4..547a6e91 100644 --- a/internal/app/sequence/handler.go +++ b/internal/app/sequence/handler.go @@ -2,6 +2,10 @@ package sequence import ( "context" + "fmt" + + "github.com/getsentry/sentry-go" + "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" @@ -29,6 +33,51 @@ func (s *sequenceService) UpdateLayout(ctx context.Context, userID, campaignID s return s.sequenceRepository.UpdateLayout(ctx, userID, campaignID, positions) } +// Delete removes a step. Its attachment rows go with it through the cascade, +// so the objects behind them are listed first and dropped once the delete has +// committed — otherwise the bytes stay in storage against the org's quota with +// no row left to reach them. func (s *sequenceService) Delete(ctx context.Context, userID, campaignID, sequenceID string) *errx.Error { - return s.sequenceRepository.Delete(ctx, userID, campaignID, sequenceID) + keys := s.stepObjectKeys(ctx, campaignID, sequenceID) + + if xerr := s.sequenceRepository.Delete(ctx, userID, campaignID, sequenceID); xerr != nil { + return xerr + } + + for _, key := range keys { + if err := s.storage.Delete(ctx, key); err != nil { + sentry.CaptureException(fmt.Errorf("sequence %s delete: object %s: %w", sequenceID, key, err)) + } + } + return nil +} + +// stepObjectKeys lists the storage keys of the files scoped to one step. Best +// effort: a step still deletes when they cannot be read, it just leaves its +// objects behind rather than refusing the edit. +func (s *sequenceService) stepObjectKeys(ctx context.Context, campaignID, sequenceID string) []string { + if s.attachmentRepo == nil || s.storage == nil { + return nil + } + cID, err := uuid.Parse(campaignID) + if err != nil { + return nil + } + sID, err := uuid.Parse(sequenceID) + if err != nil { + return nil + } + atts, err := s.attachmentRepo.ListForStep(ctx, cID, sID) + if err != nil { + sentry.CaptureException(fmt.Errorf("sequence %s delete: list attachments: %w", sequenceID, err)) + return nil + } + keys := make([]string, 0, len(atts)) + for _, a := range atts { + // ListForStep also returns the campaign-wide files, which outlive the step. + if a.SequenceID != nil && *a.SequenceID == sID { + keys = append(keys, a.S3Key) + } + } + return keys } diff --git a/internal/app/sequence/service.go b/internal/app/sequence/service.go index 4682a4d1..c2eefd0c 100644 --- a/internal/app/sequence/service.go +++ b/internal/app/sequence/service.go @@ -4,6 +4,7 @@ import ( "context" "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/infrastructure/storage" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/repository" ) @@ -18,6 +19,8 @@ type SequenceService interface { type sequenceService struct { sequenceRepository repository.SequenceRepository + attachmentRepo repository.AttachmentRepository + storage storage.Store } func NewService(sequenceRepository repository.SequenceRepository) SequenceService { @@ -25,3 +28,15 @@ func NewService(sequenceRepository repository.SequenceRepository) SequenceServic sequenceRepository: sequenceRepository, } } + +// AttachmentAware is implemented by the sequence service so main can hand it +// the attachment repository and object store. Deleting a step cascades its +// attachment rows away, so without these the files scoped to that step leave +// their bytes in storage forever, still counted against the org's quota. +type AttachmentAware interface { + WireAttachments(repo repository.AttachmentRepository, store storage.Store) +} + +func (s *sequenceService) WireAttachments(repo repository.AttachmentRepository, store storage.Store) { + s.attachmentRepo, s.storage = repo, store +}