feat: add Delete and Duplicate campaign actions to the dashboard (issue #185): every campaign row and the detail header get a ⋯ menu (Edit, Duplicate, Start/Pause, Delete) plus a Delete card at the bottom of Settings, all permission-gated with a confirm that spells out what goes; DELETE /campaigns/:id is now organization-scoped instead of user_id-scoped so teammates can delete, runs in one transaction that also deletes the campaign's pending tasks and cancels a wakeup tick claimed at that moment (campaign_tasks only nulls its link, so those rows kept firing), removes attachment objects and publishes CAMPAIGN_DELETED so a teammate's open detail page is sent back to the list; new POST /campaigns/:id/duplicate copies the campaign row as a draft with steps and their branch graph rewired onto new step ids, tags, folders, senders with rotation reset, A/B variants, advanced settings and attachments (quota-checked, blobs undone if the copy fails) and none of the leads, progress, logs, counters, ramp level, guardrail trip or past dates, naming it (copy)/(copy N) inside the 50 byte cap without splitting runes; a claimed campaign tick whose campaign vanished now ends the chain instead of staying active forever; covered by TestLiveCampaignLifecycle* against real SQL, RemapBranchTargets and duplicateName unit tests and a react-query vitest for the list cache, with API reference, endpoint map and campaigns guide updated

This commit is contained in:
Matthew Meszaros
2026-08-25 08:54:51 -07:00
parent 5da840ba72
commit d396f53fc4
29 changed files with 1695 additions and 76 deletions
+5
View File
@@ -1176,6 +1176,11 @@ func main() {
aware.WireDomainAuth(instanceSettings)
}
campaignService = campaign.NewService(campaignRepostory, taskRepository, emailRepostory, campaignLogRepository, featureGateService, dailyThrottleService, schedulerService, tasksClient, streamingPublisher)
// Delete drops attachment objects and duplicate copies them, so the
// campaign service needs the store the attachment handler writes to.
if aware, ok := campaignService.(campaign.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
+1
View File
@@ -45,6 +45,7 @@ All paths below are relative to the versioned base URL `https://api.warmbly.com/
| GET | `/campaigns/:id` | `READ_CAMPAIGNS` |
| PATCH | `/campaigns/:id` | `WRITE_CAMPAIGNS` |
| DELETE | `/campaigns/:id` | `WRITE_CAMPAIGNS` |
| POST | `/campaigns/:id/duplicate` | `WRITE_CAMPAIGNS` |
| GET | `/campaigns/:id/advanced` | `READ_CAMPAIGNS` |
| PATCH | `/campaigns/:id/advanced` | `WRITE_CAMPAIGNS` |
| GET | `/campaigns/:id/ab-variants` | `READ_CAMPAIGNS` |
+35 -1
View File
@@ -219,7 +219,11 @@ The updated `Campaign` object.
`DELETE /campaigns/:id`
Permanently delete a campaign. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`.
Permanently delete a campaign. Any member of the workspace with the permission can delete any of its campaigns, not only the ones they created. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`.
A running campaign does not need to be paused first: its pending tasks (the parked wakeup and any send not yet handed to a worker) are cancelled in the same transaction that removes the campaign, so nothing keeps sending for it. A send already in a worker's hands finishes, and its result is discarded.
What is removed with the campaign: steps, leads and their progress, the activity log, senders, A/B variants, advanced settings, daily counters, preflight reports and attachment files. What stays: contacts, emails already sent (still in the inbox and in reply threads), suppression entries, deals and bookings (their campaign link is cleared), and tracked links, so a recipient who clicks a link in an email sent earlier is still redirected.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
@@ -229,6 +233,36 @@ Permanently delete a campaign. **Scope** `WRITE_CAMPAIGNS` · **Org permission**
`204 No Content` with an empty body.
## Duplicate a campaign
`POST /campaigns/:id/duplicate`
Create a new draft campaign from an existing campaign's configuration. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`. Counts against the same daily new-campaign throttle as [create](#create-a-campaign).
Copied: name (suffixed with ` (copy)` unless you pass one), description, every sending, tracking, schedule, rotation, ramp, ESP-matching and auto-pause setting, the steps with their subjects, bodies, waits, canvas positions and branch graph (rewired onto the new step ids), email tags, folders, the explicit sender list, A/B variants, advanced settings and attachments.
Not copied: leads, progress, sent/open/click/reply statistics, the activity log, daily counters, tasks, the ramp level, an auto-pause trip, and any start or end date already in the past (a past end date would finish the copy the moment it starts). Sender rotation cursors start from zero. The copy is owned by the caller and starts as `draft`; it never sends until it is started.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign to copy. |
### Request body
Optional.
| Field | Type | Description |
| --- | --- | --- |
| `name` | string | Name for the copy, 3 to 50 characters. Defaults to the source name with ` (copy)` appended. |
```json
{ "name": "Q3 outbound, subject B" }
```
### Response
`201 Created` with the new `Campaign` object (see the [list](#list-campaigns) shape), including `senders`.
## Get advanced settings
`GET /campaigns/:id/advanced`
+8
View File
@@ -99,6 +99,14 @@ A campaign can pause itself: **paused, no accounts** when it loses every sender,
A finished campaign can be started again: after extending or clearing its end date, or adding new leads, pressing play resumes it. If there is genuinely nothing left to send it finishes again immediately with a message saying so.
## Duplicate and delete
Every campaign has a **⋯** menu, on its row in the list and in the header of its detail view, with **Edit**, **Duplicate**, **Start** or **Pause**, and **Delete**. Both actions need the **Manage campaigns** permission; a member without it is told which permission is missing instead of the action failing later.
**Duplicate** creates a new draft from the campaign's configuration and opens its settings so you can rename it (it starts as "<name> (copy)"), pick a different audience, change the copy, or adjust the schedule before starting it. The copy keeps every setting, the steps with their branching, the sending accounts, folders, tags, A/B variants and attachments. It does not keep leads, progress, statistics, activity, the ramp level, an auto-pause, or a start or end date that has already passed. The original is not touched, and the two campaigns are independent from then on. A duplicate counts as a new campaign for the daily creation limit.
**Delete** asks for confirmation that spells out what goes: the campaign, its steps, lead progress and activity. Contacts and emails already sent stay. A running campaign can be deleted directly; the confirmation says so, and sending stops immediately because every task waiting for the campaign is cancelled together with it. The same action is available at the bottom of the campaign's **Settings**. Deletion cannot be undone.
## Auto-pause guardrails
**Preferences** > **Auto-pause** stops a campaign the moment its rates leave the band you set, instead of waiting for a mailbox provider to react. Off by default.
+1 -1
View File
@@ -133,7 +133,7 @@ func (h *Handler) UploadCampaignAttachment(c *gin.Context) {
mimeType = http.DetectContentType(body)
}
key := fmt.Sprintf("attachments/%s/%s-%s", campaignID.String(), uuid.NewString(), filename)
key := models.AttachmentObjectKey(campaignID, filename)
if err := h.Storage.Put(c.Request.Context(), key, bytes.NewReader(body), mimeType); err != nil {
errx.JSON(c, errx.InternalError())
return
+66 -9
View File
@@ -1,8 +1,11 @@
package handler
import (
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -186,24 +189,78 @@ func (h *Handler) UpdateCampaign(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
// DeleteCampaign permanently removes a campaign of the current organization.
// DELETE /campaigns/:id
func (h *Handler) DeleteCampaign(c *gin.Context) {
userIDStr := middleware.GetUserID(c)
id := c.Param("id")
if err := h.CampaignService.Delete(c.Request.Context(), userIDStr, id); err != nil {
errx.JSON(c, err)
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.ErrNoOrganization)
return
}
// Audit log
if campaignID, err := uuid.Parse(id); err == nil {
h.auditOrg(c, models.AuditActionDelete, models.AuditEntityCampaign, &campaignID, nil, nil)
id := c.Param("id")
campaignID, err := uuid.Parse(id)
if err != nil {
errx.JSON(c, errx.ErrUuid)
return
}
deleted, xerr := h.CampaignService.Delete(c.Request.Context(), *orgID, id)
if xerr != nil {
errx.JSON(c, xerr)
return
}
// The row is gone, so the name travels in the audit entry.
h.auditOrg(c, models.AuditActionDelete, models.AuditEntityCampaign, &campaignID, nil, map[string]string{"name": deleted.Name})
c.Status(http.StatusNoContent)
}
// duplicateCampaignRequest is the optional body of a duplicate call.
type duplicateCampaignRequest struct {
Name string `json:"name"`
}
// DuplicateCampaign creates a draft copy of a campaign's configuration.
// POST /campaigns/:id/duplicate
func (h *Handler) DuplicateCampaign(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.ErrNoOrganization)
return
}
userID, err := middleware.GetUserUUID(c)
if err != nil {
errx.JSON(c, errx.ErrUser)
return
}
campaignID, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.ErrUuid)
return
}
// The body is optional; an empty one reads as io.EOF.
var req duplicateCampaignRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
errx.JSON(c, errx.ErrInvalid)
return
}
campaign, xerr := h.CampaignService.Duplicate(c.Request.Context(), *orgID, userID, campaignID.String(), strings.TrimSpace(req.Name))
if xerr != nil {
errx.JSON(c, xerr)
return
}
h.auditOrg(c, models.AuditActionDuplicate, models.AuditEntityCampaign, &campaign.ID, nil, map[string]string{
"source": campaignID.String(), "name": campaign.Name,
})
c.JSON(http.StatusCreated, campaign)
}
// StartCampaign starts a campaign
// POST /campaigns/:id/start
func (h *Handler) StartCampaign(c *gin.Context) {
+1
View File
@@ -424,6 +424,7 @@ func Run(
campaigns.GET("/:id", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetCampaign)
campaigns.PATCH("/:id", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.UpdateCampaign)
campaigns.DELETE("/:id", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.DeleteCampaign)
campaigns.POST("/:id/duplicate", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.DuplicateCampaign)
// Advanced campaign controls
campaigns.GET("/:id/advanced", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetCampaignAdvancedSettings)
+1 -1
View File
@@ -258,7 +258,7 @@ func (d Deps) deleteCampaign(ctx context.Context, inv Invocation, args json.RawM
if err != nil {
return "", err
}
if xerr := d.Campaigns.Delete(ctx, inv.UserID.String(), in.CampaignID); xerr != nil {
if _, xerr := d.Campaigns.Delete(ctx, inv.OrgID, in.CampaignID); xerr != nil {
return "", fromErrx(xerr)
}
d.logAudit(ctx, inv, models.AuditActionDelete, models.AuditEntityCampaign, &cid, nil)
@@ -0,0 +1,30 @@
package campaign
import (
"strings"
"testing"
"github.com/warmbly/warmbly/internal/utils/validate"
)
func TestDuplicateNameStaysWithinTheCampaignNameCap(t *testing.T) {
cases := map[string]string{
"Q3 outbound": "Q3 outbound (copy)",
" padded ": "padded (copy)",
"Q3 outbound (copy)": "Q3 outbound (copy 2)",
"Q3 outbound (copy 7)": "Q3 outbound (copy 8)",
"copy (copy) of x": "copy (copy) of x (copy)",
strings.Repeat("é", 25): strings.Repeat("é", 21) + " (copy)",
strings.Repeat("a", 50): strings.Repeat("a", 43) + " (copy)",
strings.Repeat("b", 42) + " tail": strings.Repeat("b", 42) + " (copy)",
}
for in, want := range cases {
got := duplicateName(in)
if got != want {
t.Errorf("duplicateName(%q) = %q, want %q", in, got, want)
}
if xerr := validate.CampaignName(got); xerr != nil {
t.Errorf("duplicateName(%q) = %q fails validation: %v", in, got, xerr)
}
}
}
+214 -7
View File
@@ -4,7 +4,11 @@ import (
"context"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/getsentry/sentry-go"
"github.com/google/uuid"
@@ -144,15 +148,218 @@ func (s *campaignService) rescheduleCampaignWakeup(ctx context.Context, campaign
_ = s.enqueueCampaignWakeup(ctx, campaignID)
}
func (s *campaignService) Delete(ctx context.Context, userID, id string) *errx.Error {
if err := s.campaignRepository.Delete(ctx, userID, id); err != nil {
if errors.Is(err, errx.ErrResourceNotFound) {
return errx.ErrNotFound
}
return errx.InternalError()
func (s *campaignService) Delete(ctx context.Context, orgID uuid.UUID, campaignID string) (*models.Campaign, *errx.Error) {
campaign, cID, xerr := s.campaignForOrg(ctx, orgID, campaignID)
if xerr != nil {
return nil, xerr
}
return nil
// Attachment objects are not reachable once the rows cascade, so list
// them first and drop the bytes after the delete has committed.
var attachments []models.CampaignAttachment
if s.attachmentRepo != nil {
var err error
if attachments, err = s.attachmentRepo.ListByCampaign(ctx, cID); err != nil {
sentry.CaptureException(fmt.Errorf("campaign %s delete: list attachments: %w", cID, err))
}
}
if err := s.campaignRepository.Delete(ctx, cID); err != nil {
if errors.Is(err, errx.ErrResourceNotFound) {
return nil, errx.ErrNotFound
}
return nil, errx.InternalError()
}
if s.storage != nil {
for _, att := range attachments {
if err := s.storage.Delete(ctx, att.S3Key); err != nil {
sentry.CaptureException(fmt.Errorf("campaign %s delete: object %s: %w", cID, att.S3Key, err))
}
}
}
if s.streamingPublisher != nil {
s.streamingPublisher.PublishCampaignEvent(ctx, &pubsub.CampaignEvent{
BaseEvent: pubsub.BaseEvent{
EventType: pubsub.EventCampaignDeleted,
UserID: campaign.UserID,
},
OrgID: orgID.String(),
CampaignID: cID.String(),
Name: campaign.Name,
Status: campaign.Status,
})
}
return campaign, nil
}
func (s *campaignService) Duplicate(ctx context.Context, orgID, userID uuid.UUID, campaignID, name string) (*models.Campaign, *errx.Error) {
source, cID, xerr := s.campaignForOrg(ctx, orgID, campaignID)
if xerr != nil {
return nil, xerr
}
if name == "" {
name = duplicateName(source.Name)
}
if xerr := validate.CampaignName(name); xerr != nil {
return nil, xerr
}
// A copy is a new campaign, so it spends the same daily creation budget.
if s.throttle != nil {
if xerr := s.throttle.CheckAndIncrement(ctx, orgID, dailythrottle.ResourceCampaign, config.DailyThrottleNewCampaigns); xerr != nil {
return nil, xerr
}
}
newID := uuid.New()
copied, cleanup, xerr := s.copyAttachments(ctx, orgID, cID, newID)
if xerr != nil {
return nil, xerr
}
campaign, err := s.campaignRepository.Duplicate(ctx, repository.DuplicateCampaignInput{
SourceID: cID,
NewID: newID,
UserID: userID,
Name: name,
Attachments: copied,
})
if err != nil {
cleanup()
if errors.Is(err, errx.ErrResourceNotFound) {
return nil, errx.ErrNotFound
}
return nil, errx.InternalError()
}
if s.campaignLogRepo != nil {
_ = s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{
CampaignID: newID,
EventType: "created",
Message: fmt.Sprintf("Duplicated from %q", source.Name),
Metadata: map[string]interface{}{"source_campaign_id": cID.String()},
})
}
if s.streamingPublisher != nil {
s.streamingPublisher.PublishCampaignEvent(ctx, &pubsub.CampaignEvent{
BaseEvent: pubsub.BaseEvent{
EventType: pubsub.EventCampaignCreated,
UserID: userID.String(),
},
OrgID: orgID.String(),
CampaignID: campaign.ID.String(),
Name: campaign.Name,
Status: campaign.Status,
})
}
return campaign, nil
}
// copyAttachments writes a copy of every attachment object of src under dst
// and returns the rows to insert plus a best-effort undo for when the copy
// transaction fails. The copies count against the organization's storage
// quota exactly like an upload would. An attachment whose bytes cannot be
// read is reported and skipped rather than failing the whole duplicate.
func (s *campaignService) copyAttachments(ctx context.Context, orgID, src, dst uuid.UUID) ([]models.CampaignAttachment, func(), *errx.Error) {
noop := func() {}
if s.attachmentRepo == nil || s.storage == nil {
return nil, noop, nil
}
sources, err := s.attachmentRepo.ListByCampaign(ctx, src)
if err != nil {
return nil, noop, errx.InternalError()
}
if len(sources) == 0 {
return nil, noop, nil
}
if s.featureGate != nil {
limit, xerr := s.featureGate.GetStorageLimitBytes(ctx, orgID)
if xerr != nil {
return nil, noop, xerr
}
used, err := s.attachmentRepo.SumStorageUsedByOrg(ctx, orgID)
if err != nil {
return nil, noop, errx.InternalError()
}
var adding int64
for _, att := range sources {
adding += att.Size
}
if used+adding > limit {
const mb = 1024 * 1024
return nil, noop, errx.New(errx.BadRequest, fmt.Sprintf(
"duplicating would exceed your storage limit (%d MB of %d MB used, %d MB of attachments to copy): remove attachments or upgrade your plan",
used/mb, limit/mb, adding/mb))
}
}
copied := make([]models.CampaignAttachment, 0, len(sources))
for _, att := range sources {
body, err := s.storage.Get(ctx, att.S3Key)
if err != nil {
sentry.CaptureException(fmt.Errorf("campaign %s duplicate: read %s: %w", src, att.S3Key, err))
continue
}
key := models.AttachmentObjectKey(dst, att.Filename)
err = s.storage.Put(ctx, key, body, att.MimeType)
body.Close()
if err != nil {
sentry.CaptureException(fmt.Errorf("campaign %s duplicate: write %s: %w", src, key, err))
continue
}
att.S3Key = key
copied = append(copied, att)
}
return copied, func() {
for _, att := range copied {
if err := s.storage.Delete(ctx, att.S3Key); err != nil {
sentry.CaptureException(fmt.Errorf("campaign %s duplicate undo: object %s: %w", src, att.S3Key, err))
}
}
}, nil
}
// copySuffix matches a name that is already a copy: "X (copy)" or "X (copy 3)".
var copySuffix = regexp.MustCompile(`^(.*?) \(copy(?: (\d+))?\)$`)
// duplicateName derives the copy's name inside the 50 character cap. A copy
// of a copy counts up ("X (copy 2)") instead of stacking suffixes.
func duplicateName(source string) string {
const maxLen = 50
base := strings.TrimSpace(source)
suffix := " (copy)"
if m := copySuffix.FindStringSubmatch(base); m != nil {
n := 2
if m[2] != "" {
if parsed, err := strconv.Atoi(m[2]); err == nil {
n = parsed + 1
}
}
base = m[1]
suffix = fmt.Sprintf(" (copy %d)", n)
}
if len(base)+len(suffix) > maxLen {
base = strings.TrimSpace(truncateUTF8(base, maxLen-len(suffix)))
}
return base + suffix
}
// truncateUTF8 cuts s to at most n bytes without splitting a rune.
func truncateUTF8(s string, n int) string {
if len(s) <= n {
return s
}
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return s[:n]
}
func (s *campaignService) StartCampaign(ctx context.Context, orgID uuid.UUID, campaignID string) *errx.Error {
+23 -1
View File
@@ -8,6 +8,7 @@ import (
"github.com/warmbly/warmbly/internal/app/feature"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/infrastructure/pubsub"
"github.com/warmbly/warmbly/internal/infrastructure/storage"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
"github.com/warmbly/warmbly/internal/scheduler"
@@ -22,7 +23,14 @@ type CampaignService interface {
Search(ctx context.Context, userID, query, cursor, folder, status, limit string) (*models.CampaignsResult, *errx.Error)
Overview(ctx context.Context, orgID string) (*models.CampaignsOverview, *errx.Error)
Update(ctx context.Context, userID, id string, data *models.UpdateCampaign) (*models.Campaign, *errx.Error)
Delete(ctx context.Context, userID, id string) *errx.Error
// Delete removes an organization's campaign outright. A running campaign
// is stopped as part of it: its pending tasks are cancelled in the same
// transaction, so nothing keeps sending for a campaign that is gone.
// Returns the deleted campaign so callers can name it after the fact.
Delete(ctx context.Context, orgID uuid.UUID, campaignID string) (*models.Campaign, *errx.Error)
// Duplicate creates a draft copy of a campaign's configuration owned by
// userID. An empty name derives "<source name> (copy)".
Duplicate(ctx context.Context, orgID, userID uuid.UUID, campaignID, name string) (*models.Campaign, *errx.Error)
// Start/Stop
StartCampaign(ctx context.Context, orgID uuid.UUID, campaignID string) *errx.Error
@@ -58,6 +66,20 @@ type campaignService struct {
scheduler scheduler.SchedulerService
tasksClient tasksched.Scheduler
streamingPublisher *pubsub.StreamingPublisher
attachmentRepo repository.AttachmentRepository
storage storage.Store
}
// AttachmentAware is implemented by the campaign service so main can hand it
// the attachment repository and object store once those exist. Without them
// delete leaves attachment objects behind and duplicate skips attachments.
type AttachmentAware interface {
WireAttachments(repo repository.AttachmentRepository, store storage.Store)
}
func (s *campaignService) WireAttachments(repo repository.AttachmentRepository, store storage.Store) {
s.attachmentRepo = repo
s.storage = store
}
func NewService(
+6
View File
@@ -20,6 +20,12 @@ type CampaignAttachment struct {
CreatedAt time.Time `json:"created_at"`
}
// AttachmentObjectKey is where an attachment's bytes live in object storage.
// Upload and duplicate both write keys through this so the layout stays one.
func AttachmentObjectKey(campaignID uuid.UUID, filename string) string {
return "attachments/" + campaignID.String() + "/" + uuid.NewString() + "-" + filename
}
// AttachmentRef is the lightweight reference carried through the send pipeline
// (backend → Kafka → worker). The worker fetches the bytes from S3 by key.
type AttachmentRef struct {
@@ -0,0 +1,337 @@
package repository
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// Issue #185: delete and duplicate a campaign from the dashboard. Duplicate
// must copy configuration (steps with their branch graph, senders, variants,
// advanced settings, attachments) and nothing of the execution state; delete
// must take the campaign's pending tasks with it so nothing keeps firing.
//
// Run against the dev stack:
//
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \
// go test ./internal/repository/ -run LiveCampaignLifecycle -v
type lifecycleFixture struct {
org, owner, mate, mailbox, campaign, contact uuid.UUID
steps [3]uuid.UUID
pendingTask, sentTask, claimedTask uuid.UUID
}
func newLifecycleFixture(t *testing.T, pool *pgxpool.Pool) *lifecycleFixture {
t.Helper()
ctx := context.Background()
f := &lifecycleFixture{
org: uuid.New(), owner: uuid.New(), mate: uuid.New(), mailbox: uuid.New(),
campaign: uuid.New(), contact: uuid.New(),
steps: [3]uuid.UUID{uuid.New(), uuid.New(), uuid.New()},
pendingTask: uuid.New(), sentTask: uuid.New(), claimedTask: uuid.New(),
}
exec := func(sql string, args ...any) {
t.Helper()
if _, err := pool.Exec(ctx, sql, args...); err != nil {
t.Fatalf("fixture %q: %v", sql[:min(70, len(sql))], err)
}
}
for _, u := range []struct {
id uuid.UUID
name string
}{{f.owner, "Owner"}, {f.mate, "Mate"}} {
exec(`INSERT INTO users (id, first_name, last_name, email, password_hash)
VALUES ($1, $2, 'Live', $3, 'x')`, u.id, u.name, "i185-"+u.id.String()[:8]+"@test.local")
}
exec(`INSERT INTO organizations (id, name, slug, owner_user_id) VALUES ($1, 'Issue 185', $2, $3)`,
f.org, "i185-"+f.org.String()[:8], f.owner)
exec(`INSERT INTO organization_members (organization_id, user_id, role, accepted_at)
VALUES ($1, $2, 'owner', NOW()), ($1, $3, 'admin', NOW())`, f.org, f.owner, f.mate)
exec(`INSERT INTO email_accounts (id, user_id, organization_id, email, name,
signature_plain, signature_html, provider, status, campaign_limit, min_wait_time, timezone)
VALUES ($1, $2, $3, $4, 'Live', '', '', 'smtp_imap', 'active', 50, 600, 'UTC')`,
f.mailbox, f.owner, f.org, "i185-"+f.mailbox.String()[:8]+"@test.local")
// A running campaign with history: ramped, guardrail-tripped, dated in the
// past, with a schedule and a tracking domain.
exec(`INSERT INTO campaigns (id, user_id, organization_id, name, description, status, days,
daily_limit, start_date, end_date, schedule_windows, sender_strategy,
ramp_enabled, ramp_level, ramp_level_date, guardrail_enabled, guardrail_tripped_at, guardrail_reason,
tracking_domain, tracking_domain_verified, last_status_change_at, updated_at, created_at)
VALUES ($1, $2, $3, 'Agency partnerships', 'Warm intros', 'active', 62,
40, NOW() - INTERVAL '30 days', NOW() - INTERVAL '1 day',
'[[],[{"start":540,"end":1020}],[],[],[],[],[]]'::jsonb, 'explicit',
true, 3, CURRENT_DATE, true, NOW(), 'bounce rate 7%',
't.example.test', true, NOW(), NOW(), NOW())`,
f.campaign, f.owner, f.org)
// Three steps: 1 branches to 2 (replied) or 3 (otherwise), 2 flows to 3,
// 3 points at a step that no longer exists.
gone := uuid.New()
for i, s := range []struct {
id uuid.UUID
conditions string
kind string
}{
{f.steps[0], `{"branches":[{"branch_id":"b1","target_step_id":"` + f.steps[1].String() + `","conditions":[{"field":"replied","operator":"is_true"}],"instant":true},{"branch_id":"b2","target_step_id":"` + f.steps[2].String() + `"}]}`, "email"},
{f.steps[1], `{"branches":[{"branch_id":"b3","target_step_id":"` + f.steps[2].String() + `"}]}`, "email"},
{f.steps[2], `{"branches":[{"branch_id":"b4","target_step_id":"` + gone.String() + `"}]}`, "action"},
} {
exec(`INSERT INTO sequences (id, campaign_id, organization_id, name, subject, body_plain, body_html,
wait_after, position, conditions, kind, action, x, y)
VALUES ($1, $2, $3, $4, 'Hi {{first_name}}', 'plain', '<p>html</p>', $5, $6, $7::jsonb, $8, '{"type":"add_tag"}'::jsonb, $9, 20)`,
s.id, f.campaign, f.org, "Step "+string(rune('1'+i)), i*3, i+1, s.conditions, s.kind, float64(i*100))
}
exec(`INSERT INTO campaign_senders (campaign_id, email_account_id, weight, enabled, rotation_position, last_sent_at)
VALUES ($1, $2, 7, true, 4, NOW())`, f.campaign, f.mailbox)
exec(`INSERT INTO campaign_ab_variants (campaign_id, sequence_id, name, weight, subject, is_control)
VALUES ($1, $2, 'Subject B', 50, 'Quick question', false)`, f.campaign, f.steps[0])
exec(`INSERT INTO campaign_advanced_settings (campaign_id, settings) VALUES ($1, '{"bounce_policy":"strict"}'::jsonb)`, f.campaign)
// Execution state that must not travel.
exec(`INSERT INTO contacts (id, user_id, organization_id, email, first_name, last_name, company, phone, custom_fields, updated_at, created_at)
VALUES ($1, $2, $3, $4, 'Carlos', 'Diaz', 'Pied Piper', '', '{}'::jsonb, NOW(), NOW())`,
f.contact, f.owner, f.org, "i185-"+f.contact.String()[:8]+"@test.local")
exec(`INSERT INTO campaign_leads (campaign_id, contact_id) VALUES ($1, $2)`, f.campaign, f.contact)
exec(`INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at) VALUES ($1, $2, $3, NOW())`,
f.campaign, f.contact, f.steps[0])
exec(`INSERT INTO campaign_logs (campaign_id, event_type, message) VALUES ($1, 'started', 'Campaign started by user')`, f.campaign)
exec(`INSERT INTO tasks (id, task_type, email_account_id, status, message_id, scheduled_at)
VALUES ($1, 'campaign', $2, 'pending', '', NOW() + INTERVAL '1 hour'),
($3, 'email', $2, 'completed', '<sent@test.local>', NOW() - INTERVAL '1 hour'),
($4, 'campaign', $2, 'active', '', NOW())`,
f.pendingTask, f.mailbox, f.sentTask, f.claimedTask)
exec(`INSERT INTO campaign_tasks (task_id, campaign_id) VALUES ($1, $2), ($3, $2)`, f.pendingTask, f.campaign, f.claimedTask)
exec(`INSERT INTO campaign_tasks (task_id, campaign_id, contact_id, sequence_id) VALUES ($1, $2, $3, $4)`,
f.sentTask, f.campaign, f.contact, f.steps[0])
t.Cleanup(func() {
c := context.Background()
for _, step := range []struct {
sql string
arg any
}{
{`DELETE FROM campaigns WHERE organization_id = $1`, f.org},
{`DELETE FROM tasks WHERE id = ANY($1)`, []uuid.UUID{f.pendingTask, f.sentTask, f.claimedTask}},
{`DELETE FROM contacts WHERE organization_id = $1`, f.org},
{`DELETE FROM email_accounts WHERE organization_id = $1`, f.org},
{`DELETE FROM organization_members WHERE organization_id = $1`, f.org},
{`DELETE FROM organizations WHERE id = $1`, f.org},
{`DELETE FROM users WHERE id = ANY($1)`, []uuid.UUID{f.owner, f.mate}},
} {
if _, err := pool.Exec(c, step.sql, step.arg); err != nil {
t.Errorf("cleanup %q: %v", step.sql, err)
}
}
})
return f
}
func countRows(t *testing.T, pool *pgxpool.Pool, sql string, args ...any) int {
t.Helper()
var n int
if err := pool.QueryRow(context.Background(), sql, args...).Scan(&n); err != nil {
t.Fatalf("count %q: %v", sql, err)
}
return n
}
func TestLiveCampaignLifecycleDuplicateCopiesConfigurationOnly(t *testing.T) {
handle, pool := liveContactDB(t)
f := newLifecycleFixture(t, pool)
repo := NewCampaignRepostory(handle)
ctx := context.Background()
newID := uuid.New()
dup, err := repo.Duplicate(ctx, DuplicateCampaignInput{
SourceID: f.campaign,
NewID: newID,
UserID: f.mate,
Name: "Agency partnerships (copy)",
Attachments: []models.CampaignAttachment{{
SequenceID: &f.steps[0], UserID: f.owner, Filename: "deck.pdf", Size: 1234,
MimeType: "application/pdf", S3Key: "attachments/" + newID.String() + "/deck.pdf",
}},
})
if err != nil {
t.Fatalf("duplicate: %v", err)
}
// Campaign row: configuration copied, execution state reset.
if dup.ID != newID || dup.Name != "Agency partnerships (copy)" || dup.Status != "draft" {
t.Fatalf("dup = %s %q %s, want %s / (copy) / draft", dup.ID, dup.Name, dup.Status, newID)
}
if dup.UserID != f.mate.String() || dup.OrganizationID == nil || *dup.OrganizationID != f.org {
t.Errorf("owner = %s org = %v, want mate %s in org %s", dup.UserID, dup.OrganizationID, f.mate, f.org)
}
if dup.Description != "Warm intros" || dup.DailyLimit != 40 || dup.SenderStrategy != "explicit" || !dup.RampEnabled {
t.Errorf("configuration not copied: %+v", dup)
}
if dup.TrackingDomain != "t.example.test" || !dup.TrackingDomainVerified {
t.Errorf("tracking domain not copied: %q verified=%v", dup.TrackingDomain, dup.TrackingDomainVerified)
}
if dup.ScheduleWindows.IsEmpty() {
t.Errorf("schedule windows not copied")
}
if dup.RampLevel != 0 || dup.RampLevelDate != nil {
t.Errorf("ramp level travelled: %d %v", dup.RampLevel, dup.RampLevelDate)
}
if dup.GuardrailTrippedAt != nil || dup.GuardrailReason != "" || !dup.GuardrailEnabled {
t.Errorf("guardrail trip travelled or config lost: %v %q %v", dup.GuardrailTrippedAt, dup.GuardrailReason, dup.GuardrailEnabled)
}
if dup.StartDate != nil || dup.EndDate != nil {
t.Errorf("past dates should be dropped: start=%v end=%v", dup.StartDate, dup.EndDate)
}
if dup.LastStatusChangeAt != nil {
t.Errorf("last_status_change_at travelled: %v", dup.LastStatusChangeAt)
}
if len(dup.Senders) != 1 || dup.Senders[0].EmailAccountID != f.mailbox || dup.Senders[0].Weight != 7 {
t.Errorf("senders = %+v, want the one explicit mailbox with weight 7", dup.Senders)
}
if n := countRows(t, pool, `SELECT COUNT(*) FROM campaign_senders WHERE campaign_id = $1 AND rotation_position = 0 AND last_sent_at IS NULL`, newID); n != 1 {
t.Errorf("sender rotation cursor should reset, matching rows = %d", n)
}
// Steps: same shape, fresh ids, branch graph rewired onto the copies.
steps, err := repo.GetSequencesRoutingByCampaignID(ctx, newID)
if err != nil {
t.Fatalf("steps: %v", err)
}
if len(steps) != 3 {
t.Fatalf("steps = %d, want 3", len(steps))
}
idMap := map[uuid.UUID]uuid.UUID{}
for i, s := range steps {
if s.Position != i+1 {
t.Errorf("step %d position = %d", i, s.Position)
}
for _, old := range f.steps {
if s.ID == old {
t.Errorf("step %d kept the source id %s", i, old)
}
}
idMap[f.steps[i]] = s.ID
}
branchTargets := func(s models.Sequence) []*uuid.UUID {
var bc models.BranchConditions
if err := json.Unmarshal(s.Conditions, &bc); err != nil {
t.Fatalf("conditions of %s: %v", s.ID, err)
}
out := make([]*uuid.UUID, 0, len(bc.Branches))
for _, b := range bc.Branches {
out = append(out, b.TargetSequenceID)
}
return out
}
if got := branchTargets(steps[0]); len(got) != 2 || got[0] == nil || *got[0] != idMap[f.steps[1]] || got[1] == nil || *got[1] != idMap[f.steps[2]] {
t.Errorf("step 1 branches = %v, want [%s %s]", got, idMap[f.steps[1]], idMap[f.steps[2]])
}
if got := branchTargets(steps[1]); len(got) != 1 || got[0] == nil || *got[0] != idMap[f.steps[2]] {
t.Errorf("step 2 branches = %v, want [%s]", got, idMap[f.steps[2]])
}
if got := branchTargets(steps[2]); len(got) != 1 || got[0] != nil {
t.Errorf("step 3 dangling branch = %v, want [nil]", got)
}
if steps[2].Kind != "action" {
t.Errorf("step kind not copied: %q", steps[2].Kind)
}
// Children: variant and attachment follow their step; settings copied.
if n := countRows(t, pool, `SELECT COUNT(*) FROM campaign_ab_variants WHERE campaign_id = $1 AND sequence_id = $2 AND name = 'Subject B'`, newID, idMap[f.steps[0]]); n != 1 {
t.Errorf("variant rows on the copied step = %d, want 1", n)
}
if n := countRows(t, pool, `SELECT COUNT(*) FROM campaign_attachments WHERE campaign_id = $1 AND sequence_id = $2 AND user_id = $3 AND s3_key LIKE 'attachments/' || $1::text || '/%'`, newID, idMap[f.steps[0]], f.mate); n != 1 {
t.Errorf("attachment rows on the copied step = %d, want 1", n)
}
if n := countRows(t, pool, `SELECT COUNT(*) FROM campaign_advanced_settings WHERE campaign_id = $1 AND settings->>'bounce_policy' = 'strict'`, newID); n != 1 {
t.Errorf("advanced settings rows = %d, want 1", n)
}
// Execution state stays behind.
for _, q := range []string{
`SELECT COUNT(*) FROM campaign_leads WHERE campaign_id = $1`,
`SELECT COUNT(*) FROM campaign_contact_progress WHERE campaign_id = $1`,
`SELECT COUNT(*) FROM campaign_logs WHERE campaign_id = $1`,
`SELECT COUNT(*) FROM campaign_tasks WHERE campaign_id = $1`,
`SELECT COUNT(*) FROM campaign_daily_sends WHERE campaign_id = $1`,
} {
if n := countRows(t, pool, q, newID); n != 0 {
t.Errorf("%s = %d for the copy, want 0", q, n)
}
}
// The source is untouched.
src, err := repo.GetByID(ctx, f.campaign)
if err != nil {
t.Fatalf("source: %v", err)
}
if src.Status != "active" || src.RampLevel != 3 || src.GuardrailReason != "bounce rate 7%" || src.EndDate == nil {
t.Errorf("source changed: %+v", src)
}
if n := countRows(t, pool, `SELECT COUNT(*) FROM sequences WHERE campaign_id = $1 AND id = ANY($2)`, f.campaign, f.steps[:]); n != 3 {
t.Errorf("source steps = %d, want 3", n)
}
if n := countRows(t, pool, `SELECT COUNT(*) FROM campaign_leads WHERE campaign_id = $1`, f.campaign); n != 1 {
t.Errorf("source leads = %d, want 1", n)
}
}
func TestLiveCampaignLifecycleDeleteCancelsPendingTasksAndKeepsSentMail(t *testing.T) {
handle, pool := liveContactDB(t)
f := newLifecycleFixture(t, pool)
repo := NewCampaignRepostory(handle)
ctx := context.Background()
if err := repo.Delete(ctx, f.campaign); err != nil {
t.Fatalf("delete: %v", err)
}
if n := countRows(t, pool, `SELECT COUNT(*) FROM campaigns WHERE id = $1`, f.campaign); n != 0 {
t.Fatalf("campaign still present")
}
if n := countRows(t, pool, `SELECT COUNT(*) FROM tasks WHERE id = $1`, f.pendingTask); n != 0 {
t.Errorf("pending campaign task survived the delete and would fire")
}
if n := countRows(t, pool, `SELECT COUNT(*) FROM tasks WHERE id = $1 AND status = 'cancelled'`, f.claimedTask); n != 1 {
t.Errorf("a wakeup tick claimed at delete time should be marked cancelled, not left active")
}
// The completed send stays: it backs the mail already in the inbox, only
// its campaign link is cleared.
if n := countRows(t, pool, `SELECT COUNT(*) FROM tasks t JOIN campaign_tasks ct ON ct.task_id = t.id WHERE t.id = $1 AND ct.campaign_id IS NULL`, f.sentTask); n != 1 {
t.Errorf("completed send task should remain with a null campaign link")
}
for _, q := range []string{
`SELECT COUNT(*) FROM sequences WHERE campaign_id = $1`,
`SELECT COUNT(*) FROM campaign_leads WHERE campaign_id = $1`,
`SELECT COUNT(*) FROM campaign_contact_progress WHERE campaign_id = $1`,
`SELECT COUNT(*) FROM campaign_logs WHERE campaign_id = $1`,
`SELECT COUNT(*) FROM campaign_senders WHERE campaign_id = $1`,
`SELECT COUNT(*) FROM campaign_ab_variants WHERE campaign_id = $1`,
`SELECT COUNT(*) FROM campaign_advanced_settings WHERE campaign_id = $1`,
} {
if n := countRows(t, pool, q, f.campaign); n != 0 {
t.Errorf("%s = %d after delete, want 0", q, n)
}
}
if n := countRows(t, pool, `SELECT COUNT(*) FROM contacts WHERE id = $1`, f.contact); n != 1 {
t.Errorf("deleting a campaign must not delete its contacts")
}
if n := countRows(t, pool, `SELECT COUNT(*) FROM email_accounts WHERE id = $1`, f.mailbox); n != 1 {
t.Errorf("deleting a campaign must not delete its mailboxes")
}
if err := repo.Delete(ctx, f.campaign); !errors.Is(err, errx.ErrResourceNotFound) {
t.Errorf("second delete = %v, want ErrResourceNotFound", err)
}
if err := repo.Delete(ctx, uuid.New()); !errors.Is(err, errx.ErrResourceNotFound) {
t.Errorf("delete unknown = %v, want ErrResourceNotFound", err)
}
}
@@ -0,0 +1,74 @@
package repository
import (
"encoding/json"
"testing"
"github.com/google/uuid"
)
func TestRemapBranchTargetsRewritesKnownStepsAndStopsDanglingOnes(t *testing.T) {
oldA, oldB, gone := uuid.New(), uuid.New(), uuid.New()
newA, newB := uuid.New(), uuid.New()
idMap := map[uuid.UUID]uuid.UUID{oldA: newA, oldB: newB}
in := []byte(`{"branches":[
{"branch_id":"b1","target_step_id":"` + oldB.String() + `","conditions":[{"field":"opened","operator":"is_true","extra":"kept"}],"instant":false},
{"branch_id":"b2","target_step_id":"` + gone.String() + `"},
{"branch_id":"b3","target_step_id":null},
{"branch_id":"b4"}
],"editor_note":"survives"}`)
out, err := RemapBranchTargets(in, idMap)
if err != nil {
t.Fatalf("remap: %v", err)
}
var got struct {
Branches []struct {
BranchID string `json:"branch_id"`
Target *string `json:"target_step_id"`
Cond json.RawMessage `json:"conditions"`
Instant *bool `json:"instant"`
} `json:"branches"`
Note string `json:"editor_note"`
}
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Note != "survives" {
t.Errorf("unknown top-level field dropped: %s", out)
}
if len(got.Branches) != 4 {
t.Fatalf("branches = %d, want 4", len(got.Branches))
}
if got.Branches[0].Target == nil || *got.Branches[0].Target != newB.String() {
t.Errorf("mapped target = %v, want %s", got.Branches[0].Target, newB)
}
if got.Branches[0].Instant == nil || *got.Branches[0].Instant {
t.Errorf("instant flag not preserved: %s", out)
}
if string(got.Branches[0].Cond) == "" || !json.Valid(got.Branches[0].Cond) {
t.Errorf("conditions not preserved: %s", out)
}
if got.Branches[1].Target != nil {
t.Errorf("dangling target should become null, got %s", *got.Branches[1].Target)
}
if got.Branches[2].Target != nil {
t.Errorf("explicit null target changed: %s", out)
}
if got.Branches[3].Target != nil {
t.Errorf("absent target gained a value: %s", out)
}
}
func TestRemapBranchTargetsToleratesEmptyConditions(t *testing.T) {
for _, in := range [][]byte{nil, []byte(``), []byte(`{}`), []byte(`null`)} {
out, err := RemapBranchTargets(in, nil)
if err != nil {
t.Fatalf("%q: %v", in, err)
}
if string(out) != `{}` {
t.Errorf("%q -> %s, want {}", in, out)
}
}
}
+7 -28
View File
@@ -43,7 +43,13 @@ type CampaignRepository interface {
Update(ctx context.Context, userID, query string, data *models.UpdateCampaign) (*models.Campaign, *errx.Error)
UpdateStatus(ctx context.Context, campaignID uuid.UUID, status string) error
UpdateStatusWithLock(ctx context.Context, campaignID uuid.UUID, status string) error
Delete(ctx context.Context, userID, id string) error
// Delete removes the campaign, its cascading data and every pending task
// parked for it, in one transaction. Tenancy is the caller's job.
Delete(ctx context.Context, campaignID uuid.UUID) error
// Duplicate copies a campaign's configuration into a new draft (steps
// with their branch graph, tags, folders, senders, variants, advanced
// settings, attachment rows) and none of its execution state.
Duplicate(ctx context.Context, in DuplicateCampaignInput) (*models.Campaign, error)
// Campaign start/stop
StartCampaign(ctx context.Context, campaignID uuid.UUID) error
@@ -804,33 +810,6 @@ func (r *campaignRepository) Overview(ctx context.Context, orgID string) (*model
return &overview, nil
}
func (r *campaignRepository) Delete(ctx context.Context, userID, campaignID string) error {
query := `
DELETE FROM campaigns WHERE user_id = $1 AND id = $2
`
params := []any{
userID,
campaignID,
}
cmd, err := r.DB.Exec(
ctx,
query,
params...,
)
if err != nil {
db.CaptureError(err, query, params, "exec")
return err
}
if cmd.RowsAffected() == 0 {
return errx.ErrResourceNotFound
}
return nil
}
func (r *campaignRepository) Update(ctx context.Context, userID, campaignID string, data *models.UpdateCampaign) (*models.Campaign, *errx.Error) {
setClauses := []string{}
args := []any{userID, campaignID}
@@ -0,0 +1,359 @@
package repository
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/infrastructure/db"
"github.com/warmbly/warmbly/internal/models"
)
// DuplicateCampaignInput describes one campaign copy. Attachments carry the
// source rows with S3Key already pointing at the copied object; rows for
// attachments whose bytes could not be copied are simply left out.
type DuplicateCampaignInput struct {
SourceID uuid.UUID
NewID uuid.UUID
UserID uuid.UUID
Name string
Attachments []models.CampaignAttachment
}
// Delete removes a campaign and everything that only means something inside
// it. The pending tasks parked for the campaign (its wakeup chain and any
// not-yet-dispatched sends) go in the same transaction: campaign_tasks only
// nulls its link on delete, so without this the rows would still fire and
// then fail on a campaign that no longer exists. A wakeup tick that is
// executing right now is marked cancelled so it is not left claimed forever.
// Completed task rows stay, they back the sent mail already in the inbox.
func (r *campaignRepository) Delete(ctx context.Context, campaignID uuid.UUID) error {
tx, err := r.DB.Begin(ctx)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return err
}
defer tx.Rollback(ctx)
const cancelPending = `
DELETE FROM tasks
WHERE status = 'pending'
AND id IN (SELECT task_id FROM campaign_tasks WHERE campaign_id = $1)
`
if _, err := tx.Exec(ctx, cancelPending, campaignID); err != nil {
db.CaptureError(err, cancelPending, []any{campaignID}, "exec")
return err
}
const cancelClaimed = `
UPDATE tasks SET status = 'cancelled', updated_at = NOW()
WHERE status = 'active' AND task_type = 'campaign'
AND id IN (SELECT task_id FROM campaign_tasks WHERE campaign_id = $1)
`
if _, err := tx.Exec(ctx, cancelClaimed, campaignID); err != nil {
db.CaptureError(err, cancelClaimed, []any{campaignID}, "exec")
return err
}
const del = `DELETE FROM campaigns WHERE id = $1`
cmd, err := tx.Exec(ctx, del, campaignID)
if err != nil {
db.CaptureError(err, del, []any{campaignID}, "exec")
return err
}
if cmd.RowsAffected() == 0 {
return errx.ErrResourceNotFound
}
if err := tx.Commit(ctx); err != nil {
db.CaptureError(err, "", nil, "commit")
return err
}
return nil
}
// Duplicate copies a campaign's configuration into a fresh draft in one
// transaction: the campaign row, steps (with the branch graph rewritten onto
// the new step ids), tags, folders, sender pool, A/B variants, advanced
// settings and attachment rows. Execution state is never copied: no leads,
// progress, logs, daily counters, tasks, ramp level or guardrail trip. Dates
// already in the past are dropped so the copy does not finish the moment it
// starts.
func (r *campaignRepository) Duplicate(ctx context.Context, in DuplicateCampaignInput) (*models.Campaign, error) {
tx, err := r.DB.Begin(ctx)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return nil, err
}
defer tx.Rollback(ctx)
const copyCampaign = `
INSERT INTO campaigns (
id, user_id, organization_id, name, description, status,
stop_on_reply, open_tracking, link_tracking, text_only,
daily_limit, unsubscribe_header, risky_emails,
cc_addr, bcc_addr,
start_date, end_date, timezone, days, start_time, end_time, schedule_windows,
contact_order_by, contact_order_dir, contact_order_field,
sender_strategy, rotation_mode,
ramp_enabled, ramp_start, ramp_increment, ramp_ceiling, ramp_level, ramp_level_date,
esp_match_mode, max_new_leads_per_day, prioritize_new_leads,
tracking_domain, tracking_domain_verified, tracking_domain_verified_at,
guardrail_enabled, guardrail_bounce_rate_max, guardrail_complaint_rate_max,
guardrail_reply_rate_min, guardrail_min_sample, guardrail_window_days,
guardrail_tripped_at, guardrail_reason,
last_status_change_at, updated_at, created_at
)
SELECT
$2, $3, organization_id, $4, description, 'draft',
stop_on_reply, open_tracking, link_tracking, text_only,
daily_limit, unsubscribe_header, risky_emails,
cc_addr, bcc_addr,
CASE WHEN start_date > NOW() THEN start_date END,
CASE WHEN end_date > NOW() THEN end_date END,
timezone, days, start_time, end_time, schedule_windows,
contact_order_by, contact_order_dir, contact_order_field,
sender_strategy, rotation_mode,
ramp_enabled, ramp_start, ramp_increment, ramp_ceiling, 0, NULL,
esp_match_mode, max_new_leads_per_day, prioritize_new_leads,
tracking_domain, tracking_domain_verified, tracking_domain_verified_at,
guardrail_enabled, guardrail_bounce_rate_max, guardrail_complaint_rate_max,
guardrail_reply_rate_min, guardrail_min_sample, guardrail_window_days,
NULL, '',
NULL, NOW(), NOW()
FROM campaigns
WHERE id = $1
`
cmd, err := tx.Exec(ctx, copyCampaign, in.SourceID, in.NewID, in.UserID, in.Name)
if err != nil {
db.CaptureError(err, copyCampaign, []any{in.SourceID, in.NewID}, "exec")
return nil, err
}
if cmd.RowsAffected() == 0 {
return nil, errx.ErrResourceNotFound
}
stepIDs, err := copyCampaignStepsTx(ctx, tx, in.SourceID, in.NewID)
if err != nil {
return nil, err
}
for _, q := range []string{
`INSERT INTO campaign_email_tags (campaign_id, tag_id)
SELECT $2, tag_id FROM campaign_email_tags WHERE campaign_id = $1`,
`INSERT INTO campaign_folders (campaign_id, folder_id)
SELECT $2, folder_id FROM campaign_folders WHERE campaign_id = $1`,
`INSERT INTO campaign_senders (campaign_id, email_account_id, weight, enabled)
SELECT $2, email_account_id, weight, enabled FROM campaign_senders WHERE campaign_id = $1`,
`INSERT INTO campaign_advanced_settings (campaign_id, settings, updated_at)
SELECT $2, settings, NOW() FROM campaign_advanced_settings WHERE campaign_id = $1`,
} {
if _, err := tx.Exec(ctx, q, in.SourceID, in.NewID); err != nil {
db.CaptureError(err, q, []any{in.SourceID, in.NewID}, "exec")
return nil, err
}
}
if err := copyCampaignVariantsTx(ctx, tx, in.SourceID, in.NewID, stepIDs); err != nil {
return nil, err
}
const insertAttachment = `
INSERT INTO campaign_attachments (campaign_id, sequence_id, user_id, filename, size, mime_type, s3_key)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
for _, att := range in.Attachments {
var seqID *uuid.UUID
if att.SequenceID != nil {
if mapped, ok := stepIDs[*att.SequenceID]; ok {
seqID = &mapped
}
}
args := []any{in.NewID, seqID, in.UserID, att.Filename, att.Size, att.MimeType, att.S3Key}
if _, err := tx.Exec(ctx, insertAttachment, args...); err != nil {
db.CaptureError(err, insertAttachment, args, "exec")
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
db.CaptureError(err, "", nil, "commit")
return nil, err
}
campaign, err := r.GetByID(ctx, in.NewID)
if err != nil {
return nil, err
}
if campaign.Senders, err = r.GetCampaignSenders(ctx, in.NewID); err != nil {
return nil, err
}
return campaign, nil
}
// copyCampaignStepsTx copies every step of src under dst with fresh ids and
// returns the old-to-new id map. Every new id is chosen before the first
// insert so each step's branch targets can be rewritten in the same statement.
func copyCampaignStepsTx(ctx context.Context, tx pgx.Tx, src, dst uuid.UUID) (map[uuid.UUID]uuid.UUID, error) {
const list = `
SELECT id, conditions
FROM sequences
WHERE campaign_id = $1
ORDER BY position ASC, created_at ASC
`
rows, err := tx.Query(ctx, list, src)
if err != nil {
db.CaptureError(err, list, []any{src}, "query")
return nil, err
}
type step struct {
id uuid.UUID
conditions []byte
}
var steps []step
for rows.Next() {
var s step
if err := rows.Scan(&s.id, &s.conditions); err != nil {
rows.Close()
db.CaptureError(err, list, []any{src}, "scan")
return nil, err
}
steps = append(steps, s)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
idMap := make(map[uuid.UUID]uuid.UUID, len(steps))
for _, s := range steps {
idMap[s.id] = uuid.New()
}
const copyStep = `
INSERT INTO sequences (
id, campaign_id, organization_id, name, subject,
body_plain, body_html, body_sync, body_code,
wait_after, position, conditions, kind, action, x, y,
created_at, updated_at
)
SELECT
$2, $3, organization_id, name, subject,
body_plain, body_html, body_sync, body_code,
wait_after, position, $4::jsonb, kind, action, x, y,
NOW(), NOW()
FROM sequences
WHERE id = $1
`
for _, s := range steps {
newID := idMap[s.id]
conditions, err := RemapBranchTargets(s.conditions, idMap)
if err != nil {
return nil, fmt.Errorf("step %s conditions: %w", s.id, err)
}
if _, err := tx.Exec(ctx, copyStep, s.id, newID, dst, conditions); err != nil {
db.CaptureError(err, copyStep, []any{s.id, newID, dst}, "exec")
return nil, err
}
}
return idMap, nil
}
// copyCampaignVariantsTx copies the A/B variants, pointing per-step variants
// at the copied step.
func copyCampaignVariantsTx(ctx context.Context, tx pgx.Tx, src, dst uuid.UUID, stepIDs map[uuid.UUID]uuid.UUID) error {
const list = `SELECT id, sequence_id FROM campaign_ab_variants WHERE campaign_id = $1 ORDER BY created_at ASC`
rows, err := tx.Query(ctx, list, src)
if err != nil {
db.CaptureError(err, list, []any{src}, "query")
return err
}
type variant struct {
id uuid.UUID
seqID *uuid.UUID
}
var variants []variant
for rows.Next() {
var v variant
if err := rows.Scan(&v.id, &v.seqID); err != nil {
rows.Close()
db.CaptureError(err, list, []any{src}, "scan")
return err
}
variants = append(variants, v)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
const copyVariant = `
INSERT INTO campaign_ab_variants (
campaign_id, sequence_id, name, weight, subject, body_html, body_plain,
is_control, is_active, metadata, created_at, updated_at
)
SELECT $2, $3, name, weight, subject, body_html, body_plain,
is_control, is_active, metadata, NOW(), NOW()
FROM campaign_ab_variants
WHERE id = $1
`
for _, v := range variants {
var seqID *uuid.UUID
if v.seqID != nil {
if mapped, ok := stepIDs[*v.seqID]; ok {
seqID = &mapped
}
}
if _, err := tx.Exec(ctx, copyVariant, v.id, dst, seqID); err != nil {
db.CaptureError(err, copyVariant, []any{v.id, dst}, "exec")
return err
}
}
return nil
}
// RemapBranchTargets rewrites every branch target_step_id in a step's
// conditions jsonb through idMap. It edits the generic JSON rather than the
// typed BranchConditions so fields the editor stores that the Go struct does
// not know about survive the copy. A target with no mapping (a dangling
// reference to a deleted step) becomes null, which routing already treats as
// STOP.
func RemapBranchTargets(conditions []byte, idMap map[uuid.UUID]uuid.UUID) ([]byte, error) {
if len(conditions) == 0 {
return []byte(`{}`), nil
}
var root map[string]any
if err := json.Unmarshal(conditions, &root); err != nil {
return nil, err
}
if root == nil {
return []byte(`{}`), nil
}
branches, _ := root["branches"].([]any)
for _, b := range branches {
branch, ok := b.(map[string]any)
if !ok {
continue
}
raw, present := branch["target_step_id"]
if !present || raw == nil {
continue
}
var target *uuid.UUID
if s, ok := raw.(string); ok {
if old, err := uuid.Parse(s); err == nil {
if mapped, ok := idMap[old]; ok {
target = &mapped
}
}
}
if target == nil {
branch["target_step_id"] = nil
} else {
branch["target_step_id"] = target.String()
}
}
return json.Marshal(root)
}
+14 -1
View File
@@ -83,9 +83,16 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error {
return errx.InternalError()
}
if campaignTask == nil || campaignTask.CampaignID == nil {
if campaignTask == nil {
return errx.ErrNotFound
}
// campaign_tasks nulls its link when the campaign is deleted: the chain
// ends here rather than leaving the row claimed as active.
if campaignTask.CampaignID == nil {
s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled")
executionStatus = "completed"
return nil
}
// Get campaign progress for task progress events
campaignProgress, _ := s.campaignProgressRepo.GetCampaignProgress(ctx, *campaignTask.CampaignID)
@@ -98,6 +105,12 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error {
// STEP 5: Load campaign
campaign, err := s.campaignRepo.GetByID(ctx, *campaignTask.CampaignID)
if err != nil {
// Deleted between the pending check and here: the chain ends.
if errors.Is(err, errx.ErrResourceNotFound) {
s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled")
executionStatus = "completed"
return nil
}
sentry.CaptureException(err)
return errx.InternalError()
}
+32 -7
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import PermissionButton from "@/components/ui/PermissionButton";
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Link, Outlet, useLocation, useNavigate, useParams } from "react-router-dom";
import { motion } from "framer-motion";
import {
ArrowLeftIcon,
@@ -19,6 +19,10 @@ import useStopCampaign from "@/lib/api/hooks/app/campaigns/useStopCampaign";
import { CampaignContext } from "@/hooks/context/campaign";
import { useConfirm } from "@/hooks/context/confirm";
import LaunchCampaignDialog from "@/components/app/campaigns/LaunchCampaignDialog";
import CampaignActionsMenu from "@/components/app/campaigns/CampaignActionsMenu";
import { canStartCampaign } from "@/components/app/campaigns/useCampaignActions";
import toast from "react-hot-toast";
import { CAMPAIGN_DELETED_EVENT, type CampaignDeletedDetail } from "@/lib/realtime/campaignDeleted";
import ResourceViewers from "@/components/app/presence/ResourceViewers";
import { usePresenceResource } from "@/hooks/PresenceProvider";
@@ -40,6 +44,7 @@ const STATUS_PILL: Record<string, string> = {
export default function CampaignLayout() {
const { pathname } = useLocation();
const { id } = useParams();
const navigate = useNavigate();
const campaignData = useCampaign(id ?? "");
const confirm = useConfirm();
const startCampaign = useStartCampaign();
@@ -50,6 +55,20 @@ export default function CampaignLayout() {
// who's already in here (header pill + the org-wide presence stack).
usePresenceResource(id ? `campaign:${id}` : null);
// A teammate deleted the campaign this page shows: leave it with a note
// instead of refetching into a 404.
useEffect(() => {
if (!id) return;
const onDeleted = (e: Event) => {
const detail = (e as CustomEvent<CampaignDeletedDetail>).detail;
if (detail?.id !== id) return;
toast(`"${detail.name || "This campaign"}" was deleted by a teammate`);
navigate("/app/campaigns", { replace: true });
};
window.addEventListener(CAMPAIGN_DELETED_EVENT, onDeleted);
return () => window.removeEventListener(CAMPAIGN_DELETED_EVENT, onDeleted);
}, [id, navigate]);
if (campaignData.isLoading) {
return (
<div className="px-5 pt-5 space-y-4">
@@ -88,7 +107,7 @@ export default function CampaignLayout() {
const pill = STATUS_PILL[status] ?? STATUS_PILL.draft;
const isActive = status === "active";
const canStart = status === "draft" || status === "paused";
const canStart = canStartCampaign(status);
const canToggle = isActive || canStart;
const pending = isActive ? stopCampaign.isPending : startCampaign.isPending;
@@ -126,8 +145,8 @@ export default function CampaignLayout() {
<p className="text-[11px] text-slate-400 font-mono mt-1 truncate">{campaign.id}</p>
</div>
{canToggle && (
<div className="ml-auto shrink-0">
<div className="ml-auto shrink-0 flex items-center gap-1.5">
{canToggle && (
<PermissionButton
permission="SEND_CAMPAIGNS"
type="button"
@@ -144,8 +163,14 @@ export default function CampaignLayout() {
)}
{isActive ? "Pause" : "Start"}
</PermissionButton>
</div>
)}
)}
<CampaignActionsMenu
campaign={campaign}
variant="header"
onToggle={canToggle ? onToggle : undefined}
afterDelete={() => navigate("/app/campaigns", { replace: true })}
/>
</div>
</div>
<div className="shrink-0 px-3 flex items-center gap-1 border-b border-slate-200 overflow-x-auto no-scrollbar">
@@ -19,6 +19,7 @@ import CampaignContactOrder from "@/components/app/campaigns/preferences/Campaig
import { GuardrailsSection } from "@/components/app/campaigns/preferences/CampaignGuardrails";
import { guardrailValidationError } from "@/lib/helper/guardrail";
import CampaignFolderField from "@/components/app/campaigns/CampaignFolderField";
import CampaignDangerZone from "@/components/app/campaigns/preferences/CampaignDangerZone";
import useUpdateCampaign from "@/lib/api/hooks/app/campaigns/useUpdateCampaign";
import toast from "react-hot-toast";
import type { AppError } from "@/lib/api/client/normalizeError";
@@ -70,6 +71,7 @@ const SECTIONS = [
description: "Copy extra addresses on every email sent by this campaign.",
},
{ id: "order", label: "Contact order", description: "The order contacts are sent in." },
{ id: "danger", label: "Delete campaign", description: "" },
] as const;
type SectionId = (typeof SECTIONS)[number]["id"];
@@ -367,6 +369,8 @@ export default function CampaignPreferences() {
return <CcBccSection newCampaign={newData} setNewCampaign={setNewData} />;
case "order":
return <CampaignContactOrder campaign={campaign} newCampaign={newData} setNewCampaign={setNewData} />;
case "danger":
return <CampaignDangerZone campaign={campaign} />;
}
};
+17 -8
View File
@@ -11,6 +11,7 @@ import AdvisorRowFlag from "@/components/app/advisor/AdvisorRowFlag";
import AdvisorSummaryBar from "@/components/app/advisor/AdvisorSummaryBar";
import { useAdvisorEntityIndex } from "@/lib/api/hooks/app/advisor/useAdvisor";
import LaunchCampaignDialog from "@/components/app/campaigns/LaunchCampaignDialog";
import CampaignActionsMenu from "@/components/app/campaigns/CampaignActionsMenu";
import toast from "react-hot-toast";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
@@ -292,6 +293,16 @@ export default function CampaignsPage() {
}
}
// Row start/pause: pause asks first, start opens the launch dialog.
function toggleRow(c: Campaign) {
const cstatus = c.status ?? "draft";
if (cstatus === "active") {
confirm?.show(`Pause ${c.name}?`, () => toggleCampaign(c.id, cstatus));
} else {
setLaunchTarget(c);
}
}
const campaignsData = useCampaigns({ query, folder });
// One query for the surface; a step's copy problem indexes onto its parent
// campaign, so it flags the row even though the step has no row of its own.
@@ -563,14 +574,7 @@ export default function CampaignsPage() {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (cstatus === "active") {
confirm?.show(
`Pause ${c.name}?`,
() => toggleCampaign(c.id, cstatus),
);
} else {
setLaunchTarget(c);
}
toggleRow(c);
}}
disabled={
(cstatus === "active" && stopCampaign.isPending) ||
@@ -583,6 +587,11 @@ export default function CampaignsPage() {
>
<StateIcon className="w-3.5 h-3.5" />
</button>
<CampaignActionsMenu
campaign={c}
variant="row"
onToggle={() => toggleRow(c)}
/>
</Link>
);
})}
@@ -0,0 +1,102 @@
// The campaign "⋯" menu: Edit, Duplicate, Start/Pause, Delete. Used by the
// campaigns list row (compact, hover-revealed trigger) and the campaign
// detail header. Destructive and copy actions are permission-gated: a member
// without manage_campaigns gets the standard explanation instead of a request.
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
CopyIcon,
MoreHorizontalIcon,
PauseIcon,
PencilIcon,
PlayIcon,
TrashIcon,
} from "lucide-react";
import {
PopoverMenu,
PopoverMenuContent,
PopoverMenuItem,
PopoverMenuSeparator,
PopoverMenuTrigger,
} from "@/components/ui/popover-menu";
import { cn } from "@/lib/utils";
import type Campaign from "@/lib/api/models/app/campaigns/Campaign";
import { canStartCampaign, gate, useCampaignActions } from "./useCampaignActions";
interface Props {
campaign: Campaign;
variant: "row" | "header";
/** Start or pause handler. Omit to hide the item. */
onToggle?: () => void;
/** Runs after a successful delete (the detail header navigates away). */
afterDelete?: () => void;
}
export default function CampaignActionsMenu({ campaign, variant, onToggle, afterDelete }: Props) {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const actions = useCampaignActions();
const status = campaign.status ?? "draft";
const isActive = status === "active";
const showToggle = onToggle !== undefined && (isActive || canStartCampaign(status));
return (
<PopoverMenu open={open} onOpenChange={setOpen} align="end">
<PopoverMenuTrigger asChild>
<button
type="button"
aria-label={`Actions for ${campaign.name}`}
title="More actions"
className={cn(
"inline-flex items-center justify-center shrink-0 transition-colors",
variant === "row"
? "size-6 rounded text-slate-400 hover:text-slate-900 hover:bg-slate-100 opacity-100 md:opacity-0 md:group-hover:opacity-100 aria-expanded:opacity-100 transition-opacity"
: "h-7 w-7 rounded-md border border-slate-200 text-slate-500 hover:text-slate-900 hover:bg-slate-50",
)}
>
<MoreHorizontalIcon className="w-3.5 h-3.5" />
</button>
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={184}>
<PopoverMenuItem
onSelect={() => navigate(`/app/campaigns/${campaign.id}/preferences`)}
icon={<PencilIcon className="w-3 h-3" />}
>
{variant === "row" ? "Edit" : "Edit settings"}
</PopoverMenuItem>
<PopoverMenuItem
onSelect={gate("MANAGE_CAMPAIGNS", () => actions.duplicateAndOpen(campaign))}
disabled={actions.duplicating}
icon={<CopyIcon className="w-3 h-3" />}
>
Duplicate
</PopoverMenuItem>
{showToggle && (
<PopoverMenuItem
onSelect={gate("SEND_CAMPAIGNS", () => onToggle?.())}
icon={
isActive ? (
<PauseIcon className="w-3 h-3" />
) : (
<PlayIcon className="w-3 h-3" />
)
}
>
{isActive ? "Pause" : "Start"}
</PopoverMenuItem>
)}
<PopoverMenuSeparator />
<PopoverMenuItem
danger
onSelect={gate("MANAGE_CAMPAIGNS", () => actions.requestDelete(campaign, { afterDelete }))}
disabled={actions.deleting}
icon={<TrashIcon className="w-3 h-3" />}
>
Delete
</PopoverMenuItem>
</PopoverMenuContent>
</PopoverMenu>
);
}
@@ -0,0 +1,39 @@
import { useNavigate } from "react-router-dom";
import { TrashIcon } from "lucide-react";
import PermissionButton from "@/components/ui/PermissionButton";
import type Campaign from "@/lib/api/models/app/campaigns/Campaign";
import { useCampaignActions } from "@/components/app/campaigns/useCampaignActions";
// Settings > Delete campaign. Same confirm and cleanup as the "⋯" menu; on
// success the settings page no longer exists, so it lands on the list.
export default function CampaignDangerZone({ campaign }: { campaign: Campaign }) {
const navigate = useNavigate();
const actions = useCampaignActions();
const running = campaign.status === "active";
return (
<div className="rounded-md border border-red-200 bg-red-50/40 p-4 flex flex-col sm:flex-row sm:items-center gap-3">
<div className="min-w-0 flex-1">
<p className="text-[12.5px] font-medium text-slate-900">Delete this campaign</p>
<p className="text-[11.5px] text-slate-500 mt-0.5 leading-relaxed">
{running
? "It is still running. Deleting stops sending immediately and removes the campaign, its steps, lead progress and activity. Contacts and emails already sent stay."
: "Removes the campaign, its steps, lead progress and activity. Contacts and emails already sent stay."}{" "}
This can't be undone.
</p>
</div>
<PermissionButton
permission="MANAGE_CAMPAIGNS"
type="button"
onClick={() =>
actions.requestDelete(campaign, { afterDelete: () => navigate("/app/campaigns") })
}
disabled={actions.deleting}
className="h-7 px-2.5 rounded-md border border-red-200 text-red-600 hover:bg-red-600 hover:border-red-600 hover:text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60 shrink-0"
>
<TrashIcon className="w-3.5 h-3.5" />
Delete campaign
</PermissionButton>
</div>
);
}
@@ -0,0 +1,92 @@
// Shared delete / duplicate flows for a campaign, so the list row menu, the
// detail header and the settings danger zone behave identically: same
// confirmation copy, same toasts, same permission gate, same landing page
// after a duplicate.
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import { useConfirm } from "@/hooks/context/confirm";
import { checkPermission, showPermissionDenied, type PermissionKey } from "@/hooks/usePermission";
import useDeleteCampaign from "@/lib/api/hooks/app/campaigns/useDeleteCampaign";
import useDuplicateCampaign from "@/lib/api/hooks/app/campaigns/useDuplicateCampaign";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
export interface CampaignLike {
id: string;
name: string;
status?: string | null;
}
// Statuses from which a start is accepted by the backend.
const STARTABLE = new Set(["draft", "paused", "paused_no_accounts", "paused_guardrail", "completed"]);
export function canStartCampaign(status: string | null | undefined): boolean {
return STARTABLE.has(status ?? "draft");
}
// The confirmation spells out what is removed and, for a running campaign,
// that sending stops as part of the delete rather than requiring a pause.
export function deleteCampaignPrompt(c: CampaignLike): string {
const what =
"The campaign, its steps, lead progress and activity are permanently removed. Contacts and emails already sent stay.";
if (c.status === "active") {
return `Delete "${c.name}"? It is still running: sending stops immediately. ${what} This can't be undone.`;
}
return `Delete "${c.name}"? ${what} This can't be undone.`;
}
// gate runs fn when the member holds the permission and pops the standard
// permission explanation otherwise, for controls that are not buttons.
export function gate(key: PermissionKey, fn: () => void): () => void {
return () => {
if (!checkPermission(key)) {
showPermissionDenied(key);
return;
}
fn();
};
}
export function useCampaignActions() {
const confirm = useConfirm();
const navigate = useNavigate();
const del = useDeleteCampaign();
const duplicate = useDuplicateCampaign();
// Confirm, then delete. A failure keeps the dialog open (the provider
// stays up when the callback throws) with the error in a toast, so the
// user can retry or cancel; success closes it and runs afterDelete.
function requestDelete(c: CampaignLike, opts?: { afterDelete?: () => void }) {
confirm.show(deleteCampaignPrompt(c), async () => {
await toast.promise(del.mutateAsync(c.id), {
loading: "Deleting campaign…",
success: `Deleted "${c.name}"`,
error: (e: AppError) => buildError(e),
});
opts?.afterDelete?.();
});
}
// Duplicate into a draft and land on its settings so it can be renamed
// and adjusted before it is started.
async function duplicateAndOpen(c: CampaignLike) {
try {
const created = await toast.promise(duplicate.mutateAsync({ id: c.id }), {
loading: "Duplicating campaign…",
success: (copy) => `Created "${copy.name}" as a draft`,
error: (e: AppError) => buildError(e),
});
navigate(`/app/campaigns/${created.id}/preferences`);
} catch {
/* toast.promise already surfaced */
}
}
return {
requestDelete,
duplicateAndOpen,
deleting: del.isPending,
duplicating: duplicate.isPending,
};
}
+15
View File
@@ -5,6 +5,7 @@ import { useSocket } from './context/socket'
import { useAppStore } from '@/stores'
import { useUserProfile } from './context/user'
import { markSelfMutation } from '@/lib/realtime/selfActivity'
import { announceCampaignDeleted } from '@/lib/realtime/campaignDeleted'
// Bridges realtime socket events into both the zustand store and react-query
// cache so list pages, detail panes, counters, and workflow states stay live.
@@ -140,6 +141,19 @@ export function useRealtimeEvents() {
return
}
// A deleted campaign: its detail caches are dropped, not refetched (a
// refetch only 404s). The deleter's own page has already left; a
// teammate's open detail page is sent back by the announcement.
if (event === 'CAMPAIGN_DELETED' && campaignId) {
if (getString('user_id') !== myId) {
announceCampaignDeleted({ id: campaignId, name: getString('name') ?? '' })
}
queryClient.removeQueries({ queryKey: ['campaigns', campaignId] })
queryClient.removeQueries({ queryKey: ['analytics', 'campaigns', campaignId] })
invalidate([['campaigns', 'list'], ['analytics'], ['contacts']])
return
}
if (
includes(
'CAMPAIGN',
@@ -394,6 +408,7 @@ export function useRealtimeEvents() {
incrementUnseenCount,
invalidate,
myId,
queryClient,
setSubscription,
updateCampaign,
updateDeal,
@@ -0,0 +1,13 @@
import type Campaign from "@/lib/api/models/app/campaigns/Campaign";
import Request from "../../Request";
// Creates a draft copy of the campaign's configuration. Without a name the
// backend derives "<source name> (copy)".
export default async function duplicateCampaign(id: string, name?: string): Promise<Campaign> {
return await Request<Campaign>({
method: "POST",
url: `/campaigns/${id}/duplicate`,
data: name ? { name } : {},
authorization: true,
});
}
@@ -0,0 +1,132 @@
// Issue #185: delete and duplicate from the campaigns UI. The list is an
// infinite query with a 5 minute staleTime, so a deleted campaign has to leave
// the cached pages immediately and a duplicate has to appear in them, or the
// page keeps showing the old set until something else refetches it.
import React from "react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { QueryClient, QueryClientProvider, type InfiniteData } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import type Campaign from "@/lib/api/models/app/campaigns/Campaign";
import type GetCampaigns from "@/lib/api/models/app/campaigns/GetCampaigns";
const getCampaigns = vi.fn();
const deleteCampaign = vi.fn();
const duplicateCampaign = vi.fn();
vi.mock("@/lib/api/client/app/campaigns/getCampaigns", () => ({
default: (...args: unknown[]) => getCampaigns(...args),
}));
vi.mock("@/lib/api/client/app/campaigns/deleteCampaign", () => ({
default: (...args: unknown[]) => deleteCampaign(...args),
}));
vi.mock("@/lib/api/client/app/campaigns/duplicateCampaign", () => ({
default: (...args: unknown[]) => duplicateCampaign(...args),
}));
const useCampaigns = (await import("./useCampaigns")).default;
const useDeleteCampaign = (await import("./useDeleteCampaign")).default;
const useDuplicateCampaign = (await import("./useDuplicateCampaign")).default;
function campaign(id: string, name: string, status = "active"): Campaign {
return { id, name, status, description: "" } as unknown as Campaign;
}
function page(data: Campaign[]): GetCampaigns {
return { data, pagination: { total: data.length, next_cursor: null, has_more: false } } as GetCampaigns;
}
// The ids in the cached list pages right now, before any refetch settles.
function cachedIds(client: QueryClient): string[] {
const [entry] = client.getQueriesData<InfiniteData<GetCampaigns>>({ queryKey: ["campaigns", "list", "", ""] });
return entry?.[1]?.pages.flatMap((p) => p.data.map((c) => c.id)) ?? [];
}
function wrapper(client: QueryClient) {
return function Wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
const A = campaign("camp-a", "Agency partnerships");
const B = campaign("camp-b", "RevOps outreach", "draft");
describe("campaign delete and duplicate keep the campaigns list current", () => {
let client: QueryClient;
let server: Campaign[];
beforeEach(() => {
vi.clearAllMocks();
client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
server = [A, B];
getCampaigns.mockImplementation(async () => page([...server]));
deleteCampaign.mockImplementation(async (id: string) => {
server = server.filter((c) => c.id !== id);
});
duplicateCampaign.mockImplementation(async (id: string) => {
const source = server.find((c) => c.id === id)!;
const copy = campaign(`${id}-copy`, `${source.name} (copy)`, "draft");
server = [copy, ...server];
return copy;
});
});
it("removes a deleted campaign from every cached list page without waiting for a refetch", async () => {
const list = renderHook(() => useCampaigns({ query: "", folder: "" }), { wrapper: wrapper(client) });
await waitFor(() => expect(list.result.current.isSuccess).toBe(true));
expect(list.result.current.campaigns.map((c) => c.id)).toEqual(["camp-a", "camp-b"]);
client.setQueryData(["campaigns", "camp-a"], A);
const del = renderHook(() => useDeleteCampaign(), { wrapper: wrapper(client) });
await del.result.current.mutateAsync("camp-a");
expect(deleteCampaign).toHaveBeenCalledWith("camp-a");
// The cache was patched, not merely invalidated: the row is gone from
// the stored pages before any refetch has answered.
expect(cachedIds(client)).toEqual(["camp-b"]);
expect(client.getQueryData(["campaigns", "camp-a"])).toBeUndefined();
await waitFor(() => expect(getCampaigns).toHaveBeenCalledTimes(2));
await waitFor(() => expect(list.result.current.campaigns.map((c) => c.id)).toEqual(["camp-b"]));
});
it("places the duplicate at the top of the list and primes its detail cache", async () => {
const list = renderHook(() => useCampaigns({ query: "", folder: "" }), { wrapper: wrapper(client) });
await waitFor(() => expect(list.result.current.isSuccess).toBe(true));
// A filtered list may not contain the copy, so it is refetched, never patched.
client.setQueryData(["campaigns", "list", "webinar", "", 20], {
pages: [page([B])],
pageParams: [null],
});
const dup = renderHook(() => useDuplicateCampaign(), { wrapper: wrapper(client) });
const copy = await dup.result.current.mutateAsync({ id: "camp-a" });
expect(duplicateCampaign).toHaveBeenCalledWith("camp-a", undefined);
expect(copy.name).toBe("Agency partnerships (copy)");
expect(copy.status).toBe("draft");
expect(cachedIds(client)).toEqual(["camp-a-copy", "camp-a", "camp-b"]);
const filtered = client.getQueryData<InfiniteData<GetCampaigns>>(["campaigns", "list", "webinar", "", 20]);
expect(filtered?.pages[0].data.map((c) => c.id)).toEqual(["camp-b"]);
// Navigating straight to the copy must not flash a skeleton.
expect(client.getQueryData(["campaigns", "camp-a-copy"])).toEqual(copy);
await waitFor(() => expect(getCampaigns).toHaveBeenCalledTimes(2));
await waitFor(() =>
expect(list.result.current.campaigns.map((c) => c.id)).toEqual(["camp-a-copy", "camp-a", "camp-b"]),
);
});
it("surfaces a failed delete and leaves the list untouched", async () => {
deleteCampaign.mockRejectedValueOnce(new Error("campaign is locked"));
const list = renderHook(() => useCampaigns({ query: "", folder: "" }), { wrapper: wrapper(client) });
await waitFor(() => expect(list.result.current.isSuccess).toBe(true));
const del = renderHook(() => useDeleteCampaign(), { wrapper: wrapper(client) });
await expect(del.result.current.mutateAsync("camp-a")).rejects.toThrow("campaign is locked");
expect(cachedIds(client)).toEqual(["camp-a", "camp-b"]);
expect(getCampaigns).toHaveBeenCalledTimes(1);
});
});
@@ -2,20 +2,22 @@ import deleteCampaign from "@/lib/api/client/app/campaigns/deleteCampaign";
import type GetCampaigns from "@/lib/api/models/app/campaigns/GetCampaigns";
import { type InfiniteData, useMutation, useQueryClient } from "@tanstack/react-query";
export default function useDeleteCampaign(id: string) {
// Deletes a campaign. The row leaves every cached list at once so the
// campaigns page updates without a refetch. Its detail and analytics caches
// are dropped rather than invalidated (a refetch would only 404); the copy a
// mounted detail page still observes is left alone, since that page
// navigates away as soon as the delete resolves.
export default function useDeleteCampaign() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () =>
deleteCampaign(id),
onSuccess: () => {
mutationFn: (id: string) => deleteCampaign(id),
onSuccess: (_data, id) => {
const allLists = queryClient.getQueriesData<InfiniteData<GetCampaigns>>({
queryKey: ["campaigns", "list"],
});
for (const [key, oldData] of allLists) {
if (!oldData) continue;
queryClient.setQueryData(key, {
...oldData,
pages: oldData.pages.map((page) => ({
@@ -24,10 +26,11 @@ export default function useDeleteCampaign(id: string) {
})),
});
}
queryClient.invalidateQueries({
queryKey: ["campaigns", id]
});
}
})
queryClient.removeQueries({ queryKey: ["campaigns", id], type: "inactive" });
queryClient.removeQueries({ queryKey: ["analytics", "campaigns", id], type: "inactive" });
queryClient.invalidateQueries({ queryKey: ["campaigns", "list"] });
queryClient.invalidateQueries({ queryKey: ["analytics"] });
queryClient.invalidateQueries({ queryKey: ["contacts"] });
},
});
}
@@ -0,0 +1,37 @@
import duplicateCampaign from "@/lib/api/client/app/campaigns/duplicateCampaign";
import type Campaign from "@/lib/api/models/app/campaigns/Campaign";
import type GetCampaigns from "@/lib/api/models/app/campaigns/GetCampaigns";
import { type InfiniteData, useMutation, useQueryClient } from "@tanstack/react-query";
// Duplicates a campaign into a new draft. The copy is placed at the top of
// the cached unfiltered lists immediately (it is the newest campaign; a
// search- or folder-filtered list may not contain it, so those only refetch),
// the detail entry is primed so navigating to it does not flash a skeleton,
// and every list is refetched to settle ordering.
export default function useDuplicateCampaign() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, name }: { id: string; name?: string }) => duplicateCampaign(id, name),
onSuccess: (campaign: Campaign) => {
const allLists = queryClient.getQueriesData<InfiniteData<GetCampaigns>>({
queryKey: ["campaigns", "list"],
});
for (const [key, oldData] of allLists) {
const [, , query, folder] = key as [string, string, string, string, number];
if (query || folder) continue;
if (!oldData || oldData.pages.length === 0) continue;
queryClient.setQueryData(key, {
...oldData,
pages: oldData.pages.map((page, i) =>
i === 0
? { ...page, data: [campaign, ...page.data.filter((c) => c.id !== campaign.id)] }
: page,
),
});
}
queryClient.setQueryData(["campaigns", campaign.id], campaign);
queryClient.invalidateQueries({ queryKey: ["campaigns", "list"] });
},
});
}
+15
View File
@@ -0,0 +1,15 @@
// Bridges a realtime CAMPAIGN_DELETED frame to whichever detail page has the
// campaign open. useRealtimeEvents has no router access, so it announces on
// window and the campaign layout listens and navigates away.
export const CAMPAIGN_DELETED_EVENT = "warmbly:campaign-deleted";
export interface CampaignDeletedDetail {
id: string;
name: string;
}
export function announceCampaignDeleted(detail: CampaignDeletedDetail) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent<CampaignDeletedDetail>(CAMPAIGN_DELETED_EVENT, { detail }));
}