From d396f53fc468296691ebf095fc922ca27751cd75 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Tue, 25 Aug 2026 08:54:51 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20add=20Delete=20and=20Duplicate=20campai?= =?UTF-8?q?gn=20actions=20to=20the=20dashboard=20(issue=20#185):=20every?= =?UTF-8?q?=20campaign=20row=20and=20the=20detail=20header=20get=20a=20?= =?UTF-8?q?=E2=8B=AF=20menu=20(Edit,=20Duplicate,=20Start/Pause,=20Delete)?= =?UTF-8?q?=20plus=20a=20Delete=20card=20at=20the=20bottom=20of=20Settings?= =?UTF-8?q?,=20all=20permission-gated=20with=20a=20confirm=20that=20spells?= =?UTF-8?q?=20out=20what=20goes;=20DELETE=20/campaigns/:id=20is=20now=20or?= =?UTF-8?q?ganization-scoped=20instead=20of=20user=5Fid-scoped=20so=20team?= =?UTF-8?q?mates=20can=20delete,=20runs=20in=20one=20transaction=20that=20?= =?UTF-8?q?also=20deletes=20the=20campaign's=20pending=20tasks=20and=20can?= =?UTF-8?q?cels=20a=20wakeup=20tick=20claimed=20at=20that=20moment=20(camp?= =?UTF-8?q?aign=5Ftasks=20only=20nulls=20its=20link,=20so=20those=20rows?= =?UTF-8?q?=20kept=20firing),=20removes=20attachment=20objects=20and=20pub?= =?UTF-8?q?lishes=20CAMPAIGN=5FDELETED=20so=20a=20teammate's=20open=20deta?= =?UTF-8?q?il=20page=20is=20sent=20back=20to=20the=20list;=20new=20POST=20?= =?UTF-8?q?/campaigns/:id/duplicate=20copies=20the=20campaign=20row=20as?= =?UTF-8?q?=20a=20draft=20with=20steps=20and=20their=20branch=20graph=20re?= =?UTF-8?q?wired=20onto=20new=20step=20ids,=20tags,=20folders,=20senders?= =?UTF-8?q?=20with=20rotation=20reset,=20A/B=20variants,=20advanced=20sett?= =?UTF-8?q?ings=20and=20attachments=20(quota-checked,=20blobs=20undone=20i?= =?UTF-8?q?f=20the=20copy=20fails)=20and=20none=20of=20the=20leads,=20prog?= =?UTF-8?q?ress,=20logs,=20counters,=20ramp=20level,=20guardrail=20trip=20?= =?UTF-8?q?or=20past=20dates,=20naming=20it=20(copy)/(copy=20N)=20inside?= =?UTF-8?q?=20the=2050=20byte=20cap=20without=20splitting=20runes;=20a=20c?= =?UTF-8?q?laimed=20campaign=20tick=20whose=20campaign=20vanished=20now=20?= =?UTF-8?q?ends=20the=20chain=20instead=20of=20staying=20active=20forever;?= =?UTF-8?q?=20covered=20by=20TestLiveCampaignLifecycle*=20against=20real?= =?UTF-8?q?=20SQL,=20RemapBranchTargets=20and=20duplicateName=20unit=20tes?= =?UTF-8?q?ts=20and=20a=20react-query=20vitest=20for=20the=20list=20cache,?= =?UTF-8?q?=20with=20API=20reference,=20endpoint=20map=20and=20campaigns?= =?UTF-8?q?=20guide=20updated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/backend/main.go | 5 + docs/content/docs/api/endpoints.mdx | 1 + docs/content/docs/api/reference/campaigns.mdx | 36 +- docs/content/docs/guides/campaigns.mdx | 8 + internal/api/handler/attachment.go | 2 +- internal/api/handler/campaign.go | 75 +++- internal/api/routes.go | 1 + internal/app/aitools/tools_campaigns.go | 2 +- internal/app/campaign/duplicate_name_test.go | 30 ++ internal/app/campaign/handlers.go | 221 ++++++++++- internal/app/campaign/service.go | 24 +- internal/models/attachment.go | 6 + .../campaign_lifecycle_live_test.go | 337 ++++++++++++++++ .../repository/campaign_lifecycle_test.go | 74 ++++ internal/repository/pg_campaign.go | 35 +- internal/repository/pg_campaign_lifecycle.go | 359 ++++++++++++++++++ internal/tasks/campaign_task.go | 15 +- web/src/app/app/campaigns/[id]/layout.tsx | 39 +- .../app/campaigns/[id]/preferences/page.tsx | 4 + web/src/app/app/campaigns/page.tsx | 25 +- .../app/campaigns/CampaignActionsMenu.tsx | 102 +++++ .../preferences/CampaignDangerZone.tsx | 39 ++ .../app/campaigns/useCampaignActions.ts | 92 +++++ web/src/hooks/useRealtimeEvents.ts | 15 + .../client/app/campaigns/duplicateCampaign.ts | 13 + .../app/campaigns/campaignLifecycle.test.tsx | 132 +++++++ .../hooks/app/campaigns/useDeleteCampaign.ts | 27 +- .../app/campaigns/useDuplicateCampaign.ts | 37 ++ web/src/lib/realtime/campaignDeleted.ts | 15 + 29 files changed, 1695 insertions(+), 76 deletions(-) create mode 100644 internal/app/campaign/duplicate_name_test.go create mode 100644 internal/repository/campaign_lifecycle_live_test.go create mode 100644 internal/repository/campaign_lifecycle_test.go create mode 100644 internal/repository/pg_campaign_lifecycle.go create mode 100644 web/src/components/app/campaigns/CampaignActionsMenu.tsx create mode 100644 web/src/components/app/campaigns/preferences/CampaignDangerZone.tsx create mode 100644 web/src/components/app/campaigns/useCampaignActions.ts create mode 100644 web/src/lib/api/client/app/campaigns/duplicateCampaign.ts create mode 100644 web/src/lib/api/hooks/app/campaigns/campaignLifecycle.test.tsx create mode 100644 web/src/lib/api/hooks/app/campaigns/useDuplicateCampaign.ts create mode 100644 web/src/lib/realtime/campaignDeleted.ts diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 10e7bb46..e70c5cba 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -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 diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index a5c6383b..17a56db4 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -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` | diff --git a/docs/content/docs/api/reference/campaigns.mdx b/docs/content/docs/api/reference/campaigns.mdx index 6cd5cd2f..088a11b9 100644 --- a/docs/content/docs/api/reference/campaigns.mdx +++ b/docs/content/docs/api/reference/campaigns.mdx @@ -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` diff --git a/docs/content/docs/guides/campaigns.mdx b/docs/content/docs/guides/campaigns.mdx index dddf1cb8..4f3bf2ba 100644 --- a/docs/content/docs/guides/campaigns.mdx +++ b/docs/content/docs/guides/campaigns.mdx @@ -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 " (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. diff --git a/internal/api/handler/attachment.go b/internal/api/handler/attachment.go index 4a100714..859554ca 100644 --- a/internal/api/handler/attachment.go +++ b/internal/api/handler/attachment.go @@ -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 diff --git a/internal/api/handler/campaign.go b/internal/api/handler/campaign.go index 20895e8c..029eec49 100644 --- a/internal/api/handler/campaign.go +++ b/internal/api/handler/campaign.go @@ -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) { diff --git a/internal/api/routes.go b/internal/api/routes.go index 2019b804..3584dd04 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -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) diff --git a/internal/app/aitools/tools_campaigns.go b/internal/app/aitools/tools_campaigns.go index d6ff0a18..62ba7b2a 100644 --- a/internal/app/aitools/tools_campaigns.go +++ b/internal/app/aitools/tools_campaigns.go @@ -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) diff --git a/internal/app/campaign/duplicate_name_test.go b/internal/app/campaign/duplicate_name_test.go new file mode 100644 index 00000000..041a700f --- /dev/null +++ b/internal/app/campaign/duplicate_name_test.go @@ -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) + } + } +} diff --git a/internal/app/campaign/handlers.go b/internal/app/campaign/handlers.go index 25ab2074..54a33eb1 100644 --- a/internal/app/campaign/handlers.go +++ b/internal/app/campaign/handlers.go @@ -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 { diff --git a/internal/app/campaign/service.go b/internal/app/campaign/service.go index 774c2600..53c3c9f9 100644 --- a/internal/app/campaign/service.go +++ b/internal/app/campaign/service.go @@ -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 " (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( diff --git a/internal/models/attachment.go b/internal/models/attachment.go index f0e30668..d2b8a98a 100644 --- a/internal/models/attachment.go +++ b/internal/models/attachment.go @@ -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 { diff --git a/internal/repository/campaign_lifecycle_live_test.go b/internal/repository/campaign_lifecycle_live_test.go new file mode 100644 index 00000000..80a1bcd1 --- /dev/null +++ b/internal/repository/campaign_lifecycle_live_test.go @@ -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', '

html

', $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', '', 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) + } +} diff --git a/internal/repository/campaign_lifecycle_test.go b/internal/repository/campaign_lifecycle_test.go new file mode 100644 index 00000000..9abda905 --- /dev/null +++ b/internal/repository/campaign_lifecycle_test.go @@ -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) + } + } +} diff --git a/internal/repository/pg_campaign.go b/internal/repository/pg_campaign.go index afb094c2..49e00f2f 100644 --- a/internal/repository/pg_campaign.go +++ b/internal/repository/pg_campaign.go @@ -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} diff --git a/internal/repository/pg_campaign_lifecycle.go b/internal/repository/pg_campaign_lifecycle.go new file mode 100644 index 00000000..37738ed9 --- /dev/null +++ b/internal/repository/pg_campaign_lifecycle.go @@ -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) +} diff --git a/internal/tasks/campaign_task.go b/internal/tasks/campaign_task.go index 3694a6da..0a94e2c8 100644 --- a/internal/tasks/campaign_task.go +++ b/internal/tasks/campaign_task.go @@ -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() } diff --git a/web/src/app/app/campaigns/[id]/layout.tsx b/web/src/app/app/campaigns/[id]/layout.tsx index 06c54a7d..7cacf454 100644 --- a/web/src/app/app/campaigns/[id]/layout.tsx +++ b/web/src/app/app/campaigns/[id]/layout.tsx @@ -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 = { 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).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 (
@@ -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() {

{campaign.id}

- {canToggle && ( -
+
+ {canToggle && ( -
- )} + )} + navigate("/app/campaigns", { replace: true })} + /> +
diff --git a/web/src/app/app/campaigns/[id]/preferences/page.tsx b/web/src/app/app/campaigns/[id]/preferences/page.tsx index 6e9192cf..4234c49b 100644 --- a/web/src/app/app/campaigns/[id]/preferences/page.tsx +++ b/web/src/app/app/campaigns/[id]/preferences/page.tsx @@ -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 ; case "order": return ; + case "danger": + return ; } }; diff --git a/web/src/app/app/campaigns/page.tsx b/web/src/app/app/campaigns/page.tsx index 81a68cc2..f83d46d1 100644 --- a/web/src/app/app/campaigns/page.tsx +++ b/web/src/app/app/campaigns/page.tsx @@ -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() { > + toggleRow(c)} + /> ); })} diff --git a/web/src/components/app/campaigns/CampaignActionsMenu.tsx b/web/src/components/app/campaigns/CampaignActionsMenu.tsx new file mode 100644 index 00000000..6810ec01 --- /dev/null +++ b/web/src/components/app/campaigns/CampaignActionsMenu.tsx @@ -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 ( + + + + + + navigate(`/app/campaigns/${campaign.id}/preferences`)} + icon={} + > + {variant === "row" ? "Edit" : "Edit settings"} + + actions.duplicateAndOpen(campaign))} + disabled={actions.duplicating} + icon={} + > + Duplicate + + {showToggle && ( + onToggle?.())} + icon={ + isActive ? ( + + ) : ( + + ) + } + > + {isActive ? "Pause" : "Start"} + + )} + + actions.requestDelete(campaign, { afterDelete }))} + disabled={actions.deleting} + icon={} + > + Delete + + + + ); +} diff --git a/web/src/components/app/campaigns/preferences/CampaignDangerZone.tsx b/web/src/components/app/campaigns/preferences/CampaignDangerZone.tsx new file mode 100644 index 00000000..d353e46f --- /dev/null +++ b/web/src/components/app/campaigns/preferences/CampaignDangerZone.tsx @@ -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 ( +
+
+

Delete this campaign

+

+ {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. +

+
+ + 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" + > + + Delete campaign + +
+ ); +} diff --git a/web/src/components/app/campaigns/useCampaignActions.ts b/web/src/components/app/campaigns/useCampaignActions.ts new file mode 100644 index 00000000..e327b4b8 --- /dev/null +++ b/web/src/components/app/campaigns/useCampaignActions.ts @@ -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, + }; +} diff --git a/web/src/hooks/useRealtimeEvents.ts b/web/src/hooks/useRealtimeEvents.ts index 524a297f..e32c22b9 100644 --- a/web/src/hooks/useRealtimeEvents.ts +++ b/web/src/hooks/useRealtimeEvents.ts @@ -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, diff --git a/web/src/lib/api/client/app/campaigns/duplicateCampaign.ts b/web/src/lib/api/client/app/campaigns/duplicateCampaign.ts new file mode 100644 index 00000000..9d940f1a --- /dev/null +++ b/web/src/lib/api/client/app/campaigns/duplicateCampaign.ts @@ -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 " (copy)". +export default async function duplicateCampaign(id: string, name?: string): Promise { + return await Request({ + method: "POST", + url: `/campaigns/${id}/duplicate`, + data: name ? { name } : {}, + authorization: true, + }); +} diff --git a/web/src/lib/api/hooks/app/campaigns/campaignLifecycle.test.tsx b/web/src/lib/api/hooks/app/campaigns/campaignLifecycle.test.tsx new file mode 100644 index 00000000..32181d2b --- /dev/null +++ b/web/src/lib/api/hooks/app/campaigns/campaignLifecycle.test.tsx @@ -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>({ 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 {children}; + }; +} + +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>(["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); + }); +}); diff --git a/web/src/lib/api/hooks/app/campaigns/useDeleteCampaign.ts b/web/src/lib/api/hooks/app/campaigns/useDeleteCampaign.ts index 2bb391dd..8967bac8 100644 --- a/web/src/lib/api/hooks/app/campaigns/useDeleteCampaign.ts +++ b/web/src/lib/api/hooks/app/campaigns/useDeleteCampaign.ts @@ -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>({ 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"] }); + }, + }); } diff --git a/web/src/lib/api/hooks/app/campaigns/useDuplicateCampaign.ts b/web/src/lib/api/hooks/app/campaigns/useDuplicateCampaign.ts new file mode 100644 index 00000000..685709dd --- /dev/null +++ b/web/src/lib/api/hooks/app/campaigns/useDuplicateCampaign.ts @@ -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>({ + 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"] }); + }, + }); +} diff --git a/web/src/lib/realtime/campaignDeleted.ts b/web/src/lib/realtime/campaignDeleted.ts new file mode 100644 index 00000000..432f9d96 --- /dev/null +++ b/web/src/lib/realtime/campaignDeleted.ts @@ -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(CAMPAIGN_DELETED_EVENT, { detail })); +}