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

This commit is contained in:
Matthew Meszaros
2026-09-04 06:20:26 -07:00
parent a54774c0a5
commit fb868dfe40
3 changed files with 70 additions and 1 deletions
+5
View File
@@ -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
+50 -1
View File
@@ -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
}
+15
View File
@@ -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
}