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 +}