From aec1d744cf7e06ee7c46031ae51bc1168e0ac5ff Mon Sep 17 00:00:00 2001 From: Ahmustufa Date: Wed, 23 Sep 2026 19:32:32 +0500 Subject: [PATCH 1/9] feat(sequences): conditional branching for campaign steps A step can now carry one branch that routes each enrollment after the step is sent: always / opened / clicked / replied / not_opened / not_replied, within N days (1-90), optionally narrowed to a reply label, with a yes exit and a no exit (a null exit ends the path). Steps without a branch keep the original linear code path unchanged, and a test pins that. - New table sequence_step_branches, one row per source step. Composite FKs keep both exits in the same campaign and tenant. Deleting a target step nulls only that exit. - The route is recomputed from events inside a fixed window measured from the step's own send, so a decided answer can't flip. Opens and clicks count human events only. Replies look at inbox_messages (the other leg) and exclude auto-replies unless the branch names that label. - Cycles are refused at save time under a per-campaign lock, including for step delete and reorder. A runtime backstop ends the path if a loop ever gets in. - Mid-flight edits use the graph as it is when the decision is made. The rule is documented in branchroute.go. - GET /campaigns/{id}/graph, PUT/DELETE /campaigns/{id}/steps/{stepId}/branch. - Threading replies to the most recently sent email, not the highest-numbered step, since a path can go 1 -> 3 -> 2. Co-Authored-By: Claude Opus 5.5 (1M context) --- api/openapi.yaml | 161 ++++++ cmd/inroad/main.go | 2 +- docs/src/content/docs/security.md | 42 +- internal/app/enrollment/service.go | 14 + internal/app/enrollment/service_test.go | 33 ++ internal/app/enrollment/store.go | 16 + internal/app/sequencestep/branch.go | 167 ++++++ .../sequencestep/branch_integration_test.go | 287 ++++++++++ internal/app/sequencestep/branch_test.go | 312 +++++++++++ internal/app/sequencestep/branchhandler.go | 225 ++++++++ .../app/sequencestep/branchhandler_test.go | 53 ++ internal/app/sequencestep/branchstore.go | 171 ++++++ internal/app/sequencestep/handler.go | 7 + .../sequencestep/reorder_integration_test.go | 16 +- internal/app/sequencestep/routes.go | 9 + internal/app/sequencestep/service.go | 17 +- internal/app/sequencestep/service_test.go | 27 +- internal/app/sequencestep/store.go | 82 +-- internal/app/sequencestep/variant_test.go | 2 +- internal/coreapi/coreapi.go | 12 + internal/coreapi/inprocess/branchroute.go | 410 +++++++++++++++ .../coreapi/inprocess/branchroute_test.go | 303 +++++++++++ internal/coreapi/inprocess/inboxpoll.go | 13 +- internal/coreapi/inprocess/stepsendjob.go | 70 ++- internal/platform/db/gen/enrollment.sql.go | 81 ++- internal/platform/db/gen/models.go | 52 +- internal/platform/db/gen/stepbranch.sql.go | 274 ++++++++++ internal/platform/db/gen/stepsend.sql.go | 83 +-- ...0923110214_sequence_step_branches.down.sql | 7 + ...260923110214_sequence_step_branches.up.sql | 113 ++++ internal/platform/db/queries/enrollment.sql | 43 ++ internal/platform/db/queries/stepbranch.sql | 89 ++++ internal/platform/db/queries/stepsend.sql | 9 +- internal/platform/seqgraph/seqgraph.go | 492 ++++++++++++++++++ internal/platform/seqgraph/seqgraph_test.go | 354 +++++++++++++ internal/worker/sequence/advance.go | 16 + .../sequence/branching_integration_test.go | 460 ++++++++++++++++ internal/worker/sequence/branchwait_test.go | 49 ++ web/src/store/api.ts | 100 ++++ 39 files changed, 4522 insertions(+), 151 deletions(-) create mode 100644 internal/app/sequencestep/branch.go create mode 100644 internal/app/sequencestep/branch_integration_test.go create mode 100644 internal/app/sequencestep/branch_test.go create mode 100644 internal/app/sequencestep/branchhandler.go create mode 100644 internal/app/sequencestep/branchhandler_test.go create mode 100644 internal/app/sequencestep/branchstore.go create mode 100644 internal/coreapi/inprocess/branchroute.go create mode 100644 internal/coreapi/inprocess/branchroute_test.go create mode 100644 internal/platform/db/gen/stepbranch.sql.go create mode 100644 internal/platform/db/migrations/20260923110214_sequence_step_branches.down.sql create mode 100644 internal/platform/db/migrations/20260923110214_sequence_step_branches.up.sql create mode 100644 internal/platform/db/queries/stepbranch.sql create mode 100644 internal/platform/seqgraph/seqgraph.go create mode 100644 internal/platform/seqgraph/seqgraph_test.go create mode 100644 internal/worker/sequence/branching_integration_test.go create mode 100644 internal/worker/sequence/branchwait_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index 9ec1f41d..7c9ac8fc 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2097,6 +2097,12 @@ paths: '401': { description: Unauthorized } '404': { description: Campaign or step not found } '409': { description: Campaign not draft (delete is a structural edit) } + '422': + description: >- + Deleting the step would close a loop: the step before it falls + through to the step after it, and a branch leads back (code cycle). + Branch exits that pointed AT a deleted step become path ends. + content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } } /campaigns/{id}/steps/{stepId}/variants: parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } @@ -2238,6 +2244,99 @@ paths: '401': { description: Unauthorized } '404': { description: Campaign or step not found } '409': { description: Campaign not draft (reorder is a structural edit) } + '422': + description: The new order's fall-through edges would close a loop through a branch (code cycle) + content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } } + /campaigns/{id}/graph: + get: + operationId: getCampaignGraph + tags: [campaigns] + security: [{ bearerAuth: [] }] + parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] + description: >- + The campaign's sequence as a routing graph: one node per step (in + step_order), each with its branch (if any) and the step it falls + through to when it has none. A campaign with no branches is the linear + sequence - every node has branch null and falls through to the next step + by step_order, and the last one ends. + + + Edges to draw: for a node WITH a branch, its yes_step_id / no_step_id + (null = the path ends there; 'always' has only yes_step_id). For a node + WITHOUT one, default_next_step_id (null = the path ends). + responses: + '200': { description: The graph, content: { application/json: { schema: { $ref: '#/components/schemas/CampaignGraph' } } } } + '400': { description: Campaign id is not a uuid } + '401': { description: Unauthorized } + '403': { description: Insufficient scope (campaigns:read) } + '404': { description: Campaign not found } + /campaigns/{id}/steps/{stepId}/branch: + parameters: + - { name: id, in: path, required: true, schema: { type: string, format: uuid } } + - { name: stepId, in: path, required: true, schema: { type: string, format: uuid } } + put: + operationId: setStepBranch + tags: [campaigns] + security: [{ bearerAuth: [] }] + description: >- + Create or replace the branch that routes an enrollment OUT of this step + after it has been sent. One branch per step. + + + Allowed on a RUNNING campaign (like a step content or variant edit). + Every routing decision uses the graph as it is when the decision is + made: a contact already waiting on this step is evaluated against the + new branch at its next check (at most an hour away), with the window + still measured from when this step was sent. Messages already sent are + never affected. + + + Conditions are evaluated against THIS step's send: opened / clicked + count HUMAN tracking events only (machine prefetches are ignored); + replied / not_replied count inbound replies from the contact on this + campaign, excluding automated ones (out-of-office, auto-reply) unless + reply_label_key names one explicitly. A reply whose label stops the + enrollment (the default for every human label) still stops it - a + branch never overrides a reply label's automation - so a replied branch + acts on replies whose label has stops_enrollment=false. + + + Refused with 422 (code cycle) when the result would let any path revisit + a step - including a loop closed by another step's linear fall-through - + and with 422 (code unknown_target) when an exit names a step outside + this campaign. + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/StepBranchRequest' } } } + responses: + '200': { description: The saved branch, content: { application/json: { schema: { $ref: '#/components/schemas/StepBranch' } } } } + '400': + description: "Malformed branch (codes invalid_condition, invalid_within_days, invalid_reply_label, no_exit_not_allowed; cycle for an exit to the step itself). Invalid json or path ids return a plain {error} body." + content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } } + '401': { description: Unauthorized } + '403': { description: Insufficient scope (campaigns:write) } + '404': { description: Campaign or step not found } + '422': + description: The graph refuses the edit (codes cycle, unknown_target) + content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } } + delete: + operationId: deleteStepBranch + tags: [campaigns] + security: [{ bearerAuth: [] }] + description: >- + Remove the step's branch, returning it to linear fall-through (the next + step by step_order). Idempotent. Allowed on a running campaign; a contact + waiting on the removed condition proceeds to the fall-through step, which + still waits out its own delay_seconds from this step's send. Refused with + 422 (code cycle) if the restored fall-through would close a loop. + responses: + '204': { description: Removed (or there was none) } + '401': { description: Unauthorized } + '403': { description: Insufficient scope (campaigns:write) } + '404': { description: Campaign or step not found } + '422': + description: The restored fall-through would close a loop (code cycle) + content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } } /campaigns/{id}/launch: post: operationId: launchCampaign @@ -5041,6 +5140,68 @@ components: type: array description: the FULL ordered list of the campaign's step ids, in the desired order items: { type: string, format: uuid } + StepBranchCondition: + type: string + enum: [always, opened, clicked, replied, not_opened, not_replied] + description: >- + What the branch tests, against the step it is attached to. + always - unconditional: go to yes_step_id (null = end the path). + opened / clicked - a HUMAN open/click of this step within within_days + of its send (yes as soon as it happens; no when the window closes). + replied - a counted reply within the window (yes as soon as it arrives). + not_opened / not_replied - the negation: no as soon as the event + happens, yes when the window closes without it. + StepBranch: + type: object + required: [step_id, condition, within_days, reply_label_key, yes_step_id, no_step_id, updated_at] + properties: + step_id: { type: string, format: uuid, description: The step this branch routes out of } + condition: { $ref: '#/components/schemas/StepBranchCondition' } + within_days: { type: integer, minimum: 1, maximum: 90, nullable: true, description: "Evaluation window in days after the step's send; null exactly when condition is always" } + reply_label_key: { type: string, nullable: true, description: "replied / not_replied only: count only replies classified with this reply label key" } + yes_step_id: { type: string, format: uuid, nullable: true, description: "Where a true condition (or always) goes; null ends the path" } + no_step_id: { type: string, format: uuid, nullable: true, description: "Where a false condition goes; null ends the path; always null for always" } + updated_at: { type: string, format: date-time } + StepBranchRequest: + type: object + required: [condition] + properties: + condition: { $ref: '#/components/schemas/StepBranchCondition' } + within_days: { type: integer, minimum: 1, maximum: 90, nullable: true, description: Required for every condition except always; must be absent or null for always } + reply_label_key: { type: string, nullable: true, description: "Optional, replied / not_replied only; must name a reply label key in the workspace. Empty string is treated as null." } + yes_step_id: { type: string, format: uuid, nullable: true, description: A step of the same campaign, not this step; null or absent ends the path } + no_step_id: { type: string, format: uuid, nullable: true, description: As yes_step_id; must be null or absent for always } + CampaignGraphNode: + type: object + required: [step_id, step_order, default_next_step_id, branch] + properties: + step_id: { type: string, format: uuid } + step_order: { type: integer } + default_next_step_id: { type: string, format: uuid, nullable: true, description: "The next step by step_order - where this step goes when branch is null. Null for the last step. Present even when a branch overrides it." } + branch: + allOf: [{ $ref: '#/components/schemas/StepBranch' }] + type: object + nullable: true + description: The step's branch, or null for linear fall-through to default_next_step_id. + CampaignGraph: + type: object + required: [campaign_id, entry_step_id, nodes] + properties: + campaign_id: { type: string, format: uuid } + entry_step_id: { type: string, format: uuid, nullable: true, description: The first step (lowest step_order) every enrollment starts at; null for a campaign with no steps } + nodes: { type: array, items: { $ref: '#/components/schemas/CampaignGraphNode' } } + BranchValidationError: + type: object + required: [error, code] + properties: + error: { type: string, description: Human-readable message } + code: + type: string + enum: [invalid_condition, invalid_within_days, invalid_reply_label, no_exit_not_allowed, unknown_step, unknown_target, cycle] + step_ids: + type: array + items: { type: string, format: uuid } + description: "code cycle only: the steps on the loop, in path order" CreateCampaignRequest: type: object required: [name, mailbox_id, list_id, subject] diff --git a/cmd/inroad/main.go b/cmd/inroad/main.go index 7343047d..5e4886ae 100644 --- a/cmd/inroad/main.go +++ b/cmd/inroad/main.go @@ -521,7 +521,7 @@ func run() error { // campaign status (draft-gating) via an adapter over the campaign store. stepHandler := sequencestep.NewHandler( sequencestep.NewService(sequencestep.NewPgStore(pool), campaignStatusChecker{campaigns: campaignStore}, - sequencestep.NewPgVariantStore(queries)), + sequencestep.NewPgVariantStore(queries), sequencestep.NewPgBranchStore(pool)), cfg.JWTSecret, ) // Deliverability guardrails. One service backs BOTH the API endpoints and the diff --git a/docs/src/content/docs/security.md b/docs/src/content/docs/security.md index 3f3f5e3d..aab390cf 100644 --- a/docs/src/content/docs/security.md +++ b/docs/src/content/docs/security.md @@ -2220,20 +2220,36 @@ write history that never happened. `coreapi` (the worker holds no database access of its own). A malformed or out-of-range value stops the process at startup. +## Conditional branching (sequence step routing) +84. **A branch predicate reads the stored HUMAN verdict, and nothing else.** + `opened`/`clicked` conditions (`FirstHumanTrackingEventAt`, + `internal/platform/db/queries/stepbranch.sql`) filter + `kind = ... AND NOT is_machine` on the cursor step's OWN deterministic send + id, the same definition `CountHumanOpens` reports, so a branch and the open + rate cannot disagree about a contact and a scanner's prefetch cannot fire an + "if opened" branch. Two cautions carry over from invariant 63: a `human` + verdict is only "not obviously a machine", so a branch whose wrong side is + expensive should prefer the negative path; and rows recorded before the + classification migration are all marked human, so a branch evaluated over old + history can over-fire. A reply condition counts inbound `inbox_messages` by + `created_at` (when WE ingested it), never the sender-controlled `Date` + header, and excludes automated labels unless one is named explicitly. + + **Tenancy.** Every branch read and write is `workspace_id`-pinned, and every + step reference (source and both exits) is a composite FK on + `(id, campaign_id)` with `(campaign_id, workspace_id)` pinned to `campaigns`, + so a branch pointing into another campaign or tenant is unrepresentable even + for a write that skips the service. Graph writes (branch upsert/delete, step + delete, reorder) run under a per-campaign `FOR NO KEY UPDATE` lock that + matches zero rows for a foreign workspace (404) and re-validate acyclicity + inside the transaction. The send path has a runtime loop backstop that ends + the path rather than recovering-forward forever. + + **A branch never overrides reply-label automation.** A reply whose label + stops the enrollment still stops it; compliance dispatch (invariants 20, 45) + is untouched. A branch only routes enrollments the labels leave active. + ## Deferred (documented, not yet built) -- **Conditional branching on a sequence step must gate on HUMAN events only** - (invariant 63). This is written down BEFORE the feature exists because getting - it wrong is silent: a scanner's prefetch would fire an "if opened" branch and - send the contact the wrong follow-up, with nothing in the UI to show that a bot - rewrote a real sequence. A branch predicate must read the stored verdict — - `... AND kind = 'open' AND NOT is_machine`, the same filter `CountHumanOpens` - uses — and must never re-derive its own definition of an open, or the branch - and the reported open rate will disagree about the same contact. Two further - cautions: a `human` verdict is "not obviously a machine", so a branch whose - wrong side is expensive or irreversible should prefer the negative path; and - because the verdict is computed once at write time, rows recorded before that - migration are all marked human and a branch reading old history will - over-fire. - Datacenter/cloud IP ranges as a refreshed table (AWS/GCP/Azure publish machine-readable lists; Apple's MPP relay egress likewise). `botfilter`'s compiled-in range list covers only what is knowable from the address itself, diff --git a/internal/app/enrollment/service.go b/internal/app/enrollment/service.go index 63b5783d..481eed5f 100644 --- a/internal/app/enrollment/service.go +++ b/internal/app/enrollment/service.go @@ -50,6 +50,20 @@ func (s *Service) MarkStepStopped(ctx context.Context, ws, id uuid.UUID, reason return s.store.Stop(ctx, ws, id, reason) } +// FinishRoute completes an enrollment whose branch routed it to the end of its +// path without another send. Distinct from the lastStep completion in +// MarkStepSent, which records the final send; nothing was sent here. +func (s *Service) FinishRoute(ctx context.Context, ws, id uuid.UUID) error { + return s.store.Finish(ctx, ws, id) +} + +// AwaitCondition parks an active enrollment until recheckAt because its next +// move depends on a branch condition that has not been decided, or on a routed +// step that is not yet due. +func (s *Service) AwaitCondition(ctx context.Context, ws, id uuid.UUID, recheckAt time.Time) error { + return s.store.AwaitCondition(ctx, ws, id, recheckAt) +} + // Reschedule re-stamps an active enrollment's next due time (launch stagger). func (s *Service) Reschedule(ctx context.Context, ws, id uuid.UUID, nextDueAt time.Time) error { return s.store.SetDue(ctx, ws, id, nextDueAt) diff --git a/internal/app/enrollment/service_test.go b/internal/app/enrollment/service_test.go index f694a406..826add4a 100644 --- a/internal/app/enrollment/service_test.go +++ b/internal/app/enrollment/service_test.go @@ -17,6 +17,8 @@ type fakeStore struct { completed bool stoppedReason StopReason threadRoot string + finished bool + awaitAt time.Time } func (f *fakeStore) Enroll(context.Context, uuid.UUID, uuid.UUID) ([]uuid.UUID, error) { @@ -38,6 +40,14 @@ func (f *fakeStore) Stop(_ context.Context, _, _ uuid.UUID, r StopReason) error return nil } func (f *fakeStore) SetDue(context.Context, uuid.UUID, uuid.UUID, time.Time) error { return nil } +func (f *fakeStore) Finish(context.Context, uuid.UUID, uuid.UUID) error { + f.finished = true + return nil +} +func (f *fakeStore) AwaitCondition(_ context.Context, _, _ uuid.UUID, at time.Time) error { + f.awaitAt = at + return nil +} func (f *fakeStore) SetThreadRoot(_ context.Context, _, _ uuid.UUID, mid string) error { f.threadRoot = mid return nil @@ -104,3 +114,26 @@ func TestMarkStepStoppedPassesReason(t *testing.T) { t.Fatalf("want suppressed, got %s", f.stoppedReason) } } + +// A routed end completes WITHOUT recording a send: FinishRoute must not go +// through Complete (which stamps current_step and last_sent_at). +func TestFinishRouteCompletesWithoutASend(t *testing.T) { + f := &fakeStore{} + if err := NewService(f).FinishRoute(context.Background(), uuid.New(), uuid.New()); err != nil { + t.Fatal(err) + } + if !f.finished || f.completed { + t.Fatalf("finished=%v completed=%v, want the no-send finish only", f.finished, f.completed) + } +} + +func TestAwaitConditionStampsRecheck(t *testing.T) { + f := &fakeStore{} + at := time.Now().Add(time.Hour) + if err := NewService(f).AwaitCondition(context.Background(), uuid.New(), uuid.New(), at); err != nil { + t.Fatal(err) + } + if !f.awaitAt.Equal(at) { + t.Fatalf("recheck = %v, want %v", f.awaitAt, at) + } +} diff --git a/internal/app/enrollment/store.go b/internal/app/enrollment/store.go index ad2abaf3..693fff43 100644 --- a/internal/app/enrollment/store.go +++ b/internal/app/enrollment/store.go @@ -33,6 +33,14 @@ type Store interface { SetDue(ctx context.Context, ws, id uuid.UUID, nextDueAt time.Time) error // SetThreadRoot stores step 1's Message-ID once (while still empty). SetThreadRoot(ctx context.Context, ws, id uuid.UUID, messageID string) error + // Finish completes an active enrollment WITHOUT a send (a branch routed it + // to the end of its path): current_step and last_sent_at are left alone. + // Guarded on status='active' like Complete. + Finish(ctx context.Context, ws, id uuid.UUID) error + // AwaitCondition re-stamps next_due_at for an active enrollment whose next + // move waits on a branch condition, and records that the wait is a + // condition's (awaiting_condition_step = current_step). + AwaitCondition(ctx context.Context, ws, id uuid.UUID, recheckAt time.Time) error CountByStatus(ctx context.Context, ws, campaignID uuid.UUID) (map[string]int64, error) } @@ -72,6 +80,14 @@ func (s *PgStore) SetDue(ctx context.Context, ws, id uuid.UUID, nextDueAt time.T func (s *PgStore) SetThreadRoot(ctx context.Context, ws, id uuid.UUID, messageID string) error { return s.q.SetThreadRoot(ctx, gen.SetThreadRootParams{ID: id, WorkspaceID: ws, ThreadRootID: messageID}) } +func (s *PgStore) Finish(ctx context.Context, ws, id uuid.UUID) error { + return s.q.FinishEnrollment(ctx, gen.FinishEnrollmentParams{ID: id, WorkspaceID: ws}) +} +func (s *PgStore) AwaitCondition(ctx context.Context, ws, id uuid.UUID, recheckAt time.Time) error { + return s.q.AwaitEnrollmentCondition(ctx, gen.AwaitEnrollmentConditionParams{ + ID: id, WorkspaceID: ws, NextDueAt: tsz(recheckAt), + }) +} func (s *PgStore) CountByStatus(ctx context.Context, ws, campaignID uuid.UUID) (map[string]int64, error) { rows, err := s.q.CountEnrollmentsByStatus(ctx, gen.CountEnrollmentsByStatusParams{CampaignID: campaignID, WorkspaceID: ws}) if err != nil { diff --git a/internal/app/sequencestep/branch.go b/internal/app/sequencestep/branch.go new file mode 100644 index 00000000..e32e0b11 --- /dev/null +++ b/internal/app/sequencestep/branch.go @@ -0,0 +1,167 @@ +package sequencestep + +import ( + "context" + "fmt" + "slices" + + "github.com/google/uuid" + + "github.com/inroad/inroad/internal/platform/db/gen" + "github.com/inroad/inroad/internal/platform/seqgraph" +) + +// Graph is a campaign's steps (in step_order) and its routers — everything a +// client needs to draw the sequence as a graph. +type Graph struct { + Steps []gen.SequenceStep + Branches []gen.SequenceStepBranch +} + +// Graph returns the campaign's routing graph. Reading is allowed on any status. +func (s *Service) Graph(ctx context.Context, ws, campaignID uuid.UUID) (Graph, error) { + if _, err := s.checker.CampaignStatus(ctx, ws, campaignID); err != nil { + return Graph{}, ErrCampaignNotFound + } + steps, err := s.store.List(ctx, ws, campaignID) + if err != nil { + return Graph{}, fmt.Errorf("list steps: %w", err) + } + branches, err := s.branches.ListBranches(ctx, ws, campaignID) + if err != nil { + return Graph{}, fmt.Errorf("list branches: %w", err) + } + return Graph{Steps: steps, Branches: branches}, nil +} + +// SetBranch creates or replaces the router on one step. +// +// Allowed on a RUNNING campaign, like a content or variant edit and unlike a +// step create/delete/reorder. The send path re-reads the graph on every advance +// (see the mid-flight rule on inprocess.routeGraph), so an edit governs every +// decision made after it commits and none made before — which is the same +// live-reference contract a body edit has. +// +// Validation happens twice, on purpose. The shape and label checks here give a +// precise error before any write; the graph check then runs AGAIN inside the +// store's transaction, under the campaign's graph lock, against the graph that +// is actually being committed — the only place a loop formed by two concurrent +// edits can be caught. +func (s *Service) SetBranch(ctx context.Context, ws, campaignID uuid.UUID, in BranchInput) (gen.SequenceStepBranch, error) { + if _, err := s.checker.CampaignStatus(ctx, ws, campaignID); err != nil { + return gen.SequenceStepBranch{}, ErrCampaignNotFound + } + if err := s.assertStepInCampaign(ctx, ws, campaignID, in.StepID); err != nil { + return gen.SequenceStepBranch{}, err + } + // The model reads an absent window as 0, which is exactly right for a real + // condition (0 is out of range) but would let an explicit "within_days": 0 on + // an 'always' branch through; refuse any window on 'always' here. + if in.Condition == string(seqgraph.Always) && in.WithinDays != nil { + return gen.SequenceStepBranch{}, &seqgraph.ShapeError{ + Code: seqgraph.CodeInvalidWithinDays, Msg: "within_days is not allowed on an 'always' branch", + } + } + if err := branchModel(in).ValidateShape(); err != nil { + return gen.SequenceStepBranch{}, err + } + if in.ReplyLabelKey != nil { + ok, err := s.branches.ReplyLabelExists(ctx, ws, *in.ReplyLabelKey) + if err != nil { + return gen.SequenceStepBranch{}, fmt.Errorf("check reply label: %w", err) + } + if !ok { + return gen.SequenceStepBranch{}, &seqgraph.ShapeError{ + Code: seqgraph.CodeLabelNotAllowed, + Msg: fmt.Sprintf("reply label %q does not exist in this workspace", *in.ReplyLabelKey), + } + } + } + // Refuse an exit to a step outside the campaign BEFORE writing: the + // composite FK would refuse it too, but as a constraint violation rather than + // an error that names the offending target. + steps, err := s.store.List(ctx, ws, campaignID) + if err != nil { + return gen.SequenceStepBranch{}, fmt.Errorf("list steps: %w", err) + } + for _, target := range []*uuid.UUID{in.YesStepID, in.NoStepID} { + if target != nil && !containsStep(steps, *target) { + return gen.SequenceStepBranch{}, &seqgraph.TargetError{StepID: in.StepID, Target: *target} + } + } + in.CampaignID = campaignID + return s.branches.UpsertBranch(ctx, ws, in, checkGraph) +} + +// DeleteBranch removes the router on one step, returning it to linear +// fall-through. Idempotent. Allowed live, for the reason SetBranch is; refused +// with a CycleError when the restored fall-through would close a loop. +func (s *Service) DeleteBranch(ctx context.Context, ws, campaignID, stepID uuid.UUID) error { + if _, err := s.checker.CampaignStatus(ctx, ws, campaignID); err != nil { + return ErrCampaignNotFound + } + if err := s.assertStepInCampaign(ctx, ws, campaignID, stepID); err != nil { + return err + } + return s.branches.DeleteBranch(ctx, ws, campaignID, stepID, checkGraph) +} + +// checkGraph is the one GraphCheck every graph-changing write commits under. +func checkGraph(steps []gen.SequenceStep, branches []gen.SequenceStepBranch) error { + return BuildGraph(steps, branches).Validate() +} + +// BuildGraph converts the persistence rows into the routing model. Exported so +// the handler can compute each step's fall-through with the SAME rule the send +// path and the validator use, rather than re-deriving "next by step_order". +func BuildGraph(steps []gen.SequenceStep, branches []gen.SequenceStepBranch) seqgraph.Graph { + nodes := make([]seqgraph.Step, len(steps)) + for i, st := range steps { + nodes[i] = seqgraph.Step{ID: st.ID, Order: st.StepOrder, DelaySeconds: st.DelaySeconds} + } + routers := make([]seqgraph.Branch, len(branches)) + for i, b := range branches { + routers[i] = BranchFromRow(b) + } + return seqgraph.New(nodes, routers) +} + +// BranchFromRow converts one stored router into the routing model. +func BranchFromRow(b gen.SequenceStepBranch) seqgraph.Branch { + out := seqgraph.Branch{StepID: b.StepID, Condition: seqgraph.Condition(b.Condition)} + if b.WithinDays != nil { + out.WithinDays = int(*b.WithinDays) + } + if b.ReplyLabelKey != nil { + out.ReplyLabelKey = *b.ReplyLabelKey + } + if b.YesStepID.Valid { + out.Yes = b.YesStepID.Bytes + } + if b.NoStepID.Valid { + out.No = b.NoStepID.Bytes + } + return out +} + +// branchModel converts a write request into the routing model for validation. +func branchModel(in BranchInput) seqgraph.Branch { + out := seqgraph.Branch{StepID: in.StepID, Condition: seqgraph.Condition(in.Condition)} + if in.WithinDays != nil { + out.WithinDays = int(*in.WithinDays) + } + if in.ReplyLabelKey != nil { + out.ReplyLabelKey = *in.ReplyLabelKey + } + if in.YesStepID != nil { + out.Yes = *in.YesStepID + } + if in.NoStepID != nil { + out.No = *in.NoStepID + } + return out +} + +func containsStep(steps []gen.SequenceStep, id uuid.UUID) bool { + return slices.ContainsFunc(steps, func(st gen.SequenceStep) bool { return st.ID == id }) +} diff --git a/internal/app/sequencestep/branch_integration_test.go b/internal/app/sequencestep/branch_integration_test.go new file mode 100644 index 00000000..1bb1a27a --- /dev/null +++ b/internal/app/sequencestep/branch_integration_test.go @@ -0,0 +1,287 @@ +//go:build integration + +package sequencestep + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/inroad/inroad/internal/platform/db" + "github.com/inroad/inroad/internal/platform/db/dbtest" + "github.com/inroad/inroad/internal/platform/db/gen" + "github.com/inroad/inroad/internal/platform/seqgraph" +) + +// sqlChecker is the CampaignChecker the composition root builds over the +// campaign store, reduced to the one workspace-pinned read it needs. +type sqlChecker struct{ pool *pgxpool.Pool } + +func (c sqlChecker) CampaignStatus(ctx context.Context, ws, campaignID uuid.UUID) (string, error) { + var status string + err := c.pool.QueryRow(ctx, `SELECT status FROM campaigns WHERE id = $1 AND workspace_id = $2`, campaignID, ws).Scan(&status) + return status, err +} + +type branchIT struct { + pool *pgxpool.Pool + q *gen.Queries + svc *Service + ws uuid.UUID + campaign uuid.UUID + steps []uuid.UUID +} + +func newBranchIT(t *testing.T, label string) (branchIT, func()) { + t.Helper() + ctx := context.Background() + if err := db.Migrate(dbtest.DSN(t)); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := db.Connect(ctx, dbtest.DSN(t)) + if err != nil { + t.Fatalf("connect: %v", err) + } + q := gen.New(pool) + ws, campaign, ids := seedThreeSteps(t, ctx, q, label) + svc := NewService(NewPgStore(pool), sqlChecker{pool: pool}, NewPgVariantStore(q), NewPgBranchStore(pool)) + return branchIT{pool: pool, q: q, svc: svc, ws: ws, campaign: campaign, steps: ids}, pool.Close +} + +func (b branchIT) addStep(t *testing.T, order int32) uuid.UUID { + t.Helper() + st, err := b.q.CreateStep(context.Background(), gen.CreateStepParams{ + WorkspaceID: b.ws, CampaignID: b.campaign, StepOrder: order, Subject: "s", BodyText: "b", + }) + if err != nil { + t.Fatalf("step %d: %v", order, err) + } + return st.ID +} + +func (b branchIT) always(t *testing.T, from uuid.UUID, to *uuid.UUID) { + t.Helper() + if _, err := b.svc.SetBranch(context.Background(), b.ws, b.campaign, BranchInput{StepID: from, Condition: "always", YesStepID: to}); err != nil { + t.Fatalf("always %v -> %v: %v", from, to, err) + } +} + +func TestBranchSaveAndReadBack(t *testing.T) { + b, done := newBranchIT(t, "Branch save") + defer done() + ctx := context.Background() + three := int32(3) + label := "positive" // seeded for every workspace by migration 000047 + got, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{ + StepID: b.steps[0], Condition: "replied", WithinDays: &three, ReplyLabelKey: &label, + YesStepID: &b.steps[2], NoStepID: &b.steps[1], + }) + if err != nil { + t.Fatalf("SetBranch: %v", err) + } + if got.WorkspaceID != b.ws || got.CampaignID != b.campaign || got.ReplyLabelKey == nil || *got.ReplyLabelKey != label { + t.Fatalf("stored = %+v", got) + } + + // Replace in place: one row per step. + if _, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always"}); err != nil { + t.Fatalf("replace: %v", err) + } + g, err := b.svc.Graph(ctx, b.ws, b.campaign) + if err != nil { + t.Fatal(err) + } + if len(g.Branches) != 1 || g.Branches[0].Condition != "always" || g.Branches[0].WithinDays != nil || g.Branches[0].YesStepID.Valid { + t.Fatalf("graph branches = %+v", g.Branches) + } + + if err := b.svc.DeleteBranch(ctx, b.ws, b.campaign, b.steps[0]); err != nil { + t.Fatalf("delete: %v", err) + } + if g, _ := b.svc.Graph(ctx, b.ws, b.campaign); len(g.Branches) != 0 { + t.Fatalf("branch survived delete: %+v", g.Branches) + } +} + +func TestBranchCycleRefusedAndNothingWritten(t *testing.T) { + b, done := newBranchIT(t, "Branch cycle") + defer done() + _, err := b.svc.SetBranch(context.Background(), b.ws, b.campaign, BranchInput{ + StepID: b.steps[2], Condition: "always", YesStepID: &b.steps[0], + }) + var cyc *seqgraph.CycleError + if !errors.As(err, &cyc) { + t.Fatalf("want CycleError, got %v", err) + } + if g, _ := b.svc.Graph(context.Background(), b.ws, b.campaign); len(g.Branches) != 0 { + t.Fatalf("a refused edit was committed: %+v", g.Branches) + } +} + +// A target in ANOTHER campaign of the same workspace is refused by the service +// with a named error, and by the composite FK if the service is bypassed. +func TestBranchTargetMustBeInSameCampaign(t *testing.T) { + b, done := newBranchIT(t, "Branch target owner") + defer done() + ctx := context.Background() + _, _, others := seedThreeSteps(t, ctx, b.q, "Branch target other") + + _, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", YesStepID: &others[0]}) + var te *seqgraph.TargetError + if !errors.As(err, &te) { + t.Fatalf("service: want TargetError, got %v", err) + } + + store := NewPgBranchStore(b.pool) + _, err = store.UpsertBranch(ctx, b.ws, BranchInput{ + CampaignID: b.campaign, StepID: b.steps[0], Condition: "always", YesStepID: &others[0], + }, checkGraph) + if !errors.Is(err, ErrTargetGone) { + t.Fatalf("store backstop: want ErrTargetGone (composite FK), got %v", err) + } +} + +// Every graph write takes the campaign lock on (campaign, workspace) first, so +// a foreign workspace writes nothing — even through the store directly. +func TestBranchWritesAreWorkspacePinned(t *testing.T) { + b, done := newBranchIT(t, "Branch tenant owner") + defer done() + ctx := context.Background() + intruder, err := b.q.CreateWorkspace(ctx, "Branch tenant intruder "+uuid.NewString()) + if err != nil { + t.Fatal(err) + } + + if _, err := b.svc.SetBranch(ctx, intruder.ID, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always"}); !errors.Is(err, ErrCampaignNotFound) { + t.Fatalf("service: want ErrCampaignNotFound, got %v", err) + } + store := NewPgBranchStore(b.pool) + if _, err := store.UpsertBranch(ctx, intruder.ID, BranchInput{CampaignID: b.campaign, StepID: b.steps[0], Condition: "always"}, checkGraph); !errors.Is(err, ErrCampaignNotFound) { + t.Fatalf("store: want ErrCampaignNotFound, got %v", err) + } + if err := store.DeleteBranch(ctx, intruder.ID, b.campaign, b.steps[0], checkGraph); !errors.Is(err, ErrCampaignNotFound) { + t.Fatalf("store delete: want ErrCampaignNotFound, got %v", err) + } + if rows, _ := b.q.ListBranchesByCampaign(ctx, gen.ListBranchesByCampaignParams{CampaignID: b.campaign, WorkspaceID: intruder.ID}); len(rows) != 0 { + t.Fatalf("foreign workspace sees %d branches", len(rows)) + } + if g, _ := b.svc.Graph(ctx, b.ws, b.campaign); len(g.Branches) != 0 { + t.Fatalf("intruder wrote into the owner's graph: %+v", g.Branches) + } +} + +// Deleting a target step turns the exits that pointed at it into ends (ON +// DELETE SET NULL on the target column only), and deleting a SOURCE step +// removes its router. +func TestStepDeleteNullsExitsAndDropsRouter(t *testing.T) { + b, done := newBranchIT(t, "Branch step delete") + defer done() + ctx := context.Background() + two := int32(2) + if _, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{ + StepID: b.steps[0], Condition: "opened", WithinDays: &two, YesStepID: &b.steps[2], NoStepID: &b.steps[1], + }); err != nil { + t.Fatal(err) + } + b.always(t, b.steps[1], nil) + + if err := b.svc.Delete(ctx, b.ws, b.campaign, b.steps[2]); err != nil { + t.Fatalf("delete target: %v", err) + } + g, err := b.svc.Graph(ctx, b.ws, b.campaign) + if err != nil { + t.Fatal(err) + } + for _, br := range g.Branches { + if br.StepID == b.steps[0] { + if br.YesStepID.Valid || !br.NoStepID.Valid || br.CampaignID != b.campaign { + t.Fatalf("after target delete = %+v, want yes nulled, no kept", br) + } + } + } + + if err := b.svc.Delete(ctx, b.ws, b.campaign, b.steps[1]); err != nil { + t.Fatalf("delete source: %v", err) + } + g, _ = b.svc.Graph(ctx, b.ws, b.campaign) + if len(g.Branches) != 1 || g.Branches[0].StepID != b.steps[0] || g.Branches[0].NoStepID.Valid { + t.Fatalf("after source delete = %+v", g.Branches) + } +} + +// A delete re-links the fall-through around the removed step. Here A -> B (by +// order), B -> D, C -> A, D ends: acyclic. Deleting B makes A fall through to C, +// and C -> A closes a loop, so the delete is refused and rolled back. +func TestStepDeleteRefusedWhenFallThroughClosesLoop(t *testing.T) { + b, done := newBranchIT(t, "Branch delete cycle") + defer done() + ctx := context.Background() + a, bb, c := b.steps[0], b.steps[1], b.steps[2] + d := b.addStep(t, 4) + b.always(t, bb, &d) + b.always(t, c, &a) + + err := b.svc.Delete(ctx, b.ws, b.campaign, bb) + if seqgraph.CodeOf(err) != seqgraph.CodeCycle { + t.Fatalf("want a cycle refusal, got %v", err) + } + if _, err := b.q.GetStep(ctx, gen.GetStepParams{ID: bb, WorkspaceID: b.ws}); err != nil { + t.Fatalf("the refused delete must be rolled back: %v", err) + } +} + +// Reordering moves every fall-through edge. A -> C explicitly with B and C +// falling through is acyclic; the order [C, B, A] makes C -> B -> A by order, +// and A -> C closes the loop. +func TestReorderRefusedWhenFallThroughClosesLoop(t *testing.T) { + b, done := newBranchIT(t, "Branch reorder cycle") + defer done() + ctx := context.Background() + a, bb, c := b.steps[0], b.steps[1], b.steps[2] + b.always(t, a, &c) + + _, err := b.svc.Reorder(ctx, b.ws, b.campaign, []uuid.UUID{c, bb, a}) + if seqgraph.CodeOf(err) != seqgraph.CodeCycle { + t.Fatalf("want a cycle refusal, got %v", err) + } + after, err := b.q.ListStepsByCampaign(ctx, gen.ListStepsByCampaignParams{CampaignID: b.campaign, WorkspaceID: b.ws}) + if err != nil { + t.Fatal(err) + } + if got := orderedIDs(after); !equalIDs(got, b.steps) { + t.Fatalf("refused reorder was committed: %v", got) + } +} + +// The composite FKs make a cross-campaign reference unrepresentable even for a +// raw write that skips every Go check. +func TestBranchSchemaRefusesForeignReferences(t *testing.T) { + b, done := newBranchIT(t, "Branch schema owner") + defer done() + ctx := context.Background() + otherWS, otherCampaign, others := seedThreeSteps(t, ctx, b.q, "Branch schema other") + + cases := map[string]struct { + ws, campaign, step uuid.UUID + yes *uuid.UUID + }{ + "source step of another campaign": {b.ws, b.campaign, others[0], nil}, + "campaign of another workspace": {b.ws, otherCampaign, others[0], nil}, + "target in another campaign": {b.ws, b.campaign, b.steps[0], &others[1]}, + "workspace mismatching campaign": {otherWS, b.campaign, b.steps[0], nil}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + _, err := b.q.UpsertBranch(ctx, gen.UpsertBranchParams{ + StepID: tc.step, WorkspaceID: tc.ws, CampaignID: tc.campaign, Condition: "always", + YesStepID: nullUUID(tc.yes), + }) + if err == nil { + t.Fatal("the schema accepted a foreign reference") + } + }) + } +} diff --git a/internal/app/sequencestep/branch_test.go b/internal/app/sequencestep/branch_test.go new file mode 100644 index 00000000..01b528d7 --- /dev/null +++ b/internal/app/sequencestep/branch_test.go @@ -0,0 +1,312 @@ +package sequencestep + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/inroad/inroad/internal/platform/db/gen" + "github.com/inroad/inroad/internal/platform/seqgraph" +) + +// fakeBranchStore keeps routers in memory and, like the real store, runs the +// GraphCheck against the graph AS IT WOULD BE COMMITTED, rolling back on error. +// steps is the campaign's step list the check sees. +type fakeBranchStore struct { + steps []gen.SequenceStep + branches map[uuid.UUID]gen.SequenceStepBranch + labels map[string]bool + upserts int +} + +func (f *fakeBranchStore) ListBranches(context.Context, uuid.UUID, uuid.UUID) ([]gen.SequenceStepBranch, error) { + return f.list(), nil +} + +func (f *fakeBranchStore) ReplyLabelExists(_ context.Context, _ uuid.UUID, key string) (bool, error) { + return f.labels[key], nil +} + +func (f *fakeBranchStore) UpsertBranch(_ context.Context, ws uuid.UUID, in BranchInput, check GraphCheck) (gen.SequenceStepBranch, error) { + row := gen.SequenceStepBranch{ + StepID: in.StepID, WorkspaceID: ws, CampaignID: in.CampaignID, Condition: in.Condition, + WithinDays: in.WithinDays, ReplyLabelKey: in.ReplyLabelKey, + YesStepID: nullUUID(in.YesStepID), NoStepID: nullUUID(in.NoStepID), + } + prev, had := f.branches[in.StepID] + if f.branches == nil { + f.branches = map[uuid.UUID]gen.SequenceStepBranch{} + } + f.branches[in.StepID] = row + if err := check(f.steps, f.list()); err != nil { + if had { + f.branches[in.StepID] = prev + } else { + delete(f.branches, in.StepID) + } + return gen.SequenceStepBranch{}, err + } + f.upserts++ + return row, nil +} + +func (f *fakeBranchStore) DeleteBranch(_ context.Context, _, _, stepID uuid.UUID, check GraphCheck) error { + prev, had := f.branches[stepID] + delete(f.branches, stepID) + if err := check(f.steps, f.list()); err != nil { + if had { + f.branches[stepID] = prev + } + return err + } + return nil +} + +func (f *fakeBranchStore) list() []gen.SequenceStepBranch { + out := make([]gen.SequenceStepBranch, 0, len(f.branches)) + for _, b := range f.branches { + out = append(out, b) + } + return out +} + +// branchFixture is a campaign with three linear steps and both fakes wired so +// the service's own step lookups and the store's graph check see the same steps. +type branchFixture struct { + svc *Service + branches *fakeBranchStore + campaign uuid.UUID + ws uuid.UUID + step []uuid.UUID +} + +func newBranchFixture(t *testing.T, status string) branchFixture { + t.Helper() + campaign := uuid.New() + var steps []gen.SequenceStep + var ids []uuid.UUID + for i := range 3 { + id := uuid.New() + ids = append(ids, id) + steps = append(steps, gen.SequenceStep{ID: id, CampaignID: campaign, StepOrder: int32(i + 1)}) + } + bs := &fakeBranchStore{steps: steps, labels: map[string]bool{"positive": true}} + store := &stepsByID{steps: steps} + return branchFixture{ + svc: NewService(store, fakeChecker{status: status}, &fakeVariantStore{}, bs), + branches: bs, campaign: campaign, ws: uuid.New(), step: ids, + } +} + +// stepsByID is a Store whose Get and List answer from a fixed step set, so +// assertStepInCampaign behaves as it does against the database. +type stepsByID struct { + fakeStore + steps []gen.SequenceStep +} + +func (s *stepsByID) Get(_ context.Context, _, id uuid.UUID) (gen.SequenceStep, error) { + for _, st := range s.steps { + if st.ID == id { + return st, nil + } + } + return gen.SequenceStep{}, errors.New("no rows") +} + +func (s *stepsByID) List(context.Context, uuid.UUID, uuid.UUID) ([]gen.SequenceStep, error) { + return s.steps, nil +} + +func ptr[T any](v T) *T { return &v } + +func TestSetBranchHappyPathOnRunningCampaign(t *testing.T) { + f := newBranchFixture(t, "running") + b, err := f.svc.SetBranch(context.Background(), f.ws, f.campaign, BranchInput{ + StepID: f.step[0], Condition: "opened", WithinDays: ptr(int32(3)), + YesStepID: &f.step[2], NoStepID: &f.step[1], + }) + if err != nil { + t.Fatalf("a branch edit is allowed live: %v", err) + } + if b.CampaignID != f.campaign || b.Condition != "opened" || !b.YesStepID.Valid || b.YesStepID.Bytes != f.step[2] { + t.Fatalf("stored branch = %+v", b) + } +} + +func TestSetBranchRejectsMissingCampaign(t *testing.T) { + f := newBranchFixture(t, "draft") + svc := NewService(&fakeStore{}, fakeChecker{err: errors.New("no rows")}, &fakeVariantStore{}, f.branches) + _, err := svc.SetBranch(context.Background(), f.ws, f.campaign, BranchInput{StepID: f.step[0], Condition: "always"}) + if !errors.Is(err, ErrCampaignNotFound) { + t.Fatalf("want ErrCampaignNotFound, got %v", err) + } +} + +// A step of a different campaign (or tenant — the workspace-pinned Get simply +// does not find it) is not routable through this campaign's URL. +func TestSetBranchRejectsStepFromAnotherCampaign(t *testing.T) { + f := newBranchFixture(t, "draft") + _, err := f.svc.SetBranch(context.Background(), f.ws, f.campaign, BranchInput{StepID: uuid.New(), Condition: "always"}) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("want ErrNotFound, got %v", err) + } + if f.branches.upserts != 0 { + t.Fatal("nothing may be written for a foreign step") + } +} + +func TestSetBranchRejectsTargetOutsideCampaign(t *testing.T) { + f := newBranchFixture(t, "draft") + foreign := uuid.New() + _, err := f.svc.SetBranch(context.Background(), f.ws, f.campaign, BranchInput{ + StepID: f.step[0], Condition: "replied", WithinDays: ptr(int32(2)), NoStepID: &foreign, + }) + var te *seqgraph.TargetError + if !errors.As(err, &te) || te.Target != foreign { + t.Fatalf("want TargetError naming %v, got %v", foreign, err) + } + if f.branches.upserts != 0 { + t.Fatal("an unknown target must be refused before any write") + } +} + +func TestSetBranchRejectsCycle(t *testing.T) { + f := newBranchFixture(t, "running") + // 3 -> 1 closes 1 -> 2 -> 3 (implicit fall-through). + _, err := f.svc.SetBranch(context.Background(), f.ws, f.campaign, BranchInput{ + StepID: f.step[2], Condition: "always", YesStepID: &f.step[0], + }) + var cyc *seqgraph.CycleError + if !errors.As(err, &cyc) { + t.Fatalf("want CycleError, got %v", err) + } + if len(f.branches.branches) != 0 { + t.Fatal("a refused edit must leave the graph as it was") + } +} + +func TestSetBranchShapeErrors(t *testing.T) { + cases := map[string]struct { + in func(f branchFixture) BranchInput + code string + }{ + "unknown condition": {func(f branchFixture) BranchInput { + return BranchInput{StepID: f.step[0], Condition: "bounced", WithinDays: ptr(int32(1))} + }, seqgraph.CodeInvalidCondition}, + "missing window": {func(f branchFixture) BranchInput { + return BranchInput{StepID: f.step[0], Condition: "clicked"} + }, seqgraph.CodeInvalidWithinDays}, + "explicit zero window on always": {func(f branchFixture) BranchInput { + return BranchInput{StepID: f.step[0], Condition: "always", WithinDays: ptr(int32(0))} + }, seqgraph.CodeInvalidWithinDays}, + "no exit on always": {func(f branchFixture) BranchInput { + return BranchInput{StepID: f.step[0], Condition: "always", NoStepID: &f.step[1]} + }, seqgraph.CodeNoExitNotAllowed}, + "label on an open": {func(f branchFixture) BranchInput { + return BranchInput{StepID: f.step[0], Condition: "opened", WithinDays: ptr(int32(1)), ReplyLabelKey: ptr("positive")} + }, seqgraph.CodeLabelNotAllowed}, + "label that does not exist": {func(f branchFixture) BranchInput { + return BranchInput{StepID: f.step[0], Condition: "replied", WithinDays: ptr(int32(1)), ReplyLabelKey: ptr("ghost")} + }, seqgraph.CodeLabelNotAllowed}, + "self loop": {func(f branchFixture) BranchInput { + return BranchInput{StepID: f.step[1], Condition: "not_opened", WithinDays: ptr(int32(1)), YesStepID: &f.step[1]} + }, seqgraph.CodeCycle}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + f := newBranchFixture(t, "draft") + _, err := f.svc.SetBranch(context.Background(), f.ws, f.campaign, tc.in(f)) + if got := seqgraph.CodeOf(err); got != tc.code { + t.Fatalf("code = %q (%v), want %q", got, err, tc.code) + } + if f.branches.upserts != 0 { + t.Fatal("a malformed branch must not be written") + } + }) + } +} + +// Removing a router restores the step's fall-through, which is itself an edge: +// here step 2 routes back to step 1, which is only acyclic because step 1 ends +// explicitly. Deleting step 1's router would restore 1 -> 2 and close the loop. +func TestDeleteBranchRefusedWhenFallThroughClosesLoop(t *testing.T) { + f := newBranchFixture(t, "running") + ctx := context.Background() + if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, BranchInput{StepID: f.step[0], Condition: "always"}); err != nil { + t.Fatalf("1 ends: %v", err) + } + if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, BranchInput{ + StepID: f.step[1], Condition: "always", YesStepID: &f.step[0], + }); err != nil { + t.Fatalf("2 -> 1 while 1 ends is acyclic: %v", err) + } + err := f.svc.DeleteBranch(ctx, f.ws, f.campaign, f.step[0]) + if seqgraph.CodeOf(err) != seqgraph.CodeCycle { + t.Fatalf("want a cycle refusal, got %v", err) + } + if _, still := f.branches.branches[f.step[0]]; !still { + t.Fatal("the refused delete must be rolled back") + } +} + +func TestDeleteBranchIsIdempotent(t *testing.T) { + f := newBranchFixture(t, "running") + if err := f.svc.DeleteBranch(context.Background(), f.ws, f.campaign, f.step[0]); err != nil { + t.Fatalf("deleting a router that does not exist is a no-op: %v", err) + } +} + +func TestGraphReturnsStepsAndBranches(t *testing.T) { + f := newBranchFixture(t, "running") + f.branches.branches = map[uuid.UUID]gen.SequenceStepBranch{ + f.step[0]: {StepID: f.step[0], Condition: "always", YesStepID: pgtype.UUID{Bytes: f.step[2], Valid: true}}, + } + g, err := f.svc.Graph(context.Background(), f.ws, f.campaign) + if err != nil { + t.Fatalf("Graph: %v", err) + } + if len(g.Steps) != 3 || len(g.Branches) != 1 { + t.Fatalf("graph = %+v", g) + } + resp := toGraphResponse(f.campaign, g) + if resp.EntryStepID == nil || *resp.EntryStepID != f.step[0].String() { + t.Fatalf("entry = %v", resp.EntryStepID) + } + if resp.Nodes[0].Branch == nil || resp.Nodes[0].Branch.YesStepID == nil || *resp.Nodes[0].Branch.YesStepID != f.step[2].String() { + t.Fatalf("node 1 branch = %+v", resp.Nodes[0].Branch) + } + // The fall-through is served whether or not a branch overrides it. + if resp.Nodes[0].DefaultNextStepID == nil || *resp.Nodes[0].DefaultNextStepID != f.step[1].String() { + t.Fatalf("node 1 default next = %v", resp.Nodes[0].DefaultNextStepID) + } + if resp.Nodes[2].DefaultNextStepID != nil { + t.Fatalf("the last step has no fall-through, got %v", *resp.Nodes[2].DefaultNextStepID) + } +} + +func TestGraphRejectsMissingCampaign(t *testing.T) { + svc := NewService(&fakeStore{}, fakeChecker{err: errors.New("no rows")}, &fakeVariantStore{}, &fakeBranchStore{}) + if _, err := svc.Graph(context.Background(), uuid.New(), uuid.New()); !errors.Is(err, ErrCampaignNotFound) { + t.Fatalf("want ErrCampaignNotFound, got %v", err) + } +} + +// A linear campaign (no routers) validates under every structural edit exactly +// as before branching existed: the check a delete/reorder now commits under +// never refuses a graph with no branches. +func TestCheckGraphAcceptsEveryLinearCampaign(t *testing.T) { + var steps []gen.SequenceStep + for i := range 5 { + steps = append(steps, gen.SequenceStep{ID: uuid.New(), StepOrder: int32(i*2 + 1)}) // gaps too + } + if err := checkGraph(steps, nil); err != nil { + t.Fatalf("linear campaign refused: %v", err) + } + if err := checkGraph(nil, nil); err != nil { + t.Fatalf("empty campaign refused: %v", err) + } +} diff --git a/internal/app/sequencestep/branchhandler.go b/internal/app/sequencestep/branchhandler.go new file mode 100644 index 00000000..1ca8501d --- /dev/null +++ b/internal/app/sequencestep/branchhandler.go @@ -0,0 +1,225 @@ +package sequencestep + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/inroad/inroad/internal/app/auth" + "github.com/inroad/inroad/internal/platform/db/gen" + "github.com/inroad/inroad/internal/platform/httpx" + "github.com/inroad/inroad/internal/platform/seqgraph" +) + +// branchRequest is the PUT body (StepBranchRequest in api/openapi.yaml). A null +// or absent exit ends the path. +type branchRequest struct { + Condition string `json:"condition"` + WithinDays *int32 `json:"within_days"` + ReplyLabelKey *string `json:"reply_label_key"` + YesStepID *uuid.UUID `json:"yes_step_id"` + NoStepID *uuid.UUID `json:"no_step_id"` +} + +// branchResponse is the wire shape of one router (StepBranch). +type branchResponse struct { + StepID string `json:"step_id"` + Condition string `json:"condition"` + WithinDays *int32 `json:"within_days"` + ReplyLabelKey *string `json:"reply_label_key"` + YesStepID *string `json:"yes_step_id"` + NoStepID *string `json:"no_step_id"` + UpdatedAt string `json:"updated_at"` +} + +// graphNodeResponse is one step as a graph node (CampaignGraphNode). +type graphNodeResponse struct { + StepID string `json:"step_id"` + StepOrder int32 `json:"step_order"` + // DefaultNextStepID is where the step goes when it has NO branch: the next + // step by step_order, or null for the last step. Served rather than left for + // the client to derive so the canvas draws the same fall-through edge the + // send path follows. + DefaultNextStepID *string `json:"default_next_step_id"` + Branch *branchResponse `json:"branch"` +} + +// graphResponse is GET /campaigns/{id}/graph (CampaignGraph). +type graphResponse struct { + CampaignID string `json:"campaign_id"` + EntryStepID *string `json:"entry_step_id"` + Nodes []graphNodeResponse `json:"nodes"` +} + +// graphErrorResponse is every graph validation failure (BranchValidationError): +// the usual human message plus a stable code, and — for a loop — the steps on +// it, so the canvas can highlight the offending edges. +type graphErrorResponse struct { + Error string `json:"error"` + Code string `json:"code"` + StepIDs []string `json:"step_ids,omitempty"` +} + +// Graph handles GET /campaigns/{id}/graph. +func (h *Handler) Graph(w http.ResponseWriter, r *http.Request) { + ws, ok := auth.WorkspaceID(w, r) + if !ok { + return + } + campaignID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + httpx.Error(w, http.StatusBadRequest, "bad campaign id") + return + } + g, err := h.svc.Graph(r.Context(), ws, campaignID) + if err != nil { + writeGraphError(w, err, "could not load the sequence graph") + return + } + httpx.JSON(w, http.StatusOK, toGraphResponse(campaignID, g)) +} + +// SetBranch handles PUT /campaigns/{id}/steps/{stepId}/branch. +func (h *Handler) SetBranch(w http.ResponseWriter, r *http.Request) { + ws, campaignID, stepID, ok := campaignAndStepIDs(w, r) + if !ok { + return + } + var req branchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.Error(w, http.StatusBadRequest, "invalid json") + return + } + // An empty label is "no label", not a label whose key is "". + if req.ReplyLabelKey != nil && *req.ReplyLabelKey == "" { + req.ReplyLabelKey = nil + } + b, err := h.svc.SetBranch(r.Context(), ws, campaignID, BranchInput{ + StepID: stepID, Condition: req.Condition, WithinDays: req.WithinDays, + ReplyLabelKey: req.ReplyLabelKey, YesStepID: req.YesStepID, NoStepID: req.NoStepID, + }) + if err != nil { + writeGraphError(w, err, "could not save branch") + return + } + httpx.JSON(w, http.StatusOK, toBranchResponse(b)) +} + +// DeleteBranch handles DELETE /campaigns/{id}/steps/{stepId}/branch. +func (h *Handler) DeleteBranch(w http.ResponseWriter, r *http.Request) { + ws, campaignID, stepID, ok := campaignAndStepIDs(w, r) + if !ok { + return + } + if err := h.svc.DeleteBranch(r.Context(), ws, campaignID, stepID); err != nil { + writeGraphError(w, err, "could not remove branch") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// campaignAndStepIDs reads the pinned workspace and the two path ids, writing +// the error response itself when any is missing or malformed. +func campaignAndStepIDs(w http.ResponseWriter, r *http.Request) (ws, campaignID, stepID uuid.UUID, ok bool) { + ws, ok = auth.WorkspaceID(w, r) + if !ok { + return uuid.Nil, uuid.Nil, uuid.Nil, false + } + campaignID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + httpx.Error(w, http.StatusBadRequest, "bad campaign id") + return uuid.Nil, uuid.Nil, uuid.Nil, false + } + stepID, err = uuid.Parse(chi.URLParam(r, "stepId")) + if err != nil { + httpx.Error(w, http.StatusBadRequest, "bad step id") + return uuid.Nil, uuid.Nil, uuid.Nil, false + } + return ws, campaignID, stepID, true +} + +// writeGraphError maps every error a graph read or write can return. +// +// A malformed branch (seqgraph shape codes) is 400, like any other invalid +// body. A well-formed edit the GRAPH refuses — an exit to a step outside the +// campaign, or a loop — is 422: the request was understood and is the wrong +// thing to commit. Both carry `code`. +func writeGraphError(w http.ResponseWriter, err error, fallback string) { + var cycle *seqgraph.CycleError + switch code := seqgraph.CodeOf(err); { + case errors.Is(err, ErrCampaignNotFound): + httpx.Error(w, http.StatusNotFound, "campaign not found") + case errors.Is(err, ErrNotFound), errors.Is(err, ErrBranchConflict): + httpx.Error(w, http.StatusNotFound, "step not found") + case errors.Is(err, ErrCampaignNotDraft): + httpx.Error(w, http.StatusConflict, "steps can only be changed structurally while the campaign is draft") + case errors.Is(err, ErrTargetGone): + httpx.JSON(w, http.StatusUnprocessableEntity, graphErrorResponse{Error: err.Error(), Code: seqgraph.CodeUnknownTarget}) + case errors.As(err, &cycle): + httpx.JSON(w, http.StatusUnprocessableEntity, graphErrorResponse{ + Error: err.Error(), Code: code, StepIDs: uuidStrings(cycle.StepIDs), + }) + case code == seqgraph.CodeUnknownTarget || code == seqgraph.CodeUnknownStep: + httpx.JSON(w, http.StatusUnprocessableEntity, graphErrorResponse{Error: err.Error(), Code: code}) + case code != "": + httpx.JSON(w, http.StatusBadRequest, graphErrorResponse{Error: err.Error(), Code: code}) + default: + httpx.Error(w, http.StatusInternalServerError, fallback) + } +} + +func toGraphResponse(campaignID uuid.UUID, g Graph) graphResponse { + model := BuildGraph(g.Steps, g.Branches) + byStep := make(map[uuid.UUID]gen.SequenceStepBranch, len(g.Branches)) + for _, b := range g.Branches { + byStep[b.StepID] = b + } + out := graphResponse{CampaignID: campaignID.String(), Nodes: make([]graphNodeResponse, 0, len(g.Steps))} + if entry, ok := model.Entry(); ok { + out.EntryStepID = uuidString(entry.ID) + } + for _, st := range g.Steps { + node := graphNodeResponse{StepID: st.ID.String(), StepOrder: st.StepOrder} + if next, ok := model.DefaultNext(st.ID); ok { + node.DefaultNextStepID = uuidString(next.ID) + } + if b, ok := byStep[st.ID]; ok { + resp := toBranchResponse(b) + node.Branch = &resp + } + out.Nodes = append(out.Nodes, node) + } + return out +} + +func toBranchResponse(b gen.SequenceStepBranch) branchResponse { + out := branchResponse{ + StepID: b.StepID.String(), Condition: b.Condition, + WithinDays: b.WithinDays, ReplyLabelKey: b.ReplyLabelKey, + UpdatedAt: b.UpdatedAt.Time.UTC().Format(time.RFC3339), + } + if b.YesStepID.Valid { + out.YesStepID = uuidString(b.YesStepID.Bytes) + } + if b.NoStepID.Valid { + out.NoStepID = uuidString(b.NoStepID.Bytes) + } + return out +} + +func uuidString(id uuid.UUID) *string { + s := id.String() + return &s +} + +func uuidStrings(ids []uuid.UUID) []string { + out := make([]string, len(ids)) + for i, id := range ids { + out[i] = id.String() + } + return out +} diff --git a/internal/app/sequencestep/branchhandler_test.go b/internal/app/sequencestep/branchhandler_test.go new file mode 100644 index 00000000..60fba6b7 --- /dev/null +++ b/internal/app/sequencestep/branchhandler_test.go @@ -0,0 +1,53 @@ +package sequencestep + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + + "github.com/inroad/inroad/internal/platform/seqgraph" +) + +// The status + body shape the canvas keys its error handling on. +func TestWriteGraphErrorContract(t *testing.T) { + loop := []uuid.UUID{uuid.New(), uuid.New()} + cases := []struct { + name string + err error + status int + code string + stepIDs int + hasError bool + }{ + {"shape", &seqgraph.ShapeError{Code: seqgraph.CodeInvalidWithinDays, Msg: "x"}, http.StatusBadRequest, seqgraph.CodeInvalidWithinDays, 0, true}, + {"cycle", &seqgraph.CycleError{StepIDs: loop}, http.StatusUnprocessableEntity, seqgraph.CodeCycle, 2, true}, + {"wrapped cycle", fmt.Errorf("commit: %w", &seqgraph.CycleError{StepIDs: loop}), http.StatusUnprocessableEntity, seqgraph.CodeCycle, 2, true}, + {"unknown target", &seqgraph.TargetError{StepID: uuid.New(), Target: uuid.New()}, http.StatusUnprocessableEntity, seqgraph.CodeUnknownTarget, 0, true}, + {"target gone", ErrTargetGone, http.StatusUnprocessableEntity, seqgraph.CodeUnknownTarget, 0, true}, + {"campaign missing", ErrCampaignNotFound, http.StatusNotFound, "", 0, true}, + {"step missing", ErrNotFound, http.StatusNotFound, "", 0, true}, + {"not draft", ErrCampaignNotDraft, http.StatusConflict, "", 0, true}, + {"unexpected", errors.New("db down"), http.StatusInternalServerError, "", 0, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + writeGraphError(rec, tc.err, "fallback") + if rec.Code != tc.status { + t.Fatalf("status = %d, want %d", rec.Code, tc.status) + } + var body graphErrorResponse + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("body %q: %v", rec.Body.String(), err) + } + if body.Code != tc.code || len(body.StepIDs) != tc.stepIDs || (tc.hasError && body.Error == "") { + t.Fatalf("body = %+v", body) + } + }) + } +} diff --git a/internal/app/sequencestep/branchstore.go b/internal/app/sequencestep/branchstore.go new file mode 100644 index 00000000..42d291f2 --- /dev/null +++ b/internal/app/sequencestep/branchstore.go @@ -0,0 +1,171 @@ +package sequencestep + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/inroad/inroad/internal/platform/db/gen" +) + +// GraphCheck validates a campaign's routing graph as it would be committed: the +// campaign's steps and branches AFTER a mutation, read inside the mutation's own +// transaction. A non-nil error rolls the mutation back and is returned to the +// caller unchanged. +// +// It is a function rather than a rule baked into the store so the store stays +// policy-free: what makes a graph valid is the service's decision +// (internal/platform/seqgraph), the store only guarantees the check sees exactly +// the graph it is about to commit. +type GraphCheck func(steps []gen.SequenceStep, branches []gen.SequenceStepBranch) error + +// BranchInput is one step's router as written. A nil exit ends the path. +type BranchInput struct { + CampaignID uuid.UUID + StepID uuid.UUID + Condition string + WithinDays *int32 + ReplyLabelKey *string + YesStepID *uuid.UUID + NoStepID *uuid.UUID +} + +// BranchStore is the persistence seam for routers. A distinct interface from +// Store for the reason VariantStore is one: distinct callers, and it keeps the +// graph rules testable without a database. +type BranchStore interface { + ListBranches(ctx context.Context, ws, campaignID uuid.UUID) ([]gen.SequenceStepBranch, error) + ReplyLabelExists(ctx context.Context, ws uuid.UUID, key string) (bool, error) + // UpsertBranch creates or replaces the router on in.StepID, then runs check + // on the result, in one transaction holding the campaign's graph lock. + UpsertBranch(ctx context.Context, ws uuid.UUID, in BranchInput, check GraphCheck) (gen.SequenceStepBranch, error) + // DeleteBranch removes the router on stepID (a no-op when there is none), + // then runs check, in one transaction holding the campaign's graph lock. + // Removing a router is a graph change too: it restores the step's linear + // fall-through, which can close a loop. + DeleteBranch(ctx context.Context, ws, campaignID, stepID uuid.UUID, check GraphCheck) error +} + +// ErrBranchConflict is an upsert that matched a router row owned by a different +// workspace or campaign. Unreachable through the service (the step is +// ownership-checked first), and reported rather than swallowed so a future +// caller that skips that check fails closed. +var ErrBranchConflict = errors.New("step branch belongs to another campaign") + +// ErrTargetGone is an exit whose target step stopped existing between the +// service's pre-check and the write (a concurrent delete): the composite FK +// refused it. Reported as the same unknown-target failure the pre-check gives. +var ErrTargetGone = errors.New("a branch exit points at a step that is no longer in this campaign") + +// foreignKeyViolation is PostgreSQL's SQLSTATE for a refused foreign key. +const foreignKeyViolation = "23503" + +// PgBranchStore implements BranchStore over sqlc. The pool backs the +// lock-mutate-check transaction. +type PgBranchStore struct { + pool *pgxpool.Pool + q *gen.Queries +} + +func NewPgBranchStore(pool *pgxpool.Pool) *PgBranchStore { + return &PgBranchStore{pool: pool, q: gen.New(pool)} +} + +func (s *PgBranchStore) ListBranches(ctx context.Context, ws, campaignID uuid.UUID) ([]gen.SequenceStepBranch, error) { + return s.q.ListBranchesByCampaign(ctx, gen.ListBranchesByCampaignParams{CampaignID: campaignID, WorkspaceID: ws}) +} + +func (s *PgBranchStore) ReplyLabelExists(ctx context.Context, ws uuid.UUID, key string) (bool, error) { + return s.q.ReplyLabelKeyExists(ctx, gen.ReplyLabelKeyExistsParams{WorkspaceID: ws, Key: key}) +} + +func (s *PgBranchStore) UpsertBranch(ctx context.Context, ws uuid.UUID, in BranchInput, check GraphCheck) (gen.SequenceStepBranch, error) { + var out gen.SequenceStepBranch + err := withGraphLock(ctx, s.pool, s.q, ws, in.CampaignID, check, func(q *gen.Queries) error { + row, err := q.UpsertBranch(ctx, gen.UpsertBranchParams{ + StepID: in.StepID, WorkspaceID: ws, CampaignID: in.CampaignID, Condition: in.Condition, + WithinDays: in.WithinDays, ReplyLabelKey: in.ReplyLabelKey, + YesStepID: nullUUID(in.YesStepID), NoStepID: nullUUID(in.NoStepID), + }) + if errors.Is(err, pgx.ErrNoRows) { + return ErrBranchConflict + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == foreignKeyViolation { + return ErrTargetGone + } + if err != nil { + return fmt.Errorf("upsert branch: %w", err) + } + out = row + return nil + }) + return out, err +} + +func (s *PgBranchStore) DeleteBranch(ctx context.Context, ws, campaignID, stepID uuid.UUID, check GraphCheck) error { + return withGraphLock(ctx, s.pool, s.q, ws, campaignID, check, func(q *gen.Queries) error { + if err := q.DeleteBranch(ctx, gen.DeleteBranchParams{StepID: stepID, CampaignID: campaignID, WorkspaceID: ws}); err != nil { + return fmt.Errorf("delete branch: %w", err) + } + return nil + }) +} + +// withGraphLock runs mutate and then check in ONE transaction that first takes +// the campaign's graph lock (LockCampaignGraph). Serializing on the campaign is +// what makes the check sound: two edits that are each acyclic alone can close a +// loop together, and without the lock both would pass their own check against a +// graph that no longer exists by the time they commit. Every write is pinned on +// workspace_id by the queries mutate calls; the lock itself matches zero rows for +// a campaign outside ws, which is reported as ErrCampaignNotFound. +func withGraphLock(ctx context.Context, pool *pgxpool.Pool, q *gen.Queries, ws, campaignID uuid.UUID, + check GraphCheck, mutate func(q *gen.Queries) error) error { + tx, err := pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin graph tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() // no-op once committed + qtx := q.WithTx(tx) + + if _, err := qtx.LockCampaignGraph(ctx, gen.LockCampaignGraphParams{ID: campaignID, WorkspaceID: ws}); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrCampaignNotFound + } + return fmt.Errorf("lock campaign graph: %w", err) + } + if err := mutate(qtx); err != nil { + return err + } + if check != nil { + steps, err := qtx.ListStepsByCampaign(ctx, gen.ListStepsByCampaignParams{CampaignID: campaignID, WorkspaceID: ws}) + if err != nil { + return fmt.Errorf("list steps: %w", err) + } + branches, err := qtx.ListBranchesByCampaign(ctx, gen.ListBranchesByCampaignParams{CampaignID: campaignID, WorkspaceID: ws}) + if err != nil { + return fmt.Errorf("list branches: %w", err) + } + if err := check(steps, branches); err != nil { + return err + } + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit graph tx: %w", err) + } + return nil +} + +// nullUUID converts an optional id into the nullable column value. +func nullUUID(id *uuid.UUID) pgtype.UUID { + if id == nil { + return pgtype.UUID{} + } + return pgtype.UUID{Bytes: *id, Valid: true} +} diff --git a/internal/app/sequencestep/handler.go b/internal/app/sequencestep/handler.go index e782c4c0..a0e09221 100644 --- a/internal/app/sequencestep/handler.go +++ b/internal/app/sequencestep/handler.go @@ -11,6 +11,7 @@ import ( "github.com/inroad/inroad/internal/app/auth" "github.com/inroad/inroad/internal/platform/db/gen" "github.com/inroad/inroad/internal/platform/httpx" + "github.com/inroad/inroad/internal/platform/seqgraph" "github.com/inroad/inroad/internal/platform/validate" ) @@ -131,6 +132,9 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { httpx.Error(w, http.StatusConflict, "steps can only be removed while the campaign is draft") case errors.Is(err, ErrNotFound): httpx.Error(w, http.StatusNotFound, "step not found") + case seqgraph.CodeOf(err) != "": + // The re-linked fall-through would close a loop through a branch (422). + writeGraphError(w, err, "could not delete step") case err != nil: httpx.Error(w, http.StatusInternalServerError, "could not delete step") default: @@ -175,6 +179,9 @@ func (h *Handler) Reorder(w http.ResponseWriter, r *http.Request) { httpx.Error(w, http.StatusNotFound, "step not found") case errors.Is(err, ErrInvalidOrder): httpx.Error(w, http.StatusBadRequest, err.Error()) + case seqgraph.CodeOf(err) != "": + // The new order's fall-through would close a loop through a branch (422). + writeGraphError(w, err, "could not reorder steps") case err != nil: httpx.Error(w, http.StatusInternalServerError, "could not reorder steps") default: diff --git a/internal/app/sequencestep/reorder_integration_test.go b/internal/app/sequencestep/reorder_integration_test.go index e571af7c..b4db7583 100644 --- a/internal/app/sequencestep/reorder_integration_test.go +++ b/internal/app/sequencestep/reorder_integration_test.go @@ -4,6 +4,7 @@ package sequencestep import ( "context" + "errors" "testing" "github.com/google/uuid" @@ -85,7 +86,7 @@ func TestReorderPersistsNewOrder(t *testing.T) { // Reverse the order: [3,2,1]. want := []uuid.UUID{ids[2], ids[1], ids[0]} - got, err := store.Reorder(ctx, ws, campaignID, want) + got, err := store.Reorder(ctx, ws, campaignID, want, checkGraph) if err != nil { t.Fatalf("Reorder: %v", err) } @@ -109,8 +110,8 @@ func TestReorderPersistsNewOrder(t *testing.T) { } // TestReorderIsWorkspaceScoped proves the workspace pin: reordering under a -// foreign workspace id touches zero rows (returns an empty list) and leaves the -// owning workspace's step_order untouched. +// foreign workspace id is refused (ErrCampaignNotFound, no rows returned) and +// leaves the owning workspace's step_order untouched. func TestReorderIsWorkspaceScoped(t *testing.T) { ctx := context.Background() if err := db.Migrate(dbtest.DSN(t)); err != nil { @@ -131,9 +132,12 @@ func TestReorderIsWorkspaceScoped(t *testing.T) { } // Intruder attempts to reorder the owner's steps under its own workspace id. - got, err := store.Reorder(ctx, other.ID, campaignID, []uuid.UUID{ids[2], ids[1], ids[0]}) - if err != nil { - t.Fatalf("cross-tenant Reorder: %v", err) + // The graph lock is taken on (campaign, workspace) before any write, so a + // foreign workspace fails there — refused outright rather than, as before the + // lock existed, running every UPDATE against zero rows. + got, err := store.Reorder(ctx, other.ID, campaignID, []uuid.UUID{ids[2], ids[1], ids[0]}, checkGraph) + if !errors.Is(err, ErrCampaignNotFound) { + t.Fatalf("cross-tenant Reorder: want ErrCampaignNotFound, got %v", err) } if len(got) != 0 { t.Fatalf("cross-tenant reorder returned %d rows, want 0", len(got)) diff --git a/internal/app/sequencestep/routes.go b/internal/app/sequencestep/routes.go index 4887df03..9c8f2737 100644 --- a/internal/app/sequencestep/routes.go +++ b/internal/app/sequencestep/routes.go @@ -27,6 +27,15 @@ func (h *Handler) Register(r chi.Router) { r.With(write).Put("/{id}/steps/{stepId}", h.Update) r.With(write).Delete("/{id}/steps/{stepId}", h.Delete) + // Conditional branching. The graph read is the whole campaign's routing in + // one response; a branch is written per step, nested under the step it + // routes out of so the ownership check has the step id without trusting the + // body. Writes take campaigns:write and, like variant writes, are allowed on + // a running campaign — see Service.SetBranch for the mid-flight contract. + r.With(read).Get("/{id}/graph", h.Graph) + r.With(write).Put("/{id}/steps/{stepId}/branch", h.SetBranch) + r.With(write).Delete("/{id}/steps/{stepId}/branch", h.DeleteBranch) + // A/B variants. Nested under the step they belong to: a variant has no // meaning apart from its step, and the nesting is what makes the step id // available for the ownership check without trusting the body. diff --git a/internal/app/sequencestep/service.go b/internal/app/sequencestep/service.go index ff5ecb16..105549b3 100644 --- a/internal/app/sequencestep/service.go +++ b/internal/app/sequencestep/service.go @@ -33,13 +33,15 @@ type Service struct { store Store checker CampaignChecker variants VariantStore + branches BranchStore } // NewService builds the step service. variants is a second, narrow seam for A/B // variants: a distinct responsibility with distinct callers, and keeping it // separate is what lets the weight invariant be tested without a database. -func NewService(store Store, checker CampaignChecker, variants VariantStore) *Service { - return &Service{store: store, checker: checker, variants: variants} +// branches is the third, for the routing graph, for the same reason. +func NewService(store Store, checker CampaignChecker, variants VariantStore, branches BranchStore) *Service { + return &Service{store: store, checker: checker, variants: variants, branches: branches} } // Create appends a step at max(step_order)+1. Structural change → requires the @@ -82,6 +84,11 @@ func (s *Service) Update(ctx context.Context, ws, campaignID uuid.UUID, in Updat // Delete removes a step. Structural change → requires the campaign to be // draft (running/paused/done return 409). +// +// Branch exits that pointed at the deleted step become path ends (the FK nulls +// them), and the linear fall-through re-links around the gap. That re-link can +// close a loop through a branch elsewhere, so the delete commits only if the +// resulting graph validates — refused with a *seqgraph.CycleError otherwise. func (s *Service) Delete(ctx context.Context, ws, campaignID, stepID uuid.UUID) error { if err := s.requireDraft(ctx, ws, campaignID); err != nil { return err @@ -89,7 +96,7 @@ func (s *Service) Delete(ctx context.Context, ws, campaignID, stepID uuid.UUID) if err := s.assertStepInCampaign(ctx, ws, campaignID, stepID); err != nil { return err } - return s.store.Delete(ctx, ws, stepID) + return s.store.Delete(ctx, ws, campaignID, stepID, checkGraph) } // Reorder rewrites the campaign's step_order to match stepIDs' order. @@ -109,7 +116,9 @@ func (s *Service) Reorder(ctx context.Context, ws, campaignID uuid.UUID, stepIDs if err := validatePermutation(current, stepIDs); err != nil { return nil, err } - return s.store.Reorder(ctx, ws, campaignID, stepIDs) + // Reordering moves every linear fall-through edge, so like a delete it is + // committed only if the resulting graph has no loop. + return s.store.Reorder(ctx, ws, campaignID, stepIDs, checkGraph) } // validatePermutation confirms stepIDs contains each of current's ids exactly diff --git a/internal/app/sequencestep/service_test.go b/internal/app/sequencestep/service_test.go index 18c89096..f422466c 100644 --- a/internal/app/sequencestep/service_test.go +++ b/internal/app/sequencestep/service_test.go @@ -33,7 +33,7 @@ func (f *fakeStore) Get(context.Context, uuid.UUID, uuid.UUID) (gen.SequenceStep func (f *fakeStore) List(context.Context, uuid.UUID, uuid.UUID) ([]gen.SequenceStep, error) { return f.listSteps, nil } -func (f *fakeStore) Reorder(_ context.Context, _, _ uuid.UUID, stepIDs []uuid.UUID) ([]gen.SequenceStep, error) { +func (f *fakeStore) Reorder(_ context.Context, _, _ uuid.UUID, stepIDs []uuid.UUID, _ GraphCheck) ([]gen.SequenceStep, error) { f.reorderedTo = stepIDs out := make([]gen.SequenceStep, len(stepIDs)) for i, id := range stepIDs { @@ -45,7 +45,10 @@ func (f *fakeStore) Update(_ context.Context, _ uuid.UUID, in UpdateInput) (gen. f.updated = in return gen.SequenceStep{ID: in.StepID, Subject: in.Subject}, nil } -func (f *fakeStore) Delete(_ context.Context, _, id uuid.UUID) error { f.deletedID = id; return nil } +func (f *fakeStore) Delete(_ context.Context, _, _, id uuid.UUID, _ GraphCheck) error { + f.deletedID = id + return nil +} func (f *fakeStore) MaxStepOrder(context.Context, uuid.UUID, uuid.UUID) (int32, error) { return f.maxOrder, nil } @@ -60,7 +63,7 @@ func (c fakeChecker) CampaignStatus(context.Context, uuid.UUID, uuid.UUID) (stri } func TestCreateRejectsNonDraftCampaign(t *testing.T) { - svc := NewService(&fakeStore{}, fakeChecker{status: "running"}, &fakeVariantStore{}) + svc := NewService(&fakeStore{}, fakeChecker{status: "running"}, &fakeVariantStore{}, &fakeBranchStore{}) _, err := svc.Create(context.Background(), uuid.New(), uuid.New(), CreateInput{Subject: "x", BodyText: "y"}) if !errors.Is(err, ErrCampaignNotDraft) { t.Fatalf("want ErrCampaignNotDraft, got %v", err) @@ -68,7 +71,7 @@ func TestCreateRejectsNonDraftCampaign(t *testing.T) { } func TestCreateRejectsMissingCampaign(t *testing.T) { - svc := NewService(&fakeStore{}, fakeChecker{err: errors.New("no rows")}, &fakeVariantStore{}) + svc := NewService(&fakeStore{}, fakeChecker{err: errors.New("no rows")}, &fakeVariantStore{}, &fakeBranchStore{}) _, err := svc.Create(context.Background(), uuid.New(), uuid.New(), CreateInput{Subject: "x", BodyText: "y"}) if !errors.Is(err, ErrCampaignNotFound) { t.Fatalf("want ErrCampaignNotFound, got %v", err) @@ -77,7 +80,7 @@ func TestCreateRejectsMissingCampaign(t *testing.T) { func TestCreateAppendsAtNextOrder(t *testing.T) { store := &fakeStore{maxOrder: 2} - svc := NewService(store, fakeChecker{status: "draft"}, &fakeVariantStore{}) + svc := NewService(store, fakeChecker{status: "draft"}, &fakeVariantStore{}, &fakeBranchStore{}) campaignID := uuid.New() st, err := svc.Create(context.Background(), uuid.New(), campaignID, CreateInput{Subject: "x", BodyText: "y"}) if err != nil { @@ -96,7 +99,7 @@ func TestUpdateAllowedOnRunningCampaign(t *testing.T) { campaignID := uuid.New() stepID := uuid.New() store := &fakeStore{getStep: gen.SequenceStep{ID: stepID, CampaignID: campaignID}} - svc := NewService(store, fakeChecker{status: "running"}, &fakeVariantStore{}) + svc := NewService(store, fakeChecker{status: "running"}, &fakeVariantStore{}, &fakeBranchStore{}) _, err := svc.Update(context.Background(), uuid.New(), campaignID, UpdateInput{StepID: stepID, Subject: "new"}) if err != nil { t.Fatalf("update on running should be allowed (live-reference), got %v", err) @@ -111,7 +114,7 @@ func TestDeleteRejectsRunningCampaign(t *testing.T) { campaignID := uuid.New() stepID := uuid.New() store := &fakeStore{getStep: gen.SequenceStep{ID: stepID, CampaignID: campaignID}} - svc := NewService(store, fakeChecker{status: "running"}, &fakeVariantStore{}) + svc := NewService(store, fakeChecker{status: "running"}, &fakeVariantStore{}, &fakeBranchStore{}) err := svc.Delete(context.Background(), uuid.New(), campaignID, stepID) if !errors.Is(err, ErrCampaignNotDraft) { t.Fatalf("want ErrCampaignNotDraft, got %v", err) @@ -126,7 +129,7 @@ func TestReorderHappyPath(t *testing.T) { store := &fakeStore{listSteps: []gen.SequenceStep{ {ID: a, StepOrder: 1}, {ID: b, StepOrder: 2}, {ID: c, StepOrder: 3}, }} - svc := NewService(store, fakeChecker{status: "draft"}, &fakeVariantStore{}) + svc := NewService(store, fakeChecker{status: "draft"}, &fakeVariantStore{}, &fakeBranchStore{}) newOrder := []uuid.UUID{c, a, b} got, err := svc.Reorder(context.Background(), uuid.New(), campaignID, newOrder) if err != nil { @@ -156,7 +159,7 @@ func TestReorderRejectsNonPermutation(t *testing.T) { for name, ids := range cases { t.Run(name, func(t *testing.T) { store := &fakeStore{listSteps: []gen.SequenceStep{{ID: a, StepOrder: 1}, {ID: b, StepOrder: 2}}} - svc := NewService(store, fakeChecker{status: "draft"}, &fakeVariantStore{}) + svc := NewService(store, fakeChecker{status: "draft"}, &fakeVariantStore{}, &fakeBranchStore{}) _, err := svc.Reorder(context.Background(), uuid.New(), campaignID, ids) if !errors.Is(err, ErrInvalidOrder) { t.Fatalf("want ErrInvalidOrder, got %v", err) @@ -171,7 +174,7 @@ func TestReorderRejectsNonPermutation(t *testing.T) { // Reorder is a structural edit → forbidden on a non-draft campaign (409). func TestReorderRejectsNonDraftCampaign(t *testing.T) { store := &fakeStore{} - svc := NewService(store, fakeChecker{status: "running"}, &fakeVariantStore{}) + svc := NewService(store, fakeChecker{status: "running"}, &fakeVariantStore{}, &fakeBranchStore{}) _, err := svc.Reorder(context.Background(), uuid.New(), uuid.New(), []uuid.UUID{uuid.New()}) if !errors.Is(err, ErrCampaignNotDraft) { t.Fatalf("want ErrCampaignNotDraft, got %v", err) @@ -183,7 +186,7 @@ func TestReorderRejectsNonDraftCampaign(t *testing.T) { // Reorder on a missing campaign is 404, checked before any store read. func TestReorderRejectsMissingCampaign(t *testing.T) { - svc := NewService(&fakeStore{}, fakeChecker{err: errors.New("no rows")}, &fakeVariantStore{}) + svc := NewService(&fakeStore{}, fakeChecker{err: errors.New("no rows")}, &fakeVariantStore{}, &fakeBranchStore{}) _, err := svc.Reorder(context.Background(), uuid.New(), uuid.New(), []uuid.UUID{uuid.New()}) if !errors.Is(err, ErrCampaignNotFound) { t.Fatalf("want ErrCampaignNotFound, got %v", err) @@ -197,7 +200,7 @@ func TestUpdateRejectsStepFromAnotherCampaign(t *testing.T) { otherCampaign := uuid.New() stepID := uuid.New() store := &fakeStore{getStep: gen.SequenceStep{ID: stepID, CampaignID: otherCampaign}} - svc := NewService(store, fakeChecker{status: "draft"}, &fakeVariantStore{}) + svc := NewService(store, fakeChecker{status: "draft"}, &fakeVariantStore{}, &fakeBranchStore{}) _, err := svc.Update(context.Background(), uuid.New(), urlCampaign, UpdateInput{StepID: stepID, Subject: "x"}) if !errors.Is(err, ErrNotFound) { t.Fatalf("want ErrNotFound for mismatched campaign, got %v", err) diff --git a/internal/app/sequencestep/store.go b/internal/app/sequencestep/store.go index 1046deb4..2dc762fc 100644 --- a/internal/app/sequencestep/store.go +++ b/internal/app/sequencestep/store.go @@ -43,13 +43,18 @@ type Store interface { Get(ctx context.Context, ws, id uuid.UUID) (gen.SequenceStep, error) List(ctx context.Context, ws, campaignID uuid.UUID) ([]gen.SequenceStep, error) Update(ctx context.Context, ws uuid.UUID, in UpdateInput) (gen.SequenceStep, error) - Delete(ctx context.Context, ws, id uuid.UUID) error + // Delete removes one step of campaignID, then runs check on the campaign's + // resulting graph, in one transaction holding the campaign's graph lock (a + // delete re-links the linear fall-through around the removed step, which can + // close a loop through a branch). A check error rolls the delete back. + Delete(ctx context.Context, ws, campaignID, id uuid.UUID, check GraphCheck) error MaxStepOrder(ctx context.Context, ws, campaignID uuid.UUID) (int32, error) - // Reorder rewrites step_order to 1..N to match stepIDs' order, in one - // transaction, and returns the campaign's steps in the new order. Every - // write is pinned by campaign_id AND workspace_id. Callers must pre-validate - // that stepIDs is a permutation of the campaign's current step ids. - Reorder(ctx context.Context, ws, campaignID uuid.UUID, stepIDs []uuid.UUID) ([]gen.SequenceStep, error) + // Reorder rewrites step_order to 1..N to match stepIDs' order, then runs + // check on the resulting graph, in one transaction holding the campaign's + // graph lock, and returns the campaign's steps in the new order. Every write + // is pinned by campaign_id AND workspace_id. Callers must pre-validate that + // stepIDs is a permutation of the campaign's current step ids. + Reorder(ctx context.Context, ws, campaignID uuid.UUID, stepIDs []uuid.UUID, check GraphCheck) ([]gen.SequenceStep, error) } // CampaignChecker reports a campaign's status (and existence) within the @@ -87,8 +92,13 @@ func (s *PgStore) Update(ctx context.Context, ws uuid.UUID, in UpdateInput) (gen Subject: in.Subject, BodyText: in.BodyText, BodyHtml: in.BodyHTML, }) } -func (s *PgStore) Delete(ctx context.Context, ws, id uuid.UUID) error { - return s.q.DeleteStep(ctx, gen.DeleteStepParams{ID: id, WorkspaceID: ws}) +func (s *PgStore) Delete(ctx context.Context, ws, campaignID, id uuid.UUID, check GraphCheck) error { + return withGraphLock(ctx, s.pool, s.q, ws, campaignID, check, func(q *gen.Queries) error { + if err := q.DeleteStep(ctx, gen.DeleteStepParams{ID: id, WorkspaceID: ws}); err != nil { + return fmt.Errorf("delete step: %w", err) + } + return nil + }) } func (s *PgStore) MaxStepOrder(ctx context.Context, ws, campaignID uuid.UUID) (int32, error) { return s.q.MaxStepOrder(ctx, gen.MaxStepOrderParams{CampaignID: campaignID, WorkspaceID: ws}) @@ -100,36 +110,38 @@ func (s *PgStore) MaxStepOrder(ctx context.Context, ws, campaignID uuid.UUID) (i // final 1-based position. All writes are pinned by campaign_id AND // workspace_id, so a foreign id would update zero rows (belt-and-braces on top // of the service's permutation check). Either every write commits or none does. -func (s *PgStore) Reorder(ctx context.Context, ws, campaignID uuid.UUID, stepIDs []uuid.UUID) ([]gen.SequenceStep, error) { - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, fmt.Errorf("begin reorder tx: %w", err) - } - defer func() { _ = tx.Rollback(ctx) }() // no-op once committed - qtx := s.q.WithTx(tx) - - maxOrder, err := qtx.MaxStepOrder(ctx, gen.MaxStepOrderParams{CampaignID: campaignID, WorkspaceID: ws}) - if err != nil { - return nil, fmt.Errorf("max step order: %w", err) - } - if err := qtx.ShiftStepOrders(ctx, gen.ShiftStepOrdersParams{ - CampaignID: campaignID, WorkspaceID: ws, StepOrder: maxOrder, - }); err != nil { - return nil, fmt.Errorf("shift step orders: %w", err) - } - for i, id := range stepIDs { - if err := qtx.SetStepOrder(ctx, gen.SetStepOrderParams{ - ID: id, CampaignID: campaignID, WorkspaceID: ws, StepOrder: int32(i + 1), +// +// The transaction is the graph-lock one (withGraphLock): reordering moves the +// linear fall-through edges, so it is a routing change like any branch edit. A +// campaign outside ws fails the lock and returns ErrCampaignNotFound before any +// write. +func (s *PgStore) Reorder(ctx context.Context, ws, campaignID uuid.UUID, stepIDs []uuid.UUID, check GraphCheck) ([]gen.SequenceStep, error) { + var steps []gen.SequenceStep + err := withGraphLock(ctx, s.pool, s.q, ws, campaignID, check, func(qtx *gen.Queries) error { + maxOrder, err := qtx.MaxStepOrder(ctx, gen.MaxStepOrderParams{CampaignID: campaignID, WorkspaceID: ws}) + if err != nil { + return fmt.Errorf("max step order: %w", err) + } + if err := qtx.ShiftStepOrders(ctx, gen.ShiftStepOrdersParams{ + CampaignID: campaignID, WorkspaceID: ws, StepOrder: maxOrder, }); err != nil { - return nil, fmt.Errorf("set step order: %w", err) + return fmt.Errorf("shift step orders: %w", err) } - } - steps, err := qtx.ListStepsByCampaign(ctx, gen.ListStepsByCampaignParams{CampaignID: campaignID, WorkspaceID: ws}) + for i, id := range stepIDs { + if err := qtx.SetStepOrder(ctx, gen.SetStepOrderParams{ + ID: id, CampaignID: campaignID, WorkspaceID: ws, StepOrder: int32(i + 1), + }); err != nil { + return fmt.Errorf("set step order: %w", err) + } + } + steps, err = qtx.ListStepsByCampaign(ctx, gen.ListStepsByCampaignParams{CampaignID: campaignID, WorkspaceID: ws}) + if err != nil { + return fmt.Errorf("list steps: %w", err) + } + return nil + }) if err != nil { - return nil, fmt.Errorf("list steps: %w", err) - } - if err := tx.Commit(ctx); err != nil { - return nil, fmt.Errorf("commit reorder tx: %w", err) + return nil, err } return steps, nil } diff --git a/internal/app/sequencestep/variant_test.go b/internal/app/sequencestep/variant_test.go index 0b8204a8..6703db2b 100644 --- a/internal/app/sequencestep/variant_test.go +++ b/internal/app/sequencestep/variant_test.go @@ -18,7 +18,7 @@ func variantFixture(baseWeight int32, variants ...Variant) (*Service, *fakeVaria step := gen.SequenceStep{ID: testStepID, StepOrder: 1, Subject: "hi", VariantWeight: baseWeight} store := &fakeStore{getStep: step} vs := &fakeVariantStore{variants: variants, sent: map[uuid.UUID]int64{}} - return NewService(store, fakeChecker{status: "running"}, vs), vs + return NewService(store, fakeChecker{status: "running"}, vs, &fakeBranchStore{}), vs } func variant(label string, weight int32) Variant { diff --git a/internal/coreapi/coreapi.go b/internal/coreapi/coreapi.go index ffb73632..d6bb0bb9 100644 --- a/internal/coreapi/coreapi.go +++ b/internal/coreapi/coreapi.go @@ -730,6 +730,18 @@ type StepSendJob struct { // whereas a pause is a condition that CLEARS. The enrollment has to wait and // resume, so the worker defers it (see the blocked branch in advance.go). CampaignPaused bool `json:"campaign_paused"` + // ConditionPending means the enrollment's next move waits on a branch + // condition that is not decided yet, or on a routed step that is not due + // yet. The control plane has ALREADY re-stamped next_due_at to RecheckAt; + // the worker's only job is to schedule the next advance for then. + // + // Skip is set alongside it, deliberately: a worker built before this field + // existed decodes it as false and must not treat the otherwise-empty job as + // a send. Such a worker skips instead, and the stamped next_due_at lets the + // sweeper re-drive the enrollment. A current worker checks ConditionPending + // FIRST, so it schedules the recheck rather than leaving it to the sweeper. + ConditionPending bool `json:"condition_pending"` + RecheckAt time.Time `json:"recheck_at"` // NotDueUntil is the enrollment's persisted next_due_at, carried so the // claim can refuse a step that is not due yet. It exists because pushing // next_due_at out (DeferEnrollment, the out-of-office path) cannot cancel diff --git a/internal/coreapi/inprocess/branchroute.go b/internal/coreapi/inprocess/branchroute.go new file mode 100644 index 00000000..a8f30dca --- /dev/null +++ b/internal/coreapi/inprocess/branchroute.go @@ -0,0 +1,410 @@ +package inprocess + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/inroad/inroad/internal/app/sequencestep" + "github.com/inroad/inroad/internal/coreapi" + "github.com/inroad/inroad/internal/platform/cadence" + "github.com/inroad/inroad/internal/platform/db/gen" + "github.com/inroad/inroad/internal/platform/seqgraph" +) + +// Conditional branching on the send path. +// +// # When this code runs +// +// ONLY for a campaign that has at least one branch, or for an enrollment whose +// current wait was set by a condition (awaiting_condition_step == current_step: +// the campaign's last branch was deleted while this contact waited on it). Every +// other enrollment — every linear campaign — takes the original GetNextStep path +// in localStepSendJob, untouched. See usesGraphRouting. +// +// # The route is re-derived, never stored +// +// An enrollment carries no "which branch did I take" state. On every advance the +// route out of the step it last received is recomputed from the CURRENT graph +// and the engagement evidence. That is sound because a verdict is a pure +// function of events inside a fixed window (seqgraph.Evaluate): once decided it +// cannot change, so re-deriving it later lands on the same exit. +// +// # Mid-flight edit rule +// +// Graph edits are allowed on a running campaign, and the rule for an enrollment +// already in flight is: EVERY DECISION IS MADE AGAINST THE GRAPH AS IT IS WHEN +// THE DECISION IS MADE. Concretely, for an enrollment whose last received step +// is S: +// +// - A message already sent is never affected; nothing is re-sent or recalled. +// - A branch added to S after S was sent applies at the next advance, with its +// window measured from S's send (last_sent_at) — so a window that has +// already elapsed decides immediately. +// - A branch on S whose condition, window or exits change while the contact +// waits is evaluated with the NEW definition at the next check (at most +// conditionRecheckInterval away). A lengthened window keeps waiting; a +// shortened one decides at the next check. +// - A branch removed from S while the contact waits returns S to linear +// fall-through, and the successor still waits out its own delay from S's +// send (awaiting_condition_step is what keeps this true once the campaign +// has no branches left). +// - An exit whose target step is deleted becomes an end (the FK nulls it). +// Step deletes are draft-only, so this cannot happen to a running campaign. +// - Once the route out of S has been decided AND its target sent, the +// enrollment's cursor is on the target; later edits to S no longer matter. +const ( + // conditionRecheckInterval bounds how long an undecided condition goes + // unobserved. The route is taken the first time the advance runs after the + // deciding event, and the routed step's delay is measured from the event + // itself, so this only adds latency when that delay is shorter than the + // interval. A reply additionally nudges the enrollment (see + // NudgeEnrollmentAwaitingReply), so the one signal an operator expects to act + // on promptly does not wait for the interval. + conditionRecheckInterval = time.Hour + // routeDueTolerance absorbs the skew between the worker clock that scheduled + // an advance and the database clock that stamped last_sent_at, so an advance + // that fires on time is not bounced for being a few seconds early. + routeDueTolerance = time.Minute +) + +// usesGraphRouting reports whether an enrollment must be routed through the +// branch graph rather than the linear GetNextStep path. +func usesGraphRouting(branches []gen.SequenceStepBranch, b gen.GetStepEnrollmentBundleRow) bool { + return len(branches) > 0 || + (b.AwaitingConditionStep != nil && *b.AwaitingConditionStep == b.CurrentStep && b.CurrentStep > 0) +} + +// routeKind is what the send path does with this advance. +type routeKind int + +const ( + // routeSend: send target now. + routeSend routeKind = iota + // routeWait: nothing to send yet; look again at recheckAt. + routeWait + // routeEnd: the path has ended; complete the enrollment without a send. + routeEnd + // routeSkip: the campaign has no step to send at all (the linear path's + // ErrNoRows → Skip, preserved). + routeSkip +) + +// routeDecision is decideRoute's answer. +type routeDecision struct { + kind routeKind + target seqgraph.Step + recheckAt time.Time + // lastStep and nextDelay describe the route OUT of target once it is sent — + // what AdvanceStepCursor schedules next. They replace the linear path's + // "is there a step after this one" lookup. + lastStep bool + nextDelay int +} + +// routeInput is everything decideRoute reads. It is a value, and evidence is a +// function, so the whole decision is unit-testable without a database. +type routeInput struct { + graph seqgraph.Graph + // cursor is sequence_enrollments.current_step: the step_order last sent, 0 + // before the first send. + cursor int32 + // lastSentAt is when the cursor step was sent — the start of its window and + // the reference point for the successor's delay. + lastSentAt time.Time + now time.Time + window cadence.Window + // key seeds the window's humanization (the enrollment id), as it does for + // every other due-time computation on this path. + key string +} + +// evidenceFunc returns the earliest event of the branch's signal for the cursor +// step inside [start, start+window], or the zero time when there is none. +type evidenceFunc func(cursor seqgraph.Step, b seqgraph.Branch, start time.Time) (time.Time, error) + +// decideRoute routes one advance. See the package-level notes above for the +// rules; the order of the checks is: +// +// 1. No send yet → the entry step, now (the launch stagger scheduled it). +// 2. The route out of the cursor step: end, a fixed next step, or a condition. +// 3. A condition still open → wait until it can next change (bounded). +// 4. A decided route → wait until the target's delay has elapsed from the +// decision AND the campaign's window is open; then send. +func decideRoute(in routeInput, evidence evidenceFunc) (routeDecision, error) { + if in.cursor == 0 { + entry, ok := in.graph.Entry() + if !ok { + return routeDecision{kind: routeSkip}, nil + } + return in.sendDecision(entry), nil + } + if in.lastSentAt.IsZero() { + // current_step > 0 is only ever written together with last_sent_at, so + // this is a corrupted row. Failing loudly beats inventing a window start: + // a start of "now" would re-open the window on every advance and the + // condition would never close. + return routeDecision{}, fmt.Errorf("enrollment %s is past step %d but has no last_sent_at", in.key, in.cursor) + } + cursor, ok := in.graph.StepByOrder(in.cursor) + if !ok { + // The step this contact last received no longer exists. Step deletes are + // draft-only, so a running campaign cannot get here; ending the path is + // the answer that cannot send the wrong message. + return routeDecision{kind: routeEnd}, nil + } + + plan := in.graph.After(cursor.ID) + decidedAt := in.lastSentAt + var target seqgraph.Step + switch plan.Kind { + case seqgraph.PlanEnd: + return routeDecision{kind: routeEnd}, nil + case seqgraph.PlanNext: + target = plan.Next + case seqgraph.PlanAwait: + first, err := evidence(cursor, plan.Branch, in.lastSentAt) + if err != nil { + return routeDecision{}, err + } + verdict := seqgraph.Evaluate(plan.Branch.Condition, in.lastSentAt, plan.Branch.Window(), first, in.now) + if !verdict.Decided { + // Look again when the answer can next change: the deadline, or the + // recheck interval if that comes first. + return routeDecision{kind: routeWait, recheckAt: in.snap(minTime(verdict.At, in.now.Add(conditionRecheckInterval)))}, nil + } + next, ok := in.graph.Step(plan.Branch.Target(verdict)) + if !ok { + return routeDecision{kind: routeEnd}, nil + } + target, decidedAt = next, verdict.At + } + + due := decidedAt.Add(time.Duration(target.DelaySeconds) * time.Second) + if in.now.Before(due.Add(-routeDueTolerance)) || !in.window.Contains(in.now) { + return routeDecision{kind: routeWait, recheckAt: in.snap(maxTime(due, in.now))}, nil + } + return in.sendDecision(target), nil +} + +// sendDecision is a routeSend for target, with the route out of it resolved. +func (in routeInput) sendDecision(target seqgraph.Step) routeDecision { + d := routeDecision{kind: routeSend, target: target} + switch plan := in.graph.After(target.ID); plan.Kind { + case seqgraph.PlanEnd: + d.lastStep = true + case seqgraph.PlanNext: + d.nextDelay = int(plan.Next.DelaySeconds) + case seqgraph.PlanAwait: + // The first look at the condition: the deadline, or one recheck interval + // after the send if that is sooner. + d.nextDelay = int(min(plan.Branch.Window(), conditionRecheckInterval) / time.Second) + } + return d +} + +// snap moves t into the campaign's send window. A wait must END inside the +// window: the advance that wakes up may send immediately, and nothing later on +// this path re-checks the window. +func (in routeInput) snap(t time.Time) time.Time { + if at, err := in.window.Next(t, in.key); err == nil { + return at + } + return t +} + +func maxTime(a, b time.Time) time.Time { + if a.After(b) { + return a + } + return b +} + +func minTime(a, b time.Time) time.Time { + if a.Before(b) { + return a + } + return b +} + +// graphRouteResult is routeGraph's answer: the decision plus the full step row +// to send when there is one. +type graphRouteResult struct { + routeDecision + step gen.SequenceStep +} + +// routeGraph loads the campaign's graph and routes one advance through it. It +// applies the database-backed half of the rules decideRoute cannot see: the +// engagement evidence and the loop backstop. +func (c client) routeGraph(ctx context.Context, ws uuid.UUID, enrollmentID string, b gen.GetStepEnrollmentBundleRow, + branches []gen.SequenceStepBranch, sched cadence.Schedule, now time.Time) (graphRouteResult, error) { + steps, err := c.q.ListStepsByCampaign(ctx, gen.ListStepsByCampaignParams{CampaignID: b.CampaignID, WorkspaceID: ws}) + if err != nil { + return graphRouteResult{}, fmt.Errorf("list steps: %w", err) + } + win, err := sched.Compile() + if err != nil { + return graphRouteResult{}, fmt.Errorf("enrollment %s route: %w", enrollmentID, err) + } + g := sequencestep.BuildGraph(steps, branches) + d, err := decideRoute(routeInput{ + graph: g, cursor: b.CurrentStep, lastSentAt: b.LastSentAt.Time, now: now, window: win, key: enrollmentID, + }, func(cursor seqgraph.Step, br seqgraph.Branch, start time.Time) (time.Time, error) { + return c.firstEvidence(ctx, ws, b, cursor, br, start) + }) + if err != nil { + return graphRouteResult{}, err + } + if d.kind != routeSend { + return graphRouteResult{routeDecision: d}, nil + } + if b.CurrentStep > 0 && g.HasBranches() { + revisit, err := c.revisits(ctx, ws, b, d.target) + if err != nil { + return graphRouteResult{}, err + } + if revisit { + // The save path refuses every loop, under a lock, so this is a + // backstop: without it a loop that got in anyway would recover-forward + // through ClaimAlreadySent step after step, forever, never sending + // and never finishing. End the path loudly instead. + slog.WarnContext(ctx, "sequence_graph_loop_detected", + "enrollment_id", enrollmentID, "campaign_id", b.CampaignID, "step_order", d.target.Order) + return graphRouteResult{routeDecision: routeDecision{kind: routeEnd}}, nil + } + } + for _, st := range steps { + if st.ID == d.target.ID { + return graphRouteResult{routeDecision: d, step: st}, nil + } + } + // Unreachable: target came from this same step list. + return graphRouteResult{}, fmt.Errorf("routed step %s vanished", d.target.ID) +} + +// revisits reports whether target was already sent EARLIER on this contact's +// path. Its deterministic send row existing is not enough — that is also the +// recover-forward case, where target was sent but the cursor advance to it did +// not commit — so the test is whether the row predates the cursor step's own +// send: a recover-forward row is created after it, a loop's row long before. +func (c client) revisits(ctx context.Context, ws uuid.UUID, b gen.GetStepEnrollmentBundleRow, target seqgraph.Step) (bool, error) { + created, err := c.q.StepSendCreatedAt(ctx, gen.StepSendCreatedAtParams{ + ID: deriveStepSendID(b.CampaignID, b.ContactID, int(target.Order)), WorkspaceID: ws, + }) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("step send lookup: %w", err) + } + // No tolerance, unlike the due checks: both instants are the DATABASE's + // now() (sends.created_at's default and the cursor advance's last_sent_at), + // so there is no clock skew to absorb — and a tolerance would blind this to + // exactly the loop that matters most, zero-delay steps a few seconds apart. + return created.Time.Before(b.LastSentAt.Time), nil +} + +// firstEvidence reads the earliest qualifying event for one condition. +// +// Opens and clicks are keyed on the cursor step's OWN deterministic send id, so +// they are "opened THIS step", never an earlier one; they count HUMAN events +// only (invariant 82). Replies span both legs of the conversation: the step went +// out as a sends row, but the answer lives in inbox_messages, matched on the +// enrollment's campaign and contact rather than on any one send. +func (c client) firstEvidence(ctx context.Context, ws uuid.UUID, b gen.GetStepEnrollmentBundleRow, + cursor seqgraph.Step, br seqgraph.Branch, start time.Time) (time.Time, error) { + end := start.Add(br.Window()) + var ( + at pgtype.Timestamptz + err error + ) + switch br.Condition.Signal() { + case seqgraph.SignalOpen, seqgraph.SignalClick: + kind := gen.TrackingEventKindOpen + if br.Condition.Signal() == seqgraph.SignalClick { + kind = gen.TrackingEventKindClick + } + at, err = c.q.FirstHumanTrackingEventAt(ctx, gen.FirstHumanTrackingEventAtParams{ + SendID: deriveStepSendID(b.CampaignID, b.ContactID, int(cursor.Order)), + WorkspaceID: ws, Kind: kind, WindowEnd: pgtype.Timestamptz{Time: end, Valid: true}, + }) + case seqgraph.SignalReply: + at, err = c.q.FirstInboundReplyAt(ctx, gen.FirstInboundReplyAtParams{ + WorkspaceID: ws, + CampaignID: pgtype.UUID{Bytes: b.CampaignID, Valid: true}, + ContactID: pgtype.UUID{Bytes: b.ContactID, Valid: true}, + WindowStart: pgtype.Timestamptz{Time: start, Valid: true}, + WindowEnd: pgtype.Timestamptz{Time: end, Valid: true}, + LabelKey: br.ReplyLabelKey, + }) + default: + return time.Time{}, fmt.Errorf("condition %q has no evidence to read", br.Condition) + } + if errors.Is(err, pgx.ErrNoRows) { + return time.Time{}, nil + } + if err != nil { + return time.Time{}, fmt.Errorf("read %s evidence: %w", br.Condition, err) + } + return at.Time, nil +} + +// routeStep loads the schedule the route needs (a wait must end inside the send +// window) and routes the advance. +func (c client) routeStep(ctx context.Context, ws uuid.UUID, enrollmentID string, b gen.GetStepEnrollmentBundleRow, + branches []gen.SequenceStepBranch) (graphRouteResult, error) { + sched, err := c.loadSchedule(ctx, ws, b.CampaignID, b.Timezone) + if err != nil { + return graphRouteResult{}, err + } + return c.routeGraph(ctx, ws, enrollmentID, b, branches, sched, time.Now()) +} + +// applyRoute carries out a route that sends nothing this advance, and returns +// the job the worker acts on. +// +// These are control-plane writes made while building a job, which the linear +// path does not do. They belong here rather than behind new worker-invoked +// coreapi methods because they are DECISIONS about committed data, not outcomes +// of a delivery: both are idempotent and guarded on status='active', a retried +// or raced advance recomputes the identical decision (the verdict cannot change +// once taken), and the remote transport serves this very function in the +// control plane, so a worker never needs a way to express either write. +func (c client) applyRoute(ctx context.Context, ws, eid uuid.UUID, enrollmentID string, + b gen.GetStepEnrollmentBundleRow, d routeDecision) (coreapi.StepSendJob, error) { + switch d.kind { + case routeEnd: + if err := c.enroll.FinishRoute(ctx, ws, eid); err != nil { + return coreapi.StepSendJob{}, fmt.Errorf("finish routed enrollment: %w", err) + } + return coreapi.StepSendJob{Skip: true}, nil + case routeWait: + // Never pull a due time EARLIER than one already stamped: a later + // next_due_at is an out-of-office deferral, and waking inside the stated + // absence would route — and possibly send — into it. + recheck := d.recheckAt + if b.NextDueAt.Valid && b.NextDueAt.Time.After(recheck) { + recheck = b.NextDueAt.Time + } + if err := c.enroll.AwaitCondition(ctx, ws, eid, recheck); err != nil { + return coreapi.StepSendJob{}, fmt.Errorf("await branch condition: %w", err) + } + return coreapi.StepSendJob{ + EnrollmentID: enrollmentID, WorkspaceID: ws.String(), + ConditionPending: true, RecheckAt: recheck, + // Skip rides along so a worker built before ConditionPending existed + // treats this job as inert instead of claiming a send with an empty + // job; the next_due_at stamped above lets the sweeper re-drive it. + Skip: true, + }, nil + default: + return coreapi.StepSendJob{Skip: true}, nil + } +} diff --git a/internal/coreapi/inprocess/branchroute_test.go b/internal/coreapi/inprocess/branchroute_test.go new file mode 100644 index 00000000..fdf4d094 --- /dev/null +++ b/internal/coreapi/inprocess/branchroute_test.go @@ -0,0 +1,303 @@ +package inprocess + +import ( + "errors" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/inroad/inroad/internal/platform/cadence" + "github.com/inroad/inroad/internal/platform/db/gen" + "github.com/inroad/inroad/internal/platform/seqgraph" +) + +// alwaysOpen is a window that never blocks, so a test isolates the rule it is +// about from send-window snapping. +func alwaysOpen(t *testing.T) cadence.Window { + t.Helper() + var ws []cadence.SendWindow + for d := range 7 { + ws = append(ws, cadence.SendWindow{Weekday: d, StartMinute: 0, EndMinute: 24 * 60}) + } + w, err := cadence.Schedule{Timezone: "UTC", Windows: ws}.Compile() + if err != nil { + t.Fatal(err) + } + return w +} + +// businessHours is Mon–Fri 09:00–17:00 UTC. +func businessHours(t *testing.T) cadence.Window { + t.Helper() + w, err := cadence.DefaultSchedule("UTC").Compile() + if err != nil { + t.Fatal(err) + } + return w +} + +// routeSteps is a three-step campaign; step 3 has a 1h delay, step 2 a 2h one. +func routeSteps() (ids [3]uuid.UUID, steps []seqgraph.Step) { + for i := range ids { + ids[i] = uuid.New() + } + return ids, []seqgraph.Step{ + {ID: ids[0], Order: 1}, + {ID: ids[1], Order: 2, DelaySeconds: 7200}, + {ID: ids[2], Order: 3, DelaySeconds: 3600}, + } +} + +// noEvidence is an evidence source that has seen nothing. +func noEvidence(seqgraph.Step, seqgraph.Branch, time.Time) (time.Time, error) { + return time.Time{}, nil +} + +// eventAt is an evidence source that saw the signal at t. +func eventAt(t time.Time) evidenceFunc { + return func(seqgraph.Step, seqgraph.Branch, time.Time) (time.Time, error) { return t, nil } +} + +// A Wednesday, mid-morning UTC — inside businessHours. +var routeSentAt = time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + +func TestDecideRouteEntryStepSendsNow(t *testing.T) { + ids, steps := routeSteps() + g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.Opened, WithinDays: 2, Yes: ids[2], No: ids[1]}}) + d, err := decideRoute(routeInput{graph: g, cursor: 0, now: routeSentAt, window: alwaysOpen(t), key: "e"}, noEvidence) + if err != nil { + t.Fatal(err) + } + if d.kind != routeSend || d.target.ID != ids[0] { + t.Fatalf("entry = %+v", d) + } + // The first look at step 1's condition is one recheck interval out (the + // 2-day window is longer), and the enrollment is not on its last step. + if d.lastStep || d.nextDelay != int(conditionRecheckInterval/time.Second) { + t.Fatalf("after step 1: lastStep=%v nextDelay=%d", d.lastStep, d.nextDelay) + } +} + +func TestDecideRouteNoStepsSkips(t *testing.T) { + d, err := decideRoute(routeInput{graph: seqgraph.New(nil, nil), now: routeSentAt, window: alwaysOpen(t)}, noEvidence) + if err != nil || d.kind != routeSkip { + t.Fatalf("empty campaign = %+v, %v", d, err) + } +} + +func TestDecideRouteConditionPendingWaitsBoundedByRecheck(t *testing.T) { + ids, steps := routeSteps() + g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.Opened, WithinDays: 2, Yes: ids[2], No: ids[1]}}) + now := routeSentAt.Add(10 * time.Minute) + d, err := decideRoute(routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, now: now, window: alwaysOpen(t), key: "e"}, noEvidence) + if err != nil { + t.Fatal(err) + } + if d.kind != routeWait { + t.Fatalf("want wait, got %+v", d) + } + // Snapped into the window, which only ever moves an instant later, by under + // two minutes of humanization. + want := now.Add(conditionRecheckInterval) + if d.recheckAt.Before(want) || d.recheckAt.After(want.Add(2*time.Minute)) { + t.Fatalf("recheck = %v, want ~%v", d.recheckAt, want) + } +} + +// Near the deadline the recheck is the deadline, not a full interval later. +func TestDecideRouteConditionPendingRecheckStopsAtDeadline(t *testing.T) { + ids, steps := routeSteps() + g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.Replied, WithinDays: 1, Yes: ids[2], No: ids[1]}}) + deadline := routeSentAt.Add(24 * time.Hour) + now := deadline.Add(-10 * time.Minute) + d, err := decideRoute(routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, now: now, window: alwaysOpen(t), key: "e"}, noEvidence) + if err != nil { + t.Fatal(err) + } + if d.kind != routeWait || d.recheckAt.Before(deadline) || d.recheckAt.After(deadline.Add(2*time.Minute)) { + t.Fatalf("recheck = %+v, want ~deadline %v", d, deadline) + } +} + +// The positive event routes YES as soon as it is seen, and the target's delay +// counts from the event — not from the send, and not from when we noticed. +func TestDecideRouteEarlyEventRoutesYesWithDelayFromEvent(t *testing.T) { + ids, steps := routeSteps() + g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.Opened, WithinDays: 3, Yes: ids[2], No: ids[1]}}) + opened := routeSentAt.Add(30 * time.Minute) + in := routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, window: alwaysOpen(t), key: "e"} + + // 40 minutes after the open: step 3's 1h delay has not elapsed yet. + in.now = opened.Add(40 * time.Minute) + d, err := decideRoute(in, eventAt(opened)) + if err != nil { + t.Fatal(err) + } + due := opened.Add(time.Hour) + if d.kind != routeWait || d.recheckAt.Before(due) || d.recheckAt.After(due.Add(2*time.Minute)) { + t.Fatalf("before the yes step is due: %+v, want wait until ~%v", d, due) + } + + // Once due: send step 3 (the YES exit). Step 3 has no branch and is last. + in.now = due.Add(time.Minute) + d, err = decideRoute(in, eventAt(opened)) + if err != nil { + t.Fatal(err) + } + if d.kind != routeSend || d.target.ID != ids[2] || !d.lastStep { + t.Fatalf("yes route = %+v", d) + } +} + +func TestDecideRouteWindowCloseRoutesNo(t *testing.T) { + ids, steps := routeSteps() + g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.Clicked, WithinDays: 1, Yes: ids[2], No: ids[1]}}) + // Step 2's delay (2h) counts from the deadline. + now := routeSentAt.Add(24*time.Hour + 2*time.Hour + time.Minute) + d, err := decideRoute(routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, now: now, window: alwaysOpen(t), key: "e"}, noEvidence) + if err != nil { + t.Fatal(err) + } + if d.kind != routeSend || d.target.ID != ids[1] { + t.Fatalf("no route = %+v", d) + } + // Step 2 has no branch: it falls through to step 3 with step 3's delay. + if d.lastStep || d.nextDelay != 3600 { + t.Fatalf("after step 2: lastStep=%v nextDelay=%d", d.lastStep, d.nextDelay) + } +} + +// A negated condition is decided NO by the event, immediately. +func TestDecideRouteNotRepliedDecidedByReply(t *testing.T) { + ids, steps := routeSteps() + g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.NotReplied, WithinDays: 5, Yes: ids[1]}}) + replied := routeSentAt.Add(time.Hour) + d, err := decideRoute(routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, now: replied.Add(time.Minute), + window: alwaysOpen(t), key: "e"}, eventAt(replied)) + if err != nil { + t.Fatal(err) + } + // The NO exit is unset: the path ends, without waiting out the 5 days. + if d.kind != routeEnd { + t.Fatalf("replied on a not_replied branch with no NO exit = %+v", d) + } +} + +func TestDecideRouteAlwaysToEndFinishes(t *testing.T) { + ids, steps := routeSteps() + g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[1], Condition: seqgraph.Always}}) + d, err := decideRoute(routeInput{graph: g, cursor: 2, lastSentAt: routeSentAt, now: routeSentAt.Add(time.Hour), + window: alwaysOpen(t), key: "e"}, noEvidence) + if err != nil || d.kind != routeEnd { + t.Fatalf("always -> end = %+v, %v", d, err) + } +} + +// A decided route may not send outside the campaign's window: it waits for the +// window to open, even though the target is overdue. +func TestDecideRouteWaitsForWindow(t *testing.T) { + ids, steps := routeSteps() + g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.Opened, WithinDays: 1, Yes: ids[2], No: ids[1]}}) + opened := routeSentAt.Add(time.Hour) + saturday := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC) + d, err := decideRoute(routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, now: saturday, + window: businessHours(t), key: "e"}, eventAt(opened)) + if err != nil { + t.Fatal(err) + } + monday := time.Date(2026, 9, 28, 9, 0, 0, 0, time.UTC) + if d.kind != routeWait || d.recheckAt.Before(monday) || d.recheckAt.After(monday.Add(2*time.Minute)) { + t.Fatalf("weekend route = %+v, want wait until ~%v", d, monday) + } +} + +// The mid-flight rule for a DELETED branch: the enrollment falls through to the +// linear successor, which still honours its own delay from the last send rather +// than going out at the recheck time the condition had scheduled. +func TestDecideRouteBranchRemovedMidWaitHonoursDelay(t *testing.T) { + _, steps := routeSteps() + g := seqgraph.New(steps, nil) // the only branch was deleted + in := routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, window: alwaysOpen(t), key: "e"} + + in.now = routeSentAt.Add(time.Hour) // a recheck the old condition scheduled + d, err := decideRoute(in, noEvidence) + if err != nil { + t.Fatal(err) + } + due := routeSentAt.Add(2 * time.Hour) // step 2's delay + if d.kind != routeWait || d.recheckAt.Before(due) { + t.Fatalf("fall-through before its delay = %+v, want wait until %v", d, due) + } + + in.now = due + if d, err = decideRoute(in, noEvidence); err != nil || d.kind != routeSend || d.target.Order != 2 { + t.Fatalf("fall-through once due = %+v, %v", d, err) + } +} + +// An advance that fires on schedule is never bounced by the few seconds of skew +// between the worker's clock and the database's last_sent_at. +func TestDecideRouteToleratesClockSkew(t *testing.T) { + ids, steps := routeSteps() + g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.Always, Yes: ids[1]}}) + due := routeSentAt.Add(2 * time.Hour) + d, err := decideRoute(routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, now: due.Add(-5 * time.Second), + window: alwaysOpen(t), key: "e"}, noEvidence) + if err != nil || d.kind != routeSend { + t.Fatalf("5s early = %+v, %v", d, err) + } +} + +func TestDecideRouteDeletedCursorStepEnds(t *testing.T) { + _, steps := routeSteps() + g := seqgraph.New(steps, nil) + d, err := decideRoute(routeInput{graph: g, cursor: 9, lastSentAt: routeSentAt, now: routeSentAt, window: alwaysOpen(t)}, noEvidence) + if err != nil || d.kind != routeEnd { + t.Fatalf("cursor on a vanished step = %+v, %v", d, err) + } +} + +func TestDecideRouteMissingLastSentIsAnError(t *testing.T) { + _, steps := routeSteps() + _, err := decideRoute(routeInput{graph: seqgraph.New(steps, nil), cursor: 1, now: routeSentAt, window: alwaysOpen(t)}, noEvidence) + if err == nil { + t.Fatal("a cursor past step 0 with no last_sent_at must fail loudly") + } +} + +func TestDecideRouteEvidenceErrorPropagates(t *testing.T) { + ids, steps := routeSteps() + g := seqgraph.New(steps, []seqgraph.Branch{{StepID: ids[0], Condition: seqgraph.Opened, WithinDays: 1}}) + boom := errors.New("db down") + _, err := decideRoute(routeInput{graph: g, cursor: 1, lastSentAt: routeSentAt, now: routeSentAt, window: alwaysOpen(t)}, + func(seqgraph.Step, seqgraph.Branch, time.Time) (time.Time, error) { return time.Time{}, boom }) + if !errors.Is(err, boom) { + t.Fatalf("evidence error = %v, want %v", err, boom) + } +} + +// Linear campaigns never reach the graph router: no branches and no condition +// wait means usesGraphRouting is false and localStepSendJob runs the original +// GetNextStep path. That is the whole of the "linear campaigns are unchanged" +// guarantee on the send path, so it is pinned here explicitly. +func TestUsesGraphRoutingOnlyForBranchedOrParkedEnrollments(t *testing.T) { + two, three := int32(2), int32(3) + cases := []struct { + name string + branches []gen.SequenceStepBranch + bundle gen.GetStepEnrollmentBundleRow + want bool + }{ + {"linear, never waited", nil, gen.GetStepEnrollmentBundleRow{CurrentStep: 2}, false}, + {"linear, fresh enrollment", nil, gen.GetStepEnrollmentBundleRow{CurrentStep: 0}, false}, + {"linear, waited at an EARLIER step", nil, gen.GetStepEnrollmentBundleRow{CurrentStep: 3, AwaitingConditionStep: &two}, false}, + {"branch deleted while waiting here", nil, gen.GetStepEnrollmentBundleRow{CurrentStep: 3, AwaitingConditionStep: &three}, true}, + {"campaign has a branch", []gen.SequenceStepBranch{{Condition: "always"}}, gen.GetStepEnrollmentBundleRow{}, true}, + } + for _, tc := range cases { + if got := usesGraphRouting(tc.branches, tc.bundle); got != tc.want { + t.Errorf("%s: usesGraphRouting = %v, want %v", tc.name, got, tc.want) + } + } +} diff --git a/internal/coreapi/inprocess/inboxpoll.go b/internal/coreapi/inprocess/inboxpoll.go index 271dcb7a..617112ff 100644 --- a/internal/coreapi/inprocess/inboxpoll.go +++ b/internal/coreapi/inprocess/inboxpoll.go @@ -191,7 +191,18 @@ func (c client) localRecordReplyClass(ctx context.Context, enrollmentID, workspa if err != nil { return err } - return c.recordReplyClass(ctx, eid, ws, class, source, confidence) + if err := c.recordReplyClass(ctx, eid, ws, class, source, confidence); err != nil { + return err + } + // A reply that did not stop the sequence can still decide a branch + // condition on the step the contact is waiting at ("replied within N + // days"). Pull that enrollment's due time to now so the next sweep routes it + // instead of leaving it for the next hourly recheck. A no-op for every + // enrollment not waiting on a reply condition, and for automated replies, + // which are the ones that defer an enrollment (see the query). + return c.q.NudgeEnrollmentAwaitingReply(ctx, gen.NudgeEnrollmentAwaitingReplyParams{ + ID: eid, WorkspaceID: ws, ReplyClass: class, + }) } // MarkUnsubscribed suppresses the address and, when the reply belongs to an diff --git a/internal/coreapi/inprocess/stepsendjob.go b/internal/coreapi/inprocess/stepsendjob.go index 931f112a..c0332ff2 100644 --- a/internal/coreapi/inprocess/stepsendjob.go +++ b/internal/coreapi/inprocess/stepsendjob.go @@ -231,17 +231,39 @@ func (c client) localStepSendJob(ctx context.Context, enrollmentID, workspaceID }, nil } - // Resolve the next step by order rather than current_step+1: DeleteStep does - // not renumber, so orders can have gaps (e.g. {1,3}). GetNextStep skips gaps; - // ErrNoRows means the cursor is at/after the last step → done. - step, err := c.q.GetNextStep(ctx, gen.GetNextStepParams{ - CampaignID: b.CampaignID, WorkspaceID: ws, StepOrder: b.CurrentStep, - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return coreapi.StepSendJob{Skip: true}, nil + // A campaign with branches (or an enrollment still parked by one) is routed + // through the graph; every other campaign takes the linear path below exactly + // as it did before branching existed. See branchroute.go. + branches, err := c.q.ListBranchesByCampaign(ctx, gen.ListBranchesByCampaignParams{CampaignID: b.CampaignID, WorkspaceID: ws}) + if err != nil { + return coreapi.StepSendJob{}, fmt.Errorf("list branches: %w", err) + } + var ( + step gen.SequenceStep + route *routeDecision + ) + if usesGraphRouting(branches, b) { + routed, err := c.routeStep(ctx, ws, enrollmentID, b, branches) + if err != nil { + return coreapi.StepSendJob{}, err + } + if routed.kind != routeSend { + return c.applyRoute(ctx, ws, eid, enrollmentID, b, routed.routeDecision) + } + step, route = routed.step, &routed.routeDecision + } else { + // Resolve the next step by order rather than current_step+1: DeleteStep + // does not renumber, so orders can have gaps (e.g. {1,3}). GetNextStep + // skips gaps; ErrNoRows means the cursor is at/after the last step → done. + step, err = c.q.GetNextStep(ctx, gen.GetNextStepParams{ + CampaignID: b.CampaignID, WorkspaceID: ws, StepOrder: b.CurrentStep, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return coreapi.StepSendJob{Skip: true}, nil + } + return coreapi.StepSendJob{}, err } - return coreapi.StepSendJob{}, err } nextOrder := int(step.StepOrder) @@ -329,17 +351,23 @@ func (c client) localStepSendJob(ctx context.Context, enrollmentID, workspaceID } // Is there a step after this one? Its existence decides last-step; its delay - // is the cadence gap to the following send. One query answers both. - after, err := c.q.GetNextStep(ctx, gen.GetNextStepParams{ - CampaignID: b.CampaignID, WorkspaceID: ws, StepOrder: step.StepOrder, - }) - lastStep := errors.Is(err, pgx.ErrNoRows) - if err != nil && !lastStep { - return coreapi.StepSendJob{}, err - } - nextDelay := 0 - if !lastStep { - nextDelay = int(after.DelaySeconds) + // is the cadence gap to the following send. On the linear path one query + // answers both; a routed step already carries the answer from the graph (the + // next step, the end, or the first look at its condition). + lastStep, nextDelay := false, 0 + if route != nil { + lastStep, nextDelay = route.lastStep, route.nextDelay + } else { + after, err := c.q.GetNextStep(ctx, gen.GetNextStepParams{ + CampaignID: b.CampaignID, WorkspaceID: ws, StepOrder: step.StepOrder, + }) + lastStep = errors.Is(err, pgx.ErrNoRows) + if err != nil && !lastStep { + return coreapi.StepSendJob{}, err + } + if !lastStep { + nextDelay = int(after.DelaySeconds) + } } // Thread subject is only needed to build "Re: " for a diff --git a/internal/platform/db/gen/enrollment.sql.go b/internal/platform/db/gen/enrollment.sql.go index 65ca8d11..4d3ab4bd 100644 --- a/internal/platform/db/gen/enrollment.sql.go +++ b/internal/platform/db/gen/enrollment.sql.go @@ -47,6 +47,28 @@ func (q *Queries) AdvanceEnrollmentStep(ctx context.Context, arg AdvanceEnrollme return err } +const awaitEnrollmentCondition = `-- name: AwaitEnrollmentCondition :exec +UPDATE sequence_enrollments +SET next_due_at = $3, awaiting_condition_step = current_step +WHERE id = $1 AND workspace_id = $2 AND status = 'active' +` + +type AwaitEnrollmentConditionParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + NextDueAt pgtype.Timestamptz `json:"next_due_at"` +} + +// A branch condition on the enrollment's current step is still open (or its +// routed step is not due yet): record when to look again, and that this wait is +// governed by a condition at this step (see awaiting_condition_step in migration +// 20260923110214). Stamping next_due_at keeps the sweeper from re-driving the +// enrollment every tick while it legitimately waits. Guarded on status='active'. +func (q *Queries) AwaitEnrollmentCondition(ctx context.Context, arg AwaitEnrollmentConditionParams) error { + _, err := q.db.Exec(ctx, awaitEnrollmentCondition, arg.ID, arg.WorkspaceID, arg.NextDueAt) + return err +} + const completeEnrollment = `-- name: CompleteEnrollment :exec UPDATE sequence_enrollments SET current_step = $3, last_sent_at = now(), status = 'completed', @@ -197,8 +219,29 @@ func (q *Queries) EnrollListMembers(ctx context.Context, arg EnrollListMembersPa return items, nil } +const finishEnrollment = `-- name: FinishEnrollment :exec +UPDATE sequence_enrollments +SET status = 'completed', completed_at = now(), next_due_at = NULL +WHERE id = $1 AND workspace_id = $2 AND status = 'active' +` + +type FinishEnrollmentParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +// A branch routed this enrollment to the end of its path WITHOUT a send: mark it +// completed and drop it out of the due index. Unlike CompleteEnrollment it leaves +// current_step and last_sent_at alone, because nothing was sent — they still +// describe the last message the contact actually received. Guarded on +// status='active' for the reason CompleteEnrollment is: a stop is terminal and wins. +func (q *Queries) FinishEnrollment(ctx context.Context, arg FinishEnrollmentParams) error { + _, err := q.db.Exec(ctx, finishEnrollment, arg.ID, arg.WorkspaceID) + return err +} + const getEnrollment = `-- name: GetEnrollment :one -SELECT id, workspace_id, campaign_id, contact_id, current_step, status, stop_reason, enrolled_at, last_sent_at, next_due_at, thread_root_id, completed_at, stopped_at, cap_deferrals, reply_class, reply_source, reply_confidence, replied_at, mailbox_id FROM sequence_enrollments WHERE id = $1 AND workspace_id = $2 +SELECT id, workspace_id, campaign_id, contact_id, current_step, status, stop_reason, enrolled_at, last_sent_at, next_due_at, thread_root_id, completed_at, stopped_at, cap_deferrals, reply_class, reply_source, reply_confidence, replied_at, mailbox_id, awaiting_condition_step FROM sequence_enrollments WHERE id = $1 AND workspace_id = $2 ` type GetEnrollmentParams struct { @@ -229,6 +272,7 @@ func (q *Queries) GetEnrollment(ctx context.Context, arg GetEnrollmentParams) (S &i.ReplyConfidence, &i.RepliedAt, &i.MailboxID, + &i.AwaitingConditionStep, ) return i, err } @@ -353,6 +397,41 @@ func (q *Queries) ListDueEnrollments(ctx context.Context) ([]ListDueEnrollmentsR return items, nil } +const nudgeEnrollmentAwaitingReply = `-- name: NudgeEnrollmentAwaitingReply :exec +UPDATE sequence_enrollments e +SET next_due_at = now() +WHERE e.id = $1 AND e.workspace_id = $2 AND e.status = 'active' + AND e.next_due_at > now() + AND NOT COALESCE( + (SELECT rl.is_automated FROM reply_labels rl + WHERE rl.workspace_id = e.workspace_id AND rl.key = $3::text), + $3::text IN ('auto_reply', 'out_of_office')) + AND EXISTS ( + SELECT 1 FROM sequence_step_branches b + JOIN sequence_steps s ON s.id = b.step_id AND s.campaign_id = b.campaign_id + WHERE b.campaign_id = e.campaign_id AND b.workspace_id = e.workspace_id + AND s.step_order = e.current_step + AND b.condition IN ('replied', 'not_replied')) +` + +type NudgeEnrollmentAwaitingReplyParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ReplyClass string `json:"reply_class"` +} + +// A reply that did NOT stop the enrollment just arrived. If the enrollment is +// waiting on a reply condition at its current step, pull its due time to now so +// the next sweep evaluates the condition instead of waiting out the re-check +// interval. Automated replies (out-of-office, auto-reply) are excluded: they are +// the only replies that defer an enrollment, and pulling next_due_at forward +// would undo that deferral. Never pushes a due time LATER, and is a no-op for +// every enrollment without a reply condition — which is every linear campaign. +func (q *Queries) NudgeEnrollmentAwaitingReply(ctx context.Context, arg NudgeEnrollmentAwaitingReplyParams) error { + _, err := q.db.Exec(ctx, nudgeEnrollmentAwaitingReply, arg.ID, arg.WorkspaceID, arg.ReplyClass) + return err +} + const setEnrollmentDue = `-- name: SetEnrollmentDue :exec UPDATE sequence_enrollments SET next_due_at = $3 diff --git a/internal/platform/db/gen/models.go b/internal/platform/db/gen/models.go index 551506d5..1160f5c6 100644 --- a/internal/platform/db/gen/models.go +++ b/internal/platform/db/gen/models.go @@ -959,25 +959,26 @@ type SendingDomain struct { } type SequenceEnrollment struct { - ID uuid.UUID `json:"id"` - WorkspaceID uuid.UUID `json:"workspace_id"` - CampaignID uuid.UUID `json:"campaign_id"` - ContactID uuid.UUID `json:"contact_id"` - CurrentStep int32 `json:"current_step"` - Status string `json:"status"` - StopReason *string `json:"stop_reason"` - EnrolledAt pgtype.Timestamptz `json:"enrolled_at"` - LastSentAt pgtype.Timestamptz `json:"last_sent_at"` - NextDueAt pgtype.Timestamptz `json:"next_due_at"` - ThreadRootID string `json:"thread_root_id"` - CompletedAt pgtype.Timestamptz `json:"completed_at"` - StoppedAt pgtype.Timestamptz `json:"stopped_at"` - CapDeferrals int32 `json:"cap_deferrals"` - ReplyClass *string `json:"reply_class"` - ReplySource *string `json:"reply_source"` - ReplyConfidence *float32 `json:"reply_confidence"` - RepliedAt pgtype.Timestamptz `json:"replied_at"` - MailboxID pgtype.UUID `json:"mailbox_id"` + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + CampaignID uuid.UUID `json:"campaign_id"` + ContactID uuid.UUID `json:"contact_id"` + CurrentStep int32 `json:"current_step"` + Status string `json:"status"` + StopReason *string `json:"stop_reason"` + EnrolledAt pgtype.Timestamptz `json:"enrolled_at"` + LastSentAt pgtype.Timestamptz `json:"last_sent_at"` + NextDueAt pgtype.Timestamptz `json:"next_due_at"` + ThreadRootID string `json:"thread_root_id"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + StoppedAt pgtype.Timestamptz `json:"stopped_at"` + CapDeferrals int32 `json:"cap_deferrals"` + ReplyClass *string `json:"reply_class"` + ReplySource *string `json:"reply_source"` + ReplyConfidence *float32 `json:"reply_confidence"` + RepliedAt pgtype.Timestamptz `json:"replied_at"` + MailboxID pgtype.UUID `json:"mailbox_id"` + AwaitingConditionStep *int32 `json:"awaiting_condition_step"` } type SequenceStep struct { @@ -994,6 +995,19 @@ type SequenceStep struct { VariantWeight int32 `json:"variant_weight"` } +type SequenceStepBranch struct { + StepID uuid.UUID `json:"step_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + CampaignID uuid.UUID `json:"campaign_id"` + Condition string `json:"condition"` + WithinDays *int32 `json:"within_days"` + ReplyLabelKey *string `json:"reply_label_key"` + YesStepID pgtype.UUID `json:"yes_step_id"` + NoStepID pgtype.UUID `json:"no_step_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type SequenceStepVariant struct { ID uuid.UUID `json:"id"` WorkspaceID uuid.UUID `json:"workspace_id"` diff --git a/internal/platform/db/gen/stepbranch.sql.go b/internal/platform/db/gen/stepbranch.sql.go new file mode 100644 index 00000000..6327d453 --- /dev/null +++ b/internal/platform/db/gen/stepbranch.sql.go @@ -0,0 +1,274 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: stepbranch.sql + +package gen + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteBranch = `-- name: DeleteBranch :exec +DELETE FROM sequence_step_branches +WHERE step_id = $1 AND campaign_id = $2 AND workspace_id = $3 +` + +type DeleteBranchParams struct { + StepID uuid.UUID `json:"step_id"` + CampaignID uuid.UUID `json:"campaign_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +// Remove a step's router, returning it to linear fall-through. Pinned on +// workspace_id and campaign_id. +func (q *Queries) DeleteBranch(ctx context.Context, arg DeleteBranchParams) error { + _, err := q.db.Exec(ctx, deleteBranch, arg.StepID, arg.CampaignID, arg.WorkspaceID) + return err +} + +const firstHumanTrackingEventAt = `-- name: FirstHumanTrackingEventAt :one +SELECT created_at FROM tracking_events +WHERE send_id = $1 AND workspace_id = $2 AND kind = $3 AND NOT is_machine + AND created_at <= $4::timestamptz +ORDER BY created_at ASC +LIMIT 1 +` + +type FirstHumanTrackingEventAtParams struct { + SendID uuid.UUID `json:"send_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + Kind TrackingEventKind `json:"kind"` + WindowEnd pgtype.Timestamptz `json:"window_end"` +} + +// The earliest HUMAN open or click of one send, at or before window_end. It reads +// the stored bot verdict (NOT is_machine) — the same definition CountHumanOpens +// reports — rather than deriving its own, so a branch and the open rate can +// never disagree about the same contact (docs/security.md invariant 82). Served +// by idx_tracking_send_recent (send_id, kind, is_machine, created_at). +func (q *Queries) FirstHumanTrackingEventAt(ctx context.Context, arg FirstHumanTrackingEventAtParams) (pgtype.Timestamptz, error) { + row := q.db.QueryRow(ctx, firstHumanTrackingEventAt, + arg.SendID, + arg.WorkspaceID, + arg.Kind, + arg.WindowEnd, + ) + var created_at pgtype.Timestamptz + err := row.Scan(&created_at) + return created_at, err +} + +const firstInboundReplyAt = `-- name: FirstInboundReplyAt :one +SELECT m.created_at FROM inbox_messages m +JOIN inbox_threads t ON t.id = m.thread_id AND t.workspace_id = m.workspace_id +LEFT JOIN reply_labels rl ON rl.workspace_id = m.workspace_id AND rl.key = m.reply_class +WHERE m.workspace_id = $1 AND t.campaign_id = $2 AND t.contact_id = $3 + AND m.direction = 'inbound' + AND m.created_at >= $4::timestamptz + AND m.created_at <= $5::timestamptz + AND CASE WHEN $6::text = '' + THEN NOT COALESCE(rl.is_automated, m.reply_class IN ('auto_reply', 'out_of_office')) + ELSE m.reply_class = $6::text + END +ORDER BY m.created_at ASC +LIMIT 1 +` + +type FirstInboundReplyAtParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + CampaignID pgtype.UUID `json:"campaign_id"` + ContactID pgtype.UUID `json:"contact_id"` + WindowStart pgtype.Timestamptz `json:"window_start"` + WindowEnd pgtype.Timestamptz `json:"window_end"` + LabelKey string `json:"label_key"` +} + +// The earliest inbound reply from this contact on this campaign that arrived +// inside [window_start, window_end]. created_at (when WE ingested it), not +// occurred_at: the latter is the sender's own Date header, which they control. +// +// label_key ” means "any human reply": a message whose label is automated +// (out-of-office, auto-reply) is not a reply from a person and does not count. A +// label the workspace has since deleted falls back to the builtin automated keys, +// the same degradation the inbox dispatch applies. A non-empty label_key matches +// that key exactly, automated or not — branching on an out-of-office is a +// legitimate thing to want. +func (q *Queries) FirstInboundReplyAt(ctx context.Context, arg FirstInboundReplyAtParams) (pgtype.Timestamptz, error) { + row := q.db.QueryRow(ctx, firstInboundReplyAt, + arg.WorkspaceID, + arg.CampaignID, + arg.ContactID, + arg.WindowStart, + arg.WindowEnd, + arg.LabelKey, + ) + var created_at pgtype.Timestamptz + err := row.Scan(&created_at) + return created_at, err +} + +const listBranchesByCampaign = `-- name: ListBranchesByCampaign :many +SELECT step_id, workspace_id, campaign_id, condition, within_days, reply_label_key, yes_step_id, no_step_id, created_at, updated_at FROM sequence_step_branches +WHERE campaign_id = $1 AND workspace_id = $2 +ORDER BY step_id +` + +type ListBranchesByCampaignParams struct { + CampaignID uuid.UUID `json:"campaign_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +// Every router in the campaign, workspace-pinned. The send path reads this once +// per advance: an empty result is the linear campaign, which then takes exactly +// the pre-branching code path. +func (q *Queries) ListBranchesByCampaign(ctx context.Context, arg ListBranchesByCampaignParams) ([]SequenceStepBranch, error) { + rows, err := q.db.Query(ctx, listBranchesByCampaign, arg.CampaignID, arg.WorkspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SequenceStepBranch + for rows.Next() { + var i SequenceStepBranch + if err := rows.Scan( + &i.StepID, + &i.WorkspaceID, + &i.CampaignID, + &i.Condition, + &i.WithinDays, + &i.ReplyLabelKey, + &i.YesStepID, + &i.NoStepID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const lockCampaignGraph = `-- name: LockCampaignGraph :one +SELECT id FROM campaigns WHERE id = $1 AND workspace_id = $2 FOR NO KEY UPDATE +` + +type LockCampaignGraphParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +// Serializes every write that changes a campaign's routing graph (branch +// upsert/delete, step delete, reorder), so the save-time cycle check sees the +// graph it is actually committing into — two edits that are each acyclic alone +// can form a cycle together. FOR NO KEY UPDATE rather than FOR UPDATE: it still +// conflicts with itself, but not with the FOR KEY SHARE lock every sends insert +// takes on its campaign FK, so a graph edit never stalls delivery. +func (q *Queries) LockCampaignGraph(ctx context.Context, arg LockCampaignGraphParams) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, lockCampaignGraph, arg.ID, arg.WorkspaceID) + var id uuid.UUID + err := row.Scan(&id) + return id, err +} + +const replyLabelKeyExists = `-- name: ReplyLabelKeyExists :one +SELECT EXISTS (SELECT 1 FROM reply_labels WHERE workspace_id = $1 AND key = $2)::bool +` + +type ReplyLabelKeyExistsParams struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + Key string `json:"key"` +} + +// Whether the workspace defines a reply label with this key. Save-time +// validation only: a branch naming a label that does not exist could never match. +func (q *Queries) ReplyLabelKeyExists(ctx context.Context, arg ReplyLabelKeyExistsParams) (bool, error) { + row := q.db.QueryRow(ctx, replyLabelKeyExists, arg.WorkspaceID, arg.Key) + var column_1 bool + err := row.Scan(&column_1) + return column_1, err +} + +const stepSendCreatedAt = `-- name: StepSendCreatedAt :one +SELECT created_at FROM sends WHERE id = $1 AND workspace_id = $2 +` + +type StepSendCreatedAtParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +// When a (deterministically-id'd) step send row was first created. The send +// path's cycle backstop: a routed step whose row predates the enrollment's last +// send was visited EARLIER on this path, i.e. the graph now loops. Workspace-pinned. +func (q *Queries) StepSendCreatedAt(ctx context.Context, arg StepSendCreatedAtParams) (pgtype.Timestamptz, error) { + row := q.db.QueryRow(ctx, stepSendCreatedAt, arg.ID, arg.WorkspaceID) + var created_at pgtype.Timestamptz + err := row.Scan(&created_at) + return created_at, err +} + +const upsertBranch = `-- name: UpsertBranch :one +INSERT INTO sequence_step_branches (step_id, workspace_id, campaign_id, condition, within_days, + reply_label_key, yes_step_id, no_step_id) +VALUES ($1, $2, $3, $4, $5, $6, + $7, $8) +ON CONFLICT (step_id) DO UPDATE +SET condition = EXCLUDED.condition, within_days = EXCLUDED.within_days, + reply_label_key = EXCLUDED.reply_label_key, yes_step_id = EXCLUDED.yes_step_id, + no_step_id = EXCLUDED.no_step_id, updated_at = now() +WHERE sequence_step_branches.workspace_id = EXCLUDED.workspace_id + AND sequence_step_branches.campaign_id = EXCLUDED.campaign_id +RETURNING step_id, workspace_id, campaign_id, condition, within_days, reply_label_key, yes_step_id, no_step_id, created_at, updated_at +` + +type UpsertBranchParams struct { + StepID uuid.UUID `json:"step_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + CampaignID uuid.UUID `json:"campaign_id"` + Condition string `json:"condition"` + WithinDays *int32 `json:"within_days"` + ReplyLabelKey *string `json:"reply_label_key"` + YesStepID pgtype.UUID `json:"yes_step_id"` + NoStepID pgtype.UUID `json:"no_step_id"` +} + +// Create or replace the router on one step. workspace_id and campaign_id are +// written from the caller's pinned values, and the composite FKs refuse a step +// (source or target) that is not in that campaign, so a foreign id cannot be +// smuggled in even if the service's own check were skipped. The ON CONFLICT +// update is pinned on workspace_id as well: a step id belonging to another tenant +// updates nothing and returns no row. +func (q *Queries) UpsertBranch(ctx context.Context, arg UpsertBranchParams) (SequenceStepBranch, error) { + row := q.db.QueryRow(ctx, upsertBranch, + arg.StepID, + arg.WorkspaceID, + arg.CampaignID, + arg.Condition, + arg.WithinDays, + arg.ReplyLabelKey, + arg.YesStepID, + arg.NoStepID, + ) + var i SequenceStepBranch + err := row.Scan( + &i.StepID, + &i.WorkspaceID, + &i.CampaignID, + &i.Condition, + &i.WithinDays, + &i.ReplyLabelKey, + &i.YesStepID, + &i.NoStepID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/platform/db/gen/stepsend.sql.go b/internal/platform/db/gen/stepsend.sql.go index e834d241..5bcce93f 100644 --- a/internal/platform/db/gen/stepsend.sql.go +++ b/internal/platform/db/gen/stepsend.sql.go @@ -102,6 +102,7 @@ func (q *Queries) ClaimStepSend(ctx context.Context, arg ClaimStepSendParams) (C const getStepEnrollmentBundle = `-- name: GetStepEnrollmentBundle :one SELECT e.id AS enrollment_id, e.workspace_id, e.contact_id, e.current_step, e.status, e.thread_root_id, e.next_due_at, e.mailbox_id AS enrollment_mailbox_id, + e.last_sent_at, e.awaiting_condition_step, cam.id AS campaign_id, cam.rotation_mode, cam.tracking_enabled, cam.timezone, cam.daily_limit, cam.max_new_leads_per_day, cam.status AS campaign_status, ct.email AS to_email, ct.first_name, ct.last_name, ct.company, ct.custom_fields, @@ -122,41 +123,43 @@ type GetStepEnrollmentBundleParams struct { } type GetStepEnrollmentBundleRow struct { - EnrollmentID uuid.UUID `json:"enrollment_id"` - WorkspaceID uuid.UUID `json:"workspace_id"` - ContactID uuid.UUID `json:"contact_id"` - CurrentStep int32 `json:"current_step"` - Status string `json:"status"` - ThreadRootID string `json:"thread_root_id"` - NextDueAt pgtype.Timestamptz `json:"next_due_at"` - EnrollmentMailboxID pgtype.UUID `json:"enrollment_mailbox_id"` - CampaignID uuid.UUID `json:"campaign_id"` - RotationMode string `json:"rotation_mode"` - TrackingEnabled bool `json:"tracking_enabled"` - Timezone string `json:"timezone"` - DailyLimit *int32 `json:"daily_limit"` - MaxNewLeadsPerDay *int32 `json:"max_new_leads_per_day"` - CampaignStatus string `json:"campaign_status"` - ToEmail string `json:"to_email"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Company string `json:"company"` - CustomFields []byte `json:"custom_fields"` - MailboxID uuid.UUID `json:"mailbox_id"` - Provider string `json:"provider"` - FromEmail string `json:"from_email"` - FromName string `json:"from_name"` - SmtpHost string `json:"smtp_host"` - SmtpPort int32 `json:"smtp_port"` - SmtpUsername string `json:"smtp_username"` - SecretCiphertext string `json:"secret_ciphertext"` - AllowPlaintext bool `json:"allow_plaintext"` - DailyCap int32 `json:"daily_cap"` - MinIntervalSeconds int32 `json:"min_interval_seconds"` - RampEnabled bool `json:"ramp_enabled"` - RampStartCap int32 `json:"ramp_start_cap"` - RampDays int32 `json:"ramp_days"` - MailboxCreatedAt pgtype.Timestamptz `json:"mailbox_created_at"` + EnrollmentID uuid.UUID `json:"enrollment_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ContactID uuid.UUID `json:"contact_id"` + CurrentStep int32 `json:"current_step"` + Status string `json:"status"` + ThreadRootID string `json:"thread_root_id"` + NextDueAt pgtype.Timestamptz `json:"next_due_at"` + EnrollmentMailboxID pgtype.UUID `json:"enrollment_mailbox_id"` + LastSentAt pgtype.Timestamptz `json:"last_sent_at"` + AwaitingConditionStep *int32 `json:"awaiting_condition_step"` + CampaignID uuid.UUID `json:"campaign_id"` + RotationMode string `json:"rotation_mode"` + TrackingEnabled bool `json:"tracking_enabled"` + Timezone string `json:"timezone"` + DailyLimit *int32 `json:"daily_limit"` + MaxNewLeadsPerDay *int32 `json:"max_new_leads_per_day"` + CampaignStatus string `json:"campaign_status"` + ToEmail string `json:"to_email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Company string `json:"company"` + CustomFields []byte `json:"custom_fields"` + MailboxID uuid.UUID `json:"mailbox_id"` + Provider string `json:"provider"` + FromEmail string `json:"from_email"` + FromName string `json:"from_name"` + SmtpHost string `json:"smtp_host"` + SmtpPort int32 `json:"smtp_port"` + SmtpUsername string `json:"smtp_username"` + SecretCiphertext string `json:"secret_ciphertext"` + AllowPlaintext bool `json:"allow_plaintext"` + DailyCap int32 `json:"daily_cap"` + MinIntervalSeconds int32 `json:"min_interval_seconds"` + RampEnabled bool `json:"ramp_enabled"` + RampStartCap int32 `json:"ramp_start_cap"` + RampDays int32 `json:"ramp_days"` + MailboxCreatedAt pgtype.Timestamptz `json:"mailbox_created_at"` } // Everything needed to build one step-send job, workspace-pinned. Joins the @@ -188,6 +191,8 @@ func (q *Queries) GetStepEnrollmentBundle(ctx context.Context, arg GetStepEnroll &i.ThreadRootID, &i.NextDueAt, &i.EnrollmentMailboxID, + &i.LastSentAt, + &i.AwaitingConditionStep, &i.CampaignID, &i.RotationMode, &i.TrackingEnabled, @@ -222,7 +227,7 @@ func (q *Queries) GetStepEnrollmentBundle(ctx context.Context, arg GetStepEnroll const latestSentForContact = `-- name: LatestSentForContact :one SELECT message_id, references_header FROM sends WHERE campaign_id = $1 AND contact_id = $2 AND status = 'sent' -ORDER BY step_order DESC +ORDER BY sent_at DESC NULLS LAST, step_order DESC LIMIT 1 ` @@ -238,6 +243,12 @@ type LatestSentForContactRow struct { // The most recent successfully-sent step for a (campaign, contact), used to // thread the next step (In-Reply-To = its message_id; References = its chain). +// +// "Most recent" is by sent_at, with step_order only as the tie-break. On a linear +// sequence the two orders are the same (steps send in step_order), but a branched +// path need not visit steps in step_order — 1 → 3 → 2 is a valid path — and +// threading onto the highest-numbered step would reply to a message that is not +// the latest one the contact received. func (q *Queries) LatestSentForContact(ctx context.Context, arg LatestSentForContactParams) (LatestSentForContactRow, error) { row := q.db.QueryRow(ctx, latestSentForContact, arg.CampaignID, arg.ContactID) var i LatestSentForContactRow diff --git a/internal/platform/db/migrations/20260923110214_sequence_step_branches.down.sql b/internal/platform/db/migrations/20260923110214_sequence_step_branches.down.sql new file mode 100644 index 00000000..a080d200 --- /dev/null +++ b/internal/platform/db/migrations/20260923110214_sequence_step_branches.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS idx_inbox_threads_campaign_contact; + +ALTER TABLE sequence_enrollments DROP COLUMN awaiting_condition_step; + +DROP TABLE sequence_step_branches; + +ALTER TABLE sequence_steps DROP CONSTRAINT sequence_steps_id_campaign_key; diff --git a/internal/platform/db/migrations/20260923110214_sequence_step_branches.up.sql b/internal/platform/db/migrations/20260923110214_sequence_step_branches.up.sql new file mode 100644 index 00000000..d01d1c13 --- /dev/null +++ b/internal/platform/db/migrations/20260923110214_sequence_step_branches.up.sql @@ -0,0 +1,113 @@ +-- Conditional branching for campaign sequences. +-- +-- A branch is the ROUTER attached to one step: it decides where an enrollment +-- goes after that step has been sent. A step with no branch row keeps today's +-- behaviour exactly — it falls through to the next step by step_order — so every +-- existing linear campaign is a campaign with zero rows here and nothing changes +-- for it. +-- +-- One row per source step (step_id is the primary key), rather than a generic +-- edge table or a "condition" step kind, for three reasons: +-- * a condition is always evaluated against the step it hangs off ("opened +-- THIS step within N days"), so tying it to that step makes the reference +-- point unambiguous and impossible to dangle; +-- * a non-email node would have to be taught to every send-path query that +-- reads sequence_steps (content, variants, threading, results); a router row +-- is invisible to all of them; +-- * each router has at most two exits (yes/no), so an edge table would only +-- add a way to express three. +-- +-- condition = 'always' is the unconditional router: yes_step_id is the next step +-- (NULL = the path ends here). It is what lets a branch END a path, or jump to a +-- step other than the next by order, without inventing a special node. +-- +-- Same-campaign targets are enforced by the database, not only by the service: +-- every step reference is a composite FK on (id, campaign_id), so a branch cannot +-- point at another campaign's (or another tenant's) step even if a caller skips +-- validation. Cycles cannot be expressed as a constraint and are rejected by the +-- service at save time (internal/platform/seqgraph), with a runtime backstop in +-- the send path. + +-- Referenceable by the composite FKs below. Redundant with the primary key for +-- uniqueness; exists solely to be referenced, the same shape as +-- campaigns_id_workspace_key (migration 000028). +ALTER TABLE sequence_steps ADD CONSTRAINT sequence_steps_id_campaign_key UNIQUE (id, campaign_id); + +CREATE TABLE sequence_step_branches ( + step_id UUID NOT NULL PRIMARY KEY, + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + campaign_id UUID NOT NULL, + condition TEXT NOT NULL + CHECK (condition IN ('always', 'opened', 'clicked', 'replied', 'not_opened', 'not_replied')), + -- The evaluation window, in days after the source step's send. Required for + -- every real condition, forbidden on 'always' (see the CHECK below). + within_days INT CHECK (within_days BETWEEN 1 AND 90), + -- Optional narrowing of a reply condition to one reply label, by its stable + -- key (the value inbox_messages.reply_class and sequence_enrollments. + -- reply_class already store). A key rather than an FK to reply_labels for the + -- reason migration 000047 gives for reply_class: a label may be deleted, and + -- the branch must degrade to "never matches" rather than vanish. + reply_label_key TEXT CHECK (reply_label_key ~ '^[a-z][a-z0-9_]{0,63}$'), + -- NULL on either exit means that exit ends the path (the enrollment + -- completes). Deleting a target step therefore turns its incoming exits into + -- ends rather than blocking the delete or orphaning the reference. + yes_step_id UUID, + no_step_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT sequence_step_branches_step_fkey + FOREIGN KEY (step_id, campaign_id) REFERENCES sequence_steps (id, campaign_id) ON DELETE CASCADE, + CONSTRAINT sequence_step_branches_campaign_workspace_fkey + FOREIGN KEY (campaign_id, workspace_id) REFERENCES campaigns (id, workspace_id) ON DELETE CASCADE, + -- SET NULL (col) nulls only the target column: a plain SET NULL would also + -- null campaign_id, which is NOT NULL and shared with the other references. + -- Column-list SET NULL is PostgreSQL 15+; every supported deployment runs 16. + CONSTRAINT sequence_step_branches_yes_fkey + FOREIGN KEY (yes_step_id, campaign_id) REFERENCES sequence_steps (id, campaign_id) + ON DELETE SET NULL (yes_step_id), + CONSTRAINT sequence_step_branches_no_fkey + FOREIGN KEY (no_step_id, campaign_id) REFERENCES sequence_steps (id, campaign_id) + ON DELETE SET NULL (no_step_id), + + -- A real condition has a window; 'always' has none. + CONSTRAINT sequence_step_branches_window_chk + CHECK ((condition = 'always') = (within_days IS NULL)), + -- 'always' has exactly one exit. + CONSTRAINT sequence_step_branches_always_single_exit_chk + CHECK (condition <> 'always' OR no_step_id IS NULL), + -- A label only narrows a REPLY condition. + CONSTRAINT sequence_step_branches_label_chk + CHECK (reply_label_key IS NULL OR condition IN ('replied', 'not_replied')), + -- A self-reference is the smallest cycle; refused here as well as in the service. + CONSTRAINT sequence_step_branches_no_self_chk + CHECK (yes_step_id IS DISTINCT FROM step_id AND no_step_id IS DISTINCT FROM step_id) +); + +-- The send path loads a campaign's routers in one read (ListBranchesByCampaign). +CREATE INDEX idx_sequence_step_branches_campaign ON sequence_step_branches (campaign_id, workspace_id); +-- ON DELETE SET NULL of a step looks up the rows referencing it; without these a +-- step delete would scan the table (same reasoning as migration 000030). +CREATE INDEX idx_sequence_step_branches_yes ON sequence_step_branches (yes_step_id) WHERE yes_step_id IS NOT NULL; +CREATE INDEX idx_sequence_step_branches_no ON sequence_step_branches (no_step_id) WHERE no_step_id IS NOT NULL; + +-- The current_step at which this enrollment last waited on a branch condition. +-- +-- It exists for ONE decision: whether the send path must re-check a step's due +-- time instead of trusting the advance task's schedule. A linear enrollment's +-- advance task is scheduled for exactly the step's due time, so today's path +-- trusts it — but a task scheduled by a condition re-check is not, and if the +-- operator deletes the campaign's last branch while a contact is waiting, that +-- task would otherwise send the linear successor at the re-check time, days +-- ahead of its delay. Stale by construction once current_step moves on, so the +-- cursor advance never has to clear it. +ALTER TABLE sequence_enrollments ADD COLUMN awaiting_condition_step INT; + +-- Reply conditions look for an inbound message on the enrollment's campaign and +-- contact; every existing inbox_threads index leads with workspace_id and a +-- mailbox or a sort key, so that lookup would otherwise walk every thread in the +-- workspace, once per waiting enrollment per re-check. Not CONCURRENTLY: +-- golang-migrate runs a file in one transaction (see +-- 20260827185855_inbox_messages_mailbox_id). +CREATE INDEX idx_inbox_threads_campaign_contact + ON inbox_threads (campaign_id, contact_id) WHERE campaign_id IS NOT NULL; diff --git a/internal/platform/db/queries/enrollment.sql b/internal/platform/db/queries/enrollment.sql index 62857510..e07e9179 100644 --- a/internal/platform/db/queries/enrollment.sql +++ b/internal/platform/db/queries/enrollment.sql @@ -134,3 +134,46 @@ WHERE status = 'active' AND next_due_at IS NOT NULL AND next_due_at < now() - interval '5 minutes' ORDER BY next_due_at ASC LIMIT 500; + +-- name: FinishEnrollment :exec +-- A branch routed this enrollment to the end of its path WITHOUT a send: mark it +-- completed and drop it out of the due index. Unlike CompleteEnrollment it leaves +-- current_step and last_sent_at alone, because nothing was sent — they still +-- describe the last message the contact actually received. Guarded on +-- status='active' for the reason CompleteEnrollment is: a stop is terminal and wins. +UPDATE sequence_enrollments +SET status = 'completed', completed_at = now(), next_due_at = NULL +WHERE id = $1 AND workspace_id = $2 AND status = 'active'; + +-- name: AwaitEnrollmentCondition :exec +-- A branch condition on the enrollment's current step is still open (or its +-- routed step is not due yet): record when to look again, and that this wait is +-- governed by a condition at this step (see awaiting_condition_step in migration +-- 20260923110214). Stamping next_due_at keeps the sweeper from re-driving the +-- enrollment every tick while it legitimately waits. Guarded on status='active'. +UPDATE sequence_enrollments +SET next_due_at = $3, awaiting_condition_step = current_step +WHERE id = $1 AND workspace_id = $2 AND status = 'active'; + +-- name: NudgeEnrollmentAwaitingReply :exec +-- A reply that did NOT stop the enrollment just arrived. If the enrollment is +-- waiting on a reply condition at its current step, pull its due time to now so +-- the next sweep evaluates the condition instead of waiting out the re-check +-- interval. Automated replies (out-of-office, auto-reply) are excluded: they are +-- the only replies that defer an enrollment, and pulling next_due_at forward +-- would undo that deferral. Never pushes a due time LATER, and is a no-op for +-- every enrollment without a reply condition — which is every linear campaign. +UPDATE sequence_enrollments e +SET next_due_at = now() +WHERE e.id = $1 AND e.workspace_id = $2 AND e.status = 'active' + AND e.next_due_at > now() + AND NOT COALESCE( + (SELECT rl.is_automated FROM reply_labels rl + WHERE rl.workspace_id = e.workspace_id AND rl.key = sqlc.arg(reply_class)::text), + sqlc.arg(reply_class)::text IN ('auto_reply', 'out_of_office')) + AND EXISTS ( + SELECT 1 FROM sequence_step_branches b + JOIN sequence_steps s ON s.id = b.step_id AND s.campaign_id = b.campaign_id + WHERE b.campaign_id = e.campaign_id AND b.workspace_id = e.workspace_id + AND s.step_order = e.current_step + AND b.condition IN ('replied', 'not_replied')); diff --git a/internal/platform/db/queries/stepbranch.sql b/internal/platform/db/queries/stepbranch.sql new file mode 100644 index 00000000..5ba8fcf3 --- /dev/null +++ b/internal/platform/db/queries/stepbranch.sql @@ -0,0 +1,89 @@ +-- name: ListBranchesByCampaign :many +-- Every router in the campaign, workspace-pinned. The send path reads this once +-- per advance: an empty result is the linear campaign, which then takes exactly +-- the pre-branching code path. +SELECT * FROM sequence_step_branches +WHERE campaign_id = $1 AND workspace_id = $2 +ORDER BY step_id; + +-- name: UpsertBranch :one +-- Create or replace the router on one step. workspace_id and campaign_id are +-- written from the caller's pinned values, and the composite FKs refuse a step +-- (source or target) that is not in that campaign, so a foreign id cannot be +-- smuggled in even if the service's own check were skipped. The ON CONFLICT +-- update is pinned on workspace_id as well: a step id belonging to another tenant +-- updates nothing and returns no row. +INSERT INTO sequence_step_branches (step_id, workspace_id, campaign_id, condition, within_days, + reply_label_key, yes_step_id, no_step_id) +VALUES ($1, $2, $3, $4, sqlc.narg(within_days), sqlc.narg(reply_label_key), + sqlc.narg(yes_step_id), sqlc.narg(no_step_id)) +ON CONFLICT (step_id) DO UPDATE +SET condition = EXCLUDED.condition, within_days = EXCLUDED.within_days, + reply_label_key = EXCLUDED.reply_label_key, yes_step_id = EXCLUDED.yes_step_id, + no_step_id = EXCLUDED.no_step_id, updated_at = now() +WHERE sequence_step_branches.workspace_id = EXCLUDED.workspace_id + AND sequence_step_branches.campaign_id = EXCLUDED.campaign_id +RETURNING *; + +-- name: DeleteBranch :exec +-- Remove a step's router, returning it to linear fall-through. Pinned on +-- workspace_id and campaign_id. +DELETE FROM sequence_step_branches +WHERE step_id = $1 AND campaign_id = $2 AND workspace_id = $3; + +-- name: LockCampaignGraph :one +-- Serializes every write that changes a campaign's routing graph (branch +-- upsert/delete, step delete, reorder), so the save-time cycle check sees the +-- graph it is actually committing into — two edits that are each acyclic alone +-- can form a cycle together. FOR NO KEY UPDATE rather than FOR UPDATE: it still +-- conflicts with itself, but not with the FOR KEY SHARE lock every sends insert +-- takes on its campaign FK, so a graph edit never stalls delivery. +SELECT id FROM campaigns WHERE id = $1 AND workspace_id = $2 FOR NO KEY UPDATE; + +-- name: ReplyLabelKeyExists :one +-- Whether the workspace defines a reply label with this key. Save-time +-- validation only: a branch naming a label that does not exist could never match. +SELECT EXISTS (SELECT 1 FROM reply_labels WHERE workspace_id = $1 AND key = $2)::bool; + +-- name: FirstHumanTrackingEventAt :one +-- The earliest HUMAN open or click of one send, at or before window_end. It reads +-- the stored bot verdict (NOT is_machine) — the same definition CountHumanOpens +-- reports — rather than deriving its own, so a branch and the open rate can +-- never disagree about the same contact (docs/security.md invariant 82). Served +-- by idx_tracking_send_recent (send_id, kind, is_machine, created_at). +SELECT created_at FROM tracking_events +WHERE send_id = $1 AND workspace_id = $2 AND kind = $3 AND NOT is_machine + AND created_at <= sqlc.arg(window_end)::timestamptz +ORDER BY created_at ASC +LIMIT 1; + +-- name: FirstInboundReplyAt :one +-- The earliest inbound reply from this contact on this campaign that arrived +-- inside [window_start, window_end]. created_at (when WE ingested it), not +-- occurred_at: the latter is the sender's own Date header, which they control. +-- +-- label_key '' means "any human reply": a message whose label is automated +-- (out-of-office, auto-reply) is not a reply from a person and does not count. A +-- label the workspace has since deleted falls back to the builtin automated keys, +-- the same degradation the inbox dispatch applies. A non-empty label_key matches +-- that key exactly, automated or not — branching on an out-of-office is a +-- legitimate thing to want. +SELECT m.created_at FROM inbox_messages m +JOIN inbox_threads t ON t.id = m.thread_id AND t.workspace_id = m.workspace_id +LEFT JOIN reply_labels rl ON rl.workspace_id = m.workspace_id AND rl.key = m.reply_class +WHERE m.workspace_id = $1 AND t.campaign_id = $2 AND t.contact_id = $3 + AND m.direction = 'inbound' + AND m.created_at >= sqlc.arg(window_start)::timestamptz + AND m.created_at <= sqlc.arg(window_end)::timestamptz + AND CASE WHEN sqlc.arg(label_key)::text = '' + THEN NOT COALESCE(rl.is_automated, m.reply_class IN ('auto_reply', 'out_of_office')) + ELSE m.reply_class = sqlc.arg(label_key)::text + END +ORDER BY m.created_at ASC +LIMIT 1; + +-- name: StepSendCreatedAt :one +-- When a (deterministically-id'd) step send row was first created. The send +-- path's cycle backstop: a routed step whose row predates the enrollment's last +-- send was visited EARLIER on this path, i.e. the graph now loops. Workspace-pinned. +SELECT created_at FROM sends WHERE id = $1 AND workspace_id = $2; diff --git a/internal/platform/db/queries/stepsend.sql b/internal/platform/db/queries/stepsend.sql index 6e33e930..6352d08b 100644 --- a/internal/platform/db/queries/stepsend.sql +++ b/internal/platform/db/queries/stepsend.sql @@ -18,6 +18,7 @@ -- the next advance. SELECT e.id AS enrollment_id, e.workspace_id, e.contact_id, e.current_step, e.status, e.thread_root_id, e.next_due_at, e.mailbox_id AS enrollment_mailbox_id, + e.last_sent_at, e.awaiting_condition_step, cam.id AS campaign_id, cam.rotation_mode, cam.tracking_enabled, cam.timezone, cam.daily_limit, cam.max_new_leads_per_day, cam.status AS campaign_status, ct.email AS to_email, ct.first_name, ct.last_name, ct.company, ct.custom_fields, @@ -96,7 +97,13 @@ SELECT COALESCE(sqlc.narg(not_due_until)::timestamptz > now(), false)::bool AS n -- name: LatestSentForContact :one -- The most recent successfully-sent step for a (campaign, contact), used to -- thread the next step (In-Reply-To = its message_id; References = its chain). +-- +-- "Most recent" is by sent_at, with step_order only as the tie-break. On a linear +-- sequence the two orders are the same (steps send in step_order), but a branched +-- path need not visit steps in step_order — 1 → 3 → 2 is a valid path — and +-- threading onto the highest-numbered step would reply to a message that is not +-- the latest one the contact received. SELECT message_id, references_header FROM sends WHERE campaign_id = $1 AND contact_id = $2 AND status = 'sent' -ORDER BY step_order DESC +ORDER BY sent_at DESC NULLS LAST, step_order DESC LIMIT 1; diff --git a/internal/platform/seqgraph/seqgraph.go b/internal/platform/seqgraph/seqgraph.go new file mode 100644 index 00000000..63dfbee8 --- /dev/null +++ b/internal/platform/seqgraph/seqgraph.go @@ -0,0 +1,492 @@ +// Package seqgraph is the pure routing model of a branched campaign sequence: +// which step an enrollment goes to after the one it just received, whether a +// condition on that step has been decided yet, and whether a proposed graph is +// safe to save. +// +// It does no I/O. The control plane loads steps, branches and the engagement +// evidence, and asks this package what they mean; the sequence-step service asks +// it whether an edit may be committed. Keeping both callers on one definition is +// the point — a cycle rule that the save path and the send path disagreed on +// would let a graph be saved that the send path then loops on. +// +// # The model +// +// Every step is a node. A step's exits are: +// - its Branch, when it has one: 'always' has one exit (Yes), every real +// condition has two (Yes, No). An unset exit (uuid.Nil) ends the path. +// - otherwise the next step by step_order — the linear fall-through every +// campaign had before branching existed, so a campaign with no branches is +// exactly the linear sequence. +// +// The fall-through is a real edge for cycle detection: 1 -> 2 -> 3 with a single +// explicit 3 -> 1 is a cycle even though only one edge was drawn. +package seqgraph + +import ( + "errors" + "fmt" + "regexp" + "slices" + "time" + + "github.com/google/uuid" +) + +// Condition is what a branch tests. Values are the stored +// sequence_step_branches.condition strings and the API enum. +type Condition string + +const ( + // Always routes unconditionally to Yes (uuid.Nil = end the path). It is how + // a path ends early or jumps somewhere other than the next step by order. + Always Condition = "always" + Opened Condition = "opened" + Clicked Condition = "clicked" + Replied Condition = "replied" + NotOpened Condition = "not_opened" + NotReplied Condition = "not_replied" +) + +// ParseCondition maps a stored/API string onto a known condition. +func ParseCondition(s string) (Condition, bool) { + c := Condition(s) + switch c { + case Always, Opened, Clicked, Replied, NotOpened, NotReplied: + return c, true + } + return "", false +} + +// Signal is the engagement a condition watches. +type Signal int + +const ( + SignalNone Signal = iota + SignalOpen + SignalClick + SignalReply +) + +// Signal reports which engagement evidence decides c. +func (c Condition) Signal() Signal { + switch c { + case Opened, NotOpened: + return SignalOpen + case Clicked: + return SignalClick + case Replied, NotReplied: + return SignalReply + } + return SignalNone +} + +// negated reports whether the condition is satisfied by the ABSENCE of its +// signal, so that seeing the signal decides it false. +func (c Condition) negated() bool { return c == NotOpened || c == NotReplied } + +// Window bounds, in days. A day minimum because every engagement signal here is +// human-paced; 90 because a sequence waiting a quarter for one open is almost +// certainly a mis-set value, and a bounded window is what guarantees every wait +// ends. +const ( + MinWithinDays = 1 + MaxWithinDays = 90 +) + +// Step is one node: the step's id, its position, and its delay (the minimum gap +// between the decision that routes to it and its send). +type Step struct { + ID uuid.UUID + Order int32 + DelaySeconds int32 +} + +// Branch is the router on one step. +type Branch struct { + StepID uuid.UUID + Condition Condition + WithinDays int + // ReplyLabelKey narrows a reply condition to one reply label ("" = any + // human reply). + ReplyLabelKey string + // Yes and No are the exits; uuid.Nil ends the path. 'always' uses Yes only. + Yes uuid.UUID + No uuid.UUID +} + +// Window is how long after the source step's send the condition watches. +func (b Branch) Window() time.Duration { + return time.Duration(b.WithinDays) * 24 * time.Hour +} + +// Target is the exit a decided verdict takes (uuid.Nil = end). +func (b Branch) Target(v Verdict) uuid.UUID { + if v.Yes { + return b.Yes + } + return b.No +} + +// exits lists the branch's set exits, Yes first. +func (b Branch) exits() []uuid.UUID { + var out []uuid.UUID + for _, id := range []uuid.UUID{b.Yes, b.No} { + if id != uuid.Nil { + out = append(out, id) + } + } + return out +} + +// Validation codes. Stable strings: the API returns them as `code` so a client +// can react to the failure without parsing the message. +const ( + CodeInvalidCondition = "invalid_condition" + CodeInvalidWithinDays = "invalid_within_days" + CodeLabelNotAllowed = "invalid_reply_label" + CodeNoExitNotAllowed = "no_exit_not_allowed" + CodeUnknownStep = "unknown_step" + CodeUnknownTarget = "unknown_target" + CodeCycle = "cycle" +) + +// ShapeError is a branch that is malformed on its own, before any graph is +// considered. +type ShapeError struct { + Code string + Msg string +} + +func (e *ShapeError) Error() string { return e.Msg } + +// TargetError is an exit pointing at a step that is not in the campaign. +type TargetError struct { + StepID uuid.UUID + Target uuid.UUID +} + +func (e *TargetError) Error() string { + return fmt.Sprintf("branch on step %s points at %s, which is not a step of this campaign", e.StepID, e.Target) +} + +// UnknownStepError is a branch whose source step is not in the campaign. +type UnknownStepError struct{ StepID uuid.UUID } + +func (e *UnknownStepError) Error() string { + return fmt.Sprintf("step %s is not a step of this campaign", e.StepID) +} + +// CycleError is a graph in which some path revisits a step. StepIDs is the loop, +// in path order, starting at the step the loop returns to. +type CycleError struct{ StepIDs []uuid.UUID } + +func (e *CycleError) Error() string { + return fmt.Sprintf("the sequence would loop through %d step(s); every path must end", len(e.StepIDs)) +} + +// CodeOf returns the validation code carried by err, or "" for an error that is +// not a validation failure. +func CodeOf(err error) string { + var shape *ShapeError + var target *TargetError + var unknown *UnknownStepError + var cycle *CycleError + switch { + case errors.As(err, &shape): + return shape.Code + case errors.As(err, &target): + return CodeUnknownTarget + case errors.As(err, &unknown): + return CodeUnknownStep + case errors.As(err, &cycle): + return CodeCycle + } + return "" +} + +// labelKeyPattern mirrors reply_labels.key's CHECK (migration 000047), so a key +// that could never name a label is refused at the boundary rather than by the +// database. +var labelKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) + +// ValidateShape checks the branch on its own: a known condition, a window +// exactly when one is meaningful, a label only on a reply condition, one exit on +// 'always', and no exit back to itself. +func (b Branch) ValidateShape() error { + if _, ok := ParseCondition(string(b.Condition)); !ok { + return &ShapeError{Code: CodeInvalidCondition, Msg: fmt.Sprintf("unknown condition %q", b.Condition)} + } + if b.Condition == Always { + if b.WithinDays != 0 { + return &ShapeError{Code: CodeInvalidWithinDays, Msg: "within_days is not allowed on an 'always' branch"} + } + if b.No != uuid.Nil { + return &ShapeError{Code: CodeNoExitNotAllowed, Msg: "an 'always' branch has only a yes exit"} + } + } else if b.WithinDays < MinWithinDays || b.WithinDays > MaxWithinDays { + return &ShapeError{Code: CodeInvalidWithinDays, + Msg: fmt.Sprintf("within_days must be between %d and %d", MinWithinDays, MaxWithinDays)} + } + if b.ReplyLabelKey != "" { + if b.Condition.Signal() != SignalReply { + return &ShapeError{Code: CodeLabelNotAllowed, Msg: "reply_label_key is only allowed on a replied/not_replied branch"} + } + if !labelKeyPattern.MatchString(b.ReplyLabelKey) { + return &ShapeError{Code: CodeLabelNotAllowed, Msg: "reply_label_key is not a valid label key"} + } + } + if b.Yes == b.StepID || b.No == b.StepID { + return &CycleError{StepIDs: []uuid.UUID{b.StepID}} + } + return nil +} + +// Graph is one campaign's steps and branches. +type Graph struct { + ordered []Step // by Order ascending + byID map[uuid.UUID]Step + branches map[uuid.UUID]Branch + // branchOrder keeps validation deterministic: the order branches were given. + branchOrder []uuid.UUID +} + +// New builds a graph. Steps may arrive in any order; step_order gaps (left by a +// delete, which does not renumber) are tolerated exactly as the linear send path +// tolerates them. +func New(steps []Step, branches []Branch) Graph { + g := Graph{ + ordered: slices.Clone(steps), + byID: make(map[uuid.UUID]Step, len(steps)), + branches: make(map[uuid.UUID]Branch, len(branches)), + } + slices.SortFunc(g.ordered, func(a, b Step) int { return int(a.Order) - int(b.Order) }) + for _, s := range g.ordered { + g.byID[s.ID] = s + } + for _, b := range branches { + if _, dup := g.branches[b.StepID]; !dup { + g.branchOrder = append(g.branchOrder, b.StepID) + } + g.branches[b.StepID] = b + } + return g +} + +// HasBranches reports whether any step has a router. A graph without one is the +// linear sequence. +func (g Graph) HasBranches() bool { return len(g.branches) > 0 } + +// Entry is the first step (lowest step_order), where every enrollment starts. +func (g Graph) Entry() (Step, bool) { + if len(g.ordered) == 0 { + return Step{}, false + } + return g.ordered[0], true +} + +// Step looks a step up by id. +func (g Graph) Step(id uuid.UUID) (Step, bool) { + s, ok := g.byID[id] + return s, ok +} + +// StepByOrder looks a step up by its step_order — the enrollment cursor's unit. +func (g Graph) StepByOrder(order int32) (Step, bool) { + i, found := slices.BinarySearchFunc(g.ordered, order, func(s Step, o int32) int { return int(s.Order) - int(o) }) + if !found { + return Step{}, false + } + return g.ordered[i], true +} + +// Branch returns the router on a step, if it has one. +func (g Graph) Branch(stepID uuid.UUID) (Branch, bool) { + b, ok := g.branches[stepID] + return b, ok +} + +// DefaultNext is the linear fall-through: the next step by step_order. +func (g Graph) DefaultNext(stepID uuid.UUID) (Step, bool) { + s, ok := g.byID[stepID] + if !ok { + return Step{}, false + } + i, _ := slices.BinarySearchFunc(g.ordered, s.Order+1, func(x Step, o int32) int { return int(x.Order) - int(o) }) + if i >= len(g.ordered) { + return Step{}, false + } + return g.ordered[i], true +} + +// exits lists a step's effective successors: its branch's set exits, or the +// linear fall-through when it has no branch. +func (g Graph) exits(stepID uuid.UUID) []uuid.UUID { + if b, ok := g.branches[stepID]; ok { + return b.exits() + } + if n, ok := g.DefaultNext(stepID); ok { + return []uuid.UUID{n.ID} + } + return nil +} + +// Validate checks every branch's shape, that every branch and exit names a step +// of this campaign, and that no path revisits a step. The first problem found is +// returned; the order is deterministic (branches as given, then steps by order). +func (g Graph) Validate() error { + for _, id := range g.branchOrder { + b := g.branches[id] + if err := b.ValidateShape(); err != nil { + return err + } + if _, ok := g.byID[b.StepID]; !ok { + return &UnknownStepError{StepID: b.StepID} + } + for _, t := range b.exits() { + if _, ok := g.byID[t]; !ok { + return &TargetError{StepID: b.StepID, Target: t} + } + } + } + return g.findCycle() +} + +// findCycle is a three-colour depth-first search over the effective edges, +// iterative so a long sequence cannot grow the goroutine stack without bound. +func (g Graph) findCycle() error { + const ( + white = iota + grey + black + ) + colour := make(map[uuid.UUID]int, len(g.ordered)) + type frame struct { + id uuid.UUID + exits []uuid.UUID + next int + } + for _, root := range g.ordered { + if colour[root.ID] != white { + continue + } + stack := []frame{{id: root.ID, exits: g.exits(root.ID)}} + colour[root.ID] = grey + for len(stack) > 0 { + top := &stack[len(stack)-1] + if top.next == len(top.exits) { + colour[top.id] = black + stack = stack[:len(stack)-1] + continue + } + child := top.exits[top.next] + top.next++ + switch colour[child] { + case grey: + // The loop is the stack from child's frame to the top. + var loop []uuid.UUID + for i := range stack { + if stack[i].id == child { + for _, f := range stack[i:] { + loop = append(loop, f.id) + } + break + } + } + return &CycleError{StepIDs: loop} + case white: + colour[child] = grey + stack = append(stack, frame{id: child, exits: g.exits(child)}) + } + } + } + return nil +} + +// PlanKind is what happens after a step has been sent. +type PlanKind int + +const ( + // PlanEnd: the path ends after this step; the enrollment completes. + PlanEnd PlanKind = iota + // PlanNext: Next is sent next, DelaySeconds after this step's send. + PlanNext + // PlanAwait: a condition must be decided first; see Branch. + PlanAwait +) + +// Plan is the route out of one step. +type Plan struct { + Kind PlanKind + Next Step + Branch Branch +} + +// After is the route out of stepID. An exit to a step that is not in the graph +// ends the path — the database already nulls such exits when a step is deleted, +// and routing into nothing is never the right answer. An unknown stepID also +// yields PlanEnd; callers that can do better (the send path falls back to the +// linear rule for a cursor on a deleted step) check Step first. +func (g Graph) After(stepID uuid.UUID) Plan { + if _, ok := g.byID[stepID]; !ok { + return Plan{Kind: PlanEnd} + } + b, ok := g.branches[stepID] + if !ok { + if n, ok := g.DefaultNext(stepID); ok { + return Plan{Kind: PlanNext, Next: n} + } + return Plan{Kind: PlanEnd} + } + if b.Condition != Always { + return Plan{Kind: PlanAwait, Branch: b} + } + if n, ok := g.byID[b.Yes]; ok { + return Plan{Kind: PlanNext, Next: n} + } + return Plan{Kind: PlanEnd} +} + +// Verdict is the state of one condition. +type Verdict struct { + // Decided is false while the window is open and nothing has settled it. + Decided bool + // Yes is the outcome once decided. + Yes bool + // At is when it was decided (the deciding event, or the deadline); while + // undecided it is the deadline, the latest moment the verdict can settle. + At time.Time +} + +// Evaluate decides a condition from the earliest qualifying event. +// +// start is when the source step was sent and window how long the condition +// watches; firstEvent is the earliest event of the condition's signal at or +// before start+window (zero = none seen). The verdict is a pure function of +// those, so re-evaluating an enrollment later can never change a decided answer: +// events after the window are ignored, and once the window has closed no new +// event can land inside it. That is what lets the send path re-derive the route +// on every advance instead of storing it. +// +// - A positive condition (opened/clicked/replied) is decided YES the moment the +// event happens, and NO when the window closes without it. +// - A negated one (not_opened/not_replied) is decided NO the moment the event +// happens, and YES when the window closes without it. +// +// A caller that passes an event after the deadline gets the no-event answer. +func Evaluate(c Condition, start time.Time, window time.Duration, firstEvent, now time.Time) Verdict { + if c == Always { + return Verdict{Decided: true, Yes: true, At: start} + } + deadline := start.Add(window) + if !firstEvent.IsZero() && !firstEvent.After(deadline) { + at := firstEvent + if at.Before(start) { + at = start + } + return Verdict{Decided: true, Yes: !c.negated(), At: at} + } + if !now.Before(deadline) { + return Verdict{Decided: true, Yes: c.negated(), At: deadline} + } + return Verdict{At: deadline} +} diff --git a/internal/platform/seqgraph/seqgraph_test.go b/internal/platform/seqgraph/seqgraph_test.go new file mode 100644 index 00000000..63c5bf00 --- /dev/null +++ b/internal/platform/seqgraph/seqgraph_test.go @@ -0,0 +1,354 @@ +package seqgraph + +import ( + "errors" + "slices" + "testing" + "time" + + "github.com/google/uuid" +) + +// ids returns n deterministic, distinct step ids so failures print stably. +func ids(n int) []uuid.UUID { + out := make([]uuid.UUID, n) + for i := range out { + out[i] = uuid.NewSHA1(uuid.NameSpaceOID, []byte{byte(i + 1)}) + } + return out +} + +// linear builds steps 1..n with the given delays (all 0 when omitted). +func linear(id []uuid.UUID) []Step { + steps := make([]Step, len(id)) + for i := range id { + steps[i] = Step{ID: id[i], Order: int32(i + 1), DelaySeconds: int32(i * 60)} + } + return steps +} + +func TestParseCondition(t *testing.T) { + for _, c := range []Condition{Always, Opened, Clicked, Replied, NotOpened, NotReplied} { + got, ok := ParseCondition(string(c)) + if !ok || got != c { + t.Errorf("ParseCondition(%q) = %q, %v", c, got, ok) + } + } + for _, bad := range []string{"", "OPENED", "bounced", "not-opened"} { + if _, ok := ParseCondition(bad); ok { + t.Errorf("ParseCondition(%q) accepted an unknown condition", bad) + } + } +} + +func TestValidateShape(t *testing.T) { + id := ids(3) + cases := []struct { + name string + b Branch + code string // "" = valid + }{ + {"always to a step", Branch{StepID: id[0], Condition: Always, Yes: id[1]}, ""}, + {"always to the end", Branch{StepID: id[0], Condition: Always}, ""}, + {"opened both exits", Branch{StepID: id[0], Condition: Opened, WithinDays: 3, Yes: id[1], No: id[2]}, ""}, + {"replied with label", Branch{StepID: id[0], Condition: Replied, WithinDays: 1, ReplyLabelKey: "positive"}, ""}, + {"not_replied with label", Branch{StepID: id[0], Condition: NotReplied, WithinDays: 90, ReplyLabelKey: "out_of_office"}, ""}, + {"unknown condition", Branch{StepID: id[0], Condition: "bounced", WithinDays: 3}, CodeInvalidCondition}, + {"condition without window", Branch{StepID: id[0], Condition: Opened}, CodeInvalidWithinDays}, + {"window above max", Branch{StepID: id[0], Condition: Opened, WithinDays: MaxWithinDays + 1}, CodeInvalidWithinDays}, + {"negative window", Branch{StepID: id[0], Condition: Clicked, WithinDays: -1}, CodeInvalidWithinDays}, + {"always with window", Branch{StepID: id[0], Condition: Always, WithinDays: 3}, CodeInvalidWithinDays}, + {"always with a no exit", Branch{StepID: id[0], Condition: Always, No: id[1]}, CodeNoExitNotAllowed}, + {"label on an open condition", Branch{StepID: id[0], Condition: Opened, WithinDays: 3, ReplyLabelKey: "positive"}, CodeLabelNotAllowed}, + {"malformed label key", Branch{StepID: id[0], Condition: Replied, WithinDays: 3, ReplyLabelKey: "Positive!"}, CodeLabelNotAllowed}, + {"yes to itself", Branch{StepID: id[0], Condition: Opened, WithinDays: 3, Yes: id[0]}, CodeCycle}, + {"no to itself", Branch{StepID: id[0], Condition: Opened, WithinDays: 3, No: id[0]}, CodeCycle}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.b.ValidateShape() + if tc.code == "" { + if err != nil { + t.Fatalf("want valid, got %v", err) + } + return + } + if got := CodeOf(err); got != tc.code { + t.Fatalf("code = %q (err %v), want %q", got, err, tc.code) + } + }) + } +} + +func TestValidateLinearIsAcyclic(t *testing.T) { + g := New(linear(ids(4)), nil) + if err := g.Validate(); err != nil { + t.Fatalf("a linear sequence must validate: %v", err) + } + if g.HasBranches() { + t.Fatal("no branches were given") + } +} + +func TestValidateEmptyGraph(t *testing.T) { + if err := New(nil, nil).Validate(); err != nil { + t.Fatalf("an empty campaign must validate: %v", err) + } +} + +func TestValidateRejectsExplicitCycle(t *testing.T) { + id := ids(3) + // 1 -always-> 2 -always-> 3 -always-> 1 + g := New(linear(id), []Branch{ + {StepID: id[0], Condition: Always, Yes: id[1]}, + {StepID: id[1], Condition: Always, Yes: id[2]}, + {StepID: id[2], Condition: Always, Yes: id[0]}, + }) + var cyc *CycleError + if err := g.Validate(); !errors.As(err, &cyc) { + t.Fatalf("want CycleError, got %v", err) + } + if CodeOf(cyc) != CodeCycle { + t.Fatalf("cycle code = %q", CodeOf(cyc)) + } + for _, want := range id { + if !slices.Contains(cyc.StepIDs, want) { + t.Fatalf("cycle %v is missing step %v", cyc.StepIDs, want) + } + } +} + +// The cycle a naive check misses: only ONE explicit edge, closed by the implicit +// "next by step_order" fall-through of steps that have no branch. +func TestValidateRejectsCycleThroughFallThrough(t *testing.T) { + id := ids(3) + g := New(linear(id), []Branch{ + // Step 3 jumps back to step 1; 1 -> 2 -> 3 is implicit. + {StepID: id[2], Condition: Always, Yes: id[0]}, + }) + var cyc *CycleError + if err := g.Validate(); !errors.As(err, &cyc) { + t.Fatalf("want CycleError for a cycle closed by fall-through, got %v", err) + } + if len(cyc.StepIDs) != 3 { + t.Fatalf("cycle = %v, want all three steps", cyc.StepIDs) + } +} + +// A conditional's NO exit is an edge like any other. +func TestValidateRejectsCycleThroughNoExit(t *testing.T) { + id := ids(3) + g := New(linear(id), []Branch{ + {StepID: id[1], Condition: NotOpened, WithinDays: 2, Yes: id[2], No: id[0]}, + }) + if err := g.Validate(); CodeOf(err) != CodeCycle { + t.Fatalf("want cycle via the no exit, got %v", err) + } +} + +// A backward edge is NOT a cycle when the target cannot reach the source: here +// step 3 jumps to step 2, and step 2 ends explicitly. +func TestValidateAllowsBackwardEdgeWithoutCycle(t *testing.T) { + id := ids(3) + g := New(linear(id), []Branch{ + {StepID: id[0], Condition: Opened, WithinDays: 3, Yes: id[2], No: id[1]}, + {StepID: id[1], Condition: Always}, // 2 ends + {StepID: id[2], Condition: Always, Yes: id[1]}, // 3 -> 2 (backward, acyclic) + }) + if err := g.Validate(); err != nil { + t.Fatalf("1 -> {3 -> 2, 2} is acyclic: %v", err) + } +} + +func TestValidateRejectsTargetOutsideCampaign(t *testing.T) { + id := ids(3) + foreign := uuid.New() + g := New(linear(id[:2]), []Branch{ + {StepID: id[0], Condition: Replied, WithinDays: 3, Yes: foreign}, + }) + var te *TargetError + if err := g.Validate(); !errors.As(err, &te) { + t.Fatalf("want TargetError, got %v", err) + } + if te.Target != foreign || te.StepID != id[0] || CodeOf(te) != CodeUnknownTarget { + t.Fatalf("TargetError = %+v", te) + } +} + +func TestValidateRejectsBranchOnForeignStep(t *testing.T) { + id := ids(2) + g := New(linear(id[:1]), []Branch{{StepID: id[1], Condition: Always}}) + if err := g.Validate(); CodeOf(err) != CodeUnknownStep { + t.Fatalf("want %q, got %v", CodeUnknownStep, err) + } +} + +func TestValidateReportsShapeErrors(t *testing.T) { + id := ids(2) + g := New(linear(id), []Branch{{StepID: id[0], Condition: Opened}}) + if err := g.Validate(); CodeOf(err) != CodeInvalidWithinDays { + t.Fatalf("Validate must include the per-branch shape check, got %v", err) + } +} + +func TestDefaultNextToleratesGaps(t *testing.T) { + id := ids(3) + g := New([]Step{ + {ID: id[2], Order: 7}, + {ID: id[0], Order: 1}, + {ID: id[1], Order: 3}, + }, nil) + if n, ok := g.DefaultNext(id[0]); !ok || n.ID != id[1] { + t.Fatalf("next after order 1 = %+v %v, want order 3", n, ok) + } + if n, ok := g.DefaultNext(id[1]); !ok || n.ID != id[2] { + t.Fatalf("next after order 3 = %+v %v, want order 7", n, ok) + } + if _, ok := g.DefaultNext(id[2]); ok { + t.Fatal("the last step has no default next") + } + if e, ok := g.Entry(); !ok || e.ID != id[0] { + t.Fatalf("entry = %+v %v", e, ok) + } + if s, ok := g.StepByOrder(3); !ok || s.ID != id[1] { + t.Fatalf("StepByOrder(3) = %+v %v", s, ok) + } + if _, ok := g.StepByOrder(2); ok { + t.Fatal("order 2 is a gap") + } +} + +func TestAfter(t *testing.T) { + id := ids(4) + g := New(linear(id), []Branch{ + {StepID: id[0], Condition: Opened, WithinDays: 2, Yes: id[2], No: id[1]}, + {StepID: id[1], Condition: Always, Yes: id[3]}, + {StepID: id[2], Condition: Always}, + }) + + if p := g.After(id[0]); p.Kind != PlanAwait || p.Branch.Condition != Opened { + t.Fatalf("step 1 has a condition: %+v", p) + } + if p := g.After(id[1]); p.Kind != PlanNext || p.Next.ID != id[3] { + t.Fatalf("step 2 always -> 4: %+v", p) + } + if p := g.After(id[2]); p.Kind != PlanEnd { + t.Fatalf("step 3 always -> end: %+v", p) + } + if p := g.After(id[3]); p.Kind != PlanEnd { + t.Fatalf("step 4 is last with no branch: %+v", p) + } + + // No branch at all: fall through by order — the linear rule. + lin := New(linear(id), nil) + if p := lin.After(id[1]); p.Kind != PlanNext || p.Next.ID != id[2] { + t.Fatalf("linear step 2 -> 3: %+v", p) + } + // An unknown step (deleted while an enrollment sat on it) has no plan to + // follow; the caller decides. + if p := lin.After(uuid.New()); p.Kind != PlanEnd { + t.Fatalf("unknown step: %+v", p) + } +} + +// An exit whose target no longer exists in the graph ends the path rather than +// routing into nothing. The database nulls such exits (ON DELETE SET NULL); this +// is the in-memory half of the same rule. +func TestAfterMissingTargetEnds(t *testing.T) { + id := ids(2) + g := New(linear(id[:1]), []Branch{{StepID: id[0], Condition: Always, Yes: id[1]}}) + if p := g.After(id[0]); p.Kind != PlanEnd { + t.Fatalf("always -> missing step must end: %+v", p) + } +} + +func TestEvaluate(t *testing.T) { + start := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + window := 3 * 24 * time.Hour + deadline := start.Add(window) + early := start.Add(5 * time.Hour) + late := deadline.Add(time.Hour) + var none time.Time + + cases := []struct { + name string + cond Condition + first time.Time + now time.Time + decided bool + yes bool + at time.Time + }{ + // Positive conditions: the event decides YES the moment it happens. + {"opened early", Opened, early, early.Add(time.Minute), true, true, early}, + {"clicked early", Clicked, early, deadline.Add(-time.Minute), true, true, early}, + {"replied early", Replied, early, early, true, true, early}, + // ...and the window closing without it decides NO, at the deadline. + {"opened window closed", Opened, none, deadline, true, false, deadline}, + {"replied window long closed", Replied, none, late, true, false, deadline}, + // Still open, nothing seen: wait. + {"opened pending", Opened, none, early, false, false, deadline}, + // Negated conditions: the event decides NO immediately... + {"not_opened but opened", NotOpened, early, early, true, false, early}, + {"not_replied but replied", NotReplied, early, early.Add(time.Hour), true, false, early}, + // ...and silence until the deadline decides YES. + {"not_opened window closed", NotOpened, none, deadline, true, true, deadline}, + {"not_replied pending", NotReplied, none, early, false, false, deadline}, + // An event after the window does not count, whatever the clock says now. + {"opened after window", Opened, late, late, true, false, deadline}, + {"not_opened open after window", NotOpened, late, late.Add(time.Hour), true, true, deadline}, + // Exactly on the deadline is inside the window. + {"opened on the deadline", Opened, deadline, deadline, true, true, deadline}, + // An event stamped before the window start (clock skew between the send + // and the cursor stamp) decides at the start, never before it. + {"opened before start", Opened, start.Add(-time.Second), start, true, true, start}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + v := Evaluate(tc.cond, start, window, tc.first, tc.now) + if v.Decided != tc.decided || v.Yes != tc.yes || !v.At.Equal(tc.at) { + t.Fatalf("Evaluate = %+v, want decided=%v yes=%v at=%v", v, tc.decided, tc.yes, tc.at) + } + }) + } +} + +// 'always' is not evaluated against evidence; it is routed by After. Evaluate +// must still be total, and decides YES immediately so a caller that asks cannot +// wedge an enrollment. +func TestEvaluateAlwaysIsImmediateYes(t *testing.T) { + start := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + v := Evaluate(Always, start, 0, time.Time{}, start) + if !v.Decided || !v.Yes || !v.At.Equal(start) { + t.Fatalf("Evaluate(always) = %+v", v) + } +} + +func TestBranchTarget(t *testing.T) { + id := ids(3) + b := Branch{StepID: id[0], Condition: Opened, WithinDays: 1, Yes: id[1], No: id[2]} + if got := b.Target(Verdict{Decided: true, Yes: true}); got != id[1] { + t.Fatalf("yes target = %v", got) + } + if got := b.Target(Verdict{Decided: true, Yes: false}); got != id[2] { + t.Fatalf("no target = %v", got) + } + if got := (Branch{Condition: Opened, WithinDays: 1}).Target(Verdict{Decided: true, Yes: true}); got != uuid.Nil { + t.Fatalf("an unset exit is the end, got %v", got) + } + if b.Window() != 24*time.Hour { + t.Fatalf("window = %v", b.Window()) + } +} + +func TestSignal(t *testing.T) { + cases := map[Condition]Signal{ + Always: SignalNone, Opened: SignalOpen, NotOpened: SignalOpen, + Clicked: SignalClick, Replied: SignalReply, NotReplied: SignalReply, + } + for c, want := range cases { + if got := c.Signal(); got != want { + t.Errorf("%q.Signal() = %v, want %v", c, got, want) + } + } +} diff --git a/internal/worker/sequence/advance.go b/internal/worker/sequence/advance.go index 7cb75af2..cc752d1f 100644 --- a/internal/worker/sequence/advance.go +++ b/internal/worker/sequence/advance.go @@ -167,6 +167,22 @@ func AdvanceHandler(core coreapi.Client, sender Sender, enq Enqueuer, publicURL // The gmail access token is a decrypted secret too; wipe it after use. defer zeroize(job.AccessToken) + // A branch condition on the enrollment's current step is still open, or + // the step it routed to is not due yet. The control plane has already + // stamped next_due_at; all that is left is to look again then. Checked + // BEFORE Skip, which rides along on this job only so that a worker built + // before ConditionPending existed does nothing harmful with it. + // result=deferred: a self-clearing wait, the same bucket as the + // campaign-limit and capacity defers below. Metric AFTER the enqueue, for + // their double-count reason. + if job.ConditionPending { + if err := enq.EnqueueAdvanceAt(ctx, p.EnrollmentID, p.WorkspaceID, job.RecheckAt); err != nil { + return err + } + mtx.SendFinalized(sendKind, "deferred") + return nil + } + // Enrollment no longer active (stopped/completed) or no next step. // result=skipped: nothing was ever actionable — same bucket as a // racing worker's already-claimed row below (ClaimSkip), never diff --git a/internal/worker/sequence/branching_integration_test.go b/internal/worker/sequence/branching_integration_test.go new file mode 100644 index 00000000..84a501c4 --- /dev/null +++ b/internal/worker/sequence/branching_integration_test.go @@ -0,0 +1,460 @@ +//go:build integration + +package sequence + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/inroad/inroad/internal/platform/db/gen" +) + +// branchFixture is a running three-step campaign (subjects "S1".."S3", all +// zero-delay) with a send window open around the clock — so no test outcome +// depends on the hour it happens to run — and one enrolled contact. +type branchFixture struct { + itFixture + pool *pgxpool.Pool + eid string + steps [3]uuid.UUID + snd *itSender + enq *itEnq +} + +func seedBranchCampaign(t *testing.T) (branchFixture, func()) { + t.Helper() + ctx := context.Background() + pool, q, closeFn := connect(t) + fx := seedCampaign(t, ctx, pool, q, newSealer(t), [][3]string{ + {"S1", "one", "0"}, {"S2", "two", "0"}, {"S3", "three", "0"}, + }) + if _, err := pool.Exec(ctx, ` + INSERT INTO campaign_send_windows (workspace_id, campaign_id, weekday, start_minute, end_minute) + SELECT $1, $2, d, 0, 1440 FROM generate_series(0, 6) AS d`, fx.ws, fx.campaignID); err != nil { + t.Fatalf("send windows: %v", err) + } + steps, err := q.ListStepsByCampaign(ctx, gen.ListStepsByCampaignParams{CampaignID: fx.campaignID, WorkspaceID: fx.ws}) + if err != nil || len(steps) != 3 { + t.Fatalf("steps: %v (%d)", err, len(steps)) + } + ids, err := q.EnrollListMembers(ctx, gen.EnrollListMembersParams{ID: fx.campaignID, WorkspaceID: fx.ws}) + if err != nil || len(ids) != 1 { + t.Fatalf("enroll: %v", err) + } + return branchFixture{ + itFixture: fx, pool: pool, eid: ids[0].ID.String(), + steps: [3]uuid.UUID{steps[0].ID, steps[1].ID, steps[2].ID}, + snd: &itSender{}, enq: newITEnq(), + }, closeFn +} + +// branch writes a router directly through the query (the save-time validation +// has its own tests in internal/app/sequencestep). +func (f branchFixture) branch(t *testing.T, step uuid.UUID, cond string, within int32, yes, no *uuid.UUID) { + t.Helper() + var w *int32 + if cond != "always" { + w = &within + } + if _, err := f.q.UpsertBranch(context.Background(), gen.UpsertBranchParams{ + StepID: step, WorkspaceID: f.ws, CampaignID: f.campaignID, Condition: cond, WithinDays: w, + YesStepID: optUUID(yes), NoStepID: optUUID(no), + }); err != nil { + t.Fatalf("branch: %v", err) + } +} + +func optUUID(id *uuid.UUID) pgtype.UUID { + if id == nil { + return pgtype.UUID{} + } + return pgtype.UUID{Bytes: *id, Valid: true} +} + +func (f branchFixture) advance(t *testing.T) { + t.Helper() + advance(t, f.core, f.snd, f.enq, f.eid, f.ws.String()) +} + +// due models the advance task's wait: next_due_at back to now. +func (f branchFixture) due(t *testing.T) { + t.Helper() + arriveAtDueTime(t, context.Background(), f.pool, f.eid) +} + +// ageLastSend moves the cursor step's send (and its window) d into the past. +func (f branchFixture) ageLastSend(t *testing.T, d time.Duration) { + t.Helper() + if _, err := f.pool.Exec(context.Background(), + `UPDATE sequence_enrollments SET last_sent_at = last_sent_at - make_interval(secs => $2), next_due_at = now() + WHERE id = $1`, f.eid, d.Seconds()); err != nil { + t.Fatalf("age last send: %v", err) + } +} + +// sendID is the deterministic sends row id of one step for this contact. +func (f branchFixture) sendID(t *testing.T, order int) uuid.UUID { + t.Helper() + var id uuid.UUID + if err := f.pool.QueryRow(context.Background(), + `SELECT id FROM sends WHERE campaign_id = $1 AND contact_id = $2 AND step_order = $3`, + f.campaignID, f.contactID, order).Scan(&id); err != nil { + t.Fatalf("send row for step %d: %v", order, err) + } + return id +} + +func (f branchFixture) track(t *testing.T, order int, kind string, machine bool) { + t.Helper() + reason := "" + if machine { + reason = "proxy_user_agent" + } + if _, err := f.pool.Exec(context.Background(), ` + INSERT INTO tracking_events (workspace_id, campaign_id, send_id, kind, is_machine, machine_reason) + VALUES ($1, $2, $3, $4, $5, $6)`, f.ws, f.campaignID, f.sendID(t, order), kind, machine, reason); err != nil { + t.Fatalf("tracking event: %v", err) + } +} + +// reply stores an inbound reply the way the inbox poller does: a thread on the +// campaign + contact, and an inbound message classified replyClass. +func (f branchFixture) reply(t *testing.T, replyClass string) { + t.Helper() + ctx := context.Background() + var mailbox uuid.UUID + if err := f.pool.QueryRow(ctx, `SELECT mailbox_id FROM campaigns WHERE id = $1`, f.campaignID).Scan(&mailbox); err != nil { + t.Fatalf("mailbox: %v", err) + } + var thread uuid.UUID + if err := f.pool.QueryRow(ctx, ` + INSERT INTO inbox_threads (workspace_id, mailbox_id, campaign_id, contact_id, root_message_id) + VALUES ($1, $2, $3, $4, $5) RETURNING id`, + f.ws, mailbox, f.campaignID, f.contactID, "").Scan(&thread); err != nil { + t.Fatalf("thread: %v", err) + } + if _, err := f.pool.Exec(ctx, ` + INSERT INTO inbox_messages (thread_id, workspace_id, mailbox_id, direction, message_id, reply_class, occurred_at) + VALUES ($1, $2, $3, 'inbound', $4, $5, now())`, + thread, f.ws, mailbox, "", replyClass); err != nil { + t.Fatalf("message: %v", err) + } +} + +func (f branchFixture) enrollment(t *testing.T) gen.SequenceEnrollment { + t.Helper() + e, err := f.q.GetEnrollment(context.Background(), gen.GetEnrollmentParams{ID: uuid.MustParse(f.eid), WorkspaceID: f.ws}) + if err != nil { + t.Fatalf("enrollment: %v", err) + } + return e +} + +func (f branchFixture) subjects() []string { + out := make([]string, len(f.snd.sent)) + for i, m := range f.snd.sent { + out[i] = m.Subject + } + return out +} + +// requireSent asserts the exact sequence of steps the contact has received. +func (f branchFixture) requireSent(t *testing.T, want ...string) { + t.Helper() + got := f.subjects() + if len(got) != len(want) { + t.Fatalf("sent %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("sent %v, want %v", got, want) + } + } +} + +// A human open inside the window routes YES — step 1 → step 3, never step 2 — +// and the enrollment completes on step 3. +func TestBranchOpenedRoutesYes(t *testing.T) { + f, done := seedBranchCampaign(t) + defer done() + f.branch(t, f.steps[0], "opened", 2, &f.steps[2], &f.steps[1]) + + f.advance(t) + f.requireSent(t, "S1") + f.track(t, 1, "open", false) + + f.due(t) + f.advance(t) + f.requireSent(t, "S1", "S3") + if e := f.enrollment(t); e.Status != "completed" || e.CurrentStep != 3 { + t.Fatalf("enrollment = %s at %d, want completed at 3", e.Status, e.CurrentStep) + } +} + +// A MACHINE open (a proxy prefetch) is not an open: the branch keeps waiting, +// and when the window closes it routes NO (docs/security.md invariant 82). +func TestBranchMachineOpenDoesNotCount(t *testing.T) { + f, done := seedBranchCampaign(t) + defer done() + f.branch(t, f.steps[0], "opened", 1, &f.steps[2], &f.steps[1]) + + f.advance(t) + f.track(t, 1, "open", true) + + f.due(t) + f.advance(t) + f.requireSent(t, "S1") + e := f.enrollment(t) + if e.Status != "active" || !e.NextDueAt.Valid || !e.NextDueAt.Time.After(time.Now()) { + t.Fatalf("a pending condition must park the enrollment in the future, got %s due=%v", e.Status, e.NextDueAt) + } + if e.AwaitingConditionStep == nil || *e.AwaitingConditionStep != 1 { + t.Fatalf("awaiting_condition_step = %v, want 1", e.AwaitingConditionStep) + } + // Postgres keeps microseconds; the enqueued instant carries nanoseconds. + if at, ok := f.enq.at[f.eid]; !ok || at.Sub(e.NextDueAt.Time).Abs() > time.Millisecond { + t.Fatalf("recheck enqueued at %v, stamped %v — they must agree", at, e.NextDueAt.Time) + } + + f.ageLastSend(t, 25*time.Hour) + f.advance(t) + f.requireSent(t, "S1", "S2") +} + +// Replies span both legs: the step went out as a sends row, the answer is an +// inbox_messages row. An out-of-office is not a reply; a human one is, and it +// routes YES without waiting out the window. +func TestBranchRepliedCountsHumanRepliesOnly(t *testing.T) { + f, done := seedBranchCampaign(t) + defer done() + f.branch(t, f.steps[0], "replied", 3, &f.steps[2], &f.steps[1]) + + f.advance(t) + f.reply(t, "out_of_office") + f.due(t) + f.advance(t) + f.requireSent(t, "S1") + + f.reply(t, "neutral") + f.due(t) + f.advance(t) + f.requireSent(t, "S1", "S3") +} + +// A reply that does not stop the enrollment nudges one waiting on a reply +// condition to now, so the next sweep routes it; an automated reply must not +// (it is the kind that DEFERS an enrollment, and pulling it forward would undo +// the deferral). +func TestBranchReplyNudgesAwaitingEnrollment(t *testing.T) { + f, done := seedBranchCampaign(t) + defer done() + ctx := context.Background() + f.branch(t, f.steps[0], "replied", 3, &f.steps[2], nil) + + f.advance(t) + f.due(t) + f.advance(t) // parks until the next recheck + parked := f.enrollment(t).NextDueAt.Time + if !parked.After(time.Now()) { + t.Fatalf("precondition: enrollment should be parked, due %v", parked) + } + + if err := f.core.RecordReplyClass(ctx, f.eid, f.ws.String(), "out_of_office", "rules", 1); err != nil { + t.Fatal(err) + } + if got := f.enrollment(t).NextDueAt.Time; !got.Equal(parked) { + t.Fatalf("an automated reply moved the due time %v -> %v", parked, got) + } + + if err := f.core.RecordReplyClass(ctx, f.eid, f.ws.String(), "neutral", "rules", 1); err != nil { + t.Fatal(err) + } + // A minute of slack for the database container's clock against this one; + // the parked due time was an hour out. + if got := f.enrollment(t).NextDueAt.Time; got.After(time.Now().Add(time.Minute)) { + t.Fatalf("a human reply must pull the due time to now, still %v", got) + } +} + +// A route to an unset exit ENDS the path: the enrollment completes without a +// send, and current_step/last_sent_at still describe the last real message. +func TestBranchRoutedEndCompletesWithoutSend(t *testing.T) { + f, done := seedBranchCampaign(t) + defer done() + // not_opened: silence routes YES (unset = end); an open would route NO. + f.branch(t, f.steps[0], "not_opened", 1, nil, &f.steps[1]) + + f.advance(t) + before := f.enrollment(t) + f.ageLastSend(t, 25*time.Hour) + f.advance(t) + + f.requireSent(t, "S1") + e := f.enrollment(t) + if e.Status != "completed" || e.CurrentStep != 1 || !e.CompletedAt.Valid || e.NextDueAt.Valid { + t.Fatalf("routed end = status %s step %d completed %v due %v", e.Status, e.CurrentStep, e.CompletedAt, e.NextDueAt) + } + // Aged by the test, not re-stamped by the finish. + if !e.LastSentAt.Time.Before(before.LastSentAt.Time) { + t.Fatalf("a routed end must not stamp last_sent_at (%v -> %v)", before.LastSentAt.Time, e.LastSentAt.Time) + } +} + +// Mid-flight rule: a branch removed while the contact waits on it returns the +// step to linear fall-through, and the successor still waits out ITS OWN delay +// from the last send — even though the campaign now has no branches at all, so +// only awaiting_condition_step routes it through the graph. +func TestBranchRemovedMidWaitHonoursSuccessorDelay(t *testing.T) { + f, done := seedBranchCampaign(t) + defer done() + ctx := context.Background() + f.branch(t, f.steps[0], "opened", 3, &f.steps[2], &f.steps[1]) + + f.advance(t) + f.due(t) + f.advance(t) // parked on the condition + f.requireSent(t, "S1") + + if err := f.q.DeleteBranch(ctx, gen.DeleteBranchParams{StepID: f.steps[0], CampaignID: f.campaignID, WorkspaceID: f.ws}); err != nil { + t.Fatal(err) + } + if _, err := f.pool.Exec(ctx, `UPDATE sequence_steps SET delay_seconds = 7200 WHERE id = $1`, f.steps[1]); err != nil { + t.Fatal(err) + } + + f.due(t) // the recheck the deleted condition had scheduled fires + f.advance(t) + f.requireSent(t, "S1") + + f.ageLastSend(t, 3*time.Hour) + f.advance(t) + f.requireSent(t, "S1", "S2") +} + +// Mid-flight rule: a branch ADDED to a step after the contact received it +// governs the next advance, with its window measured from that step's send. +func TestBranchAddedAfterSendAppliesToNextAdvance(t *testing.T) { + f, done := seedBranchCampaign(t) + defer done() + f.advance(t) // S1 sent while the campaign was linear + f.branch(t, f.steps[0], "clicked", 1, &f.steps[2], &f.steps[1]) + + f.due(t) + f.advance(t) + f.requireSent(t, "S1") // now waiting on the click + + f.track(t, 1, "click", false) + f.due(t) + f.advance(t) + f.requireSent(t, "S1", "S3") +} + +// The runtime loop backstop: a cycle written past the save-time validation (a +// direct write, here) ends the path instead of recovering-forward forever. +func TestBranchLoopBackstopEndsPath(t *testing.T) { + f, done := seedBranchCampaign(t) + defer done() + f.branch(t, f.steps[0], "always", 0, &f.steps[1], nil) + f.branch(t, f.steps[1], "always", 0, &f.steps[0], nil) // 1 -> 2 -> 1 + + f.advance(t) + f.due(t) + f.advance(t) + f.requireSent(t, "S1", "S2") + + f.due(t) + f.advance(t) + f.requireSent(t, "S1", "S2") + if e := f.enrollment(t); e.Status != "completed" || e.CurrentStep != 2 { + t.Fatalf("loop = %s at %d, want completed at 2", e.Status, e.CurrentStep) + } +} + +// Branch routing threads onto the LATEST message the contact received, not the +// highest-numbered step. On the path 1 -> 3 -> 2 -> 4 the third send (step 2) +// is the latest when step 4 goes out, while step 3 is the highest-numbered one +// sent — ordering by step_order would thread step 4 onto the wrong message. +func TestBranchThreadsOntoLatestSend(t *testing.T) { + f, done := seedBranchCampaign(t) + defer done() + s4, err := f.q.CreateStep(context.Background(), gen.CreateStepParams{ + WorkspaceID: f.ws, CampaignID: f.campaignID, StepOrder: 4, Subject: "S4", BodyText: "four", + }) + if err != nil { + t.Fatal(err) + } + f.branch(t, f.steps[0], "always", 0, &f.steps[2], nil) + f.branch(t, f.steps[2], "always", 0, &f.steps[1], nil) + f.branch(t, f.steps[1], "always", 0, &s4.ID, nil) + + for range 4 { + f.advance(t) + f.due(t) + } + f.requireSent(t, "S1", "S3", "S2", "S4") + + var step2MessageID string + if err := f.pool.QueryRow(context.Background(), `SELECT message_id FROM sends WHERE id = $1`, f.sendID(t, 2)). + Scan(&step2MessageID); err != nil { + t.Fatal(err) + } + if got := f.snd.sent[3].InReplyTo; got != step2MessageID { + t.Fatalf("step 4 In-Reply-To = %q, want step 2's %q (the latest send)", got, step2MessageID) + } +} + +// A campaign with NO branches behaves exactly as before branching existed: +// every job the control plane builds is the linear one (next step by order, the +// following step's delay, last-step on the final step), nothing is ever parked, +// and awaiting_condition_step is never written. +func TestLinearCampaignUnchangedByBranching(t *testing.T) { + f, done := seedBranchCampaign(t) + defer done() + ctx := context.Background() + if _, err := f.pool.Exec(ctx, `UPDATE sequence_steps SET delay_seconds = 60 * step_order WHERE campaign_id = $1`, f.campaignID); err != nil { + t.Fatal(err) + } + want := []struct { + order, nextDelay int + last bool + }{{1, 120, false}, {2, 180, false}, {3, 0, true}} + for _, w := range want { + job, err := f.core.GetStepSendJob(ctx, f.eid, f.ws.String()) + if err != nil { + t.Fatal(err) + } + if job.Skip || job.ConditionPending || job.StepOrder != w.order || job.NextDelaySeconds != w.nextDelay || job.LastStep != w.last { + t.Fatalf("linear job = order %d next %d last %v skip %v pending %v, want %+v", + job.StepOrder, job.NextDelaySeconds, job.LastStep, job.Skip, job.ConditionPending, w) + } + f.advance(t) + f.due(t) + } + f.requireSent(t, "S1", "S2", "S3") + e := f.enrollment(t) + if e.Status != "completed" || e.AwaitingConditionStep != nil { + t.Fatalf("linear enrollment = %s awaiting=%v", e.Status, e.AwaitingConditionStep) + } + if job, err := f.core.GetStepSendJob(ctx, f.eid, f.ws.String()); err != nil || !job.Skip { + t.Fatalf("completed enrollment job = %+v, %v", job, err) + } +} + +// The workspace pin holds on the graph path too: a foreign workspace id finds +// no enrollment, so nothing is routed, parked or finished. +func TestBranchRoutingIsWorkspacePinned(t *testing.T) { + f, done := seedBranchCampaign(t) + defer done() + f.branch(t, f.steps[0], "always", 0, nil, nil) + if _, err := f.core.GetStepSendJob(context.Background(), f.eid, uuid.NewString()); err == nil { + t.Fatal("a foreign workspace must not resolve the enrollment") + } + if e := f.enrollment(t); e.Status != "active" || e.CurrentStep != 0 { + t.Fatalf("foreign-workspace advance touched the enrollment: %s at %d", e.Status, e.CurrentStep) + } +} diff --git a/internal/worker/sequence/branchwait_test.go b/internal/worker/sequence/branchwait_test.go new file mode 100644 index 00000000..926ae1b5 --- /dev/null +++ b/internal/worker/sequence/branchwait_test.go @@ -0,0 +1,49 @@ +package sequence + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/inroad/inroad/internal/coreapi" +) + +// A pending branch condition schedules the next look at exactly RecheckAt and +// does nothing else: no claim, no send, no stop. The job also carries Skip (for +// workers that predate ConditionPending), so this proves ConditionPending is +// checked first — with Skip winning, nothing would be enqueued. +func TestAdvanceConditionPendingSchedulesRecheck(t *testing.T) { + at := time.Date(2026, 9, 24, 14, 3, 7, 0, time.UTC) + core := &stubCore{job: coreapi.StepSendJob{ConditionPending: true, RecheckAt: at, Skip: true}} + snd, enq := &fakeSender{}, &fakeEnq{} + if err := run(t, core, snd, enq); err != nil { + t.Fatal(err) + } + if !enq.atCalled || !enq.at.Equal(at) { + t.Fatalf("recheck enqueued=%v at=%v, want %v", enq.atCalled, enq.at, at) + } + if snd.called() || core.claimCalls != 0 || core.stopped != "" || core.finalized != nil || enq.inCalled { + t.Fatal("a pending condition must not claim, send, stop or back off") + } +} + +// An enqueue failure is returned so asynq retries: the control plane already +// stamped next_due_at, so even a lost retry is re-driven by the sweeper, but +// swallowing the error would hide a Redis outage. +func TestAdvanceConditionPendingEnqueueErrorIsReturned(t *testing.T) { + core := &stubCore{job: coreapi.StepSendJob{ConditionPending: true, RecheckAt: time.Now(), Skip: true}} + enq := &failingAtEnq{err: errors.New("redis down")} + if err := run(t, core, &fakeSender{}, enq); err == nil { + t.Fatal("an enqueue failure must surface") + } +} + +type failingAtEnq struct { + fakeEnq + err error +} + +func (f *failingAtEnq) EnqueueAdvanceAt(context.Context, string, string, time.Time) error { + return f.err +} diff --git a/web/src/store/api.ts b/web/src/store/api.ts index c8509107..154c5cf0 100644 --- a/web/src/store/api.ts +++ b/web/src/store/api.ts @@ -994,6 +994,31 @@ const injectedRtkApi = api.injectEndpoints({ body: queryArg.reorderStepsRequest, }), }), + getCampaignGraph: build.query< + GetCampaignGraphApiResponse, + GetCampaignGraphApiArg + >({ + query: (queryArg) => ({ url: `/campaigns/${queryArg.id}/graph` }), + }), + setStepBranch: build.mutation< + SetStepBranchApiResponse, + SetStepBranchApiArg + >({ + query: (queryArg) => ({ + url: `/campaigns/${queryArg.id}/steps/${queryArg.stepId}/branch`, + method: "PUT", + body: queryArg.stepBranchRequest, + }), + }), + deleteStepBranch: build.mutation< + DeleteStepBranchApiResponse, + DeleteStepBranchApiArg + >({ + query: (queryArg) => ({ + url: `/campaigns/${queryArg.id}/steps/${queryArg.stepId}/branch`, + method: "DELETE", + }), + }), launchCampaign: build.mutation< LaunchCampaignApiResponse, LaunchCampaignApiArg @@ -2506,6 +2531,23 @@ export type ReorderStepsApiArg = { id: string; reorderStepsRequest: ReorderStepsRequest; }; +export type GetCampaignGraphApiResponse = + /** status 200 The graph */ CampaignGraph; +export type GetCampaignGraphApiArg = { + id: string; +}; +export type SetStepBranchApiResponse = + /** status 200 The saved branch */ StepBranch; +export type SetStepBranchApiArg = { + id: string; + stepId: string; + stepBranchRequest: StepBranchRequest; +}; +export type DeleteStepBranchApiResponse = unknown; +export type DeleteStepBranchApiArg = { + id: string; + stepId: string; +}; export type LaunchCampaignApiResponse = /** status 200 Enrollment + queue counts */ { queued?: number; @@ -4375,6 +4417,20 @@ export type StepRequest = { body_text?: string; body_html?: string; }; +export type BranchValidationError = { + /** Human-readable message */ + error: string; + code: + | "invalid_condition" + | "invalid_within_days" + | "invalid_reply_label" + | "no_exit_not_allowed" + | "unknown_step" + | "unknown_target" + | "cycle"; + /** code cycle only: the steps on the loop, in path order */ + step_ids?: string[]; +}; export type StepVariant = { id: string; step_id: string; @@ -4401,6 +4457,47 @@ export type ReorderStepsRequest = { /** the FULL ordered list of the campaign's step ids, in the desired order */ step_ids: string[]; }; +export type StepBranchCondition = + "always" | "opened" | "clicked" | "replied" | "not_opened" | "not_replied"; +export type StepBranch = { + /** The step this branch routes out of */ + step_id: string; + condition: StepBranchCondition; + /** Evaluation window in days after the step's send; null exactly when condition is always */ + within_days: number | null; + /** replied / not_replied only: count only replies classified with this reply label key */ + reply_label_key: string | null; + /** Where a true condition (or always) goes; null ends the path */ + yes_step_id: string | null; + /** Where a false condition goes; null ends the path; always null for always */ + no_step_id: string | null; + updated_at: string; +}; +export type CampaignGraphNode = { + step_id: string; + step_order: number; + /** The next step by step_order - where this step goes when branch is null. Null for the last step. Present even when a branch overrides it. */ + default_next_step_id: string | null; + /** The step's branch, or null for linear fall-through to default_next_step_id. */ + branch: StepBranch | null; +}; +export type CampaignGraph = { + campaign_id: string; + /** The first step (lowest step_order) every enrollment starts at; null for a campaign with no steps */ + entry_step_id: string | null; + nodes: CampaignGraphNode[]; +}; +export type StepBranchRequest = { + condition: StepBranchCondition; + /** Required for every condition except always; must be absent or null for always */ + within_days?: number | null; + /** Optional, replied / not_replied only; must name a reply label key in the workspace. Empty string is treated as null. */ + reply_label_key?: string | null; + /** A step of the same campaign */ + yes_step_id?: string | null; + /** As yes_step_id; must be null or absent for always */ + no_step_id?: string | null; +}; export type CampaignPreflightCheck = { /** `personalization_tokens` FAILS (does not warn) when a step contains a `{{...}}` placeholder nothing will substitute, which is harsher than the neighbouring content checks on purpose: an empty body is visible the moment an operator looks at it, whereas a bad token produces an email that looks fine in the editor and arrives reading "Hi {{firstname}}" or "Hi ,". A token nothing resolves is always a typo or a since-archived field, never an intent. */ id: @@ -5351,6 +5448,9 @@ export const { useDeleteStepVariantMutation, useSetStepBaseWeightMutation, useReorderStepsMutation, + useGetCampaignGraphQuery, + useSetStepBranchMutation, + useDeleteStepBranchMutation, useLaunchCampaignMutation, useGetCampaignPreflightQuery, useTestSendCampaignMutation, From aadd279d5764eae47e7a3f0a7beb7d0ef358d503 Mon Sep 17 00:00:00 2001 From: Ahmustufa Date: Wed, 23 Sep 2026 19:40:23 +0500 Subject: [PATCH 2/9] fix(api): quote the yes_step_id description so the contract lints Co-Authored-By: Claude Opus 5.5 (1M context) --- api/openapi.yaml | 2 +- web/src/store/api.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 7c9ac8fc..f5c83026 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -5169,7 +5169,7 @@ components: condition: { $ref: '#/components/schemas/StepBranchCondition' } within_days: { type: integer, minimum: 1, maximum: 90, nullable: true, description: Required for every condition except always; must be absent or null for always } reply_label_key: { type: string, nullable: true, description: "Optional, replied / not_replied only; must name a reply label key in the workspace. Empty string is treated as null." } - yes_step_id: { type: string, format: uuid, nullable: true, description: A step of the same campaign, not this step; null or absent ends the path } + yes_step_id: { type: string, format: uuid, nullable: true, description: "A step of the same campaign, not this step; null or absent ends the path" } no_step_id: { type: string, format: uuid, nullable: true, description: As yes_step_id; must be null or absent for always } CampaignGraphNode: type: object diff --git a/web/src/store/api.ts b/web/src/store/api.ts index 154c5cf0..ef43d8d1 100644 --- a/web/src/store/api.ts +++ b/web/src/store/api.ts @@ -4493,7 +4493,7 @@ export type StepBranchRequest = { within_days?: number | null; /** Optional, replied / not_replied only; must name a reply label key in the workspace. Empty string is treated as null. */ reply_label_key?: string | null; - /** A step of the same campaign */ + /** A step of the same campaign, not this step; null or absent ends the path */ yes_step_id?: string | null; /** As yes_step_id; must be null or absent for always */ no_step_id?: string | null; From b39f88d72a6c3b94e784a05127661aac0eebed40 Mon Sep 17 00:00:00 2001 From: Ahmustufa Date: Wed, 23 Sep 2026 20:06:14 +0500 Subject: [PATCH 3/9] fix(sequences): branching review and security fixes Reply branches (product decision: stop-on-reply stays, so labels win): - A reply branch on a label that stops the sequence is refused at save (400 reply_label_stops_sequence). The API says every default human label stops, so a default-label "replied" branch never fires. The replacement tests go through the real poll -> classify -> dispatch path. - A reply nudge only pulls forward a due time that a condition wait set, never an out-of-office deferral. Routing: - The loop backstop compares against the current step's own send row, not enrollment.last_sent_at, which a recover-forward re-stamps. - Paused and done campaigns neither finish nor park enrollments; the status gate now runs before routing. - opened/clicked/not_opened are refused without tracking or an HTML body (400 tracking_required). - Condition waits no longer count as "deferred" sends. - Only a real miss is a 404. - LatestSentForContact is workspace-pinned. - New test: a routed send still honours suppression. Migration: the inbox_threads index is its own single-statement CONCURRENTLY migration (asserted indisvalid on a scratch DB), so the branching migration no longer blocks the send path. Docs: Postgres 15+ minimum; mid-flight edit rules corrected; invariant 82 notes that reply evidence is thread-scoped. Co-Authored-By: Claude Opus 5.5 (1M context) --- api/openapi.yaml | 53 +++-- docs/src/content/docs/deploy/aws-terraform.md | 2 +- .../src/content/docs/deploy/docker-compose.md | 2 +- .../content/docs/deploy/kubernetes-helm.md | 4 + docs/src/content/docs/security.md | 17 +- internal/app/sequencestep/branch.go | 144 +++++++++-- .../sequencestep/branch_integration_test.go | 54 ++++- internal/app/sequencestep/branch_test.go | 111 ++++++++- internal/app/sequencestep/branchstore.go | 25 +- internal/app/sequencestep/service.go | 10 +- internal/coreapi/coreapi.go | 10 +- internal/coreapi/inprocess/branchroute.go | 62 +++-- internal/coreapi/inprocess/stepsendjob.go | 50 ++-- internal/coreapi/remote/jobs_test.go | 30 +++ .../db/branchmigrations_integration_test.go | 89 +++++++ internal/platform/db/gen/enrollment.sql.go | 7 + internal/platform/db/gen/stepbranch.sql.go | 46 +++- internal/platform/db/gen/stepsend.sql.go | 13 +- ...0923110214_sequence_step_branches.down.sql | 2 - ...260923110214_sequence_step_branches.up.sql | 13 +- ...ox_threads_campaign_contact_index.down.sql | 2 + ...nbox_threads_campaign_contact_index.up.sql | 18 ++ internal/platform/db/queries/enrollment.sql | 7 + internal/platform/db/queries/stepbranch.sql | 21 +- internal/platform/db/queries/stepsend.sql | 6 +- internal/platform/db/tenancyqueries_test.go | 3 +- internal/platform/seqgraph/seqgraph.go | 14 +- .../inbox/branching_integration_test.go | 224 ++++++++++++++++++ internal/worker/sequence/advance.go | 14 +- .../sequence/branching_integration_test.go | 118 ++++----- internal/worker/sequence/branchwait_test.go | 17 ++ web/src/store/api.ts | 6 +- 32 files changed, 991 insertions(+), 203 deletions(-) create mode 100644 internal/platform/db/branchmigrations_integration_test.go create mode 100644 internal/platform/db/migrations/20260923144758_inbox_threads_campaign_contact_index.down.sql create mode 100644 internal/platform/db/migrations/20260923144758_inbox_threads_campaign_contact_index.up.sql create mode 100644 internal/worker/inbox/branching_integration_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index f5c83026..1d5bbb92 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2245,7 +2245,7 @@ paths: '404': { description: Campaign or step not found } '409': { description: Campaign not draft (reorder is a structural edit) } '422': - description: The new order's fall-through edges would close a loop through a branch (code cycle) + description: "The new order's fall-through edges would close a loop through a branch (code cycle)" content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } } /campaigns/{id}/graph: get: @@ -2268,7 +2268,7 @@ paths: '200': { description: The graph, content: { application/json: { schema: { $ref: '#/components/schemas/CampaignGraph' } } } } '400': { description: Campaign id is not a uuid } '401': { description: Unauthorized } - '403': { description: Insufficient scope (campaigns:read) } + '403': { description: "Insufficient scope (campaigns:read)" } '404': { description: Campaign not found } /campaigns/{id}/steps/{stepId}/branch: parameters: @@ -2291,14 +2291,25 @@ paths: never affected. - Conditions are evaluated against THIS step's send: opened / clicked - count HUMAN tracking events only (machine prefetches are ignored); - replied / not_replied count inbound replies from the contact on this - campaign, excluding automated ones (out-of-office, auto-reply) unless - reply_label_key names one explicitly. A reply whose label stops the - enrollment (the default for every human label) still stops it - a - branch never overrides a reply label's automation - so a replied branch - acts on replies whose label has stops_enrollment=false. + Conditions are evaluated against THIS step's send. opened / clicked / + not_opened count HUMAN tracking events only (machine prefetches are + ignored), so they are refused with 400 (code tracking_required) unless + the campaign has tracking on and the step and every one of its variants + has an HTML body. + + + replied / not_replied only ever route on replies whose reply label does + NOT stop the sequence. Reply labels decide what a reply does, and a + branch never overrides them: a reply whose label has + stops_enrollment=true - which by default is EVERY human label + (Interested, Not interested, Neutral, Unclassified, Unsubscribe) - + stops the sequence the moment it arrives, before any branch is + consulted. So with the default labels a replied branch never fires; to + route on replies, turn stops_enrollment off on the label (or create one + that does not stop) and name it in reply_label_key. Naming a stopping + label is refused with 400 (code reply_label_stops_sequence). Without a + label, replied counts any non-automated reply that leaves the sequence + running; out-of-office and auto-replies count only when named. Refused with 422 (code cycle) when the result would let any path revisit @@ -2311,13 +2322,13 @@ paths: responses: '200': { description: The saved branch, content: { application/json: { schema: { $ref: '#/components/schemas/StepBranch' } } } } '400': - description: "Malformed branch (codes invalid_condition, invalid_within_days, invalid_reply_label, no_exit_not_allowed; cycle for an exit to the step itself). Invalid json or path ids return a plain {error} body." + description: "Malformed or unusable branch (codes invalid_condition, invalid_within_days, invalid_reply_label, reply_label_stops_sequence, tracking_required, no_exit_not_allowed; cycle for an exit to the step itself). Invalid json or path ids return a plain {error} body." content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } } '401': { description: Unauthorized } - '403': { description: Insufficient scope (campaigns:write) } + '403': { description: "Insufficient scope (campaigns:write)" } '404': { description: Campaign or step not found } '422': - description: The graph refuses the edit (codes cycle, unknown_target) + description: "The graph refuses the edit (codes cycle, unknown_target)" content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } } delete: operationId: deleteStepBranch @@ -2332,7 +2343,7 @@ paths: responses: '204': { description: Removed (or there was none) } '401': { description: Unauthorized } - '403': { description: Insufficient scope (campaigns:write) } + '403': { description: "Insufficient scope (campaigns:write)" } '404': { description: Campaign or step not found } '422': description: The restored fall-through would close a loop (code cycle) @@ -5158,7 +5169,7 @@ components: step_id: { type: string, format: uuid, description: The step this branch routes out of } condition: { $ref: '#/components/schemas/StepBranchCondition' } within_days: { type: integer, minimum: 1, maximum: 90, nullable: true, description: "Evaluation window in days after the step's send; null exactly when condition is always" } - reply_label_key: { type: string, nullable: true, description: "replied / not_replied only: count only replies classified with this reply label key" } + reply_label_key: { type: string, nullable: true, description: "replied / not_replied only: count only replies classified with this reply label key (always a label that does not stop the sequence)" } yes_step_id: { type: string, format: uuid, nullable: true, description: "Where a true condition (or always) goes; null ends the path" } no_step_id: { type: string, format: uuid, nullable: true, description: "Where a false condition goes; null ends the path; always null for always" } updated_at: { type: string, format: date-time } @@ -5167,10 +5178,10 @@ components: required: [condition] properties: condition: { $ref: '#/components/schemas/StepBranchCondition' } - within_days: { type: integer, minimum: 1, maximum: 90, nullable: true, description: Required for every condition except always; must be absent or null for always } - reply_label_key: { type: string, nullable: true, description: "Optional, replied / not_replied only; must name a reply label key in the workspace. Empty string is treated as null." } + within_days: { type: integer, minimum: 1, maximum: 90, nullable: true, description: "Required for every condition except always; must be absent or null for always" } + reply_label_key: { type: string, nullable: true, description: "Optional, replied / not_replied only. Must name a reply label key in the workspace whose stops_enrollment is false (a stopping label ends the sequence before any branch runs). Empty string is treated as null." } yes_step_id: { type: string, format: uuid, nullable: true, description: "A step of the same campaign, not this step; null or absent ends the path" } - no_step_id: { type: string, format: uuid, nullable: true, description: As yes_step_id; must be null or absent for always } + no_step_id: { type: string, format: uuid, nullable: true, description: "As yes_step_id; must be null or absent for always" } CampaignGraphNode: type: object required: [step_id, step_order, default_next_step_id, branch] @@ -5182,13 +5193,13 @@ components: allOf: [{ $ref: '#/components/schemas/StepBranch' }] type: object nullable: true - description: The step's branch, or null for linear fall-through to default_next_step_id. + description: "The step's branch, or null for linear fall-through to default_next_step_id." CampaignGraph: type: object required: [campaign_id, entry_step_id, nodes] properties: campaign_id: { type: string, format: uuid } - entry_step_id: { type: string, format: uuid, nullable: true, description: The first step (lowest step_order) every enrollment starts at; null for a campaign with no steps } + entry_step_id: { type: string, format: uuid, nullable: true, description: "The first step (lowest step_order) every enrollment starts at; null for a campaign with no steps" } nodes: { type: array, items: { $ref: '#/components/schemas/CampaignGraphNode' } } BranchValidationError: type: object @@ -5197,7 +5208,7 @@ components: error: { type: string, description: Human-readable message } code: type: string - enum: [invalid_condition, invalid_within_days, invalid_reply_label, no_exit_not_allowed, unknown_step, unknown_target, cycle] + enum: [invalid_condition, invalid_within_days, invalid_reply_label, reply_label_stops_sequence, tracking_required, no_exit_not_allowed, unknown_step, unknown_target, cycle] step_ids: type: array items: { type: string, format: uuid } diff --git a/docs/src/content/docs/deploy/aws-terraform.md b/docs/src/content/docs/deploy/aws-terraform.md index bdc5ec81..2cbb4cd9 100644 --- a/docs/src/content/docs/deploy/aws-terraform.md +++ b/docs/src/content/docs/deploy/aws-terraform.md @@ -38,7 +38,7 @@ It is not, by itself, a multi-IP sending fleet. ## Architecture Infrastructure - **VPC Subnets:** Public, Private App, and Private Database subnets across 2 Availability Zones with NAT Gateways. -- **Managed Database:** Amazon RDS PostgreSQL 16 (Multi-AZ encrypted). +- **Managed Database:** Amazon RDS PostgreSQL 16 (Multi-AZ encrypted). Keep `engine_version` at 15 or above: PostgreSQL **15 or newer is required**; 16 is what every bundled deployment runs and what CI tests against. The schema uses column-list `ON DELETE SET NULL (column)` foreign keys (the conditional-branching migration, `20260923110214_sequence_step_branches`), which PostgreSQL 14 and older reject, so migrations stop there on an older server. - **In-Memory Cache:** Amazon ElastiCache for Redis cluster. - **Container Compute:** AWS ECS Fargate Task Definitions & Services for API (`cmd/inroad`) and Worker (`cmd/worker`). - **Load Balancing:** AWS Application Load Balancer (ALB) with HTTPS listener and `/healthz` health checks. diff --git a/docs/src/content/docs/deploy/docker-compose.md b/docs/src/content/docs/deploy/docker-compose.md index c45a4a1e..f697b79f 100644 --- a/docs/src/content/docs/deploy/docker-compose.md +++ b/docs/src/content/docs/deploy/docker-compose.md @@ -29,7 +29,7 @@ volume, not in the container's environment). The root `docker-compose.yml` includes 7 services: - `init-secrets`: One-shot service that generates a real random `INROAD_JWT_SECRET` and `INROAD_MASTER_KEY` into a Docker volume on first boot, so a bare `docker compose up` never runs on fixed, publicly-known secrets. Set both explicitly in the environment (or a `.env` file) to override — required for any multi-host deployment, since the generated file lives on a volume local to this host. -- `postgres`: PostgreSQL 16 database. +- `postgres`: PostgreSQL 16 database. PostgreSQL **15 or newer is required**; 16 is what every bundled deployment runs and what CI tests against. The schema uses column-list `ON DELETE SET NULL (column)` foreign keys (the conditional-branching migration, `20260923110214_sequence_step_branches`), which PostgreSQL 14 and older reject, so migrations stop there on an older server. - `redis`: Redis 7 in-memory queue & cache. - `migrate`: Automatic schema migration service; API and Worker wait for it to complete. - `api`: Control plane REST API server (`cmd/inroad`). diff --git a/docs/src/content/docs/deploy/kubernetes-helm.md b/docs/src/content/docs/deploy/kubernetes-helm.md index 0bd65bb8..2cafb083 100644 --- a/docs/src/content/docs/deploy/kubernetes-helm.md +++ b/docs/src/content/docs/deploy/kubernetes-helm.md @@ -19,6 +19,10 @@ helm upgrade --install inroad ./deploy/helm/inroad \ --set secrets.masterKey="$(openssl rand -base64 32)" ``` +## Database requirement + +If you point the chart at an existing or managed Postgres rather than the one it deploys, check the version first. PostgreSQL **15 or newer is required**; 16 is what every bundled deployment runs and what CI tests against. The schema uses column-list `ON DELETE SET NULL (column)` foreign keys (the conditional-branching migration, `20260923110214_sequence_step_branches`), which PostgreSQL 14 and older reject, so migrations stop there on an older server. + ## Chart Components - **`deployment-api.yaml`:** API server pods with HTTP liveness/readiness probes. diff --git a/docs/src/content/docs/security.md b/docs/src/content/docs/security.md index aab390cf..7f7e087f 100644 --- a/docs/src/content/docs/security.md +++ b/docs/src/content/docs/security.md @@ -2235,6 +2235,17 @@ write history that never happened. `created_at` (when WE ingested it), never the sender-controlled `Date` header, and excludes automated labels unless one is named explicitly. + **Reply evidence is thread-scoped, not sender-verified.** A reply counts + because the inbox poller matched it to one of this campaign's sends by its + In-Reply-To/References headers and stored it on the enrollment's campaign + + contact thread — the same matching MarkReplied uses. Nothing proves the + CONTACT wrote it: anyone who knows a real Message-ID of a send can place a + message in that thread (the within-workspace spoofing gap listed under + Deferred). The blast radius is the same as that gap's and no wider: it can + route one enrollment of the workspace down a branch it could already be + routed down, and it cannot reach another tenant, suppress anything, or make + a stopping label's reply do anything but stop. + **Tenancy.** Every branch read and write is `workspace_id`-pinned, and every step reference (source and both exits) is a composite FK on `(id, campaign_id)` with `(campaign_id, workspace_id)` pinned to `campaigns`, @@ -2247,7 +2258,11 @@ write history that never happened. **A branch never overrides reply-label automation.** A reply whose label stops the enrollment still stops it; compliance dispatch (invariants 20, 45) - is untouched. A branch only routes enrollments the labels leave active. + is untouched. A branch only routes enrollments the labels leave active, and + the save path refuses a reply branch that names a stopping label + (`reply_label_stops_sequence`), since it could never fire. A paused, draft + or done campaign's enrollments are not routed at all: the not-running gate + runs BEFORE routing, so a hold never finishes or parks an enrollment. ## Deferred (documented, not yet built) - Datacenter/cloud IP ranges as a refreshed table (AWS/GCP/Azure publish diff --git a/internal/app/sequencestep/branch.go b/internal/app/sequencestep/branch.go index e32e0b11..8683fc97 100644 --- a/internal/app/sequencestep/branch.go +++ b/internal/app/sequencestep/branch.go @@ -2,10 +2,12 @@ package sequencestep import ( "context" + "errors" "fmt" "slices" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/inroad/inroad/internal/platform/db/gen" "github.com/inroad/inroad/internal/platform/seqgraph" @@ -18,10 +20,24 @@ type Graph struct { Branches []gen.SequenceStepBranch } +// requireCampaign resolves the campaign in the workspace. Only a genuine miss +// is "campaign not found" (404); any other failure is a server error and is +// returned as one, so a database outage is not reported to the client as a +// campaign that does not exist. +func (s *Service) requireCampaign(ctx context.Context, ws, campaignID uuid.UUID) error { + if _, err := s.checker.CampaignStatus(ctx, ws, campaignID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrCampaignNotFound + } + return fmt.Errorf("campaign status: %w", err) + } + return nil +} + // Graph returns the campaign's routing graph. Reading is allowed on any status. func (s *Service) Graph(ctx context.Context, ws, campaignID uuid.UUID) (Graph, error) { - if _, err := s.checker.CampaignStatus(ctx, ws, campaignID); err != nil { - return Graph{}, ErrCampaignNotFound + if err := s.requireCampaign(ctx, ws, campaignID); err != nil { + return Graph{}, err } steps, err := s.store.List(ctx, ws, campaignID) if err != nil { @@ -42,16 +58,17 @@ func (s *Service) Graph(ctx context.Context, ws, campaignID uuid.UUID) (Graph, e // decision made after it commits and none made before — which is the same // live-reference contract a body edit has. // -// Validation happens twice, on purpose. The shape and label checks here give a -// precise error before any write; the graph check then runs AGAIN inside the -// store's transaction, under the campaign's graph lock, against the graph that -// is actually being committed — the only place a loop formed by two concurrent -// edits can be caught. +// Validation happens twice, on purpose. The shape, label and evidence checks +// here give a precise error before any write; the graph check then runs AGAIN +// inside the store's transaction, under the campaign's graph lock, against the +// graph that is actually being committed — the only place a loop formed by two +// concurrent edits can be caught. func (s *Service) SetBranch(ctx context.Context, ws, campaignID uuid.UUID, in BranchInput) (gen.SequenceStepBranch, error) { - if _, err := s.checker.CampaignStatus(ctx, ws, campaignID); err != nil { - return gen.SequenceStepBranch{}, ErrCampaignNotFound + if err := s.requireCampaign(ctx, ws, campaignID); err != nil { + return gen.SequenceStepBranch{}, err } - if err := s.assertStepInCampaign(ctx, ws, campaignID, in.StepID); err != nil { + step, err := s.stepInCampaign(ctx, ws, campaignID, in.StepID) + if err != nil { return gen.SequenceStepBranch{}, err } // The model reads an absent window as 0, which is exactly right for a real @@ -62,20 +79,15 @@ func (s *Service) SetBranch(ctx context.Context, ws, campaignID uuid.UUID, in Br Code: seqgraph.CodeInvalidWithinDays, Msg: "within_days is not allowed on an 'always' branch", } } - if err := branchModel(in).ValidateShape(); err != nil { + model := branchModel(in) + if err := model.ValidateShape(); err != nil { return gen.SequenceStepBranch{}, err } - if in.ReplyLabelKey != nil { - ok, err := s.branches.ReplyLabelExists(ctx, ws, *in.ReplyLabelKey) - if err != nil { - return gen.SequenceStepBranch{}, fmt.Errorf("check reply label: %w", err) - } - if !ok { - return gen.SequenceStepBranch{}, &seqgraph.ShapeError{ - Code: seqgraph.CodeLabelNotAllowed, - Msg: fmt.Sprintf("reply label %q does not exist in this workspace", *in.ReplyLabelKey), - } - } + if err := s.checkReplyLabel(ctx, ws, in.ReplyLabelKey); err != nil { + return gen.SequenceStepBranch{}, err + } + if err := s.checkTrackable(ctx, ws, campaignID, step, model.Condition.Signal()); err != nil { + return gen.SequenceStepBranch{}, err } // Refuse an exit to a step outside the campaign BEFORE writing: the // composite FK would refuse it too, but as a constraint violation rather than @@ -93,14 +105,96 @@ func (s *Service) SetBranch(ctx context.Context, ws, campaignID uuid.UUID, in Br return s.branches.UpsertBranch(ctx, ws, in, checkGraph) } +// checkReplyLabel refuses a label that could never fire a branch. +// +// Reply labels decide what a reply DOES to an enrollment, and a branch never +// overrides that (docs/security.md invariant 82). A label that stops the +// enrollment — the default for every builtin human label — ends the sequence +// the moment such a reply arrives, before any branch is consulted, so a branch +// naming it would silently never route anyone. Refusing it at save time is the +// honest answer; the operator can clear stops_enrollment on the label (or pick +// one that does not stop) if routing on it is what they want. +// +// Checked at save time only. A label edited to stop AFTER a branch names it +// does not break anything: the reply stops the sequence, which is what the label +// now says, and the branch simply never fires. +func (s *Service) checkReplyLabel(ctx context.Context, ws uuid.UUID, key *string) error { + if key == nil { + return nil + } + stops, found, err := s.branches.ReplyLabelStops(ctx, ws, *key) + if err != nil { + return fmt.Errorf("check reply label: %w", err) + } + if !found { + return &seqgraph.ShapeError{ + Code: seqgraph.CodeLabelNotAllowed, + Msg: fmt.Sprintf("reply label %q does not exist in this workspace", *key), + } + } + if stops { + return &seqgraph.ShapeError{ + Code: seqgraph.CodeLabelStopsSequence, + Msg: fmt.Sprintf("reply label %q stops the sequence, so a reply with it can never be routed; "+ + "use a label that does not stop the enrollment", *key), + } + } + return nil +} + +// checkTrackable refuses an open/click condition that has no evidence to read. +// +// Opens and clicks exist only when the campaign has tracking on AND the step +// (every copy of it — the base and each A/B variant) has an HTML body for the +// pixel and the rewritten links to live in. Without both, not one open or click +// will ever be recorded: "opened" would route every contact NO at the deadline +// and "not_opened" every contact YES, which looks like a working branch and is +// really a fixed route with a delay. +// +// Refused at save time rather than degraded, because the refusal is the only +// point where the operator can see why. Turning tracking off or editing a step +// to text-only AFTER the branch exists is not blocked — those edits belong to +// other endpoints (and, for tracking, another domain), and blocking a tracking +// toggle on account of a branch would make a privacy setting hostage to +// sequence design. Such a branch then takes the no-evidence route at its +// deadline, which is the documented behaviour. +func (s *Service) checkTrackable(ctx context.Context, ws, campaignID uuid.UUID, step gen.SequenceStep, signal seqgraph.Signal) error { + if signal != seqgraph.SignalOpen && signal != seqgraph.SignalClick { + return nil + } + enabled, err := s.branches.TrackingEnabled(ctx, ws, campaignID) + if err != nil { + return fmt.Errorf("check tracking: %w", err) + } + if !enabled { + return &seqgraph.ShapeError{ + Code: seqgraph.CodeTrackingRequired, + Msg: "open and click conditions need tracking turned on for this campaign", + } + } + variants, err := s.variants.ListForStep(ctx, ws, step.ID) + if err != nil { + return fmt.Errorf("list step variants: %w", err) + } + textOnly := step.BodyHtml == "" || + slices.ContainsFunc(variants, func(v Variant) bool { return v.BodyHTML == "" }) + if textOnly { + return &seqgraph.ShapeError{ + Code: seqgraph.CodeTrackingRequired, + Msg: "open and click conditions need an HTML body on this step and every one of its variants", + } + } + return nil +} + // DeleteBranch removes the router on one step, returning it to linear // fall-through. Idempotent. Allowed live, for the reason SetBranch is; refused // with a CycleError when the restored fall-through would close a loop. func (s *Service) DeleteBranch(ctx context.Context, ws, campaignID, stepID uuid.UUID) error { - if _, err := s.checker.CampaignStatus(ctx, ws, campaignID); err != nil { - return ErrCampaignNotFound + if err := s.requireCampaign(ctx, ws, campaignID); err != nil { + return err } - if err := s.assertStepInCampaign(ctx, ws, campaignID, stepID); err != nil { + if _, err := s.stepInCampaign(ctx, ws, campaignID, stepID); err != nil { return err } return s.branches.DeleteBranch(ctx, ws, campaignID, stepID, checkGraph) diff --git a/internal/app/sequencestep/branch_integration_test.go b/internal/app/sequencestep/branch_integration_test.go index 1bb1a27a..3768a61d 100644 --- a/internal/app/sequencestep/branch_integration_test.go +++ b/internal/app/sequencestep/branch_integration_test.go @@ -47,6 +47,11 @@ func newBranchIT(t *testing.T, label string) (branchIT, func()) { } q := gen.New(pool) ws, campaign, ids := seedThreeSteps(t, ctx, q, label) + // HTML bodies, so open/click branches have somewhere to record evidence + // (tracking_enabled defaults to true). + if _, err := pool.Exec(ctx, `UPDATE sequence_steps SET body_html = '

b

' WHERE campaign_id = $1`, campaign); err != nil { + t.Fatalf("html bodies: %v", err) + } svc := NewService(NewPgStore(pool), sqlChecker{pool: pool}, NewPgVariantStore(q), NewPgBranchStore(pool)) return branchIT{pool: pool, q: q, svc: svc, ws: ws, campaign: campaign, steps: ids}, pool.Close } @@ -74,7 +79,14 @@ func TestBranchSaveAndReadBack(t *testing.T) { defer done() ctx := context.Background() three := int32(3) - label := "positive" // seeded for every workspace by migration 000047 + // A custom label that does NOT stop the enrollment: the only kind a branch + // can route on (the builtin human labels all stop the sequence). + label := "soft_yes" + if _, err := b.pool.Exec(ctx, ` + INSERT INTO reply_labels (workspace_id, key, label, color, position, stops_enrollment) + VALUES ($1, $2, 'Soft yes', '#123456', 9, false)`, b.ws, label); err != nil { + t.Fatalf("custom label: %v", err) + } got, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{ StepID: b.steps[0], Condition: "replied", WithinDays: &three, ReplyLabelKey: &label, YesStepID: &b.steps[2], NoStepID: &b.steps[1], @@ -106,6 +118,46 @@ func TestBranchSaveAndReadBack(t *testing.T) { } } +// Against the real seeded taxonomy: every builtin human label stops the +// enrollment, so none of them can be named by a reply branch, while the +// automated ones (which leave the enrollment running) can. +func TestBranchRefusesSeededStoppingLabels(t *testing.T) { + b, done := newBranchIT(t, "Branch seeded labels") + defer done() + ctx := context.Background() + two := int32(2) + for _, key := range []string{"positive", "negative", "neutral", "unknown", "unsubscribe"} { + k := key + _, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{ + StepID: b.steps[0], Condition: "replied", WithinDays: &two, ReplyLabelKey: &k, YesStepID: &b.steps[2], + }) + if seqgraph.CodeOf(err) != seqgraph.CodeLabelStopsSequence { + t.Errorf("%s: code %q (%v), want %q", key, seqgraph.CodeOf(err), err, seqgraph.CodeLabelStopsSequence) + } + } + ooo := "out_of_office" + if _, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{ + StepID: b.steps[0], Condition: "replied", WithinDays: &two, ReplyLabelKey: &ooo, YesStepID: &b.steps[2], + }); err != nil { + t.Fatalf("an automated (non-stopping) label is routable: %v", err) + } +} + +// Tracking off on the campaign refuses an open branch against the real column. +func TestBranchOpenRefusedWhenTrackingOff(t *testing.T) { + b, done := newBranchIT(t, "Branch tracking off") + defer done() + ctx := context.Background() + if _, err := b.pool.Exec(ctx, `UPDATE campaigns SET tracking_enabled = false WHERE id = $1`, b.campaign); err != nil { + t.Fatal(err) + } + one := int32(1) + _, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "opened", WithinDays: &one}) + if seqgraph.CodeOf(err) != seqgraph.CodeTrackingRequired { + t.Fatalf("code %q (%v), want %q", seqgraph.CodeOf(err), err, seqgraph.CodeTrackingRequired) + } +} + func TestBranchCycleRefusedAndNothingWritten(t *testing.T) { b, done := newBranchIT(t, "Branch cycle") defer done() diff --git a/internal/app/sequencestep/branch_test.go b/internal/app/sequencestep/branch_test.go index 01b528d7..119c71e5 100644 --- a/internal/app/sequencestep/branch_test.go +++ b/internal/app/sequencestep/branch_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/inroad/inroad/internal/platform/db/gen" @@ -18,16 +19,25 @@ import ( type fakeBranchStore struct { steps []gen.SequenceStep branches map[uuid.UUID]gen.SequenceStepBranch - labels map[string]bool - upserts int + // labels maps a label key to whether it stops the enrollment; a missing key + // is a label that does not exist. + labels map[string]bool + // trackingOff models a campaign with tracking disabled. + trackingOff bool + upserts int } func (f *fakeBranchStore) ListBranches(context.Context, uuid.UUID, uuid.UUID) ([]gen.SequenceStepBranch, error) { return f.list(), nil } -func (f *fakeBranchStore) ReplyLabelExists(_ context.Context, _ uuid.UUID, key string) (bool, error) { - return f.labels[key], nil +func (f *fakeBranchStore) ReplyLabelStops(_ context.Context, _ uuid.UUID, key string) (stops, found bool, err error) { + stops, found = f.labels[key] + return stops, found, nil +} + +func (f *fakeBranchStore) TrackingEnabled(context.Context, uuid.UUID, uuid.UUID) (bool, error) { + return !f.trackingOff, nil } func (f *fakeBranchStore) UpsertBranch(_ context.Context, ws uuid.UUID, in BranchInput, check GraphCheck) (gen.SequenceStepBranch, error) { @@ -78,6 +88,8 @@ func (f *fakeBranchStore) list() []gen.SequenceStepBranch { type branchFixture struct { svc *Service branches *fakeBranchStore + variants *fakeVariantStore + steps *stepsByID campaign uuid.UUID ws uuid.UUID step []uuid.UUID @@ -91,13 +103,16 @@ func newBranchFixture(t *testing.T, status string) branchFixture { for i := range 3 { id := uuid.New() ids = append(ids, id) - steps = append(steps, gen.SequenceStep{ID: id, CampaignID: campaign, StepOrder: int32(i + 1)}) + steps = append(steps, gen.SequenceStep{ID: id, CampaignID: campaign, StepOrder: int32(i + 1), BodyHtml: "

hi

"}) } - bs := &fakeBranchStore{steps: steps, labels: map[string]bool{"positive": true}} + // "positive" is a builtin label (stops the enrollment); "soft_yes" is a + // custom one that does not. + bs := &fakeBranchStore{steps: steps, labels: map[string]bool{"positive": true, "soft_yes": false}} store := &stepsByID{steps: steps} + vs := &fakeVariantStore{} return branchFixture{ - svc: NewService(store, fakeChecker{status: status}, &fakeVariantStore{}, bs), - branches: bs, campaign: campaign, ws: uuid.New(), step: ids, + svc: NewService(store, fakeChecker{status: status}, vs, bs), + branches: bs, variants: vs, steps: store, campaign: campaign, ws: uuid.New(), step: ids, } } @@ -139,13 +154,86 @@ func TestSetBranchHappyPathOnRunningCampaign(t *testing.T) { func TestSetBranchRejectsMissingCampaign(t *testing.T) { f := newBranchFixture(t, "draft") - svc := NewService(&fakeStore{}, fakeChecker{err: errors.New("no rows")}, &fakeVariantStore{}, f.branches) + svc := NewService(&fakeStore{}, fakeChecker{err: pgx.ErrNoRows}, &fakeVariantStore{}, f.branches) _, err := svc.SetBranch(context.Background(), f.ws, f.campaign, BranchInput{StepID: f.step[0], Condition: "always"}) if !errors.Is(err, ErrCampaignNotFound) { t.Fatalf("want ErrCampaignNotFound, got %v", err) } } +// Only a genuine miss is a 404. A database failure looking the campaign up is +// a server error, and all three graph endpoints must say so rather than +// telling the client the campaign does not exist. +func TestGraphEndpointsDoNotReportADatabaseErrorAsNotFound(t *testing.T) { + f := newBranchFixture(t, "draft") + boom := errors.New("connection reset") + svc := NewService(f.steps, fakeChecker{err: boom}, &fakeVariantStore{}, f.branches) + ctx := context.Background() + _, gerr := svc.Graph(ctx, f.ws, f.campaign) + _, serr := svc.SetBranch(ctx, f.ws, f.campaign, BranchInput{StepID: f.step[0], Condition: "always"}) + derr := svc.DeleteBranch(ctx, f.ws, f.campaign, f.step[0]) + for name, err := range map[string]error{"Graph": gerr, "SetBranch": serr, "DeleteBranch": derr} { + if !errors.Is(err, boom) || errors.Is(err, ErrCampaignNotFound) { + t.Errorf("%s: got %v, want the wrapped database error", name, err) + } + } +} + +// A reply condition may name a label only if replies with it leave the +// enrollment running: a stopping label (every builtin human one) ends the +// sequence before any branch is consulted, so the branch could never fire. +func TestSetBranchReplyLabelMustNotStopTheSequence(t *testing.T) { + ctx := context.Background() + f := newBranchFixture(t, "running") + _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, BranchInput{ + StepID: f.step[0], Condition: "replied", WithinDays: ptr(int32(2)), ReplyLabelKey: ptr("positive"), YesStepID: &f.step[2], + }) + if seqgraph.CodeOf(err) != seqgraph.CodeLabelStopsSequence { + t.Fatalf("stopping label: code %q (%v), want %q", seqgraph.CodeOf(err), err, seqgraph.CodeLabelStopsSequence) + } + if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, BranchInput{ + StepID: f.step[0], Condition: "not_replied", WithinDays: ptr(int32(2)), ReplyLabelKey: ptr("soft_yes"), YesStepID: &f.step[2], + }); err != nil { + t.Fatalf("a non-stopping label is routable: %v", err) + } +} + +// Open and click conditions need somewhere for an open or click to be recorded: +// tracking on for the campaign, and an HTML body on the step and every variant. +// Reply conditions need neither. +func TestSetBranchOpenClickNeedTracking(t *testing.T) { + ctx := context.Background() + openIn := func(f branchFixture, cond string) BranchInput { + return BranchInput{StepID: f.step[0], Condition: cond, WithinDays: ptr(int32(1)), YesStepID: &f.step[1]} + } + + f := newBranchFixture(t, "running") + f.branches.trackingOff = true + for _, cond := range []string{"opened", "clicked", "not_opened"} { + if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, openIn(f, cond)); seqgraph.CodeOf(err) != seqgraph.CodeTrackingRequired { + t.Errorf("%s with tracking off: %v", cond, err) + } + } + if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, openIn(f, "replied")); err != nil { + t.Errorf("replied does not need tracking: %v", err) + } + + f = newBranchFixture(t, "running") + f.steps.steps[0].BodyHtml = "" + if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, openIn(f, "opened")); seqgraph.CodeOf(err) != seqgraph.CodeTrackingRequired { + t.Errorf("text-only step: %v", err) + } + + f = newBranchFixture(t, "running") + f.variants.variants = []Variant{{ID: uuid.New(), StepID: f.step[0], BodyHTML: "

b

"}, {ID: uuid.New(), StepID: f.step[0]}} + if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, openIn(f, "clicked")); seqgraph.CodeOf(err) != seqgraph.CodeTrackingRequired { + t.Errorf("a text-only variant: %v", err) + } + if f.branches.upserts != 0 { + t.Fatal("a refused open/click branch must not be written") + } +} + // A step of a different campaign (or tenant — the workspace-pinned Get simply // does not find it) is not routable through this campaign's URL. func TestSetBranchRejectsStepFromAnotherCampaign(t *testing.T) { @@ -209,6 +297,9 @@ func TestSetBranchShapeErrors(t *testing.T) { "label on an open": {func(f branchFixture) BranchInput { return BranchInput{StepID: f.step[0], Condition: "opened", WithinDays: ptr(int32(1)), ReplyLabelKey: ptr("positive")} }, seqgraph.CodeLabelNotAllowed}, + "label that stops the sequence": {func(f branchFixture) BranchInput { + return BranchInput{StepID: f.step[0], Condition: "replied", WithinDays: ptr(int32(1)), ReplyLabelKey: ptr("positive")} + }, seqgraph.CodeLabelStopsSequence}, "label that does not exist": {func(f branchFixture) BranchInput { return BranchInput{StepID: f.step[0], Condition: "replied", WithinDays: ptr(int32(1)), ReplyLabelKey: ptr("ghost")} }, seqgraph.CodeLabelNotAllowed}, @@ -289,7 +380,7 @@ func TestGraphReturnsStepsAndBranches(t *testing.T) { } func TestGraphRejectsMissingCampaign(t *testing.T) { - svc := NewService(&fakeStore{}, fakeChecker{err: errors.New("no rows")}, &fakeVariantStore{}, &fakeBranchStore{}) + svc := NewService(&fakeStore{}, fakeChecker{err: pgx.ErrNoRows}, &fakeVariantStore{}, &fakeBranchStore{}) if _, err := svc.Graph(context.Background(), uuid.New(), uuid.New()); !errors.Is(err, ErrCampaignNotFound) { t.Fatalf("want ErrCampaignNotFound, got %v", err) } diff --git a/internal/app/sequencestep/branchstore.go b/internal/app/sequencestep/branchstore.go index 42d291f2..4d5a606c 100644 --- a/internal/app/sequencestep/branchstore.go +++ b/internal/app/sequencestep/branchstore.go @@ -41,7 +41,11 @@ type BranchInput struct { // graph rules testable without a database. type BranchStore interface { ListBranches(ctx context.Context, ws, campaignID uuid.UUID) ([]gen.SequenceStepBranch, error) - ReplyLabelExists(ctx context.Context, ws uuid.UUID, key string) (bool, error) + // ReplyLabelStops reports whether the workspace's label with this key stops + // the enrollment; found=false when no such label exists. + ReplyLabelStops(ctx context.Context, ws uuid.UUID, key string) (stops, found bool, err error) + // TrackingEnabled reports whether the campaign records opens and clicks. + TrackingEnabled(ctx context.Context, ws, campaignID uuid.UUID) (bool, error) // UpsertBranch creates or replaces the router on in.StepID, then runs check // on the result, in one transaction holding the campaign's graph lock. UpsertBranch(ctx context.Context, ws uuid.UUID, in BranchInput, check GraphCheck) (gen.SequenceStepBranch, error) @@ -81,8 +85,23 @@ func (s *PgBranchStore) ListBranches(ctx context.Context, ws, campaignID uuid.UU return s.q.ListBranchesByCampaign(ctx, gen.ListBranchesByCampaignParams{CampaignID: campaignID, WorkspaceID: ws}) } -func (s *PgBranchStore) ReplyLabelExists(ctx context.Context, ws uuid.UUID, key string) (bool, error) { - return s.q.ReplyLabelKeyExists(ctx, gen.ReplyLabelKeyExistsParams{WorkspaceID: ws, Key: key}) +func (s *PgBranchStore) ReplyLabelStops(ctx context.Context, ws uuid.UUID, key string) (stops, found bool, err error) { + stops, err = s.q.ReplyLabelStopsEnrollment(ctx, gen.ReplyLabelStopsEnrollmentParams{WorkspaceID: ws, Key: key}) + if errors.Is(err, pgx.ErrNoRows) { + return false, false, nil + } + if err != nil { + return false, false, fmt.Errorf("reply label lookup: %w", err) + } + return stops, true, nil +} + +func (s *PgBranchStore) TrackingEnabled(ctx context.Context, ws, campaignID uuid.UUID) (bool, error) { + on, err := s.q.CampaignTrackingEnabled(ctx, gen.CampaignTrackingEnabledParams{ID: campaignID, WorkspaceID: ws}) + if err != nil { + return false, fmt.Errorf("campaign tracking lookup: %w", err) + } + return on, nil } func (s *PgBranchStore) UpsertBranch(ctx context.Context, ws uuid.UUID, in BranchInput, check GraphCheck) (gen.SequenceStepBranch, error) { diff --git a/internal/app/sequencestep/service.go b/internal/app/sequencestep/service.go index 105549b3..542a754f 100644 --- a/internal/app/sequencestep/service.go +++ b/internal/app/sequencestep/service.go @@ -163,9 +163,15 @@ func (s *Service) requireDraft(ctx context.Context, ws, campaignID uuid.UUID) er // campaignID; otherwise ErrNotFound (never leaks another campaign's/tenant's // step). func (s *Service) assertStepInCampaign(ctx context.Context, ws, campaignID, stepID uuid.UUID) error { + _, err := s.stepInCampaign(ctx, ws, campaignID, stepID) + return err +} + +// stepInCampaign is assertStepInCampaign for a caller that also needs the step. +func (s *Service) stepInCampaign(ctx context.Context, ws, campaignID, stepID uuid.UUID) (gen.SequenceStep, error) { st, err := s.store.Get(ctx, ws, stepID) if err != nil || st.CampaignID != campaignID { - return ErrNotFound + return gen.SequenceStep{}, ErrNotFound } - return nil + return st, nil } diff --git a/internal/coreapi/coreapi.go b/internal/coreapi/coreapi.go index d6bb0bb9..0306f17c 100644 --- a/internal/coreapi/coreapi.go +++ b/internal/coreapi/coreapi.go @@ -295,9 +295,13 @@ type Client interface { // GetStepSendJob loads everything needed to send the enrollment's next due // step (current_step+1): resolved step content, personalization vars, - // threading headers, cap gate, and decrypted transport. Read-only — it - // creates no rows, so a suppressed/capped step leaves no orphan. workspaceID - // is pinned in the SQL WHERE (defense in depth on the enrollment UUID). + // threading headers, cap gate, and decrypted transport. It creates no + // sends row (the claim does), so a suppressed/capped step leaves no orphan. + // It may write enrollment state the control plane decides on its own: a + // pool mailbox pin, and on a branched campaign a routed completion or a + // condition wait (ConditionPending). Each is guarded on status='active' and + // idempotent. workspaceID is pinned in the SQL WHERE (defense in depth on + // the enrollment UUID). GetStepSendJob(ctx context.Context, enrollmentID, workspaceID string) (StepSendJob, error) // ClaimStepSend claims one step-send for delivery (claim-before-send): the // sends row is inserted 'sending' (fresh claim), or a STALE 'sending' lease is diff --git a/internal/coreapi/inprocess/branchroute.go b/internal/coreapi/inprocess/branchroute.go index a8f30dca..b35b9b25 100644 --- a/internal/coreapi/inprocess/branchroute.go +++ b/internal/coreapi/inprocess/branchroute.go @@ -48,9 +48,21 @@ import ( // window measured from S's send (last_sent_at) — so a window that has // already elapsed decides immediately. // - A branch on S whose condition, window or exits change while the contact -// waits is evaluated with the NEW definition at the next check (at most -// conditionRecheckInterval away). A lengthened window keeps waiting; a -// shortened one decides at the next check. +// waits is evaluated with the NEW definition at the next check. That check +// is at most conditionRecheckInterval away while the condition is OPEN, but +// can be later: rechecks are snapped into the campaign's send window (a +// Friday-evening recheck lands on Monday morning), an out-of-office deferral +// that ends later wins, and once a route is decided the next check is when +// its target falls due — which may be days out. A lengthened window keeps +// waiting; a shortened one decides at the next check. Until the target is +// actually SENT, a decided route is re-derived too, so changing S's exits +// in that interval re-routes the contact to the new exit. +// - Known edge: the recover-forward window. If S's routed target T was +// delivered but the cursor advance to T failed, and S's exit is changed +// BEFORE the retry runs, the retry routes to the NEW target U and sends it +// too — the contact receives both T and U. The window is one failed +// transaction plus an asynq retry (seconds); closing it would mean storing +// the route, which the design deliberately does not do. // - A branch removed from S while the contact waits returns S to linear // fall-through, and the successor still waits out its own delay from S's // send (awaiting_condition_step is what keeps this true once the campaign @@ -266,7 +278,7 @@ func (c client) routeGraph(ctx context.Context, ws uuid.UUID, enrollmentID strin return graphRouteResult{routeDecision: d}, nil } if b.CurrentStep > 0 && g.HasBranches() { - revisit, err := c.revisits(ctx, ws, b, d.target) + revisit, err := c.revisits(ctx, ws, b, b.CurrentStep, d.target) if err != nil { return graphRouteResult{}, err } @@ -292,23 +304,43 @@ func (c client) routeGraph(ctx context.Context, ws uuid.UUID, enrollmentID strin // revisits reports whether target was already sent EARLIER on this contact's // path. Its deterministic send row existing is not enough — that is also the // recover-forward case, where target was sent but the cursor advance to it did -// not commit — so the test is whether the row predates the cursor step's own -// send: a recover-forward row is created after it, a loop's row long before. -func (c client) revisits(ctx context.Context, ws uuid.UUID, b gen.GetStepEnrollmentBundleRow, target seqgraph.Step) (bool, error) { +// not commit — so the test compares it with the CURRENT step's own send row: a +// recover-forward row for target was created after the current step's, a loop's +// row before it (the loop visited target first). +// +// Both instants are sends.created_at, the same column stamped by the same +// database clock, so there is no skew to absorb and no tolerance — a tolerance +// would blind this to the loop that matters most, zero-delay steps seconds +// apart. (enrollment.last_sent_at would be the wrong reference: a recover-forward +// re-stamps it, so it can land after a genuinely-earlier visit.) +func (c client) revisits(ctx context.Context, ws uuid.UUID, b gen.GetStepEnrollmentBundleRow, cursorOrder int32, target seqgraph.Step) (bool, error) { + targetAt, found, err := c.stepSendCreatedAt(ctx, ws, b, target.Order) + if err != nil || !found { + return false, err + } + cursorAt, found, err := c.stepSendCreatedAt(ctx, ws, b, cursorOrder) + if err != nil || !found { + // No row for the step the contact is on cannot happen past step 0 (the + // cursor only moves after a claim); with nothing to compare against, + // the claim remains the delivery guard. + return false, err + } + return targetAt.Before(cursorAt), nil +} + +// stepSendCreatedAt reads when one step's deterministic send row was created; +// found=false when the row does not exist. +func (c client) stepSendCreatedAt(ctx context.Context, ws uuid.UUID, b gen.GetStepEnrollmentBundleRow, order int32) (time.Time, bool, error) { created, err := c.q.StepSendCreatedAt(ctx, gen.StepSendCreatedAtParams{ - ID: deriveStepSendID(b.CampaignID, b.ContactID, int(target.Order)), WorkspaceID: ws, + ID: deriveStepSendID(b.CampaignID, b.ContactID, int(order)), WorkspaceID: ws, }) if errors.Is(err, pgx.ErrNoRows) { - return false, nil + return time.Time{}, false, nil } if err != nil { - return false, fmt.Errorf("step send lookup: %w", err) + return time.Time{}, false, fmt.Errorf("step send lookup: %w", err) } - // No tolerance, unlike the due checks: both instants are the DATABASE's - // now() (sends.created_at's default and the cursor advance's last_sent_at), - // so there is no clock skew to absorb — and a tolerance would blind this to - // exactly the loop that matters most, zero-delay steps a few seconds apart. - return created.Time.Before(b.LastSentAt.Time), nil + return created.Time, true, nil } // firstEvidence reads the earliest qualifying event for one condition. diff --git a/internal/coreapi/inprocess/stepsendjob.go b/internal/coreapi/inprocess/stepsendjob.go index c0332ff2..f8220d25 100644 --- a/internal/coreapi/inprocess/stepsendjob.go +++ b/internal/coreapi/inprocess/stepsendjob.go @@ -138,11 +138,13 @@ func decodeCustom(b []byte) map[string]string { // threading computes the In-Reply-To / References headers for the step about to // send. Empty for step 1. For later steps it prefers the immediately-preceding // sent message (proper chain), falling back to the stored thread root. -func (c client) threading(ctx context.Context, order int, campaignID, contactID uuid.UUID, threadRootID string) (inReplyTo, references string) { +func (c client) threading(ctx context.Context, ws uuid.UUID, order int, campaignID, contactID uuid.UUID, threadRootID string) (inReplyTo, references string) { if order <= 1 { return "", "" } - prior, err := c.q.LatestSentForContact(ctx, gen.LatestSentForContactParams{CampaignID: campaignID, ContactID: contactID}) + prior, err := c.q.LatestSentForContact(ctx, gen.LatestSentForContactParams{ + CampaignID: campaignID, ContactID: contactID, WorkspaceID: ws, + }) if err == nil && prior.MessageID != "" { return prior.MessageID, strings.TrimSpace(prior.ReferencesHeader + " " + prior.MessageID) } @@ -196,7 +198,12 @@ func (c client) newLeadLimitReached(ctx context.Context, ws, campaignID uuid.UUI } // localStepSendJob resolves the enrollment's next due step and builds the send -// job. Read-only: creates no rows. workspaceID is pinned in the SQL WHERE +// job. It creates no sends row — the claim does that — so a suppressed or +// capped step leaves no orphan. It is not strictly read-only, though: resolving +// a sender pins a pool mailbox to the enrollment, and on a branched campaign +// routing may complete the enrollment (a path that ends) or park it until a +// condition can next change (see applyRoute). Every such write is guarded on +// status='active' and idempotent. workspaceID is pinned in the SQL WHERE // (defense in depth on the unguessable enrollment UUID). func (c client) localStepSendJob(ctx context.Context, enrollmentID, workspaceID string) (coreapi.StepSendJob, error) { eid, err := uuid.Parse(enrollmentID) @@ -243,6 +250,14 @@ func (c client) localStepSendJob(ctx context.Context, enrollmentID, workspaceID route *routeDecision ) if usesGraphRouting(branches, b) { + // A campaign that is not running must not have its enrollments moved at + // all — not finished by a path that ends, nor parked by a condition — + // only held, exactly as the linear path holds them below. So on the graph + // path the gate comes BEFORE routing. (On the linear path it stays after + // GetNextStep, where it has always been, so linear behaviour is unchanged.) + if b.CampaignStatus != string(campaign.StatusRunning) { + return c.campaignPausedJob(ctx, ws, enrollmentID, b) + } routed, err := c.routeStep(ctx, ws, enrollmentID, b, branches) if err != nil { return coreapi.StepSendJob{}, err @@ -281,17 +296,7 @@ func (c client) localStepSendJob(ctx context.Context, enrollmentID, workspaceID // comes FIRST because it is already in the bundle, so it costs no query, while // campaignLimitReached runs a COUNT. if b.CampaignStatus != string(campaign.StatusRunning) { - // The schedule travels for the same reason as the daily-limit branch: a - // deferred retry SENDS as soon as it runs, so the worker has to wake inside - // the campaign's window. - sched, serr := c.loadSchedule(ctx, ws, b.CampaignID, b.Timezone) - if serr != nil { - return coreapi.StepSendJob{}, serr - } - return coreapi.StepSendJob{ - EnrollmentID: enrollmentID, WorkspaceID: ws.String(), CampaignPaused: true, - Schedule: sched, - }, nil + return c.campaignPausedJob(ctx, ws, enrollmentID, b) } // The campaign-wide daily limit, stacked on top of the per-mailbox caps: the @@ -390,7 +395,7 @@ func (c client) localStepSendJob(ctx context.Context, enrollmentID, workspaceID } } - inReplyTo, references := c.threading(ctx, nextOrder, b.CampaignID, b.ContactID, b.ThreadRootID) + inReplyTo, references := c.threading(ctx, ws, nextOrder, b.CampaignID, b.ContactID, b.ThreadRootID) // Derived now, before the step is sent, so the worker can embed it in // tracking tokens at MIME-build time; ClaimStepSend inserts it as the send @@ -469,6 +474,21 @@ func (c client) localStepSendJob(ctx context.Context, enrollmentID, workspaceID }, nil } +// campaignPausedJob is the job for a campaign that is not running: the worker +// holds the enrollment and retries later. The schedule travels for the same +// reason as the daily-limit branch: a deferred retry SENDS as soon as it runs, +// so the worker has to wake inside the campaign's window. +func (c client) campaignPausedJob(ctx context.Context, ws uuid.UUID, enrollmentID string, b gen.GetStepEnrollmentBundleRow) (coreapi.StepSendJob, error) { + sched, err := c.loadSchedule(ctx, ws, b.CampaignID, b.Timezone) + if err != nil { + return coreapi.StepSendJob{}, err + } + return coreapi.StepSendJob{ + EnrollmentID: enrollmentID, WorkspaceID: ws.String(), CampaignPaused: true, + Schedule: sched, + }, nil +} + // ClaimStepSend claims one step-send for delivery (claim-before-send). The // deterministic SendID (derived in GetStepSendJob, embedded in the tracking // tokens) is the sends row id: a fresh INSERT wins the claim, and only a STALE diff --git a/internal/coreapi/remote/jobs_test.go b/internal/coreapi/remote/jobs_test.go index a241c0ea..2ba13e7b 100644 --- a/internal/coreapi/remote/jobs_test.go +++ b/internal/coreapi/remote/jobs_test.go @@ -159,6 +159,10 @@ func TestAStepSendJobRoundTripsAndItsCredentialComesFromTheBroker(t *testing.T) MailboxID: mailbox.String(), SendID: uuid.New().String(), VariantID: uuid.New().String(), CurrentStep: 2, StepOrder: 3, NextDelaySeconds: 259200, LastStep: true, Suppressed: false, CampaignLimited: false, NewLeadLimited: false, HealthPaused: false, + // Not a combination the control plane builds (a pending job carries no + // send), set here so the round trip covers every field on the type. + ConditionPending: true, + RecheckAt: time.Date(2026, 3, 5, 9, 0, 11, 0, time.UTC), NotDueUntil: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC), EffectiveDailyCap: 50, SentToday: 12, MinIntervalSeconds: 180, ToEmail: "ada@example.test", @@ -208,6 +212,32 @@ func TestAStepSendJobRoundTripsAndItsCredentialComesFromTheBroker(t *testing.T) } } +// A branch-condition wait is the shape the control plane actually sends: no +// mailbox, so no credential is brokered, and ConditionPending/RecheckAt (with +// the Skip that protects older workers) arrive intact. A field dropped here +// reaches the worker zero-valued, and a zero RecheckAt would re-drive the +// enrollment immediately instead of waiting out the condition. +func TestAConditionPendingJobRoundTripsWithoutACredential(t *testing.T) { + ws, enrollment := uuid.New(), uuid.New() + jobs := &fakeJobs{step: coreapi.StepSendJob{ + EnrollmentID: enrollment.String(), WorkspaceID: ws.String(), + ConditionPending: true, RecheckAt: time.Date(2026, 9, 24, 14, 3, 7, 0, time.UTC), Skip: true, + }} + opener := smtpSecret("unused") + c, _ := serveJobs(t, jobs, opener) + + got, err := c.GetStepSendJob(context.Background(), enrollment.String(), ws.String()) + if err != nil { + t.Fatalf("GetStepSendJob: %v", err) + } + if !reflect.DeepEqual(got, jobs.step) { + t.Errorf("job mismatch\n got: %+v\nwant: %+v", got, jobs.step) + } + if opener.calls != 0 { + t.Errorf("a job with no mailbox asked the broker %d times", opener.calls) + } +} + // The property the whole design rests on, asserted on the BYTES: no job // response body contains a credential, whatever the control plane's own build // put on the struct. A hand-written mirror would give this by construction; the diff --git a/internal/platform/db/branchmigrations_integration_test.go b/internal/platform/db/branchmigrations_integration_test.go new file mode 100644 index 00000000..85b7d973 --- /dev/null +++ b/internal/platform/db/branchmigrations_integration_test.go @@ -0,0 +1,89 @@ +//go:build integration + +package db_test + +import ( + "context" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/inroad/inroad/internal/platform/db" + "github.com/inroad/inroad/internal/platform/db/dbtest" +) + +// The two conditional-branching migrations, in order. +const ( + branchTablesVersion = 20260923110214 + branchIndexVersion = 20260923144758 + // preBranchVersion is the migration immediately before the branching tables. + preBranchVersion = 20260921111415 +) + +// indexState reports whether the named index exists and, if so, whether it is +// VALID — a failed CREATE INDEX CONCURRENTLY leaves an invalid index behind +// rather than rolling back, and IF NOT EXISTS would skip it on every re-run. +func indexState(t *testing.T, ctx context.Context, pool *pgxpool.Pool, name string) (exists, valid bool) { + t.Helper() + err := pool.QueryRow(ctx, ` + SELECT i.indisvalid FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = $1`, name).Scan(&valid) + if err != nil { + return false, false + } + return true, valid +} + +// On a FRESH database, so the concurrent build actually runs rather than being +// skipped by IF NOT EXISTS: the migration applies (proving the golang-migrate +// pgx/v5 driver runs a single-statement file outside a transaction block, which +// CREATE INDEX CONCURRENTLY requires), the index is valid, and both branching +// migrations round-trip down and up again. +func TestInboxThreadsCampaignContactIndexIsValid(t *testing.T) { + ctx := context.Background() + dsn := dbtest.ScratchDSN(t, "branch_index") + + if err := db.Migrate(dsn); err != nil { + t.Fatalf("migrate up (fresh): %v", err) + } + pool, err := db.Connect(ctx, dsn) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + + const idx = "idx_inbox_threads_campaign_contact" + if exists, valid := indexState(t, ctx, pool, idx); !exists || !valid { + t.Fatalf("%s after a fresh migrate: exists=%v valid=%v", idx, exists, valid) + } + + // Down past both branching migrations, then up again. + if err := db.MigrateTo(dsn, branchIndexVersion); err != nil { + t.Fatalf("migrate to %d: %v", branchIndexVersion, err) + } + if err := db.MigrateTo(dsn, branchTablesVersion); err != nil { + t.Fatalf("roll back the index migration: %v", err) + } + if exists, _ := indexState(t, ctx, pool, idx); exists { + t.Fatalf("%s survived its own down migration", idx) + } + if err := db.MigrateTo(dsn, preBranchVersion); err != nil { + t.Fatalf("roll back the branch tables: %v", err) + } + var tables int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM pg_class WHERE relname = 'sequence_step_branches'`).Scan(&tables); err != nil || tables != 0 { + t.Fatalf("sequence_step_branches after down: count=%d err=%v", tables, err) + } + var col int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM information_schema.columns + WHERE table_name = 'sequence_enrollments' AND column_name = 'awaiting_condition_step'`).Scan(&col); err != nil || col != 0 { + t.Fatalf("awaiting_condition_step after down: count=%d err=%v", col, err) + } + + if err := db.Migrate(dsn); err != nil { + t.Fatalf("migrate up again: %v", err) + } + if exists, valid := indexState(t, ctx, pool, idx); !exists || !valid { + t.Fatalf("%s after up/down/up: exists=%v valid=%v", idx, exists, valid) + } +} diff --git a/internal/platform/db/gen/enrollment.sql.go b/internal/platform/db/gen/enrollment.sql.go index 4d3ab4bd..accd88dd 100644 --- a/internal/platform/db/gen/enrollment.sql.go +++ b/internal/platform/db/gen/enrollment.sql.go @@ -402,6 +402,7 @@ UPDATE sequence_enrollments e SET next_due_at = now() WHERE e.id = $1 AND e.workspace_id = $2 AND e.status = 'active' AND e.next_due_at > now() + AND e.awaiting_condition_step = e.current_step AND NOT COALESCE( (SELECT rl.is_automated FROM reply_labels rl WHERE rl.workspace_id = e.workspace_id AND rl.key = $3::text), @@ -427,6 +428,12 @@ type NudgeEnrollmentAwaitingReplyParams struct { // the only replies that defer an enrollment, and pulling next_due_at forward // would undo that deferral. Never pushes a due time LATER, and is a no-op for // every enrollment without a reply condition — which is every linear campaign. +// +// awaiting_condition_step = current_step is the second half of that deferral +// guard: it proves the due time being pulled forward was stamped by a CONDITION +// wait at this step. An out-of-office deferral is stamped by DeferEnrollment, +// which never touches that column, so an enrollment whose current due time is a +// stated absence (not yet parked by a condition at this step) is left alone. func (q *Queries) NudgeEnrollmentAwaitingReply(ctx context.Context, arg NudgeEnrollmentAwaitingReplyParams) error { _, err := q.db.Exec(ctx, nudgeEnrollmentAwaitingReply, arg.ID, arg.WorkspaceID, arg.ReplyClass) return err diff --git a/internal/platform/db/gen/stepbranch.sql.go b/internal/platform/db/gen/stepbranch.sql.go index 6327d453..03eabf28 100644 --- a/internal/platform/db/gen/stepbranch.sql.go +++ b/internal/platform/db/gen/stepbranch.sql.go @@ -12,6 +12,24 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const campaignTrackingEnabled = `-- name: CampaignTrackingEnabled :one +SELECT tracking_enabled FROM campaigns WHERE id = $1 AND workspace_id = $2 +` + +type CampaignTrackingEnabledParams struct { + ID uuid.UUID `json:"id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +// Whether the campaign rewrites links and embeds the open pixel. Save-time +// validation for open/click branches, which have no evidence to read otherwise. +func (q *Queries) CampaignTrackingEnabled(ctx context.Context, arg CampaignTrackingEnabledParams) (bool, error) { + row := q.db.QueryRow(ctx, campaignTrackingEnabled, arg.ID, arg.WorkspaceID) + var tracking_enabled bool + err := row.Scan(&tracking_enabled) + return tracking_enabled, err +} + const deleteBranch = `-- name: DeleteBranch :exec DELETE FROM sequence_step_branches WHERE step_id = $1 AND campaign_id = $2 AND workspace_id = $3 @@ -178,22 +196,25 @@ func (q *Queries) LockCampaignGraph(ctx context.Context, arg LockCampaignGraphPa return id, err } -const replyLabelKeyExists = `-- name: ReplyLabelKeyExists :one -SELECT EXISTS (SELECT 1 FROM reply_labels WHERE workspace_id = $1 AND key = $2)::bool +const replyLabelStopsEnrollment = `-- name: ReplyLabelStopsEnrollment :one +SELECT stops_enrollment FROM reply_labels WHERE workspace_id = $1 AND key = $2 ` -type ReplyLabelKeyExistsParams struct { +type ReplyLabelStopsEnrollmentParams struct { WorkspaceID uuid.UUID `json:"workspace_id"` Key string `json:"key"` } -// Whether the workspace defines a reply label with this key. Save-time -// validation only: a branch naming a label that does not exist could never match. -func (q *Queries) ReplyLabelKeyExists(ctx context.Context, arg ReplyLabelKeyExistsParams) (bool, error) { - row := q.db.QueryRow(ctx, replyLabelKeyExists, arg.WorkspaceID, arg.Key) - var column_1 bool - err := row.Scan(&column_1) - return column_1, err +// Whether the workspace's reply label with this key stops the enrollment. +// Save-time validation only: no row (pgx.ErrNoRows) means the label does not +// exist and a branch naming it could never match; true means a reply with that +// label stops the sequence before any branch can route it, so a branch naming it +// could never fire either. +func (q *Queries) ReplyLabelStopsEnrollment(ctx context.Context, arg ReplyLabelStopsEnrollmentParams) (bool, error) { + row := q.db.QueryRow(ctx, replyLabelStopsEnrollment, arg.WorkspaceID, arg.Key) + var stops_enrollment bool + err := row.Scan(&stops_enrollment) + return stops_enrollment, err } const stepSendCreatedAt = `-- name: StepSendCreatedAt :one @@ -206,8 +227,9 @@ type StepSendCreatedAtParams struct { } // When a (deterministically-id'd) step send row was first created. The send -// path's cycle backstop: a routed step whose row predates the enrollment's last -// send was visited EARLIER on this path, i.e. the graph now loops. Workspace-pinned. +// path's cycle backstop compares the routed step's row with the CURRENT step's +// own row: a routed step created before the step the contact is on was visited +// EARLIER on this path, i.e. the graph now loops. Workspace-pinned. func (q *Queries) StepSendCreatedAt(ctx context.Context, arg StepSendCreatedAtParams) (pgtype.Timestamptz, error) { row := q.db.QueryRow(ctx, stepSendCreatedAt, arg.ID, arg.WorkspaceID) var created_at pgtype.Timestamptz diff --git a/internal/platform/db/gen/stepsend.sql.go b/internal/platform/db/gen/stepsend.sql.go index 5bcce93f..28da9e1f 100644 --- a/internal/platform/db/gen/stepsend.sql.go +++ b/internal/platform/db/gen/stepsend.sql.go @@ -226,14 +226,15 @@ func (q *Queries) GetStepEnrollmentBundle(ctx context.Context, arg GetStepEnroll const latestSentForContact = `-- name: LatestSentForContact :one SELECT message_id, references_header FROM sends -WHERE campaign_id = $1 AND contact_id = $2 AND status = 'sent' +WHERE campaign_id = $1 AND contact_id = $2 AND workspace_id = $3 AND status = 'sent' ORDER BY sent_at DESC NULLS LAST, step_order DESC LIMIT 1 ` type LatestSentForContactParams struct { - CampaignID uuid.UUID `json:"campaign_id"` - ContactID uuid.UUID `json:"contact_id"` + CampaignID uuid.UUID `json:"campaign_id"` + ContactID uuid.UUID `json:"contact_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` } type LatestSentForContactRow struct { @@ -249,8 +250,12 @@ type LatestSentForContactRow struct { // path need not visit steps in step_order — 1 → 3 → 2 is a valid path — and // threading onto the highest-numbered step would reply to a message that is not // the latest one the contact received. +// +// Workspace-pinned like every other tenant read, even though (campaign_id, +// contact_id) already came from a workspace-scoped bundle: the pin costs nothing +// and keeps this from depending on its caller's discipline. func (q *Queries) LatestSentForContact(ctx context.Context, arg LatestSentForContactParams) (LatestSentForContactRow, error) { - row := q.db.QueryRow(ctx, latestSentForContact, arg.CampaignID, arg.ContactID) + row := q.db.QueryRow(ctx, latestSentForContact, arg.CampaignID, arg.ContactID, arg.WorkspaceID) var i LatestSentForContactRow err := row.Scan(&i.MessageID, &i.ReferencesHeader) return i, err diff --git a/internal/platform/db/migrations/20260923110214_sequence_step_branches.down.sql b/internal/platform/db/migrations/20260923110214_sequence_step_branches.down.sql index a080d200..bb4790a2 100644 --- a/internal/platform/db/migrations/20260923110214_sequence_step_branches.down.sql +++ b/internal/platform/db/migrations/20260923110214_sequence_step_branches.down.sql @@ -1,5 +1,3 @@ -DROP INDEX IF EXISTS idx_inbox_threads_campaign_contact; - ALTER TABLE sequence_enrollments DROP COLUMN awaiting_condition_step; DROP TABLE sequence_step_branches; diff --git a/internal/platform/db/migrations/20260923110214_sequence_step_branches.up.sql b/internal/platform/db/migrations/20260923110214_sequence_step_branches.up.sql index d01d1c13..06f4bfc1 100644 --- a/internal/platform/db/migrations/20260923110214_sequence_step_branches.up.sql +++ b/internal/platform/db/migrations/20260923110214_sequence_step_branches.up.sql @@ -27,6 +27,10 @@ -- validation. Cycles cannot be expressed as a constraint and are rejected by the -- service at save time (internal/platform/seqgraph), with a runtime backstop in -- the send path. +-- +-- The reply-condition index on inbox_threads lives in its own migration +-- (20260923144758_inbox_threads_campaign_contact_index) so it can be built +-- CONCURRENTLY, without locking a live table inside this file's transaction. -- Referenceable by the composite FKs below. Redundant with the primary key for -- uniqueness; exists solely to be referenced, the same shape as @@ -102,12 +106,3 @@ CREATE INDEX idx_sequence_step_branches_no ON sequence_step_branches (no_step_id -- ahead of its delay. Stale by construction once current_step moves on, so the -- cursor advance never has to clear it. ALTER TABLE sequence_enrollments ADD COLUMN awaiting_condition_step INT; - --- Reply conditions look for an inbound message on the enrollment's campaign and --- contact; every existing inbox_threads index leads with workspace_id and a --- mailbox or a sort key, so that lookup would otherwise walk every thread in the --- workspace, once per waiting enrollment per re-check. Not CONCURRENTLY: --- golang-migrate runs a file in one transaction (see --- 20260827185855_inbox_messages_mailbox_id). -CREATE INDEX idx_inbox_threads_campaign_contact - ON inbox_threads (campaign_id, contact_id) WHERE campaign_id IS NOT NULL; diff --git a/internal/platform/db/migrations/20260923144758_inbox_threads_campaign_contact_index.down.sql b/internal/platform/db/migrations/20260923144758_inbox_threads_campaign_contact_index.down.sql new file mode 100644 index 00000000..f3e60555 --- /dev/null +++ b/internal/platform/db/migrations/20260923144758_inbox_threads_campaign_contact_index.down.sql @@ -0,0 +1,2 @@ +-- One statement, for the reason the up file gives. +DROP INDEX CONCURRENTLY IF EXISTS idx_inbox_threads_campaign_contact; diff --git a/internal/platform/db/migrations/20260923144758_inbox_threads_campaign_contact_index.up.sql b/internal/platform/db/migrations/20260923144758_inbox_threads_campaign_contact_index.up.sql new file mode 100644 index 00000000..e4009cf8 --- /dev/null +++ b/internal/platform/db/migrations/20260923144758_inbox_threads_campaign_contact_index.up.sql @@ -0,0 +1,18 @@ +-- Reply conditions on branched sequences (20260923110214_sequence_step_branches) +-- look for an inbound message on an enrollment's campaign and contact. Every +-- earlier inbox_threads index leads with workspace_id and a mailbox or a sort key, +-- so that lookup would otherwise walk every thread in the workspace, once per +-- waiting enrollment per re-check. +-- +-- CONCURRENTLY, so the build takes no lock that blocks inbox writes on a live +-- table. That is only possible because this file is ONE statement: the pgx/v5 +-- golang-migrate driver sends a file as a single simple-protocol query, which +-- runs a lone statement outside any transaction block but wraps several in an +-- implicit one — where CREATE INDEX CONCURRENTLY is refused (see +-- 20260827185855_inbox_messages_mailbox_id). Do not add a second statement here. +-- +-- A concurrent build that fails leaves an INVALID index behind rather than +-- rolling back; IF NOT EXISTS would then skip it on a re-run. The integration +-- test TestInboxThreadsCampaignContactIndexIsValid asserts pg_index.indisvalid. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_inbox_threads_campaign_contact + ON inbox_threads (campaign_id, contact_id) WHERE campaign_id IS NOT NULL; diff --git a/internal/platform/db/queries/enrollment.sql b/internal/platform/db/queries/enrollment.sql index e07e9179..0360dca9 100644 --- a/internal/platform/db/queries/enrollment.sql +++ b/internal/platform/db/queries/enrollment.sql @@ -163,10 +163,17 @@ WHERE id = $1 AND workspace_id = $2 AND status = 'active'; -- the only replies that defer an enrollment, and pulling next_due_at forward -- would undo that deferral. Never pushes a due time LATER, and is a no-op for -- every enrollment without a reply condition — which is every linear campaign. +-- +-- awaiting_condition_step = current_step is the second half of that deferral +-- guard: it proves the due time being pulled forward was stamped by a CONDITION +-- wait at this step. An out-of-office deferral is stamped by DeferEnrollment, +-- which never touches that column, so an enrollment whose current due time is a +-- stated absence (not yet parked by a condition at this step) is left alone. UPDATE sequence_enrollments e SET next_due_at = now() WHERE e.id = $1 AND e.workspace_id = $2 AND e.status = 'active' AND e.next_due_at > now() + AND e.awaiting_condition_step = e.current_step AND NOT COALESCE( (SELECT rl.is_automated FROM reply_labels rl WHERE rl.workspace_id = e.workspace_id AND rl.key = sqlc.arg(reply_class)::text), diff --git a/internal/platform/db/queries/stepbranch.sql b/internal/platform/db/queries/stepbranch.sql index 5ba8fcf3..7d014e8f 100644 --- a/internal/platform/db/queries/stepbranch.sql +++ b/internal/platform/db/queries/stepbranch.sql @@ -40,10 +40,18 @@ WHERE step_id = $1 AND campaign_id = $2 AND workspace_id = $3; -- takes on its campaign FK, so a graph edit never stalls delivery. SELECT id FROM campaigns WHERE id = $1 AND workspace_id = $2 FOR NO KEY UPDATE; --- name: ReplyLabelKeyExists :one --- Whether the workspace defines a reply label with this key. Save-time --- validation only: a branch naming a label that does not exist could never match. -SELECT EXISTS (SELECT 1 FROM reply_labels WHERE workspace_id = $1 AND key = $2)::bool; +-- name: ReplyLabelStopsEnrollment :one +-- Whether the workspace's reply label with this key stops the enrollment. +-- Save-time validation only: no row (pgx.ErrNoRows) means the label does not +-- exist and a branch naming it could never match; true means a reply with that +-- label stops the sequence before any branch can route it, so a branch naming it +-- could never fire either. +SELECT stops_enrollment FROM reply_labels WHERE workspace_id = $1 AND key = $2; + +-- name: CampaignTrackingEnabled :one +-- Whether the campaign rewrites links and embeds the open pixel. Save-time +-- validation for open/click branches, which have no evidence to read otherwise. +SELECT tracking_enabled FROM campaigns WHERE id = $1 AND workspace_id = $2; -- name: FirstHumanTrackingEventAt :one -- The earliest HUMAN open or click of one send, at or before window_end. It reads @@ -84,6 +92,7 @@ LIMIT 1; -- name: StepSendCreatedAt :one -- When a (deterministically-id'd) step send row was first created. The send --- path's cycle backstop: a routed step whose row predates the enrollment's last --- send was visited EARLIER on this path, i.e. the graph now loops. Workspace-pinned. +-- path's cycle backstop compares the routed step's row with the CURRENT step's +-- own row: a routed step created before the step the contact is on was visited +-- EARLIER on this path, i.e. the graph now loops. Workspace-pinned. SELECT created_at FROM sends WHERE id = $1 AND workspace_id = $2; diff --git a/internal/platform/db/queries/stepsend.sql b/internal/platform/db/queries/stepsend.sql index 6352d08b..0d9028b5 100644 --- a/internal/platform/db/queries/stepsend.sql +++ b/internal/platform/db/queries/stepsend.sql @@ -103,7 +103,11 @@ SELECT COALESCE(sqlc.narg(not_due_until)::timestamptz > now(), false)::bool AS n -- path need not visit steps in step_order — 1 → 3 → 2 is a valid path — and -- threading onto the highest-numbered step would reply to a message that is not -- the latest one the contact received. +-- +-- Workspace-pinned like every other tenant read, even though (campaign_id, +-- contact_id) already came from a workspace-scoped bundle: the pin costs nothing +-- and keeps this from depending on its caller's discipline. SELECT message_id, references_header FROM sends -WHERE campaign_id = $1 AND contact_id = $2 AND status = 'sent' +WHERE campaign_id = $1 AND contact_id = $2 AND workspace_id = $3 AND status = 'sent' ORDER BY sent_at DESC NULLS LAST, step_order DESC LIMIT 1; diff --git a/internal/platform/db/tenancyqueries_test.go b/internal/platform/db/tenancyqueries_test.go index 9b33b819..2d161d43 100644 --- a/internal/platform/db/tenancyqueries_test.go +++ b/internal/platform/db/tenancyqueries_test.go @@ -113,7 +113,6 @@ var tenancyExceptions = map[string]string{ "oauth_provider.sql:RevokeOauthRefreshFamily": "revokes a whole rotation family on reuse detection; pinned to family_id, which is narrower than a workspace.", "mailbox.sql:MailboxExists": "an existence probe by mailbox id returning only a boolean — it can leak at most whether an unguessable UUID is an active mailbox, never row content.", "send.sql:CountSentToday": "the daily-cap gate, keyed on an unguessable mailbox id and returning only a count. Callers reach it having already resolved the mailbox within their workspace.", - "stepsend.sql:LatestSentForContact": "threading headers for the next step, keyed on (campaign_id, contact_id); both were resolved workspace-scoped by the caller that scheduled this step.", "tracking.sql:GetSendTrackingContext": "the public tracking pixel path has NO authenticated principal to scope by — the send id arrives in an HMAC-signed token. Returns a verdict about that one send, never row data; scoping it would mean trusting a workspace id from an unauthenticated request.", "tracking.sql:CountRecentSendOpensFromSubnet": "same unauthenticated tracking path as GetSendTrackingContext; returns a count about one send.", @@ -465,7 +464,7 @@ func TestEveryTenancyExceptionHasAWrittenReason(t *testing.T) { // this guard has stopped guarding, so the count is the size of the hole in the net. // Raising it should be a conscious act in a diff, not a drift. func TestTheTenancyAllowlistDoesNotGrowSilently(t *testing.T) { - const known = 50 + const known = 49 if got := len(tenancyExceptions); got != known { t.Errorf("tenancyExceptions has %d entries, expected %d. Every entry is a query this "+ "guard no longer checks. If you added one deliberately, update `known` in the same "+ diff --git a/internal/platform/seqgraph/seqgraph.go b/internal/platform/seqgraph/seqgraph.go index 63dfbee8..2f36b106 100644 --- a/internal/platform/seqgraph/seqgraph.go +++ b/internal/platform/seqgraph/seqgraph.go @@ -144,10 +144,16 @@ const ( CodeInvalidCondition = "invalid_condition" CodeInvalidWithinDays = "invalid_within_days" CodeLabelNotAllowed = "invalid_reply_label" - CodeNoExitNotAllowed = "no_exit_not_allowed" - CodeUnknownStep = "unknown_step" - CodeUnknownTarget = "unknown_target" - CodeCycle = "cycle" + // CodeLabelStopsSequence is a reply label whose replies stop the enrollment, + // so a branch naming it could never route anyone. + CodeLabelStopsSequence = "reply_label_stops_sequence" + // CodeTrackingRequired is an open/click condition on a campaign or step that + // can never record an open or click. + CodeTrackingRequired = "tracking_required" + CodeNoExitNotAllowed = "no_exit_not_allowed" + CodeUnknownStep = "unknown_step" + CodeUnknownTarget = "unknown_target" + CodeCycle = "cycle" ) // ShapeError is a branch that is malformed on its own, before any graph is diff --git a/internal/worker/inbox/branching_integration_test.go b/internal/worker/inbox/branching_integration_test.go new file mode 100644 index 00000000..5a91744e --- /dev/null +++ b/internal/worker/inbox/branching_integration_test.go @@ -0,0 +1,224 @@ +//go:build integration + +package inbox + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/inroad/inroad/internal/coreapi" + "github.com/inroad/inroad/internal/platform/db/gen" + "github.com/inroad/inroad/internal/platform/mail" + "github.com/inroad/inroad/internal/platform/replyclassify" +) + +// Reply-routed branches, driven through the REAL inbox poll and dispatch: the +// classifier picks the key, the workspace's label flags decide what the reply +// does, and only then does the send path see whatever the dispatch left behind. +// A test that wrote inbox rows and called RecordReplyClass by hand would pass +// for a reply the dispatch would actually have used to STOP the sequence. + +// humanReplyBody is a plain human answer to step 1; oooSubject marks an +// out-of-office one (the classifier's subject rule). +const ( + humanReplyBody = "\n\nSounds good, tell me more.\n" + oooSubject = "Out of Office: back Monday" +) + +type branchInbox struct { + itFixture + pool *pgxpool.Pool + msgID string + uidNext uint32 + classify *replyclassify.Classifier +} + +// seedReplyBranch is the inbox fixture (step 1 sent as msgID) with a +// "replied within 3 days → step 2, otherwise end" branch on step 1, step 2 +// due immediately, a send window open around the clock (so nothing depends on +// the hour the test runs), and the enrollment already parked on the condition. +func seedReplyBranch(t *testing.T) (branchInbox, func()) { + t.Helper() + ctx := context.Background() + pool, q, closeFn := connect(t) + msgID := "" + fx := seedActiveEnrollment(t, ctx, pool, q, newSealer(t), msgID) + + steps, err := q.ListStepsByCampaign(ctx, gen.ListStepsByCampaignParams{CampaignID: fx.campaignID, WorkspaceID: fx.ws}) + if err != nil || len(steps) != 2 { + t.Fatalf("steps: %v (%d)", err, len(steps)) + } + for _, stmt := range []string{ + `UPDATE sequence_steps SET delay_seconds = 0 WHERE campaign_id = $1`, + `INSERT INTO campaign_send_windows (workspace_id, campaign_id, weekday, start_minute, end_minute) + SELECT c.workspace_id, c.id, d, 0, 1440 FROM campaigns c, generate_series(0, 6) AS d WHERE c.id = $1`, + } { + if _, err := pool.Exec(ctx, stmt, fx.campaignID); err != nil { + t.Fatalf("setup: %v", err) + } + } + three := int32(3) + if _, err := q.UpsertBranch(ctx, gen.UpsertBranchParams{ + StepID: steps[0].ID, WorkspaceID: fx.ws, CampaignID: fx.campaignID, Condition: "replied", + WithinDays: &three, YesStepID: pgtype.UUID{Bytes: steps[1].ID, Valid: true}, + }); err != nil { + t.Fatalf("branch: %v", err) + } + if err := fx.core.SetInboxCursor(ctx, fx.mailboxID.String(), fx.ws.String(), 10, 5); err != nil { + t.Fatalf("seed cursor: %v", err) + } + b := branchInbox{itFixture: fx, pool: pool, msgID: msgID, uidNext: 11, classify: replyclassify.New(nil)} + + // Nothing has arrived: the condition is open, so the enrollment parks. + if job := b.job(t); !job.ConditionPending { + t.Fatalf("before any reply the enrollment must wait on the condition, got %+v", job) + } + return b, closeFn +} + +func (b *branchInbox) raw(subject, body string) string { + return "From: " + b.email + "\nTo: from@acme.test\nSubject: " + subject + + "\nMessage-ID: \nIn-Reply-To: " + b.msgID + + "\nReferences: " + b.msgID + "\n" + body +} + +// classOf is what the production classifier makes of a raw message. +func (b *branchInbox) classOf(t *testing.T, raw string) string { + t.Helper() + // The same Input poll.go builds from a fetched message. + msg := inboundMsg(t, 0, raw) + return b.classify.Classify(context.Background(), replyclassify.Input{ + Headers: map[string][]string(msg.Header), Subject: msg.Header.Get("Subject"), BodyText: string(msg.Body), + }).Class +} + +// poll delivers one message through PollHandler, exactly as the scheduler does. +func (b *branchInbox) poll(t *testing.T, raw string) { + t.Helper() + uid := b.uidNext + b.uidNext++ + reader := &fakeReader{uidValidity: 5, uidNext: b.uidNext, msgs: []mail.InboundMessage{inboundMsg(t, uid, raw)}} + if err := PollHandler(b.core, reader, nil, nil, b.classify, nil, noopEngageEnqueuer{})( + context.Background(), pollTaskFor(t, b.mailboxID.String(), b.ws.String())); err != nil { + t.Fatalf("poll: %v", err) + } +} + +func (b *branchInbox) job(t *testing.T) coreapi.StepSendJob { + t.Helper() + job, err := b.core.GetStepSendJob(context.Background(), b.enrollmentID.String(), b.ws.String()) + if err != nil { + t.Fatalf("GetStepSendJob: %v", err) + } + return job +} + +func (b *branchInbox) enrollment(t *testing.T) gen.SequenceEnrollment { + t.Helper() + return getEnrollment(t, context.Background(), b.q, b.ws, b.enrollmentID) +} + +// With the workspace's label for a human reply set NOT to stop the sequence, +// the reply is routed: an out-of-office first is neither a reply nor a nudge +// (it is the kind of reply that defers), then the human reply pulls the parked +// enrollment forward and the send path routes it down the YES exit. +func TestReplyBranchRoutesOnANonStoppingLabel(t *testing.T) { + b, done := seedReplyBranch(t) + defer done() + ctx := context.Background() + + human := b.raw("Re: Hi", humanReplyBody) + key := b.classOf(t, human) + if replyclassify.IsAutomated(key) { + t.Fatalf("precondition: a plain reply classified as automated %q", key) + } + if _, err := b.pool.Exec(ctx, + `UPDATE reply_labels SET stops_enrollment = false WHERE workspace_id = $1 AND key = $2`, b.ws, key); err != nil { + t.Fatalf("make %q non-stopping: %v", key, err) + } + + ooo := b.raw(oooSubject, "\n\nI am away until Monday.\n") + if got := b.classOf(t, ooo); got != replyclassify.ClassOutOfOffice { + t.Fatalf("precondition: out-of-office message classified %q", got) + } + parked := b.enrollment(t).NextDueAt.Time + b.poll(t, ooo) + if e := b.enrollment(t); e.Status != "active" || !e.NextDueAt.Time.Equal(parked) { + t.Fatalf("an out-of-office moved the enrollment: status %s due %v -> %v", e.Status, parked, e.NextDueAt.Time) + } + if job := b.job(t); !job.ConditionPending { + t.Fatalf("an out-of-office is not a reply, the condition must stay open: %+v", job) + } + + b.poll(t, human) + e := b.enrollment(t) + if e.Status != "active" { + t.Fatalf("a non-stopping reply stopped the enrollment: %s", e.Status) + } + if e.NextDueAt.Time.After(time.Now().Add(time.Minute)) { + t.Fatalf("the reply must nudge the parked enrollment to now, due %v", e.NextDueAt.Time) + } + job := b.job(t) + if job.ConditionPending || job.Skip || job.StepOrder != 2 { + t.Fatalf("the reply must route to step 2 (yes): pending=%v skip=%v order=%d", job.ConditionPending, job.Skip, job.StepOrder) + } +} + +// The product decision, pinned: with the workspace's DEFAULT labels a human +// reply STOPS the sequence exactly as it did before branching existed — the +// branch does not get a say, and the send path has nothing left to route. +func TestReplyBranchDefaultLabelStopsInsteadOfRouting(t *testing.T) { + b, done := seedReplyBranch(t) + defer done() + + human := b.raw("Re: Hi", humanReplyBody) + var stops bool + if err := b.pool.QueryRow(context.Background(), + `SELECT stops_enrollment FROM reply_labels WHERE workspace_id = $1 AND key = $2`, + b.ws, b.classOf(t, human)).Scan(&stops); err != nil || !stops { + t.Fatalf("precondition: the seeded label for a human reply must stop the sequence (stops=%v err=%v)", stops, err) + } + + b.poll(t, human) + e := b.enrollment(t) + if e.Status != "stopped" || e.StopReason == nil || *e.StopReason != "replied" { + t.Fatalf("a default-label reply must stop the enrollment 'replied', got %s %v", e.Status, e.StopReason) + } + if job := b.job(t); !job.Skip || job.ConditionPending || job.StepOrder != 0 { + t.Fatalf("a stopped enrollment routes nowhere: %+v", job) + } +} + +// A due time the enrollment carries for a reason OTHER than a condition wait — +// here an out-of-office deferral five days out (DeferEnrollment never stamps +// awaiting_condition_step) — is not the nudge's to pull forward, even for a +// non-stopping human reply the branch is watching for. The stated absence +// wins; the reply is still evidence, read when the enrollment next wakes. +func TestReplyNudgeLeavesAnOutOfOfficeDeferralAlone(t *testing.T) { + b, done := seedReplyBranch(t) + defer done() + ctx := context.Background() + + human := b.raw("Re: Hi", humanReplyBody) + if _, err := b.pool.Exec(ctx, + `UPDATE reply_labels SET stops_enrollment = false WHERE workspace_id = $1 AND key = $2`, b.ws, b.classOf(t, human)); err != nil { + t.Fatal(err) + } + if _, err := b.pool.Exec(ctx, `UPDATE sequence_enrollments SET awaiting_condition_step = NULL WHERE id = $1`, b.enrollmentID); err != nil { + t.Fatal(err) + } + until := time.Now().Add(5 * 24 * time.Hour).UTC().Truncate(time.Second) + if err := b.core.DeferEnrollment(ctx, b.enrollmentID.String(), b.ws.String(), until); err != nil { + t.Fatal(err) + } + + b.poll(t, human) + if e := b.enrollment(t); e.Status != "active" || !e.NextDueAt.Time.Equal(until) { + t.Fatalf("the out-of-office deferral was overwritten: status %s due %v, want %v", e.Status, e.NextDueAt.Time, until) + } +} diff --git a/internal/worker/sequence/advance.go b/internal/worker/sequence/advance.go index cc752d1f..ca8e120e 100644 --- a/internal/worker/sequence/advance.go +++ b/internal/worker/sequence/advance.go @@ -172,15 +172,13 @@ func AdvanceHandler(core coreapi.Client, sender Sender, enq Enqueuer, publicURL // stamped next_due_at; all that is left is to look again then. Checked // BEFORE Skip, which rides along on this job only so that a worker built // before ConditionPending existed does nothing harmful with it. - // result=deferred: a self-clearing wait, the same bucket as the - // campaign-limit and capacity defers below. Metric AFTER the enqueue, for - // their double-count reason. + // + // Deliberately NO inroad_sends_total increment. A condition wait is not a + // send outcome at all — nothing was due to go out — and it recurs hourly + // for every waiting enrollment, so counting it as result="deferred" would + // swamp the capacity and limit defers that bucket exists to show. if job.ConditionPending { - if err := enq.EnqueueAdvanceAt(ctx, p.EnrollmentID, p.WorkspaceID, job.RecheckAt); err != nil { - return err - } - mtx.SendFinalized(sendKind, "deferred") - return nil + return enq.EnqueueAdvanceAt(ctx, p.EnrollmentID, p.WorkspaceID, job.RecheckAt) } // Enrollment no longer active (stopped/completed) or no next step. diff --git a/internal/worker/sequence/branching_integration_test.go b/internal/worker/sequence/branching_integration_test.go index 84a501c4..af9a63ad 100644 --- a/internal/worker/sequence/branching_integration_test.go +++ b/internal/worker/sequence/branching_integration_test.go @@ -122,30 +122,6 @@ func (f branchFixture) track(t *testing.T, order int, kind string, machine bool) } } -// reply stores an inbound reply the way the inbox poller does: a thread on the -// campaign + contact, and an inbound message classified replyClass. -func (f branchFixture) reply(t *testing.T, replyClass string) { - t.Helper() - ctx := context.Background() - var mailbox uuid.UUID - if err := f.pool.QueryRow(ctx, `SELECT mailbox_id FROM campaigns WHERE id = $1`, f.campaignID).Scan(&mailbox); err != nil { - t.Fatalf("mailbox: %v", err) - } - var thread uuid.UUID - if err := f.pool.QueryRow(ctx, ` - INSERT INTO inbox_threads (workspace_id, mailbox_id, campaign_id, contact_id, root_message_id) - VALUES ($1, $2, $3, $4, $5) RETURNING id`, - f.ws, mailbox, f.campaignID, f.contactID, "").Scan(&thread); err != nil { - t.Fatalf("thread: %v", err) - } - if _, err := f.pool.Exec(ctx, ` - INSERT INTO inbox_messages (thread_id, workspace_id, mailbox_id, direction, message_id, reply_class, occurred_at) - VALUES ($1, $2, $3, 'inbound', $4, $5, now())`, - thread, f.ws, mailbox, "", replyClass); err != nil { - t.Fatalf("message: %v", err) - } -} - func (f branchFixture) enrollment(t *testing.T) gen.SequenceEnrollment { t.Helper() e, err := f.q.GetEnrollment(context.Background(), gen.GetEnrollmentParams{ID: uuid.MustParse(f.eid), WorkspaceID: f.ws}) @@ -226,58 +202,90 @@ func TestBranchMachineOpenDoesNotCount(t *testing.T) { f.requireSent(t, "S1", "S2") } -// Replies span both legs: the step went out as a sends row, the answer is an -// inbox_messages row. An out-of-office is not a reply; a human one is, and it -// routes YES without waiting out the window. -func TestBranchRepliedCountsHumanRepliesOnly(t *testing.T) { +// A contact suppressed (unsubscribed, bounced) while waiting on a branch is +// never sent the routed step: routing picks the target, and the send path's +// suppression gate then stops the enrollment exactly as on a linear sequence. +func TestBranchRoutedSendHonoursSuppression(t *testing.T) { f, done := seedBranchCampaign(t) defer done() - f.branch(t, f.steps[0], "replied", 3, &f.steps[2], &f.steps[1]) + f.branch(t, f.steps[0], "opened", 2, &f.steps[2], &f.steps[1]) f.advance(t) - f.reply(t, "out_of_office") - f.due(t) - f.advance(t) - f.requireSent(t, "S1") + f.track(t, 1, "open", false) // decides YES -> step 3 + if err := f.q.AddSuppression(context.Background(), gen.AddSuppressionParams{ + WorkspaceID: f.ws, Email: f.email, Reason: "unsubscribe", + }); err != nil { + t.Fatalf("suppress: %v", err) + } - f.reply(t, "neutral") f.due(t) f.advance(t) - f.requireSent(t, "S1", "S3") + f.requireSent(t, "S1") + e := f.enrollment(t) + if e.Status != "stopped" || e.StopReason == nil || *e.StopReason != "suppressed" { + t.Fatalf("routed send to a suppressed contact: status %s reason %v", e.Status, e.StopReason) + } } -// A reply that does not stop the enrollment nudges one waiting on a reply -// condition to now, so the next sweep routes it; an automated reply must not -// (it is the kind that DEFERS an enrollment, and pulling it forward would undo -// the deferral). -func TestBranchReplyNudgesAwaitingEnrollment(t *testing.T) { +// A campaign that is not running holds its enrollments WITHOUT routing them: a +// path that would end is not finished, a condition is not parked, nothing is +// sent — the pause gate runs before routing on the graph path, just as it runs +// before any send on the linear one. Relaunching resumes routing. +func TestBranchPausedCampaignNeitherFinishesNorParks(t *testing.T) { f, done := seedBranchCampaign(t) defer done() ctx := context.Background() - f.branch(t, f.steps[0], "replied", 3, &f.steps[2], nil) + f.branch(t, f.steps[0], "not_opened", 1, nil, &f.steps[1]) // silence -> end f.advance(t) - f.due(t) - f.advance(t) // parks until the next recheck - parked := f.enrollment(t).NextDueAt.Time - if !parked.After(time.Now()) { - t.Fatalf("precondition: enrollment should be parked, due %v", parked) + f.ageLastSend(t, 25*time.Hour) // the window has closed: this WOULD end the path + if _, err := f.pool.Exec(ctx, `UPDATE campaigns SET status = 'paused' WHERE id = $1`, f.campaignID); err != nil { + t.Fatal(err) } - - if err := f.core.RecordReplyClass(ctx, f.eid, f.ws.String(), "out_of_office", "rules", 1); err != nil { + job, err := f.core.GetStepSendJob(ctx, f.eid, f.ws.String()) + if err != nil { t.Fatal(err) } - if got := f.enrollment(t).NextDueAt.Time; !got.Equal(parked) { - t.Fatalf("an automated reply moved the due time %v -> %v", parked, got) + if !job.CampaignPaused || job.ConditionPending || job.Skip { + t.Fatalf("paused campaign job = paused %v pending %v skip %v, want paused only", job.CampaignPaused, job.ConditionPending, job.Skip) + } + if e := f.enrollment(t); e.Status != "active" || e.AwaitingConditionStep != nil { + t.Fatalf("a paused campaign moved the enrollment: status %s awaiting %v", e.Status, e.AwaitingConditionStep) } - if err := f.core.RecordReplyClass(ctx, f.eid, f.ws.String(), "neutral", "rules", 1); err != nil { + if _, err := f.pool.Exec(ctx, `UPDATE campaigns SET status = 'running' WHERE id = $1`, f.campaignID); err != nil { t.Fatal(err) } - // A minute of slack for the database container's clock against this one; - // the parked due time was an hour out. - if got := f.enrollment(t).NextDueAt.Time; got.After(time.Now().Add(time.Minute)) { - t.Fatalf("a human reply must pull the due time to now, still %v", got) + f.advance(t) + f.requireSent(t, "S1") + if e := f.enrollment(t); e.Status != "completed" { + t.Fatalf("after relaunch the ended path completes, got %s", e.Status) + } +} + +// The loop backstop's reference point is the CURRENT step's own send row, not +// enrollment.last_sent_at. A recover-forward re-stamps last_sent_at, so an old +// visit can look "later" than it; here last_sent_at is pushed into the past +// (as if long ago) and the loop must still be caught — and, conversely, the +// legitimate first visit to a step is not mistaken for a revisit. +func TestBranchLoopBackstopUsesTheCurrentStepsSendRow(t *testing.T) { + f, done := seedBranchCampaign(t) + defer done() + f.branch(t, f.steps[0], "always", 0, &f.steps[1], nil) + f.branch(t, f.steps[1], "always", 0, &f.steps[0], nil) // 1 -> 2 -> 1 + + f.advance(t) + f.due(t) + f.advance(t) + f.requireSent(t, "S1", "S2") + + // Make last_sent_at older than step 1's send row: comparing against it + // would call step 1 "not visited before" and recover-forward onto it. + f.ageLastSend(t, 48*time.Hour) + f.advance(t) + f.requireSent(t, "S1", "S2") + if e := f.enrollment(t); e.Status != "completed" || e.CurrentStep != 2 { + t.Fatalf("loop = %s at %d, want completed at 2", e.Status, e.CurrentStep) } } diff --git a/internal/worker/sequence/branchwait_test.go b/internal/worker/sequence/branchwait_test.go index 926ae1b5..39db9bc4 100644 --- a/internal/worker/sequence/branchwait_test.go +++ b/internal/worker/sequence/branchwait_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/inroad/inroad/internal/coreapi" + "github.com/inroad/inroad/internal/platform/metrics" ) // A pending branch condition schedules the next look at exactly RecheckAt and @@ -28,6 +29,22 @@ func TestAdvanceConditionPendingSchedulesRecheck(t *testing.T) { } } +// A condition wait is not a send outcome: it recurs hourly for every waiting +// enrollment, and counting it as result="deferred" would drown the capacity +// and limit defers that bucket is for. No inroad_sends_total series moves. +func TestAdvanceConditionPendingRecordsNoSendMetric(t *testing.T) { + mtx := metrics.New() + core := &stubCore{job: coreapi.StepSendJob{ConditionPending: true, RecheckAt: time.Now(), Skip: true}} + if err := runWithMetrics(t, core, &fakeSender{}, &fakeEnq{}, mtx); err != nil { + t.Fatal(err) + } + for _, result := range []string{"sent", "failed", "deferred", "skipped"} { + if n := sendsCount(t, mtx, result); n != 0 { + t.Errorf("result=%s = %v, want 0", result, n) + } + } +} + // An enqueue failure is returned so asynq retries: the control plane already // stamped next_due_at, so even a lost retry is re-driven by the sweeper, but // swallowing the error would hide a Redis outage. diff --git a/web/src/store/api.ts b/web/src/store/api.ts index ef43d8d1..2265288a 100644 --- a/web/src/store/api.ts +++ b/web/src/store/api.ts @@ -4424,6 +4424,8 @@ export type BranchValidationError = { | "invalid_condition" | "invalid_within_days" | "invalid_reply_label" + | "reply_label_stops_sequence" + | "tracking_required" | "no_exit_not_allowed" | "unknown_step" | "unknown_target" @@ -4465,7 +4467,7 @@ export type StepBranch = { condition: StepBranchCondition; /** Evaluation window in days after the step's send; null exactly when condition is always */ within_days: number | null; - /** replied / not_replied only: count only replies classified with this reply label key */ + /** replied / not_replied only: count only replies classified with this reply label key (always a label that does not stop the sequence) */ reply_label_key: string | null; /** Where a true condition (or always) goes; null ends the path */ yes_step_id: string | null; @@ -4491,7 +4493,7 @@ export type StepBranchRequest = { condition: StepBranchCondition; /** Required for every condition except always; must be absent or null for always */ within_days?: number | null; - /** Optional, replied / not_replied only; must name a reply label key in the workspace. Empty string is treated as null. */ + /** Optional, replied / not_replied only. Must name a reply label key in the workspace whose stops_enrollment is false (a stopping label ends the sequence before any branch runs). Empty string is treated as null. */ reply_label_key?: string | null; /** A step of the same campaign, not this step; null or absent ends the path */ yes_step_id?: string | null; From 0c0eef503710f37bfb9a3180adee36cfa087abee Mon Sep 17 00:00:00 2001 From: Ahmustufa Date: Thu, 24 Sep 2026 01:21:03 +0500 Subject: [PATCH 4/9] docs(sequences): cite the branching invariant as 84 after the rebase main now has the audit log at 82 and inbox search at 83 (#252). Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/app/sequencestep/branch.go | 2 +- internal/coreapi/inprocess/branchroute.go | 2 +- internal/platform/db/gen/stepbranch.sql.go | 2 +- internal/platform/db/queries/stepbranch.sql | 2 +- internal/worker/sequence/branching_integration_test.go | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/app/sequencestep/branch.go b/internal/app/sequencestep/branch.go index 8683fc97..21bd4908 100644 --- a/internal/app/sequencestep/branch.go +++ b/internal/app/sequencestep/branch.go @@ -108,7 +108,7 @@ func (s *Service) SetBranch(ctx context.Context, ws, campaignID uuid.UUID, in Br // checkReplyLabel refuses a label that could never fire a branch. // // Reply labels decide what a reply DOES to an enrollment, and a branch never -// overrides that (docs/security.md invariant 82). A label that stops the +// overrides that (docs/security.md invariant 84). A label that stops the // enrollment — the default for every builtin human label — ends the sequence // the moment such a reply arrives, before any branch is consulted, so a branch // naming it would silently never route anyone. Refusing it at save time is the diff --git a/internal/coreapi/inprocess/branchroute.go b/internal/coreapi/inprocess/branchroute.go index b35b9b25..d9da46ca 100644 --- a/internal/coreapi/inprocess/branchroute.go +++ b/internal/coreapi/inprocess/branchroute.go @@ -347,7 +347,7 @@ func (c client) stepSendCreatedAt(ctx context.Context, ws uuid.UUID, b gen.GetSt // // Opens and clicks are keyed on the cursor step's OWN deterministic send id, so // they are "opened THIS step", never an earlier one; they count HUMAN events -// only (invariant 82). Replies span both legs of the conversation: the step went +// only (invariant 84). Replies span both legs of the conversation: the step went // out as a sends row, but the answer lives in inbox_messages, matched on the // enrollment's campaign and contact rather than on any one send. func (c client) firstEvidence(ctx context.Context, ws uuid.UUID, b gen.GetStepEnrollmentBundleRow, diff --git a/internal/platform/db/gen/stepbranch.sql.go b/internal/platform/db/gen/stepbranch.sql.go index 03eabf28..d81959f1 100644 --- a/internal/platform/db/gen/stepbranch.sql.go +++ b/internal/platform/db/gen/stepbranch.sql.go @@ -66,7 +66,7 @@ type FirstHumanTrackingEventAtParams struct { // The earliest HUMAN open or click of one send, at or before window_end. It reads // the stored bot verdict (NOT is_machine) — the same definition CountHumanOpens // reports — rather than deriving its own, so a branch and the open rate can -// never disagree about the same contact (docs/security.md invariant 82). Served +// never disagree about the same contact (docs/security.md invariant 84). Served // by idx_tracking_send_recent (send_id, kind, is_machine, created_at). func (q *Queries) FirstHumanTrackingEventAt(ctx context.Context, arg FirstHumanTrackingEventAtParams) (pgtype.Timestamptz, error) { row := q.db.QueryRow(ctx, firstHumanTrackingEventAt, diff --git a/internal/platform/db/queries/stepbranch.sql b/internal/platform/db/queries/stepbranch.sql index 7d014e8f..7898bd33 100644 --- a/internal/platform/db/queries/stepbranch.sql +++ b/internal/platform/db/queries/stepbranch.sql @@ -57,7 +57,7 @@ SELECT tracking_enabled FROM campaigns WHERE id = $1 AND workspace_id = $2; -- The earliest HUMAN open or click of one send, at or before window_end. It reads -- the stored bot verdict (NOT is_machine) — the same definition CountHumanOpens -- reports — rather than deriving its own, so a branch and the open rate can --- never disagree about the same contact (docs/security.md invariant 82). Served +-- never disagree about the same contact (docs/security.md invariant 84). Served -- by idx_tracking_send_recent (send_id, kind, is_machine, created_at). SELECT created_at FROM tracking_events WHERE send_id = $1 AND workspace_id = $2 AND kind = $3 AND NOT is_machine diff --git a/internal/worker/sequence/branching_integration_test.go b/internal/worker/sequence/branching_integration_test.go index af9a63ad..c9c0a59a 100644 --- a/internal/worker/sequence/branching_integration_test.go +++ b/internal/worker/sequence/branching_integration_test.go @@ -173,7 +173,7 @@ func TestBranchOpenedRoutesYes(t *testing.T) { } // A MACHINE open (a proxy prefetch) is not an open: the branch keeps waiting, -// and when the window closes it routes NO (docs/security.md invariant 82). +// and when the window closes it routes NO (docs/security.md invariant 84). func TestBranchMachineOpenDoesNotCount(t *testing.T) { f, done := seedBranchCampaign(t) defer done() From 940b1af2e35bf9684b1b597073861f0c38c67a72 Mon Sep 17 00:00:00 2001 From: Ahmustufa Date: Thu, 24 Sep 2026 01:21:03 +0500 Subject: [PATCH 5/9] fix(db): serialize migrations with a polling lock so CONCURRENTLY builds can't deadlock golang-migrate's pgx5 driver waits for its advisory lock inside a running statement, which holds a snapshot. CREATE INDEX CONCURRENTLY waits for every snapshot, so a second migrator waiting on the lock deadlocks the build, and the migration is left dirty. This reproduced 3 of 3 times with 6 concurrent migrators on a fresh DB, and it hits both CI (-p 4) and multi-replica deploys. Migrate, MigrateDown, MigrateTo and Version now take a separate session-level lock by polling pg_try_advisory_lock on a dedicated connection. A waiter is idle between tries and holds no snapshot. The wait is bounded (15 min, ErrMigrationLockTimeout), and unlock/close run on a detached context. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/platform/db/export_test.go | 7 + internal/platform/db/migrate.go | 89 +++++++------ internal/platform/db/migratelock.go | 124 ++++++++++++++++++ .../db/migratelock_integration_test.go | 120 +++++++++++++++++ 4 files changed, 301 insertions(+), 39 deletions(-) create mode 100644 internal/platform/db/export_test.go create mode 100644 internal/platform/db/migratelock.go create mode 100644 internal/platform/db/migratelock_integration_test.go diff --git a/internal/platform/db/export_test.go b/internal/platform/db/export_test.go new file mode 100644 index 00000000..a3c1142c --- /dev/null +++ b/internal/platform/db/export_test.go @@ -0,0 +1,7 @@ +package db + +// Test-only handles on the migration lock's internals, for db_test (which has to +// be an external package: dbtest imports db). +const MigrationLockKey = migrationLockKey + +var WithMigrationLockCtx = withMigrationLockCtx diff --git a/internal/platform/db/migrate.go b/internal/platform/db/migrate.go index b15c18ea..66959e93 100644 --- a/internal/platform/db/migrate.go +++ b/internal/platform/db/migrate.go @@ -13,29 +13,24 @@ import ( var migrationsFS embed.FS // Migrate applies all up migrations. It is a no-op if the schema is current. -func Migrate(url string) (err error) { - m, err := newMigrator(url) - if err != nil { - return err - } - defer func() { err = errors.Join(err, closeMigrator(m)) }() - if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { - return err - } - return nil +// Concurrent callers on one database are serialized (see migratelock.go). +func Migrate(url string) error { + return withMigrator(url, func(m *migrate.Migrate) error { + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return err + } + return nil + }) } // MigrateDown rolls back a single migration. -func MigrateDown(url string) (err error) { - m, err := newMigrator(url) - if err != nil { - return err - } - defer func() { err = errors.Join(err, closeMigrator(m)) }() - if err := m.Steps(-1); err != nil && !errors.Is(err, migrate.ErrNoChange) { - return err - } - return nil +func MigrateDown(url string) error { + return withMigrator(url, func(m *migrate.Migrate) error { + if err := m.Steps(-1); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return err + } + return nil + }) } // MigrateTo rolls the schema to an exact version, applying or reverting whatever @@ -51,16 +46,13 @@ func MigrateDown(url string) (err error) { // A test that names the version it wants to land on is correct no matter how many // migrations follow it: MigrateTo(dsn, N-1) undoes migration N whatever N+1, N+2 // do later. -func MigrateTo(url string, version uint) (err error) { - m, err := newMigrator(url) - if err != nil { - return err - } - defer func() { err = errors.Join(err, closeMigrator(m)) }() - if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) { - return err - } - return nil +func MigrateTo(url string, version uint) error { + return withMigrator(url, func(m *migrate.Migrate) error { + if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return err + } + return nil + }) } // Version reports the schema's current migration version and whether it was @@ -69,19 +61,38 @@ func MigrateTo(url string, version uint) (err error) { // error nil — golang-migrate's own ErrNilVersion for that case is a // library-specific sentinel a caller three packages away has no reason to // know about, so it is translated here rather than propagated. +// +// It takes the migration lock like the writers do, because merely opening +// golang-migrate's driver takes the library's own lock (ensureVersionTable), and +// a blocked wait for that lock can deadlock a running CREATE INDEX CONCURRENTLY. +// A status probe during a deploy therefore waits for the migration to finish. func Version(url string) (version uint, dirty bool, err error) { - m, err := newMigrator(url) - if err != nil { - return 0, false, err - } - defer func() { err = errors.Join(err, closeMigrator(m)) }() - version, dirty, err = m.Version() - if errors.Is(err, migrate.ErrNilVersion) { - return 0, false, nil - } + err = withMigrator(url, func(m *migrate.Migrate) error { + var verr error + version, dirty, verr = m.Version() + if errors.Is(verr, migrate.ErrNilVersion) { + version, dirty, verr = 0, false, nil + } + return verr + }) return version, dirty, err } +// withMigrator holds the migration lock (migratelock.go), builds a migrator, +// runs fn and closes the migrator, in that order: the migrator must be +// constructed UNDER the lock, since constructing it is what takes +// golang-migrate's own lock. +func withMigrator(url string, fn func(m *migrate.Migrate) error) error { + return withMigrationLock(url, func() (err error) { + m, err := newMigrator(url) + if err != nil { + return err + } + defer func() { err = errors.Join(err, closeMigrator(m)) }() + return fn(m) + }) +} + func newMigrator(url string) (*migrate.Migrate, error) { src, err := iofs.New(migrationsFS, "migrations") if err != nil { diff --git a/internal/platform/db/migratelock.go b/internal/platform/db/migratelock.go new file mode 100644 index 00000000..e469fba4 --- /dev/null +++ b/internal/platform/db/migratelock.go @@ -0,0 +1,124 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// Serializing migrators BEFORE golang-migrate gets involved. +// +// golang-migrate already takes an advisory lock, and that lock is the problem. +// Its pgx/v5 driver waits for it with a blocking `SELECT pg_advisory_lock(..)` — +// a statement that stays RUNNING, and so holds a snapshot, for as long as the +// wait lasts. `CREATE INDEX CONCURRENTLY` must wait out every snapshot older +// than its own before it can finish. So when two processes migrate the same +// database at once and the first reaches a CONCURRENTLY file, the first waits +// for the second's snapshot while the second waits for the first's lock: a +// deadlock Postgres resolves by aborting the index build, which leaves the +// schema DIRTY at that version. Parallel integration packages on a fresh test +// database and replicas that migrate on boot both hit it. +// +// The fix is to never wait inside a statement. Every entry point below first +// takes a SEPARATE session-level advisory lock by POLLING pg_try_advisory_lock +// — each try is a statement that returns at once, and between tries the +// session is idle and holds no snapshot. Only the holder of that lock ever +// constructs a migrator, so golang-migrate's own lock is always uncontended and +// its blocking wait never happens. +// +// The lock is taken for EVERY migrator, including Version: opening the driver +// runs ensureVersionTable, which takes golang-migrate's lock too, so a status +// probe during a deploy is exactly as able to deadlock a CONCURRENTLY build as +// a second migrator is. + +// migrationLockKey is this package's advisory-lock key. It must differ from +// golang-migrate's own key, which that library derives from the database +// name; this is an arbitrary fixed value ("inroadmg" as bytes). Advisory locks +// are scoped to the database, so two databases on one server never contend. +const migrationLockKey int64 = 0x696e726f61646d67 + +// migrationLockPoll is the pause between lock attempts: short enough that a +// waiter starts promptly after the holder finishes, long enough that N waiters +// polling do not load the server. +const migrationLockPoll = 250 * time.Millisecond + +// migrationLockTimeout bounds how long a process waits for another's migration. +// Generous because a migration can legitimately run for minutes (a backfill, +// an index build on a large table); a waiter that gives up returns an error +// rather than proceeding, so the bound only decides how long a stuck deploy +// takes to say so. +const migrationLockTimeout = 15 * time.Minute + +// ErrMigrationLockTimeout is returned when another process held the migration +// lock for longer than the wait allowed. +var ErrMigrationLockTimeout = errors.New("db: timed out waiting for another process's migration to finish") + +// withMigrationLock runs fn while holding the migration lock on url's database. +// +// The exported migrate functions take no context (neither does golang-migrate's +// API), and they are top-level operations — a binary's startup, a CLI command, a +// test's setup — so this is where their deadline is chosen, rather than +// inherited: migrationLockTimeout for the wait, and fn itself runs unbounded +// under the held lock, exactly as it did before this lock existed. +func withMigrationLock(url string, fn func() error) (err error) { + ctx, cancel := context.WithTimeout(context.Background(), migrationLockTimeout) + defer cancel() + return withMigrationLockCtx(ctx, url, migrationLockPoll, fn) +} + +// withMigrationLockCtx is withMigrationLock with the wait bounded by ctx and +// the poll interval injectable (tests). +func withMigrationLockCtx(ctx context.Context, url string, poll time.Duration, fn func() error) (err error) { + conn, err := pgx.Connect(ctx, WithoutPoolParams(url)) + if err != nil { + return fmt.Errorf("migration lock: connect: %w", err) + } + // Closing the session releases a session-level advisory lock even if the + // explicit unlock below never ran. A fresh context: the wait's may already + // be spent, and releasing must not be cancelled with it. + defer func() { + closeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + err = errors.Join(err, conn.Close(closeCtx)) + }() + + if err := acquireMigrationLock(ctx, conn, poll); err != nil { + return err + } + defer func() { + unlockCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if _, uerr := conn.Exec(unlockCtx, `SELECT pg_advisory_unlock($1)`, migrationLockKey); uerr != nil { + err = errors.Join(err, fmt.Errorf("migration lock: unlock: %w", uerr)) + } + }() + return fn() +} + +// acquireMigrationLock polls pg_try_advisory_lock until it wins or ctx ends. +// Each attempt returns immediately, so between attempts this session is idle — +// no running statement, no snapshot for a concurrent index build to wait on. +func acquireMigrationLock(ctx context.Context, conn *pgx.Conn, poll time.Duration) error { + ticker := time.NewTicker(poll) + defer ticker.Stop() + for { + var got bool + if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`, migrationLockKey).Scan(&got); err != nil { + if ctx.Err() != nil { + return fmt.Errorf("%w: %w", ErrMigrationLockTimeout, ctx.Err()) + } + return fmt.Errorf("migration lock: try: %w", err) + } + if got { + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("%w: %w", ErrMigrationLockTimeout, ctx.Err()) + case <-ticker.C: + } + } +} diff --git a/internal/platform/db/migratelock_integration_test.go b/internal/platform/db/migratelock_integration_test.go new file mode 100644 index 00000000..a12b8815 --- /dev/null +++ b/internal/platform/db/migratelock_integration_test.go @@ -0,0 +1,120 @@ +//go:build integration + +package db_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/inroad/inroad/internal/platform/db" + "github.com/inroad/inroad/internal/platform/db/dbtest" +) + +// The failure this lock exists for, reproduced: several processes migrating the +// SAME fresh database at once. Without the lock, the migrators waiting in +// golang-migrate's blocking pg_advisory_lock hold snapshots that the CREATE +// INDEX CONCURRENTLY in 20260923144758 must wait out, the two waits form a +// cycle, Postgres aborts the index build, and the schema is left dirty. This is +// what the integration suite does on a fresh database with -p 4, and what +// replicas that migrate on boot do on every deploy. +func TestConcurrentMigratorsOnAFreshDatabaseAllSucceed(t *testing.T) { + dsn := dbtest.ScratchDSN(t, "concurrent_migrate") + const migrators = 6 + + var wg sync.WaitGroup + errs := make([]error, migrators) + start := make(chan struct{}) + for i := range migrators { + wg.Add(1) + go func() { + defer wg.Done() + <-start + errs[i] = db.Migrate(dsn) + }() + } + close(start) + wg.Wait() + for i, err := range errs { + if err != nil { + t.Errorf("migrator %d: %v", i, err) + } + } + + version, dirty, err := db.Version(dsn) + if err != nil || dirty || version == 0 { + t.Fatalf("after concurrent migrate: version %d dirty %v err %v", version, dirty, err) + } + + ctx := context.Background() + conn, err := pgx.Connect(ctx, db.WithoutPoolParams(dsn)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = conn.Close(ctx) }() + var valid bool + if err := conn.QueryRow(ctx, ` + SELECT i.indisvalid FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 'idx_inbox_threads_campaign_contact'`).Scan(&valid); err != nil || !valid { + t.Fatalf("CONCURRENTLY index after concurrent migrate: valid=%v err=%v", valid, err) + } +} + +// A waiter gives up with db.ErrMigrationLockTimeout rather than hanging or, worse, +// proceeding without the lock — and while it waits, it holds no running +// statement for a concurrent index build to deadlock against. +func TestMigrationLockWaitIsBoundedAndHoldsNoSnapshot(t *testing.T) { + dsn := dbtest.ScratchDSN(t, "migrate_lock_wait") + ctx := context.Background() + + holder, err := pgx.Connect(ctx, db.WithoutPoolParams(dsn)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = holder.Close(ctx) }() + if _, err := holder.Exec(ctx, `SELECT pg_advisory_lock($1)`, db.MigrationLockKey); err != nil { + t.Fatal(err) + } + + waitCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + done := make(chan error, 1) + ran := false + go func() { + done <- db.WithMigrationLockCtx(waitCtx, dsn, 50*time.Millisecond, func() error { ran = true; return nil }) + }() + + // Mid-wait, no other session on this database is running a statement: the + // waiter is idle between polls, which is the whole point. + time.Sleep(500 * time.Millisecond) + var active int + if err := holder.QueryRow(ctx, ` + SELECT count(*) FROM pg_stat_activity + WHERE datname = current_database() AND pid <> pg_backend_pid() + AND state = 'active' AND query ILIKE '%advisory_lock%'`).Scan(&active); err != nil { + t.Fatal(err) + } + if active != 0 { + t.Errorf("%d session(s) are waiting INSIDE an advisory-lock statement", active) + } + + err = <-done + if !errors.Is(err, db.ErrMigrationLockTimeout) { + t.Fatalf("want db.ErrMigrationLockTimeout, got %v", err) + } + if ran { + t.Fatal("fn ran without the lock") + } + + // Once released, the next caller gets it. + if _, err := holder.Exec(ctx, `SELECT pg_advisory_unlock($1)`, db.MigrationLockKey); err != nil { + t.Fatal(err) + } + if err := db.WithMigrationLockCtx(ctx, dsn, 50*time.Millisecond, func() error { ran = true; return nil }); err != nil || !ran { + t.Fatalf("after release: ran=%v err=%v", ran, err) + } +} From 66d904fe052e857e37743a68c16bad77fa8ddffe Mon Sep 17 00:00:00 2001 From: Ahmustufa Date: Thu, 24 Sep 2026 00:51:50 +0500 Subject: [PATCH 6/9] feat(sequences): optimistic concurrency for step branch writes PUT /branch takes an optional expected_updated_at: absent means last writer wins (as before), null means create-only, and a timestamp means replace only if unchanged. DELETE takes it as a query param. A mismatch returns 409 branch_changed with the current branch (or null). It is enforced atomically in SQL under the existing per-campaign graph lock. Two latent bugs made the token unusable, and both are fixed: - updated_at was serialized to whole seconds (RFC3339), so it could never match exactly. It is now RFC3339Nano (microseconds). - updated_at did not always advance. now() is the transaction start (taken before the lock), two writes in one tx collided, and ON DELETE SET NULL left it untouched. A trigger now sets greatest(clock_timestamp(), old + 1us). Co-Authored-By: Claude Opus 5.5 (1M context) --- api/openapi.yaml | 48 ++- docs/src/content/docs/security.md | 18 + internal/app/sequencestep/branch.go | 13 +- .../sequencestep/branch_integration_test.go | 4 +- internal/app/sequencestep/branch_test.go | 46 ++- internal/app/sequencestep/branchhandler.go | 113 ++++++- .../app/sequencestep/branchhandler_test.go | 130 ++++++++ .../branchprecondition_integration_test.go | 311 ++++++++++++++++++ internal/app/sequencestep/branchstore.go | 165 +++++++++- .../db/branchmigrations_integration_test.go | 21 ++ internal/platform/db/gen/stepbranch.sql.go | 173 +++++++++- ...4_step_branch_updated_at_advances.down.sql | 2 + ...044_step_branch_updated_at_advances.up.sql | 36 ++ internal/platform/db/queries/stepbranch.sql | 47 ++- web/src/store/api.ts | 45 ++- 15 files changed, 1119 insertions(+), 53 deletions(-) create mode 100644 internal/app/sequencestep/branchprecondition_integration_test.go create mode 100644 internal/platform/db/migrations/20260923161044_step_branch_updated_at_advances.down.sql create mode 100644 internal/platform/db/migrations/20260923161044_step_branch_updated_at_advances.up.sql diff --git a/api/openapi.yaml b/api/openapi.yaml index 1d5bbb92..fe515f1a 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2316,6 +2316,16 @@ paths: a step - including a loop closed by another step's linear fall-through - and with 422 (code unknown_target) when an exit names a step outside this campaign. + + + Optimistic concurrency. Without expected_updated_at the write replaces + whatever branch the step has (last writer wins). With it, the write + applies only if the step is still as the client last saw it - null + means "the step has no branch" (create-only), a timestamp means "the + branch's updated_at is still exactly this". Otherwise nothing is written + and the response is 409 (code branch_changed) carrying the branch as it + is now in current (null if the step has none). The precondition is + checked after the body is validated. requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/StepBranchRequest' } } } @@ -2327,6 +2337,9 @@ paths: '401': { description: Unauthorized } '403': { description: "Insufficient scope (campaigns:write)" } '404': { description: Campaign or step not found } + '409': + description: "expected_updated_at no longer holds - the branch was created, replaced or removed since it was read (code branch_changed, with current)" + content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } } '422': description: "The graph refuses the edit (codes cycle, unknown_target)" content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } } @@ -2336,15 +2349,32 @@ paths: security: [{ bearerAuth: [] }] description: >- Remove the step's branch, returning it to linear fall-through (the next - step by step_order). Idempotent. Allowed on a running campaign; a contact - waiting on the removed condition proceeds to the fall-through step, which - still waits out its own delay_seconds from this step's send. Refused with - 422 (code cycle) if the restored fall-through would close a loop. + step by step_order). Idempotent without expected_updated_at. Allowed on a + running campaign; a contact waiting on the removed condition proceeds to + the fall-through step, which still waits out its own delay_seconds from + this step's send. Refused with 422 (code cycle) if the restored + fall-through would close a loop. + + + With expected_updated_at the branch is removed only if its updated_at + is still exactly that value; otherwise 409 (code branch_changed) with + the branch as it is now in current - null when someone else already + removed it, which a client may treat as done. + parameters: + - name: expected_updated_at + in: query + required: false + description: "The branch's updated_at exactly as this API returned it (URL-encoded). Omit for an unconditional delete." + schema: { type: string, format: date-time } responses: '204': { description: Removed (or there was none) } + '400': { description: "expected_updated_at is not a timestamp this API could have returned" } '401': { description: Unauthorized } '403': { description: "Insufficient scope (campaigns:write)" } '404': { description: Campaign or step not found } + '409': + description: "expected_updated_at no longer holds (code branch_changed, with current)" + content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } } '422': description: The restored fall-through would close a loop (code cycle) content: { application/json: { schema: { $ref: '#/components/schemas/BranchValidationError' } } } @@ -5172,7 +5202,7 @@ components: reply_label_key: { type: string, nullable: true, description: "replied / not_replied only: count only replies classified with this reply label key (always a label that does not stop the sequence)" } yes_step_id: { type: string, format: uuid, nullable: true, description: "Where a true condition (or always) goes; null ends the path" } no_step_id: { type: string, format: uuid, nullable: true, description: "Where a false condition goes; null ends the path; always null for always" } - updated_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time, description: "When the branch last changed, at microsecond precision. Also its concurrency token - send it back verbatim as expected_updated_at (re-formatting through a millisecond clock such as a JS Date loses digits and never matches). Advances on every change, including an exit nulled because its target step was deleted." } StepBranchRequest: type: object required: [condition] @@ -5182,6 +5212,7 @@ components: reply_label_key: { type: string, nullable: true, description: "Optional, replied / not_replied only. Must name a reply label key in the workspace whose stops_enrollment is false (a stopping label ends the sequence before any branch runs). Empty string is treated as null." } yes_step_id: { type: string, format: uuid, nullable: true, description: "A step of the same campaign, not this step; null or absent ends the path" } no_step_id: { type: string, format: uuid, nullable: true, description: "As yes_step_id; must be null or absent for always" } + expected_updated_at: { type: string, format: date-time, nullable: true, description: "Optional precondition. Absent - no check (last writer wins). null - apply only if the step has no branch. A timestamp - apply only if the branch's updated_at is still exactly this (the StepBranch.updated_at value verbatim). A failed check is 409 code branch_changed." } CampaignGraphNode: type: object required: [step_id, step_order, default_next_step_id, branch] @@ -5208,11 +5239,16 @@ components: error: { type: string, description: Human-readable message } code: type: string - enum: [invalid_condition, invalid_within_days, invalid_reply_label, reply_label_stops_sequence, tracking_required, no_exit_not_allowed, unknown_step, unknown_target, cycle] + enum: [invalid_condition, invalid_within_days, invalid_reply_label, reply_label_stops_sequence, tracking_required, no_exit_not_allowed, unknown_step, unknown_target, cycle, branch_changed] step_ids: type: array items: { type: string, format: uuid } description: "code cycle only: the steps on the loop, in path order" + current: + allOf: [{ $ref: '#/components/schemas/StepBranch' }] + type: object + nullable: true + description: "code branch_changed only, and always present then: the step's branch as it is now, or null if it has none" CreateCampaignRequest: type: object required: [name, mailbox_id, list_id, subject] diff --git a/docs/src/content/docs/security.md b/docs/src/content/docs/security.md index 7f7e087f..e1ade168 100644 --- a/docs/src/content/docs/security.md +++ b/docs/src/content/docs/security.md @@ -2256,6 +2256,24 @@ write history that never happened. inside the transaction. The send path has a runtime loop backstop that ends the path rather than recovering-forward forever. + **Write preconditions do not widen tenancy.** A branch PUT/DELETE may carry + `expected_updated_at` (optimistic concurrency, so two editors cannot + silently overwrite each other). It is enforced in the write's own `WHERE` + clause (`InsertBranchIfAbsent`, `UpdateBranchIfUnchanged`, + `DeleteBranchIfUnchanged`) under the same graph lock, and the lock still + runs first: a foreign workspace gets the same 404 whatever it expects, and + never reaches the 409. The 409's `current` branch is read with the same + `(step_id, campaign_id, workspace_id)` pin as every other branch read, so it + can only ever show the caller its own workspace's row. The token is + `updated_at`, advanced by a trigger on every insert and update (including + the `ON DELETE SET NULL` of an exit) to a strictly later value than the + row's previous one, so a live row's token cannot repeat and let a stale + write through. (A branch deleted and re-created starts from the database + clock; only a clock stepping back to the exact microsecond of the deleted + row's token could collide.) It is a lost-update guard between + cooperating editors, not an access control: a client that omits it gets + last-writer-wins, exactly as before. + **A branch never overrides reply-label automation.** A reply whose label stops the enrollment still stops it; compliance dispatch (invariants 20, 45) is untouched. A branch only routes enrollments the labels leave active, and diff --git a/internal/app/sequencestep/branch.go b/internal/app/sequencestep/branch.go index 21bd4908..8e3633bb 100644 --- a/internal/app/sequencestep/branch.go +++ b/internal/app/sequencestep/branch.go @@ -63,6 +63,10 @@ func (s *Service) Graph(ctx context.Context, ws, campaignID uuid.UUID) (Graph, e // inside the store's transaction, under the campaign's graph lock, against the // graph that is actually being committed — the only place a loop formed by two // concurrent edits can be caught. +// +// in.Expect, when set, is enforced by that same write (see +// BranchStore.UpsertBranch). It is checked after validation, so a request that +// is both stale and malformed is told it is malformed. func (s *Service) SetBranch(ctx context.Context, ws, campaignID uuid.UUID, in BranchInput) (gen.SequenceStepBranch, error) { if err := s.requireCampaign(ctx, ws, campaignID); err != nil { return gen.SequenceStepBranch{}, err @@ -188,16 +192,17 @@ func (s *Service) checkTrackable(ctx context.Context, ws, campaignID uuid.UUID, } // DeleteBranch removes the router on one step, returning it to linear -// fall-through. Idempotent. Allowed live, for the reason SetBranch is; refused -// with a CycleError when the restored fall-through would close a loop. -func (s *Service) DeleteBranch(ctx context.Context, ws, campaignID, stepID uuid.UUID) error { +// fall-through. Idempotent without a precondition. Allowed live, for the reason +// SetBranch is; refused with a CycleError when the restored fall-through would +// close a loop, and with a *BranchChangedError when expect no longer holds. +func (s *Service) DeleteBranch(ctx context.Context, ws, campaignID, stepID uuid.UUID, expect BranchPrecondition) error { if err := s.requireCampaign(ctx, ws, campaignID); err != nil { return err } if _, err := s.stepInCampaign(ctx, ws, campaignID, stepID); err != nil { return err } - return s.branches.DeleteBranch(ctx, ws, campaignID, stepID, checkGraph) + return s.branches.DeleteBranch(ctx, ws, campaignID, stepID, expect, checkGraph) } // checkGraph is the one GraphCheck every graph-changing write commits under. diff --git a/internal/app/sequencestep/branch_integration_test.go b/internal/app/sequencestep/branch_integration_test.go index 3768a61d..cf347bf0 100644 --- a/internal/app/sequencestep/branch_integration_test.go +++ b/internal/app/sequencestep/branch_integration_test.go @@ -110,7 +110,7 @@ func TestBranchSaveAndReadBack(t *testing.T) { t.Fatalf("graph branches = %+v", g.Branches) } - if err := b.svc.DeleteBranch(ctx, b.ws, b.campaign, b.steps[0]); err != nil { + if err := b.svc.DeleteBranch(ctx, b.ws, b.campaign, b.steps[0], BranchPrecondition{}); err != nil { t.Fatalf("delete: %v", err) } if g, _ := b.svc.Graph(ctx, b.ws, b.campaign); len(g.Branches) != 0 { @@ -214,7 +214,7 @@ func TestBranchWritesAreWorkspacePinned(t *testing.T) { if _, err := store.UpsertBranch(ctx, intruder.ID, BranchInput{CampaignID: b.campaign, StepID: b.steps[0], Condition: "always"}, checkGraph); !errors.Is(err, ErrCampaignNotFound) { t.Fatalf("store: want ErrCampaignNotFound, got %v", err) } - if err := store.DeleteBranch(ctx, intruder.ID, b.campaign, b.steps[0], checkGraph); !errors.Is(err, ErrCampaignNotFound) { + if err := store.DeleteBranch(ctx, intruder.ID, b.campaign, b.steps[0], BranchPrecondition{}, checkGraph); !errors.Is(err, ErrCampaignNotFound) { t.Fatalf("store delete: want ErrCampaignNotFound, got %v", err) } if rows, _ := b.q.ListBranchesByCampaign(ctx, gen.ListBranchesByCampaignParams{CampaignID: b.campaign, WorkspaceID: intruder.ID}); len(rows) != 0 { diff --git a/internal/app/sequencestep/branch_test.go b/internal/app/sequencestep/branch_test.go index 119c71e5..5feb6fbc 100644 --- a/internal/app/sequencestep/branch_test.go +++ b/internal/app/sequencestep/branch_test.go @@ -3,7 +3,9 @@ package sequencestep import ( "context" "errors" + "slices" "testing" + "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -25,6 +27,8 @@ type fakeBranchStore struct { // trackingOff models a campaign with tracking disabled. trackingOff bool upserts int + // expects records every precondition the store was handed, in call order. + expects []BranchPrecondition } func (f *fakeBranchStore) ListBranches(context.Context, uuid.UUID, uuid.UUID) ([]gen.SequenceStepBranch, error) { @@ -41,6 +45,7 @@ func (f *fakeBranchStore) TrackingEnabled(context.Context, uuid.UUID, uuid.UUID) } func (f *fakeBranchStore) UpsertBranch(_ context.Context, ws uuid.UUID, in BranchInput, check GraphCheck) (gen.SequenceStepBranch, error) { + f.expects = append(f.expects, in.Expect) row := gen.SequenceStepBranch{ StepID: in.StepID, WorkspaceID: ws, CampaignID: in.CampaignID, Condition: in.Condition, WithinDays: in.WithinDays, ReplyLabelKey: in.ReplyLabelKey, @@ -63,7 +68,8 @@ func (f *fakeBranchStore) UpsertBranch(_ context.Context, ws uuid.UUID, in Branc return row, nil } -func (f *fakeBranchStore) DeleteBranch(_ context.Context, _, _, stepID uuid.UUID, check GraphCheck) error { +func (f *fakeBranchStore) DeleteBranch(_ context.Context, _, _, stepID uuid.UUID, expect BranchPrecondition, check GraphCheck) error { + f.expects = append(f.expects, expect) prev, had := f.branches[stepID] delete(f.branches, stepID) if err := check(f.steps, f.list()); err != nil { @@ -171,7 +177,7 @@ func TestGraphEndpointsDoNotReportADatabaseErrorAsNotFound(t *testing.T) { ctx := context.Background() _, gerr := svc.Graph(ctx, f.ws, f.campaign) _, serr := svc.SetBranch(ctx, f.ws, f.campaign, BranchInput{StepID: f.step[0], Condition: "always"}) - derr := svc.DeleteBranch(ctx, f.ws, f.campaign, f.step[0]) + derr := svc.DeleteBranch(ctx, f.ws, f.campaign, f.step[0], BranchPrecondition{}) for name, err := range map[string]error{"Graph": gerr, "SetBranch": serr, "DeleteBranch": derr} { if !errors.Is(err, boom) || errors.Is(err, ErrCampaignNotFound) { t.Errorf("%s: got %v, want the wrapped database error", name, err) @@ -335,7 +341,7 @@ func TestDeleteBranchRefusedWhenFallThroughClosesLoop(t *testing.T) { }); err != nil { t.Fatalf("2 -> 1 while 1 ends is acyclic: %v", err) } - err := f.svc.DeleteBranch(ctx, f.ws, f.campaign, f.step[0]) + err := f.svc.DeleteBranch(ctx, f.ws, f.campaign, f.step[0], BranchPrecondition{}) if seqgraph.CodeOf(err) != seqgraph.CodeCycle { t.Fatalf("want a cycle refusal, got %v", err) } @@ -346,7 +352,7 @@ func TestDeleteBranchRefusedWhenFallThroughClosesLoop(t *testing.T) { func TestDeleteBranchIsIdempotent(t *testing.T) { f := newBranchFixture(t, "running") - if err := f.svc.DeleteBranch(context.Background(), f.ws, f.campaign, f.step[0]); err != nil { + if err := f.svc.DeleteBranch(context.Background(), f.ws, f.campaign, f.step[0], BranchPrecondition{}); err != nil { t.Fatalf("deleting a router that does not exist is a no-op: %v", err) } } @@ -401,3 +407,35 @@ func TestCheckGraphAcceptsEveryLinearCampaign(t *testing.T) { t.Fatalf("empty campaign refused: %v", err) } } + +// The precondition is enforced by the store, in the write; the service's job is +// to hand it over untouched on both writes, and to refuse a malformed branch +// before any precondition is consulted. +func TestBranchPreconditionReachesTheStore(t *testing.T) { + ctx := context.Background() + f := newBranchFixture(t, "running") + at := time.Date(2026, 9, 23, 16, 10, 44, 123456000, time.UTC) + + if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, BranchInput{ + StepID: f.step[0], Condition: "always", Expect: ExpectNoBranch(), + }); err != nil { + t.Fatalf("SetBranch: %v", err) + } + if err := f.svc.DeleteBranch(ctx, f.ws, f.campaign, f.step[0], ExpectBranchAt(at)); err != nil { + t.Fatalf("DeleteBranch: %v", err) + } + want := []BranchPrecondition{ExpectNoBranch(), ExpectBranchAt(at)} + if !slices.Equal(f.branches.expects, want) { + t.Fatalf("store saw %+v, want %+v", f.branches.expects, want) + } + + f = newBranchFixture(t, "running") + if _, err := f.svc.SetBranch(ctx, f.ws, f.campaign, BranchInput{ + StepID: f.step[0], Condition: "bounced", Expect: ExpectBranchAt(at), + }); seqgraph.CodeOf(err) != seqgraph.CodeInvalidCondition { + t.Fatalf("a malformed branch is refused as malformed, got %v", err) + } + if len(f.branches.expects) != 0 { + t.Fatal("a malformed branch must not reach the store") + } +} diff --git a/internal/app/sequencestep/branchhandler.go b/internal/app/sequencestep/branchhandler.go index 1ca8501d..146fcaf4 100644 --- a/internal/app/sequencestep/branchhandler.go +++ b/internal/app/sequencestep/branchhandler.go @@ -3,6 +3,7 @@ package sequencestep import ( "encoding/json" "errors" + "fmt" "net/http" "time" @@ -23,6 +24,76 @@ type branchRequest struct { ReplyLabelKey *string `json:"reply_label_key"` YesStepID *uuid.UUID `json:"yes_step_id"` NoStepID *uuid.UUID `json:"no_step_id"` + // ExpectedUpdatedAt is the optimistic-concurrency precondition, and its + // three states mean three different things: absent = none, null = "expect no + // branch", a timestamp = "expect the branch at this updated_at". + ExpectedUpdatedAt presentField `json:"expected_updated_at"` +} + +// presentField records whether a JSON field was sent at all, and its raw value +// if so. encoding/json calls UnmarshalJSON for an explicit null on a non-pointer +// field, so this is the one way to tell an absent field from a null one. +type presentField struct { + set bool + raw json.RawMessage +} + +func (f *presentField) UnmarshalJSON(b []byte) error { + f.set = true + f.raw = append(f.raw[:0], b...) + return nil +} + +// expectedUpdatedAtParam is the DELETE precondition's query parameter, named +// like the PUT body field it mirrors. +const expectedUpdatedAtParam = "expected_updated_at" + +// bodyPrecondition reads expected_updated_at from a PUT body. +func bodyPrecondition(f presentField) (BranchPrecondition, error) { + if !f.set { + return BranchPrecondition{}, nil + } + if string(f.raw) == "null" { + return ExpectNoBranch(), nil + } + var raw string + if err := json.Unmarshal(f.raw, &raw); err != nil { + return BranchPrecondition{}, errors.New("expected_updated_at must be a timestamp string or null") + } + at, err := parseExpectedUpdatedAt(raw) + if err != nil { + return BranchPrecondition{}, err + } + return ExpectBranchAt(at), nil +} + +// queryPrecondition reads expected_updated_at from a DELETE's query string. +// There is no "expect no branch" form: deleting nothing needs no guard. +func queryPrecondition(r *http.Request) (BranchPrecondition, error) { + q := r.URL.Query() + if !q.Has(expectedUpdatedAtParam) { + return BranchPrecondition{}, nil + } + at, err := parseExpectedUpdatedAt(q.Get(expectedUpdatedAtParam)) + if err != nil { + return BranchPrecondition{}, err + } + return ExpectBranchAt(at), nil +} + +// parseExpectedUpdatedAt accepts an updated_at exactly as toBranchResponse +// serializes it. The column is microsecond-precision, so a value with finer +// digits cannot be one the API returned; it is refused rather than truncated, +// because truncating would let a value that differs from the stored one match. +func parseExpectedUpdatedAt(raw string) (time.Time, error) { + at, err := time.Parse(time.RFC3339Nano, raw) + if err != nil { + return time.Time{}, fmt.Errorf("expected_updated_at must be an RFC3339 timestamp: %w", err) + } + if at.Nanosecond()%int(time.Microsecond) != 0 { + return time.Time{}, errors.New("expected_updated_at has sub-microsecond digits; send updated_at back exactly as it was returned") + } + return at, nil } // branchResponse is the wire shape of one router (StepBranch). @@ -64,6 +135,19 @@ type graphErrorResponse struct { StepIDs []string `json:"step_ids,omitempty"` } +// CodeBranchChanged is the BranchValidationError code of a refused +// expected_updated_at precondition (409). +const CodeBranchChanged = "branch_changed" + +// branchChangedResponse is the 409 for a refused precondition: the same error +// shape plus the branch as it is now. current is always present — null means +// the step now has no branch — so it is not omitempty. +type branchChangedResponse struct { + Error string `json:"error"` + Code string `json:"code"` + Current *branchResponse `json:"current"` +} + // Graph handles GET /campaigns/{id}/graph. func (h *Handler) Graph(w http.ResponseWriter, r *http.Request) { ws, ok := auth.WorkspaceID(w, r) @@ -94,6 +178,11 @@ func (h *Handler) SetBranch(w http.ResponseWriter, r *http.Request) { httpx.Error(w, http.StatusBadRequest, "invalid json") return } + expect, err := bodyPrecondition(req.ExpectedUpdatedAt) + if err != nil { + httpx.Error(w, http.StatusBadRequest, err.Error()) + return + } // An empty label is "no label", not a label whose key is "". if req.ReplyLabelKey != nil && *req.ReplyLabelKey == "" { req.ReplyLabelKey = nil @@ -101,6 +190,7 @@ func (h *Handler) SetBranch(w http.ResponseWriter, r *http.Request) { b, err := h.svc.SetBranch(r.Context(), ws, campaignID, BranchInput{ StepID: stepID, Condition: req.Condition, WithinDays: req.WithinDays, ReplyLabelKey: req.ReplyLabelKey, YesStepID: req.YesStepID, NoStepID: req.NoStepID, + Expect: expect, }) if err != nil { writeGraphError(w, err, "could not save branch") @@ -115,7 +205,12 @@ func (h *Handler) DeleteBranch(w http.ResponseWriter, r *http.Request) { if !ok { return } - if err := h.svc.DeleteBranch(r.Context(), ws, campaignID, stepID); err != nil { + expect, err := queryPrecondition(r) + if err != nil { + httpx.Error(w, http.StatusBadRequest, err.Error()) + return + } + if err := h.svc.DeleteBranch(r.Context(), ws, campaignID, stepID, expect); err != nil { writeGraphError(w, err, "could not remove branch") return } @@ -147,10 +242,19 @@ func campaignAndStepIDs(w http.ResponseWriter, r *http.Request) (ws, campaignID, // A malformed branch (seqgraph shape codes) is 400, like any other invalid // body. A well-formed edit the GRAPH refuses — an exit to a step outside the // campaign, or a loop — is 422: the request was understood and is the wrong -// thing to commit. Both carry `code`. +// thing to commit. Both carry `code`. A refused expected_updated_at is 409 +// (code branch_changed) with the current branch. func writeGraphError(w http.ResponseWriter, err error, fallback string) { var cycle *seqgraph.CycleError + var changed *BranchChangedError switch code := seqgraph.CodeOf(err); { + case errors.As(err, &changed): + resp := branchChangedResponse{Error: changed.Error(), Code: CodeBranchChanged} + if changed.Current != nil { + current := toBranchResponse(*changed.Current) + resp.Current = ¤t + } + httpx.JSON(w, http.StatusConflict, resp) case errors.Is(err, ErrCampaignNotFound): httpx.Error(w, http.StatusNotFound, "campaign not found") case errors.Is(err, ErrNotFound), errors.Is(err, ErrBranchConflict): @@ -200,7 +304,10 @@ func toBranchResponse(b gen.SequenceStepBranch) branchResponse { out := branchResponse{ StepID: b.StepID.String(), Condition: b.Condition, WithinDays: b.WithinDays, ReplyLabelKey: b.ReplyLabelKey, - UpdatedAt: b.UpdatedAt.Time.UTC().Format(time.RFC3339), + // Full precision: updated_at is the concurrency token a client sends + // back as expected_updated_at, and a value rounded to the second would + // never match the stored microseconds. + UpdatedAt: b.UpdatedAt.Time.UTC().Format(time.RFC3339Nano), } if b.YesStepID.Valid { out.YesStepID = uuidString(b.YesStepID.Bytes) diff --git a/internal/app/sequencestep/branchhandler_test.go b/internal/app/sequencestep/branchhandler_test.go index 60fba6b7..80d649e8 100644 --- a/internal/app/sequencestep/branchhandler_test.go +++ b/internal/app/sequencestep/branchhandler_test.go @@ -6,10 +6,15 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" + "strings" "testing" + "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/inroad/inroad/internal/platform/db/gen" "github.com/inroad/inroad/internal/platform/seqgraph" ) @@ -51,3 +56,128 @@ func TestWriteGraphErrorContract(t *testing.T) { }) } } + +// expected_updated_at has three states in a PUT body, and each means something +// different: confusing absent with null would turn every legacy write into a +// create-only write, and confusing null with absent would drop the guard. +func TestBodyPreconditionDistinguishesAbsentNullAndTimestamp(t *testing.T) { + at := time.Date(2026, 9, 23, 16, 10, 44, 120000000, time.UTC) + cases := []struct { + body string + want BranchPrecondition + }{ + {`{"condition":"always"}`, BranchPrecondition{}}, + {`{"condition":"always","expected_updated_at":null}`, ExpectNoBranch()}, + {`{"condition":"always","expected_updated_at":"2026-09-23T16:10:44.12Z"}`, ExpectBranchAt(at)}, + } + for _, tc := range cases { + var req branchRequest + if err := json.Unmarshal([]byte(tc.body), &req); err != nil { + t.Fatalf("%s: %v", tc.body, err) + } + got, err := bodyPrecondition(req.ExpectedUpdatedAt) + if err != nil { + t.Fatalf("%s: %v", tc.body, err) + } + if got.kind != tc.want.kind || !got.updatedAt.Equal(tc.want.updatedAt) { + t.Fatalf("%s: got %+v, want %+v", tc.body, got, tc.want) + } + } +} + +func TestBodyPreconditionRefusesWhatCannotBeAStoredValue(t *testing.T) { + for _, raw := range []string{ + `12345`, + `"yesterday"`, + `""`, + // Nanoseconds: the column stores microseconds, so this is not a value the + // API returned, and truncating it would let a DIFFERENT value match. + `"2026-09-23T16:10:44.123456789Z"`, + } { + var req branchRequest + if err := json.Unmarshal([]byte(`{"expected_updated_at":`+raw+`}`), &req); err != nil { + t.Fatalf("%s: decode: %v", raw, err) + } + if _, err := bodyPrecondition(req.ExpectedUpdatedAt); err == nil { + t.Errorf("%s: accepted", raw) + } + } +} + +func TestQueryPrecondition(t *testing.T) { + at := time.Date(2026, 9, 23, 16, 10, 44, 123456000, time.UTC) + req := func(query string) *http.Request { + return httptest.NewRequestWithContext(t.Context(), http.MethodDelete, "/campaigns/x/steps/y/branch"+query, http.NoBody) + } + if got, err := queryPrecondition(req("")); err != nil || got != (BranchPrecondition{}) { + t.Fatalf("absent: %+v, %v", got, err) + } + got, err := queryPrecondition(req("?expected_updated_at=" + url.QueryEscape("2026-09-23T16:10:44.123456Z"))) + if err != nil || got.kind != preconditionAt || !got.updatedAt.Equal(at) { + t.Fatalf("timestamp: %+v, %v", got, err) + } + // An offset's '+' must be escaped; unescaped it decodes to a space and the + // value is refused rather than misread. + for _, q := range []string{"?expected_updated_at=", "?expected_updated_at=nope", "?expected_updated_at=2026-09-23T18:10:44+02:00"} { + if _, err := queryPrecondition(req(q)); err == nil { + t.Errorf("%q accepted", q) + } + } +} + +// updated_at is the token a client echoes back, so what the API serializes must +// parse to exactly the stored instant — including microseconds that end in +// zeros (RFC3339Nano trims them) and a whole second (no fraction at all). +func TestUpdatedAtRoundTripsExactly(t *testing.T) { + for _, nanos := range []int{0, 100000000, 123456000, 1000, 999999000} { + stored := time.Date(2026, 9, 23, 16, 10, 44, nanos, time.FixedZone("x", 3600)) + resp := toBranchResponse(gen.SequenceStepBranch{ + StepID: uuid.New(), Condition: "always", UpdatedAt: pgtype.Timestamptz{Time: stored, Valid: true}, + }) + back, err := parseExpectedUpdatedAt(resp.UpdatedAt) + if err != nil { + t.Fatalf("%q does not parse back: %v", resp.UpdatedAt, err) + } + if !back.Equal(stored) { + t.Fatalf("%q round-trips to %v, want %v", resp.UpdatedAt, back, stored) + } + } +} + +// A refused precondition is 409 with the branch that won; when the step has no +// branch any more, current is an explicit null, not a missing key. +func TestWriteGraphErrorBranchChanged(t *testing.T) { + step := uuid.New() + row := gen.SequenceStepBranch{StepID: step, Condition: "always", UpdatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}} + cases := []struct { + name string + err error + current bool + }{ + {"replaced", &BranchChangedError{Current: &row}, true}, + {"removed", fmt.Errorf("tx: %w", &BranchChangedError{}), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + writeGraphError(rec, tc.err, "fallback") + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409", rec.Code) + } + var body map[string]json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if string(body["code"]) != `"branch_changed"` || len(body["error"]) < 3 { + t.Fatalf("body = %s", rec.Body.String()) + } + raw, present := body["current"] + if !present { + t.Fatalf("current must always be present: %s", rec.Body.String()) + } + if tc.current != (string(raw) != "null") || (tc.current && !strings.Contains(string(raw), step.String())) { + t.Fatalf("current = %s", raw) + } + }) + } +} diff --git a/internal/app/sequencestep/branchprecondition_integration_test.go b/internal/app/sequencestep/branchprecondition_integration_test.go new file mode 100644 index 00000000..554a1e01 --- /dev/null +++ b/internal/app/sequencestep/branchprecondition_integration_test.go @@ -0,0 +1,311 @@ +//go:build integration + +package sequencestep + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/inroad/inroad/internal/platform/db/gen" +) + +// wireToken is updated_at as a client holds it: serialized by the handler and +// parsed back the way the handler parses expected_updated_at. Every test goes +// through it, so a precision loss anywhere on the wire fails here rather than +// only in a browser. +func wireToken(t *testing.T, b gen.SequenceStepBranch) BranchPrecondition { + t.Helper() + at, err := parseExpectedUpdatedAt(toBranchResponse(b).UpdatedAt) + if err != nil { + t.Fatalf("updated_at %q does not parse back: %v", toBranchResponse(b).UpdatedAt, err) + } + return ExpectBranchAt(at) +} + +func changedErr(t *testing.T, err error) *BranchChangedError { + t.Helper() + var changed *BranchChangedError + if !errors.As(err, &changed) { + t.Fatalf("want *BranchChangedError, got %v", err) + } + return changed +} + +func (b branchIT) branchOf(t *testing.T, step uuid.UUID) *gen.SequenceStepBranch { + t.Helper() + g, err := b.svc.Graph(context.Background(), b.ws, b.campaign) + if err != nil { + t.Fatal(err) + } + for _, br := range g.Branches { + if br.StepID == step { + return &br + } + } + return nil +} + +func TestBranchPreconditionMatchingApplies(t *testing.T) { + b, done := newBranchIT(t, "Precondition match") + defer done() + ctx := context.Background() + + created, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{ + StepID: b.steps[0], Condition: "always", YesStepID: &b.steps[2], Expect: ExpectNoBranch(), + }) + if err != nil { + t.Fatalf("create-if-absent on a step with no branch: %v", err) + } + replaced, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{ + StepID: b.steps[0], Condition: "always", Expect: wireToken(t, created), + }) + if err != nil { + t.Fatalf("replace at the current version: %v", err) + } + if replaced.YesStepID.Valid || !replaced.UpdatedAt.Time.After(created.UpdatedAt.Time) { + t.Fatalf("replaced = %+v (was %v)", replaced, created.UpdatedAt.Time) + } + if err := b.svc.DeleteBranch(ctx, b.ws, b.campaign, b.steps[0], wireToken(t, replaced)); err != nil { + t.Fatalf("delete at the current version: %v", err) + } + if got := b.branchOf(t, b.steps[0]); got != nil { + t.Fatalf("branch survived its delete: %+v", got) + } +} + +// The core promise: a write made against a branch someone else has since +// changed is refused, writes nothing, and says what the branch is now. +func TestBranchPreconditionStaleIsRefusedWithCurrent(t *testing.T) { + b, done := newBranchIT(t, "Precondition stale") + defer done() + ctx := context.Background() + + v1, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", YesStepID: &b.steps[2]}) + if err != nil { + t.Fatal(err) + } + stale := wireToken(t, v1) + // Another tab saves first. + v2, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", Expect: stale}) + if err != nil { + t.Fatal(err) + } + + _, err = b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{ + StepID: b.steps[0], Condition: "always", YesStepID: &b.steps[1], Expect: stale, + }) + changed := changedErr(t, err) + if changed.Current == nil || changed.Current.YesStepID.Valid || !changed.Current.UpdatedAt.Time.Equal(v2.UpdatedAt.Time) { + t.Fatalf("current = %+v, want the second tab's save %+v", changed.Current, v2) + } + if got := b.branchOf(t, b.steps[0]); got == nil || got.YesStepID.Valid || !got.UpdatedAt.Time.Equal(v2.UpdatedAt.Time) { + t.Fatalf("a refused write changed the branch: %+v", got) + } + + // "Expect no branch" against a step that now has one is the same refusal. + _, err = b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", Expect: ExpectNoBranch()}) + if changed := changedErr(t, err); changed.Current == nil || changed.Current.StepID != b.steps[0] { + t.Fatalf("current = %+v", changed.Current) + } + + // Expecting a version of a branch that is gone reports current = nil. + if err := b.svc.DeleteBranch(ctx, b.ws, b.campaign, b.steps[0], BranchPrecondition{}); err != nil { + t.Fatal(err) + } + _, err = b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", Expect: wireToken(t, v2)}) + if changed := changedErr(t, err); changed.Current != nil { + t.Fatalf("current = %+v, want nil for a removed branch", changed.Current) + } + if got := b.branchOf(t, b.steps[0]); got != nil { + t.Fatalf("an update-if-unchanged recreated a removed branch: %+v", got) + } +} + +// No precondition is exactly the old contract: last writer wins, delete is +// idempotent. API clients and agents that never send the field keep working. +func TestBranchWithoutPreconditionIsLastWriterWins(t *testing.T) { + b, done := newBranchIT(t, "Precondition absent") + defer done() + ctx := context.Background() + b.always(t, b.steps[0], &b.steps[2]) + b.always(t, b.steps[0], nil) + if got := b.branchOf(t, b.steps[0]); got == nil || got.YesStepID.Valid { + t.Fatalf("second unconditional write did not win: %+v", got) + } + for range 2 { + if err := b.svc.DeleteBranch(ctx, b.ws, b.campaign, b.steps[0], BranchPrecondition{}); err != nil { + t.Fatalf("unconditional delete: %v", err) + } + } +} + +// Two editors create a branch on the same empty step at the same moment, each +// expecting none: exactly one wins, and the loser is told what the winner saved. +func TestBranchCreateIfAbsentRace(t *testing.T) { + b, done := newBranchIT(t, "Precondition race") + defer done() + ctx := context.Background() + + targets := []*uuid.UUID{&b.steps[1], &b.steps[2]} + rows := make([]gen.SequenceStepBranch, 2) + errs := make([]error, 2) + start := make(chan struct{}) + var wg sync.WaitGroup + for i := range 2 { + wg.Go(func() { + <-start + rows[i], errs[i] = b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{ + StepID: b.steps[0], Condition: "always", YesStepID: targets[i], Expect: ExpectNoBranch(), + }) + }) + } + close(start) + wg.Wait() + + winner, loser := 0, 1 + if errs[0] != nil { + winner, loser = 1, 0 + } + if errs[winner] != nil { + t.Fatalf("both creates failed: %v / %v", errs[0], errs[1]) + } + changed := changedErr(t, errs[loser]) + if changed.Current == nil || changed.Current.YesStepID.Bytes != *targets[winner] { + t.Fatalf("loser's current = %+v, want the winner's branch", changed.Current) + } + if got := b.branchOf(t, b.steps[0]); got == nil || got.YesStepID.Bytes != *targets[winner] || + !got.UpdatedAt.Time.Equal(rows[winner].UpdatedAt.Time) { + t.Fatalf("stored = %+v, want the winner's %+v", got, rows[winner]) + } +} + +func TestBranchDeleteWithStalePreconditionIsRefused(t *testing.T) { + b, done := newBranchIT(t, "Precondition delete") + defer done() + ctx := context.Background() + v1, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", YesStepID: &b.steps[2]}) + if err != nil { + t.Fatal(err) + } + stale := wireToken(t, v1) + v2, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", Expect: stale}) + if err != nil { + t.Fatal(err) + } + + changed := changedErr(t, b.svc.DeleteBranch(ctx, b.ws, b.campaign, b.steps[0], stale)) + if changed.Current == nil || !changed.Current.UpdatedAt.Time.Equal(v2.UpdatedAt.Time) { + t.Fatalf("current = %+v, want %+v", changed.Current, v2) + } + if b.branchOf(t, b.steps[0]) == nil { + t.Fatal("a refused delete removed the branch") + } + + // Once someone else has removed it, a delete at the old version is refused + // with current = nil: the client learns it is gone rather than being told + // its own delete succeeded. + if err := b.svc.DeleteBranch(ctx, b.ws, b.campaign, b.steps[0], wireToken(t, v2)); err != nil { + t.Fatal(err) + } + if changed := changedErr(t, b.svc.DeleteBranch(ctx, b.ws, b.campaign, b.steps[0], wireToken(t, v2))); changed.Current != nil { + t.Fatalf("current = %+v, want nil", changed.Current) + } +} + +// A precondition changes nothing about tenancy: a foreign workspace is 404 +// whatever it expects, through the service or the store directly, and a +// precondition failure never leaks the owner's branch to it. +func TestBranchPreconditionStaysWorkspacePinned(t *testing.T) { + b, done := newBranchIT(t, "Precondition tenant owner") + defer done() + ctx := context.Background() + owned, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always"}) + if err != nil { + t.Fatal(err) + } + intruder, err := b.q.CreateWorkspace(ctx, "Precondition tenant intruder "+uuid.NewString()) + if err != nil { + t.Fatal(err) + } + store := NewPgBranchStore(b.pool) + for name, expect := range map[string]BranchPrecondition{ + "none": {}, "absent": ExpectNoBranch(), "at": wireToken(t, owned), + } { + in := BranchInput{CampaignID: b.campaign, StepID: b.steps[0], Condition: "always", YesStepID: &b.steps[2], Expect: expect} + if _, err := b.svc.SetBranch(ctx, intruder.ID, b.campaign, in); !errors.Is(err, ErrCampaignNotFound) { + t.Errorf("%s: service set: want ErrCampaignNotFound, got %v", name, err) + } + if _, err := store.UpsertBranch(ctx, intruder.ID, in, checkGraph); !errors.Is(err, ErrCampaignNotFound) { + t.Errorf("%s: store set: want ErrCampaignNotFound, got %v", name, err) + } + if err := b.svc.DeleteBranch(ctx, intruder.ID, b.campaign, b.steps[0], expect); !errors.Is(err, ErrCampaignNotFound) { + t.Errorf("%s: service delete: want ErrCampaignNotFound, got %v", name, err) + } + if err := store.DeleteBranch(ctx, intruder.ID, b.campaign, b.steps[0], expect, checkGraph); !errors.Is(err, ErrCampaignNotFound) { + t.Errorf("%s: store delete: want ErrCampaignNotFound, got %v", name, err) + } + } + if got := b.branchOf(t, b.steps[0]); got == nil || got.YesStepID.Valid || !got.UpdatedAt.Time.Equal(owned.UpdatedAt.Time) { + t.Fatalf("the intruder changed the owner's branch: %+v", got) + } +} + +// updated_at is a token only if every write moves it. Back-to-back writes in +// one tight loop (well inside a millisecond each on a local database) must each +// see a strictly later value, and so must the ON DELETE SET NULL that deleting +// an exit's target step performs — a write no query of ours makes. +func TestBranchUpdatedAtAdvancesOnEveryWrite(t *testing.T) { + b, done := newBranchIT(t, "Precondition advances") + defer done() + ctx := context.Background() + + prev, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", YesStepID: &b.steps[2]}) + if err != nil { + t.Fatal(err) + } + for i := range 20 { + next, err := b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", YesStepID: &b.steps[2]}) + if err != nil { + t.Fatal(err) + } + if !next.UpdatedAt.Time.After(prev.UpdatedAt.Time) { + t.Fatalf("write %d: updated_at %v did not advance past %v", i, next.UpdatedAt.Time, prev.UpdatedAt.Time) + } + prev = next + } + + // Same-transaction rewrites share now(); the trigger must still advance. + tx, err := b.pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback(ctx) }() + var first, second time.Time + for _, dst := range []*time.Time{&first, &second} { + if err := tx.QueryRow(ctx, `UPDATE sequence_step_branches SET condition = 'always' WHERE step_id = $1 RETURNING updated_at`, b.steps[0]).Scan(dst); err != nil { + t.Fatal(err) + } + } + if !second.After(first) || !first.After(prev.UpdatedAt.Time) { + t.Fatalf("in one transaction: %v then %v (after %v)", first, second, prev.UpdatedAt.Time) + } + if err := tx.Rollback(ctx); err != nil { + t.Fatal(err) + } + + if err := b.svc.Delete(ctx, b.ws, b.campaign, b.steps[2]); err != nil { + t.Fatalf("delete target step: %v", err) + } + after := b.branchOf(t, b.steps[0]) + if after == nil || after.YesStepID.Valid || !after.UpdatedAt.Time.After(prev.UpdatedAt.Time) { + t.Fatalf("after its exit's target was deleted: %+v (was %v)", after, prev.UpdatedAt.Time) + } + // So a client holding the pre-delete token is told the branch changed. + _, err = b.svc.SetBranch(ctx, b.ws, b.campaign, BranchInput{StepID: b.steps[0], Condition: "always", Expect: wireToken(t, prev)}) + changedErr(t, err) +} diff --git a/internal/app/sequencestep/branchstore.go b/internal/app/sequencestep/branchstore.go index 4d5a606c..d70a936d 100644 --- a/internal/app/sequencestep/branchstore.go +++ b/internal/app/sequencestep/branchstore.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -34,6 +35,49 @@ type BranchInput struct { ReplyLabelKey *string YesStepID *uuid.UUID NoStepID *uuid.UUID + // Expect is the write's optimistic-concurrency precondition. The zero value + // is none: the write replaces whatever is there, as it always has. + Expect BranchPrecondition +} + +// BranchPrecondition is what a branch write expects to find on the step before +// it applies, so two editors cannot silently overwrite each other. The zero +// value expects nothing (last writer wins); ExpectNoBranch and ExpectBranchAt +// are the two real preconditions. A struct with unexported fields rather than a +// *time.Time so "no precondition" and "expect no branch" cannot be confused. +type BranchPrecondition struct { + kind preconditionKind + updatedAt time.Time +} + +type preconditionKind int + +const ( + preconditionNone preconditionKind = iota + preconditionAbsent + preconditionAt +) + +// ExpectNoBranch applies the write only if the step has no branch: a create +// that must not overwrite a branch someone else just made. +func ExpectNoBranch() BranchPrecondition { return BranchPrecondition{kind: preconditionAbsent} } + +// ExpectBranchAt applies the write only if the step's branch exists and its +// updated_at is exactly at (microsecond precision, as stored). +func ExpectBranchAt(at time.Time) BranchPrecondition { + return BranchPrecondition{kind: preconditionAt, updatedAt: at} +} + +// BranchChangedError is a write whose precondition no longer holds: the branch +// was created, replaced or removed since the client read it. Current is the +// branch as it is now (nil when the step has none), read in the same +// transaction that refused the write, so the client can show what won. +type BranchChangedError struct { + Current *gen.SequenceStepBranch +} + +func (e *BranchChangedError) Error() string { + return "the branch was changed since it was loaded; reload it and try again" } // BranchStore is the persistence seam for routers. A distinct interface from @@ -47,13 +91,16 @@ type BranchStore interface { // TrackingEnabled reports whether the campaign records opens and clicks. TrackingEnabled(ctx context.Context, ws, campaignID uuid.UUID) (bool, error) // UpsertBranch creates or replaces the router on in.StepID, then runs check - // on the result, in one transaction holding the campaign's graph lock. + // on the result, in one transaction holding the campaign's graph lock. A + // precondition in in.Expect that does not hold is a *BranchChangedError and + // writes nothing. UpsertBranch(ctx context.Context, ws uuid.UUID, in BranchInput, check GraphCheck) (gen.SequenceStepBranch, error) // DeleteBranch removes the router on stepID (a no-op when there is none), // then runs check, in one transaction holding the campaign's graph lock. // Removing a router is a graph change too: it restores the step's linear - // fall-through, which can close a loop. - DeleteBranch(ctx context.Context, ws, campaignID, stepID uuid.UUID, check GraphCheck) error + // fall-through, which can close a loop. A precondition that does not hold is + // a *BranchChangedError and removes nothing. + DeleteBranch(ctx context.Context, ws, campaignID, stepID uuid.UUID, expect BranchPrecondition, check GraphCheck) error } // ErrBranchConflict is an upsert that matched a router row owned by a different @@ -104,16 +151,15 @@ func (s *PgBranchStore) TrackingEnabled(ctx context.Context, ws, campaignID uuid return on, nil } +// UpsertBranch enforces in.Expect in the write itself — a conditional INSERT or +// UPDATE, not a read followed by a write — so the precondition and the change +// are one statement, under the graph lock besides. func (s *PgBranchStore) UpsertBranch(ctx context.Context, ws uuid.UUID, in BranchInput, check GraphCheck) (gen.SequenceStepBranch, error) { var out gen.SequenceStepBranch err := withGraphLock(ctx, s.pool, s.q, ws, in.CampaignID, check, func(q *gen.Queries) error { - row, err := q.UpsertBranch(ctx, gen.UpsertBranchParams{ - StepID: in.StepID, WorkspaceID: ws, CampaignID: in.CampaignID, Condition: in.Condition, - WithinDays: in.WithinDays, ReplyLabelKey: in.ReplyLabelKey, - YesStepID: nullUUID(in.YesStepID), NoStepID: nullUUID(in.NoStepID), - }) + row, err := writeBranch(ctx, q, ws, in) if errors.Is(err, pgx.ErrNoRows) { - return ErrBranchConflict + return refusedWrite(ctx, q, ws, in) } var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == foreignKeyViolation { @@ -128,15 +174,104 @@ func (s *PgBranchStore) UpsertBranch(ctx context.Context, ws uuid.UUID, in Branc return out, err } -func (s *PgBranchStore) DeleteBranch(ctx context.Context, ws, campaignID, stepID uuid.UUID, check GraphCheck) error { +// writeBranch runs the one statement in.Expect calls for. Each returns +// pgx.ErrNoRows when it writes nothing. +func writeBranch(ctx context.Context, q *gen.Queries, ws uuid.UUID, in BranchInput) (gen.SequenceStepBranch, error) { + yes, no := nullUUID(in.YesStepID), nullUUID(in.NoStepID) + switch in.Expect.kind { + case preconditionAbsent: + return q.InsertBranchIfAbsent(ctx, gen.InsertBranchIfAbsentParams{ + StepID: in.StepID, WorkspaceID: ws, CampaignID: in.CampaignID, Condition: in.Condition, + WithinDays: in.WithinDays, ReplyLabelKey: in.ReplyLabelKey, YesStepID: yes, NoStepID: no, + }) + case preconditionAt: + return q.UpdateBranchIfUnchanged(ctx, gen.UpdateBranchIfUnchangedParams{ + StepID: in.StepID, WorkspaceID: ws, CampaignID: in.CampaignID, Condition: in.Condition, + WithinDays: in.WithinDays, ReplyLabelKey: in.ReplyLabelKey, YesStepID: yes, NoStepID: no, + ExpectedUpdatedAt: timestamptz(in.Expect.updatedAt), + }) + default: + return q.UpsertBranch(ctx, gen.UpsertBranchParams{ + StepID: in.StepID, WorkspaceID: ws, CampaignID: in.CampaignID, Condition: in.Condition, + WithinDays: in.WithinDays, ReplyLabelKey: in.ReplyLabelKey, YesStepID: yes, NoStepID: no, + }) + } +} + +// refusedWrite explains a write that matched no row. With a precondition that +// is the precondition failing, reported with the branch as it now is. Without +// one — or when "expect no branch" collided with a row this workspace cannot +// see — it is the tenant pin refusing another owner's row. +func refusedWrite(ctx context.Context, q *gen.Queries, ws uuid.UUID, in BranchInput) error { + if in.Expect.kind == preconditionNone { + return ErrBranchConflict + } + current, err := currentBranch(ctx, q, ws, in.CampaignID, in.StepID) + if err != nil { + return err + } + if current == nil && in.Expect.kind == preconditionAbsent { + return ErrBranchConflict + } + return &BranchChangedError{Current: current} +} + +// currentBranch reads one step's router inside the caller's transaction; nil +// when the step has none. +func currentBranch(ctx context.Context, q *gen.Queries, ws, campaignID, stepID uuid.UUID) (*gen.SequenceStepBranch, error) { + row, err := q.GetBranch(ctx, gen.GetBranchParams{StepID: stepID, CampaignID: campaignID, WorkspaceID: ws}) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read current branch: %w", err) + } + return &row, nil +} + +// DeleteBranch, like UpsertBranch, puts an "expect this version" precondition in +// the DELETE's own WHERE clause. +func (s *PgBranchStore) DeleteBranch(ctx context.Context, ws, campaignID, stepID uuid.UUID, expect BranchPrecondition, check GraphCheck) error { return withGraphLock(ctx, s.pool, s.q, ws, campaignID, check, func(q *gen.Queries) error { - if err := q.DeleteBranch(ctx, gen.DeleteBranchParams{StepID: stepID, CampaignID: campaignID, WorkspaceID: ws}); err != nil { - return fmt.Errorf("delete branch: %w", err) + switch expect.kind { + case preconditionAt: + n, err := q.DeleteBranchIfUnchanged(ctx, gen.DeleteBranchIfUnchangedParams{ + StepID: stepID, CampaignID: campaignID, WorkspaceID: ws, ExpectedUpdatedAt: timestamptz(expect.updatedAt), + }) + if err != nil { + return fmt.Errorf("delete branch: %w", err) + } + if n == 0 { + return changedSince(ctx, q, ws, campaignID, stepID, false) + } + return nil + case preconditionAbsent: + // Expecting no branch, a delete has nothing to remove; it holds iff + // there is still none, which the graph lock keeps true to commit. + return changedSince(ctx, q, ws, campaignID, stepID, true) + default: + if err := q.DeleteBranch(ctx, gen.DeleteBranchParams{StepID: stepID, CampaignID: campaignID, WorkspaceID: ws}); err != nil { + return fmt.Errorf("delete branch: %w", err) + } + return nil } - return nil }) } +// changedSince reports a refused precondition with the branch as it now is (nil +// when the step has none). absentIsFine is for "expect no branch", where +// finding none means the precondition held after all. +func changedSince(ctx context.Context, q *gen.Queries, ws, campaignID, stepID uuid.UUID, absentIsFine bool) error { + current, err := currentBranch(ctx, q, ws, campaignID, stepID) + if err != nil { + return err + } + if current == nil && absentIsFine { + return nil + } + return &BranchChangedError{Current: current} +} + // withGraphLock runs mutate and then check in ONE transaction that first takes // the campaign's graph lock (LockCampaignGraph). Serializing on the campaign is // what makes the check sound: two edits that are each acyclic alone can close a @@ -188,3 +323,7 @@ func nullUUID(id *uuid.UUID) pgtype.UUID { } return pgtype.UUID{Bytes: *id, Valid: true} } + +func timestamptz(t time.Time) pgtype.Timestamptz { + return pgtype.Timestamptz{Time: t, Valid: true} +} diff --git a/internal/platform/db/branchmigrations_integration_test.go b/internal/platform/db/branchmigrations_integration_test.go index 85b7d973..0087d0ab 100644 --- a/internal/platform/db/branchmigrations_integration_test.go +++ b/internal/platform/db/branchmigrations_integration_test.go @@ -20,6 +20,18 @@ const ( preBranchVersion = 20260921111415 ) +// triggerExists reports whether the trigger that makes updated_at the branch's +// concurrency token (20260923161044) is installed. +func triggerExists(t *testing.T, ctx context.Context, pool *pgxpool.Pool) bool { + t.Helper() + var n int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM pg_trigger + WHERE tgname = 'sequence_step_branches_advance_updated_at'`).Scan(&n); err != nil { + t.Fatalf("trigger lookup: %v", err) + } + return n == 1 +} + // indexState reports whether the named index exists and, if so, whether it is // VALID — a failed CREATE INDEX CONCURRENTLY leaves an invalid index behind // rather than rolling back, and IF NOT EXISTS would skip it on every re-run. @@ -56,11 +68,17 @@ func TestInboxThreadsCampaignContactIndexIsValid(t *testing.T) { if exists, valid := indexState(t, ctx, pool, idx); !exists || !valid { t.Fatalf("%s after a fresh migrate: exists=%v valid=%v", idx, exists, valid) } + if !triggerExists(t, ctx, pool) { + t.Fatal("updated_at trigger missing after a fresh migrate") + } // Down past both branching migrations, then up again. if err := db.MigrateTo(dsn, branchIndexVersion); err != nil { t.Fatalf("migrate to %d: %v", branchIndexVersion, err) } + if triggerExists(t, ctx, pool) { + t.Fatal("updated_at trigger survived its own down migration") + } if err := db.MigrateTo(dsn, branchTablesVersion); err != nil { t.Fatalf("roll back the index migration: %v", err) } @@ -86,4 +104,7 @@ func TestInboxThreadsCampaignContactIndexIsValid(t *testing.T) { if exists, valid := indexState(t, ctx, pool, idx); !exists || !valid { t.Fatalf("%s after up/down/up: exists=%v valid=%v", idx, exists, valid) } + if !triggerExists(t, ctx, pool) { + t.Fatal("updated_at trigger missing after up/down/up") + } } diff --git a/internal/platform/db/gen/stepbranch.sql.go b/internal/platform/db/gen/stepbranch.sql.go index d81959f1..c4487bd5 100644 --- a/internal/platform/db/gen/stepbranch.sql.go +++ b/internal/platform/db/gen/stepbranch.sql.go @@ -48,6 +48,34 @@ func (q *Queries) DeleteBranch(ctx context.Context, arg DeleteBranchParams) erro return err } +const deleteBranchIfUnchanged = `-- name: DeleteBranchIfUnchanged :execrows +DELETE FROM sequence_step_branches +WHERE step_id = $1 AND campaign_id = $2 AND workspace_id = $3 + AND updated_at = $4::timestamptz +` + +type DeleteBranchIfUnchangedParams struct { + StepID uuid.UUID `json:"step_id"` + CampaignID uuid.UUID `json:"campaign_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ExpectedUpdatedAt pgtype.Timestamptz `json:"expected_updated_at"` +} + +// Remove a step's router only if its updated_at still equals the value the +// client last read. Zero rows means it changed or was already removed. +func (q *Queries) DeleteBranchIfUnchanged(ctx context.Context, arg DeleteBranchIfUnchangedParams) (int64, error) { + result, err := q.db.Exec(ctx, deleteBranchIfUnchanged, + arg.StepID, + arg.CampaignID, + arg.WorkspaceID, + arg.ExpectedUpdatedAt, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const firstHumanTrackingEventAt = `-- name: FirstHumanTrackingEventAt :one SELECT created_at FROM tracking_events WHERE send_id = $1 AND workspace_id = $2 AND kind = $3 AND NOT is_machine @@ -129,6 +157,88 @@ func (q *Queries) FirstInboundReplyAt(ctx context.Context, arg FirstInboundReply return created_at, err } +const getBranch = `-- name: GetBranch :one +SELECT step_id, workspace_id, campaign_id, condition, within_days, reply_label_key, yes_step_id, no_step_id, created_at, updated_at FROM sequence_step_branches +WHERE step_id = $1 AND campaign_id = $2 AND workspace_id = $3 +` + +type GetBranchParams struct { + StepID uuid.UUID `json:"step_id"` + CampaignID uuid.UUID `json:"campaign_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` +} + +// One step's router, workspace- and campaign-pinned. Read inside a refused +// precondition's transaction, so the 409 carries the branch that won. +func (q *Queries) GetBranch(ctx context.Context, arg GetBranchParams) (SequenceStepBranch, error) { + row := q.db.QueryRow(ctx, getBranch, arg.StepID, arg.CampaignID, arg.WorkspaceID) + var i SequenceStepBranch + err := row.Scan( + &i.StepID, + &i.WorkspaceID, + &i.CampaignID, + &i.Condition, + &i.WithinDays, + &i.ReplyLabelKey, + &i.YesStepID, + &i.NoStepID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const insertBranchIfAbsent = `-- name: InsertBranchIfAbsent :one +INSERT INTO sequence_step_branches (step_id, workspace_id, campaign_id, condition, within_days, + reply_label_key, yes_step_id, no_step_id) +VALUES ($1, $2, $3, $4, $5, $6, + $7, $8) +ON CONFLICT (step_id) DO NOTHING +RETURNING step_id, workspace_id, campaign_id, condition, within_days, reply_label_key, yes_step_id, no_step_id, created_at, updated_at +` + +type InsertBranchIfAbsentParams struct { + StepID uuid.UUID `json:"step_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + CampaignID uuid.UUID `json:"campaign_id"` + Condition string `json:"condition"` + WithinDays *int32 `json:"within_days"` + ReplyLabelKey *string `json:"reply_label_key"` + YesStepID pgtype.UUID `json:"yes_step_id"` + NoStepID pgtype.UUID `json:"no_step_id"` +} + +// Create the router on one step only if the step has none: the "expect no +// branch" precondition. No row returned means one already exists (the caller +// reads it with GetBranch to report it). Atomic on its own via the primary key, +// so two concurrent creates cannot both succeed even without the graph lock. +func (q *Queries) InsertBranchIfAbsent(ctx context.Context, arg InsertBranchIfAbsentParams) (SequenceStepBranch, error) { + row := q.db.QueryRow(ctx, insertBranchIfAbsent, + arg.StepID, + arg.WorkspaceID, + arg.CampaignID, + arg.Condition, + arg.WithinDays, + arg.ReplyLabelKey, + arg.YesStepID, + arg.NoStepID, + ) + var i SequenceStepBranch + err := row.Scan( + &i.StepID, + &i.WorkspaceID, + &i.CampaignID, + &i.Condition, + &i.WithinDays, + &i.ReplyLabelKey, + &i.YesStepID, + &i.NoStepID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const listBranchesByCampaign = `-- name: ListBranchesByCampaign :many SELECT step_id, workspace_id, campaign_id, condition, within_days, reply_label_key, yes_step_id, no_step_id, created_at, updated_at FROM sequence_step_branches WHERE campaign_id = $1 AND workspace_id = $2 @@ -237,6 +347,62 @@ func (q *Queries) StepSendCreatedAt(ctx context.Context, arg StepSendCreatedAtPa return created_at, err } +const updateBranchIfUnchanged = `-- name: UpdateBranchIfUnchanged :one +UPDATE sequence_step_branches +SET condition = $1, within_days = $2, + reply_label_key = $3, yes_step_id = $4, + no_step_id = $5 +WHERE step_id = $6 AND workspace_id = $7 + AND campaign_id = $8 + AND updated_at = $9::timestamptz +RETURNING step_id, workspace_id, campaign_id, condition, within_days, reply_label_key, yes_step_id, no_step_id, created_at, updated_at +` + +type UpdateBranchIfUnchangedParams struct { + Condition string `json:"condition"` + WithinDays *int32 `json:"within_days"` + ReplyLabelKey *string `json:"reply_label_key"` + YesStepID pgtype.UUID `json:"yes_step_id"` + NoStepID pgtype.UUID `json:"no_step_id"` + StepID uuid.UUID `json:"step_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + CampaignID uuid.UUID `json:"campaign_id"` + ExpectedUpdatedAt pgtype.Timestamptz `json:"expected_updated_at"` +} + +// Replace the router on one step only if its updated_at still equals the value +// the client last read: the "expect this version" precondition. No row returned +// means the branch changed or is gone. Compared at full (microsecond) precision; +// the trigger guarantees every write moves the value. Pinned on workspace_id and +// campaign_id. +func (q *Queries) UpdateBranchIfUnchanged(ctx context.Context, arg UpdateBranchIfUnchangedParams) (SequenceStepBranch, error) { + row := q.db.QueryRow(ctx, updateBranchIfUnchanged, + arg.Condition, + arg.WithinDays, + arg.ReplyLabelKey, + arg.YesStepID, + arg.NoStepID, + arg.StepID, + arg.WorkspaceID, + arg.CampaignID, + arg.ExpectedUpdatedAt, + ) + var i SequenceStepBranch + err := row.Scan( + &i.StepID, + &i.WorkspaceID, + &i.CampaignID, + &i.Condition, + &i.WithinDays, + &i.ReplyLabelKey, + &i.YesStepID, + &i.NoStepID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const upsertBranch = `-- name: UpsertBranch :one INSERT INTO sequence_step_branches (step_id, workspace_id, campaign_id, condition, within_days, reply_label_key, yes_step_id, no_step_id) @@ -245,7 +411,7 @@ VALUES ($1, $2, $3, $4, $5, $6, ON CONFLICT (step_id) DO UPDATE SET condition = EXCLUDED.condition, within_days = EXCLUDED.within_days, reply_label_key = EXCLUDED.reply_label_key, yes_step_id = EXCLUDED.yes_step_id, - no_step_id = EXCLUDED.no_step_id, updated_at = now() + no_step_id = EXCLUDED.no_step_id WHERE sequence_step_branches.workspace_id = EXCLUDED.workspace_id AND sequence_step_branches.campaign_id = EXCLUDED.campaign_id RETURNING step_id, workspace_id, campaign_id, condition, within_days, reply_label_key, yes_step_id, no_step_id, created_at, updated_at @@ -267,7 +433,10 @@ type UpsertBranchParams struct { // (source or target) that is not in that campaign, so a foreign id cannot be // smuggled in even if the service's own check were skipped. The ON CONFLICT // update is pinned on workspace_id as well: a step id belonging to another tenant -// updates nothing and returns no row. +// updates nothing and returns no row. updated_at is not written here: a trigger +// advances it on every insert and update (migration +// 20260923161044_step_branch_updated_at_advances), because it is the branch's +// concurrency token. func (q *Queries) UpsertBranch(ctx context.Context, arg UpsertBranchParams) (SequenceStepBranch, error) { row := q.db.QueryRow(ctx, upsertBranch, arg.StepID, diff --git a/internal/platform/db/migrations/20260923161044_step_branch_updated_at_advances.down.sql b/internal/platform/db/migrations/20260923161044_step_branch_updated_at_advances.down.sql new file mode 100644 index 00000000..5bec8975 --- /dev/null +++ b/internal/platform/db/migrations/20260923161044_step_branch_updated_at_advances.down.sql @@ -0,0 +1,2 @@ +DROP TRIGGER sequence_step_branches_advance_updated_at ON sequence_step_branches; +DROP FUNCTION sequence_step_branches_advance_updated_at(); diff --git a/internal/platform/db/migrations/20260923161044_step_branch_updated_at_advances.up.sql b/internal/platform/db/migrations/20260923161044_step_branch_updated_at_advances.up.sql new file mode 100644 index 00000000..d2e93895 --- /dev/null +++ b/internal/platform/db/migrations/20260923161044_step_branch_updated_at_advances.up.sql @@ -0,0 +1,36 @@ +-- sequence_step_branches.updated_at becomes the branch's concurrency token: a +-- PUT/DELETE may carry expected_updated_at and applies only if the stored value +-- still equals it (internal/app/sequencestep/branchstore.go). A token has to +-- change on EVERY write, and a plain `updated_at = now()` in the upsert does not +-- guarantee that: +-- * now() is the TRANSACTION start, taken before the graph lock is acquired, +-- so a transaction that began first but got the lock second writes an OLDER +-- value than the one it replaced; +-- * two writes can share a microsecond, or the clock can step backwards, and +-- the token would then repeat — a stale client's precondition would match; +-- * ON DELETE SET NULL (yes_step_id / no_step_id) rewrites a row when its +-- target step is deleted, and no query of ours runs to bump the column. +-- +-- So the database owns the column, for every insert and every update including +-- the referential action: an insert takes clock_timestamp() (the statement's +-- real time, not the transaction's), and an update takes the later of +-- clock_timestamp() and one microsecond past the old value — strictly advancing +-- per row regardless of the clock. A timestamp rather than a version integer so +-- a branch deleted and re-created does not restart at a value a stale client +-- still holds, and so the already-served updated_at field is the token rather +-- than a second field saying the same thing. +CREATE FUNCTION sequence_step_branches_advance_updated_at() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + NEW.updated_at := GREATEST(clock_timestamp(), OLD.updated_at + interval '1 microsecond'); + ELSE + NEW.updated_at := clock_timestamp(); + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER sequence_step_branches_advance_updated_at +BEFORE INSERT OR UPDATE ON sequence_step_branches +FOR EACH ROW EXECUTE FUNCTION sequence_step_branches_advance_updated_at(); diff --git a/internal/platform/db/queries/stepbranch.sql b/internal/platform/db/queries/stepbranch.sql index 7898bd33..9c095f10 100644 --- a/internal/platform/db/queries/stepbranch.sql +++ b/internal/platform/db/queries/stepbranch.sql @@ -12,7 +12,10 @@ ORDER BY step_id; -- (source or target) that is not in that campaign, so a foreign id cannot be -- smuggled in even if the service's own check were skipped. The ON CONFLICT -- update is pinned on workspace_id as well: a step id belonging to another tenant --- updates nothing and returns no row. +-- updates nothing and returns no row. updated_at is not written here: a trigger +-- advances it on every insert and update (migration +-- 20260923161044_step_branch_updated_at_advances), because it is the branch's +-- concurrency token. INSERT INTO sequence_step_branches (step_id, workspace_id, campaign_id, condition, within_days, reply_label_key, yes_step_id, no_step_id) VALUES ($1, $2, $3, $4, sqlc.narg(within_days), sqlc.narg(reply_label_key), @@ -20,17 +23,57 @@ VALUES ($1, $2, $3, $4, sqlc.narg(within_days), sqlc.narg(reply_label_key), ON CONFLICT (step_id) DO UPDATE SET condition = EXCLUDED.condition, within_days = EXCLUDED.within_days, reply_label_key = EXCLUDED.reply_label_key, yes_step_id = EXCLUDED.yes_step_id, - no_step_id = EXCLUDED.no_step_id, updated_at = now() + no_step_id = EXCLUDED.no_step_id WHERE sequence_step_branches.workspace_id = EXCLUDED.workspace_id AND sequence_step_branches.campaign_id = EXCLUDED.campaign_id RETURNING *; +-- name: InsertBranchIfAbsent :one +-- Create the router on one step only if the step has none: the "expect no +-- branch" precondition. No row returned means one already exists (the caller +-- reads it with GetBranch to report it). Atomic on its own via the primary key, +-- so two concurrent creates cannot both succeed even without the graph lock. +INSERT INTO sequence_step_branches (step_id, workspace_id, campaign_id, condition, within_days, + reply_label_key, yes_step_id, no_step_id) +VALUES ($1, $2, $3, $4, sqlc.narg(within_days), sqlc.narg(reply_label_key), + sqlc.narg(yes_step_id), sqlc.narg(no_step_id)) +ON CONFLICT (step_id) DO NOTHING +RETURNING *; + +-- name: UpdateBranchIfUnchanged :one +-- Replace the router on one step only if its updated_at still equals the value +-- the client last read: the "expect this version" precondition. No row returned +-- means the branch changed or is gone. Compared at full (microsecond) precision; +-- the trigger guarantees every write moves the value. Pinned on workspace_id and +-- campaign_id. +UPDATE sequence_step_branches +SET condition = sqlc.arg(condition), within_days = sqlc.narg(within_days), + reply_label_key = sqlc.narg(reply_label_key), yes_step_id = sqlc.narg(yes_step_id), + no_step_id = sqlc.narg(no_step_id) +WHERE step_id = sqlc.arg(step_id) AND workspace_id = sqlc.arg(workspace_id) + AND campaign_id = sqlc.arg(campaign_id) + AND updated_at = sqlc.arg(expected_updated_at)::timestamptz +RETURNING *; + +-- name: GetBranch :one +-- One step's router, workspace- and campaign-pinned. Read inside a refused +-- precondition's transaction, so the 409 carries the branch that won. +SELECT * FROM sequence_step_branches +WHERE step_id = $1 AND campaign_id = $2 AND workspace_id = $3; + -- name: DeleteBranch :exec -- Remove a step's router, returning it to linear fall-through. Pinned on -- workspace_id and campaign_id. DELETE FROM sequence_step_branches WHERE step_id = $1 AND campaign_id = $2 AND workspace_id = $3; +-- name: DeleteBranchIfUnchanged :execrows +-- Remove a step's router only if its updated_at still equals the value the +-- client last read. Zero rows means it changed or was already removed. +DELETE FROM sequence_step_branches +WHERE step_id = $1 AND campaign_id = $2 AND workspace_id = $3 + AND updated_at = sqlc.arg(expected_updated_at)::timestamptz; + -- name: LockCampaignGraph :one -- Serializes every write that changes a campaign's routing graph (branch -- upsert/delete, step delete, reorder), so the save-time cycle check sees the diff --git a/web/src/store/api.ts b/web/src/store/api.ts index 2265288a..7725d412 100644 --- a/web/src/store/api.ts +++ b/web/src/store/api.ts @@ -1017,6 +1017,9 @@ const injectedRtkApi = api.injectEndpoints({ query: (queryArg) => ({ url: `/campaigns/${queryArg.id}/steps/${queryArg.stepId}/branch`, method: "DELETE", + params: { + expected_updated_at: queryArg.expectedUpdatedAt, + }, }), }), launchCampaign: build.mutation< @@ -2547,6 +2550,8 @@ export type DeleteStepBranchApiResponse = unknown; export type DeleteStepBranchApiArg = { id: string; stepId: string; + /** The branch's updated_at exactly as this API returned it (URL-encoded). Omit for an unconditional delete. */ + expectedUpdatedAt?: string; }; export type LaunchCampaignApiResponse = /** status 200 Enrollment + queue counts */ { @@ -4417,6 +4422,23 @@ export type StepRequest = { body_text?: string; body_html?: string; }; +export type StepBranchCondition = + "always" | "opened" | "clicked" | "replied" | "not_opened" | "not_replied"; +export type StepBranch = { + /** The step this branch routes out of */ + step_id: string; + condition: StepBranchCondition; + /** Evaluation window in days after the step's send; null exactly when condition is always */ + within_days: number | null; + /** replied / not_replied only: count only replies classified with this reply label key (always a label that does not stop the sequence) */ + reply_label_key: string | null; + /** Where a true condition (or always) goes; null ends the path */ + yes_step_id: string | null; + /** Where a false condition goes; null ends the path; always null for always */ + no_step_id: string | null; + /** When the branch last changed, at microsecond precision. Also its concurrency token - send it back verbatim as expected_updated_at (re-formatting through a millisecond clock such as a JS Date loses digits and never matches). Advances on every change, including an exit nulled because its target step was deleted. */ + updated_at: string; +}; export type BranchValidationError = { /** Human-readable message */ error: string; @@ -4429,9 +4451,12 @@ export type BranchValidationError = { | "no_exit_not_allowed" | "unknown_step" | "unknown_target" - | "cycle"; + | "cycle" + | "branch_changed"; /** code cycle only: the steps on the loop, in path order */ step_ids?: string[]; + /** code branch_changed only, and always present then: the step's branch as it is now, or null if it has none */ + current?: StepBranch | null; }; export type StepVariant = { id: string; @@ -4459,22 +4484,6 @@ export type ReorderStepsRequest = { /** the FULL ordered list of the campaign's step ids, in the desired order */ step_ids: string[]; }; -export type StepBranchCondition = - "always" | "opened" | "clicked" | "replied" | "not_opened" | "not_replied"; -export type StepBranch = { - /** The step this branch routes out of */ - step_id: string; - condition: StepBranchCondition; - /** Evaluation window in days after the step's send; null exactly when condition is always */ - within_days: number | null; - /** replied / not_replied only: count only replies classified with this reply label key (always a label that does not stop the sequence) */ - reply_label_key: string | null; - /** Where a true condition (or always) goes; null ends the path */ - yes_step_id: string | null; - /** Where a false condition goes; null ends the path; always null for always */ - no_step_id: string | null; - updated_at: string; -}; export type CampaignGraphNode = { step_id: string; step_order: number; @@ -4499,6 +4508,8 @@ export type StepBranchRequest = { yes_step_id?: string | null; /** As yes_step_id; must be null or absent for always */ no_step_id?: string | null; + /** Optional precondition. Absent - no check (last writer wins). null - apply only if the step has no branch. A timestamp - apply only if the branch's updated_at is still exactly this (the StepBranch.updated_at value verbatim). A failed check is 409 code branch_changed. */ + expected_updated_at?: string | null; }; export type CampaignPreflightCheck = { /** `personalization_tokens` FAILS (does not warn) when a step contains a `{{...}}` placeholder nothing will substitute, which is harsher than the neighbouring content checks on purpose: an empty body is visible the moment an operator looks at it, whereas a bad token produces an email that looks fine in the editor and arrives reading "Hi {{firstname}}" or "Hi ,". A token nothing resolves is always a typo or a since-archived field, never an intent. */ From 58f935a5160fa85cabdd5916ffcea97a1be058b2 Mon Sep 17 00:00:00 2001 From: Ahmustufa Date: Wed, 23 Sep 2026 20:45:18 +0500 Subject: [PATCH 7/9] feat(web): yes/no condition nodes on the campaign canvas The canvas is wired to the branching API (GET graph, PUT/DELETE branch): - "Add a condition" after any step, including on running campaigns. It draws as a diamond after its step, with Yes/No exit labels, the condition in words, a wait chip per exit, and a Stop per empty exit. - A condition editor in the side panel mirrors the server's rules: - within_days is 1-90, and "always" has no No exit; - only non-stopping reply labels are offered, with the reason why; - tracking and an HTML body are required for open/click conditions; - an exit can't point at its own step. - Dragging a Yes/No handle onto a step sets that exit (allowed while running). A step-to-step drag still reorders and stays draft-only. - A refused loop (422 cycle) marks the steps on it. Unreachable steps are labelled "Not reached". If the graph query fails, the canvas falls back to the linear view with a retry banner and hides condition editing, so a save can't silently overwrite a branch nobody can see. - The layout routes edges through dagre bend points, and multi-exit nodes route through per-exit relay points, so Yes/No don't cross and a skip edge doesn't hide behind a step. - New errorCode() in @/lib/rtk-error, and error copy for every BranchValidationError code. The Graph cache tag is invalidated by branch and structural step writes. Co-Authored-By: Claude Opus 5.5 (1M context) --- web/e2e/campaign-sequence-canvas.spec.ts | 104 ++++- .../shared/flow/__tests__/flow-layout.test.ts | 73 +++- web/src/components/shared/flow/flow-edge.tsx | 65 ++- web/src/components/shared/flow/flow-layout.ts | 129 ++++-- .../shared/flow/flow-node-frame.tsx | 21 +- .../campaigns/__tests__/branch-draft.test.ts | 156 ++++++++ .../campaigns/__tests__/branch-error.test.ts | 52 +++ .../__tests__/campaign-detail-layout.test.tsx | 1 + .../__tests__/fake-sequence-server.ts | 178 +++++++++ .../sequence-canvas-conditions.test.tsx | 307 +++++++++++++++ .../__tests__/sequence-canvas.test.tsx | 122 ++---- .../__tests__/sequence-editor.test.tsx | 13 + .../__tests__/sequence-graph.test.ts | 174 +++++++- web/src/features/campaigns/api.ts | 47 ++- web/src/features/campaigns/branch-draft.ts | 181 +++++++++ web/src/features/campaigns/branch-error.ts | 33 ++ web/src/features/campaigns/branch-loop.ts | 18 + .../features/campaigns/condition-editor.tsx | 371 ++++++++++++++++++ .../campaigns/condition-flow-nodes.tsx | 85 ++++ web/src/features/campaigns/node-flag.tsx | 18 + .../campaigns/sequence-canvas-actions.ts | 39 +- .../features/campaigns/sequence-canvas.tsx | 179 +++++++-- .../features/campaigns/sequence-editor.tsx | 77 +++- .../campaigns/sequence-flow-nodes.tsx | 38 +- web/src/features/campaigns/sequence-graph.ts | 213 ++++++++-- .../campaigns/sequence-node-registry.ts | 23 +- web/src/features/campaigns/step-error.ts | 11 +- web/src/lib/__tests__/rtk-error.test.ts | 16 +- web/src/lib/rtk-error.ts | 14 + 29 files changed, 2466 insertions(+), 292 deletions(-) create mode 100644 web/src/features/campaigns/__tests__/branch-draft.test.ts create mode 100644 web/src/features/campaigns/__tests__/branch-error.test.ts create mode 100644 web/src/features/campaigns/__tests__/fake-sequence-server.ts create mode 100644 web/src/features/campaigns/__tests__/sequence-canvas-conditions.test.tsx create mode 100644 web/src/features/campaigns/branch-draft.ts create mode 100644 web/src/features/campaigns/branch-error.ts create mode 100644 web/src/features/campaigns/branch-loop.ts create mode 100644 web/src/features/campaigns/condition-editor.tsx create mode 100644 web/src/features/campaigns/condition-flow-nodes.tsx create mode 100644 web/src/features/campaigns/node-flag.tsx diff --git a/web/e2e/campaign-sequence-canvas.spec.ts b/web/e2e/campaign-sequence-canvas.spec.ts index 7d54745c..154d4918 100644 --- a/web/e2e/campaign-sequence-canvas.spec.ts +++ b/web/e2e/campaign-sequence-canvas.spec.ts @@ -28,10 +28,13 @@ const TWO_STEPS: Step[] = [ async function mockApi( page: Page, initialSteps: Step[] = TWO_STEPS, -): Promise<{ reorders: string[][]; creates: unknown[] }> { +): Promise<{ reorders: string[][]; creates: unknown[]; branchWrites: unknown[] }> { const reorders: string[][] = [] const creates: unknown[] = [] + const branchWrites: unknown[] = [] let steps: Step[] = initialSteps + // Branches by source step, as the server stores them: one per step. + const branches = new Map>() const membership = { workspace_id: 'workspace-e2e', workspace_name: 'Atlas Labs', role: 'owner' } await page.route('**/api/v1/**', async (route: Route) => { @@ -63,6 +66,51 @@ async function mockApi( } if (path.endsWith('/variants')) return route.fulfill(json([])) + if (path.endsWith('/reply-labels')) { + const label = (key: string, name: string, stops: boolean) => ({ + id: `rl-${key}`, + key, + label: name, + color: '#888888', + position: 0, + is_builtin: true, + stops_enrollment: stops, + is_automated: false, + suppresses_contact: false, + captures_deal: false, + defers_enrollment: false, + created_at: '2026-09-01T00:00:00Z', + updated_at: '2026-09-01T00:00:00Z', + }) + return route.fulfill(json({ labels: [label('interested', 'Interested', true), label('question', 'Question', false)] })) + } + if (path.endsWith(`/campaigns/${CAMPAIGN_ID}/graph`)) { + return route.fulfill( + json({ + campaign_id: CAMPAIGN_ID, + entry_step_id: steps[0]?.id ?? null, + nodes: steps.map((step, index) => ({ + step_id: step.id, + step_order: step.step_order, + default_next_step_id: steps[index + 1]?.id ?? null, + branch: branches.get(step.id) ?? null, + })), + }), + ) + } + const branchPath = /\/steps\/([^/]+)\/branch$/.exec(path) + if (branchPath?.[1]) { + const stepId = branchPath[1] + if (request.method() === 'DELETE') { + branches.delete(stepId) + return route.fulfill({ status: 204, body: '' }) + } + const body = request.postDataJSON() as Record + branchWrites.push(body) + const saved = { step_id: stepId, updated_at: '2026-09-23T00:00:00Z', ...body } + branches.set(stepId, saved) + return route.fulfill(json(saved)) + } if (path.endsWith(`/campaigns/${CAMPAIGN_ID}/steps/reorder`)) { const { step_ids } = request.postDataJSON() as { step_ids: string[] } reorders.push(step_ids) @@ -104,7 +152,7 @@ async function mockApi( return route.fulfill({ status: 404, contentType: 'application/json', body: '{"error":"unhandled e2e route"}' }) }) - return { reorders, creates } + return { reorders, creates, branchWrites } } async function signIn(page: Page) { @@ -206,3 +254,55 @@ test('dragging from a step’s exit onto another step makes it next', async ({ p await expect.poll(() => reorders.at(-1)).toEqual(['step-1', 'step-3', 'step-2']) await expect(canvas.getByRole('button', { name: 'Edit step 2: Last call' })).toBeVisible() }) + +test('adding a condition draws the IF after its step, with Yes / No exits routed where they were set', async ({ page }) => { + const three: Step[] = [ + { ...TWO_STEPS[0]!, body_html: '

hello

' }, + TWO_STEPS[1]!, + { id: 'step-3', step_order: 3, delay_seconds: 86400, subject: 'Last call', body_text: 'closing', body_html: '' }, + ] + const { branchWrites } = await mockApi(page, three) + await signIn(page) + await page.goto(`/app/campaigns/${CAMPAIGN_ID}/steps`) + const canvas = page.getByRole('region', { name: 'Sequence flow' }) + await expect(canvas.getByText('Last call')).toBeVisible() + + await canvas.getByRole('button', { name: 'Add a condition after step 1' }).click() + const panel = page.getByRole('complementary', { name: 'Condition after step 1' }) + await panel.getByLabel('If they…').selectOption('opened') + await panel.getByLabel('Within (days)').fill('3') + await panel.getByLabel('Yes — go to').selectOption('step-3') + await panel.getByLabel('No — go to').selectOption('step-2') + await panel.getByRole('button', { name: 'Add condition' }).click() + + await expect.poll(() => branchWrites.at(-1)).toEqual({ + condition: 'opened', + within_days: 3, + reply_label_key: null, + yes_step_id: 'step-3', + no_step_id: 'step-2', + }) + + // The diamond, in words, right after step 1 — and it took focus. + const condition = canvas.getByRole('button', { name: 'Edit the condition after step 1: Opened within 3 days' }) + await expect(condition).toBeVisible() + await expect(condition).toBeFocused() + await expect(canvas.locator('.react-flow__node[data-id="cond:step-1"] polygon')).toHaveCount(1) + // Labelled exits, each an edge to the step it was set to. + const node = canvas.locator('.react-flow__node[data-id="cond:step-1"]') + await expect(node.getByText('Yes', { exact: true })).toBeVisible() + await expect(node.getByText('No', { exact: true })).toBeVisible() + await expect(canvas.getByTestId('rf__edge-cond:step-1:yes->step-3')).toHaveCount(1) + await expect(canvas.getByTestId('rf__edge-cond:step-1:no->step-2')).toHaveCount(1) + + // Dragging the Yes exit onto step 2 re-routes it — a real pointer drag. + const from = node.locator('.react-flow__handle.source').first() + const to = canvas.locator('.react-flow__node[data-id="step-2"] .react-flow__handle.target') + const [a, b] = await Promise.all([from.boundingBox(), to.boundingBox()]) + if (!a || !b) throw new Error('handles not rendered') + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2) + await page.mouse.down() + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 }) + await page.mouse.up() + await expect.poll(() => branchWrites.at(-1)).toMatchObject({ yes_step_id: 'step-2', no_step_id: 'step-2' }) +}) diff --git a/web/src/components/shared/flow/__tests__/flow-layout.test.ts b/web/src/components/shared/flow/__tests__/flow-layout.test.ts index 0bc1b12d..6a763a13 100644 --- a/web/src/components/shared/flow/__tests__/flow-layout.test.ts +++ b/web/src/components/shared/flow/__tests__/flow-layout.test.ts @@ -1,5 +1,6 @@ import { renderHook } from '@testing-library/react' -import type { Edge, Node } from '@xyflow/react' +import type { Node } from '@xyflow/react' +import type { FlowEdgeType } from '../flow-edge' import { describe, expect, test } from 'vitest' import { layoutFlow, useFlowLayout } from '../flow-layout' import type { FlowNodeSize } from '../node-registry' @@ -16,8 +17,8 @@ const sizeOf = (type: string | undefined): FlowNodeSize => { } const node = (id: string, type: string): Node => ({ id, type, position: { x: 0, y: 0 }, data: {} }) -const edge = (source: string, target: string, sourceHandle?: string): Edge => ({ - id: `${source}->${target}`, +const edge = (source: string, target: string, sourceHandle?: string): FlowEdgeType => ({ + id: `${source}:${sourceHandle ?? ''}->${target}`, source, target, sourceHandle, @@ -26,7 +27,7 @@ const edge = (source: string, target: string, sourceHandle?: string): Edge => ({ describe('layoutFlow', () => { test('stacks a chain top to bottom, centred on one axis, at each node’s declared size', () => { const nodes = [node('a', 'trigger'), node('b', 'action'), node('c', 'action')] - const laid = layoutFlow(nodes, [edge('a', 'b'), edge('b', 'c')], { sizeOf }) + const laid = layoutFlow(nodes, [edge('a', 'b'), edge('b', 'c')], { sizeOf }).nodes const [a, b, c] = laid expect(a && b && c).toBeTruthy() @@ -43,7 +44,7 @@ describe('layoutFlow', () => { test('puts two branches side by side in one rank, first-emitted edge on the left', () => { const nodes = [node('if', 'condition'), node('yes', 'action'), node('no', 'action')] - const laid = layoutFlow(nodes, [edge('if', 'yes', 'yes'), edge('if', 'no', 'no')], { sizeOf }) + const laid = layoutFlow(nodes, [edge('if', 'yes', 'yes'), edge('if', 'no', 'no')], { sizeOf }).nodes const byId = new Map(laid.map((n) => [n.id, n])) const yes = byId.get('yes') const no = byId.get('no') @@ -53,14 +54,68 @@ describe('layoutFlow', () => { test('the branch order follows the edges, not dagre’s own choice', () => { const nodes = [node('if', 'condition'), node('yes', 'action'), node('no', 'action')] - const laid = layoutFlow(nodes, [edge('if', 'no', 'no'), edge('if', 'yes', 'yes')], { sizeOf }) + const laid = layoutFlow(nodes, [edge('if', 'no', 'no'), edge('if', 'yes', 'yes')], { sizeOf }).nodes const byId = new Map(laid.map((n) => [n.id, n])) expect((byId.get('no')?.position.x ?? 0) + 200).toBeLessThanOrEqual(byId.get('yes')?.position.x ?? 0) }) + test('an edge that skips a rank is routed round the node in between; adjacent ones are not', () => { + // a's rank is fixed by s → a, c's by the longer s → b1 → b2 → c, so a → c + // has to cross b2's rank. + const nodes = ['s', 'a', 'b1', 'b2', 'c'].map((id) => node(id, 'action')) + const { nodes: laid, edges } = layoutFlow( + nodes, + [edge('s', 'a'), edge('s', 'b1'), edge('b1', 'b2'), edge('b2', 'c'), edge('a', 'c')], + { sizeOf }, + ) + const byId = new Map(edges.map((e) => [e.id, e])) + expect(byId.get('b1:->b2')?.data?.route).toBeUndefined() + const route = byId.get('a:->c')?.data?.route ?? [] + expect(route.length).toBeGreaterThan(0) + // The bend clears b2's box instead of running through it. + const b2 = laid.find((n) => n.id === 'b2') + const left = b2?.position.x ?? 0 + const top = b2?.position.y ?? 0 + const beside = route.filter((point) => point.y >= top && point.y <= top + 80) + expect(beside.length).toBeGreaterThan(0) + for (const point of beside) expect(point.x < left || point.x > left + 200).toBe(true) + }) + + test('a multi-exit node keeps its exits in edge order even when one skips a rank', () => { + // Yes jumps past b to c; No goes straight to b. Uncrossed means the yes + // path stays left of the no path on the way down. + const nodes = [node('if', 'condition'), node('b', 'action'), node('c', 'action')] + const { nodes: laid, edges } = layoutFlow( + nodes, + [edge('if', 'c', 'yes'), edge('if', 'b', 'no'), edge('b', 'c')], + { sizeOf }, + ) + const [yesRelay] = edges.find((e) => e.id === 'if:yes->c')?.data?.route ?? [] + const [noRelay] = edges.find((e) => e.id === 'if:no->b')?.data?.route ?? [] + expect(yesRelay && noRelay).toBeTruthy() + expect(yesRelay?.y).toBe(noRelay?.y) + expect(yesRelay?.x ?? 0).toBeLessThan(noRelay?.x ?? 0) + // …and stays left below the relays too: passing b, the yes path is on b's + // left, not looping round its right and crossing the no path to get there. + const b = laid.find((n) => n.id === 'b') + const yesRoute = edges.find((e) => e.id === 'if:yes->c')?.data?.route ?? [] + const passingB = yesRoute.filter((point) => point.y >= (b?.position.y ?? 0) && point.y <= (b?.position.y ?? 0) + 80) + expect(passingB.length).toBeGreaterThan(0) + for (const point of passingB) expect(point.x).toBeLessThan(b?.position.x ?? 0) + }) + + test('two edges between the same pair are both kept', () => { + const { edges } = layoutFlow( + [node('if', 'condition'), node('x', 'action')], + [edge('if', 'x', 'yes'), edge('if', 'x', 'no')], + { sizeOf }, + ) + expect(edges.map((e) => e.id)).toEqual(['if:yes->x', 'if:no->x']) + }) + test('does not mutate its input and ignores edges to unknown nodes', () => { const nodes = [node('a', 'trigger')] - const laid = layoutFlow(nodes, [edge('a', 'ghost')], { sizeOf }) + const laid = layoutFlow(nodes, [edge('a', 'ghost')], { sizeOf }).nodes expect(nodes[0]?.position).toEqual({ x: 0, y: 0 }) expect(nodes[0]?.width).toBeUndefined() expect(laid).toHaveLength(1) @@ -69,7 +124,7 @@ describe('layoutFlow', () => { test('LR direction lays ranks out horizontally', () => { const laid = layoutFlow([node('a', 'trigger'), node('b', 'action')], [edge('a', 'b')], { sizeOf }, { direction: 'LR', - }) + }).nodes expect(laid[1]?.position.x).toBeGreaterThan((laid[0]?.position.x ?? 0) + 100) }) }) @@ -80,7 +135,7 @@ describe('useFlowLayout', () => { const shapes = { sizeOf } const nodes = [node('a', 'trigger'), node('b', 'action')] const edges = [edge('a', 'b')] - const { result, rerender } = renderHook(({ n, e }) => useFlowLayout(n, e, shapes), { + const { result, rerender } = renderHook(({ n, e }) => useFlowLayout(n, e, shapes).nodes, { initialProps: { n: nodes, e: edges }, }) const first = result.current diff --git a/web/src/components/shared/flow/flow-edge.tsx b/web/src/components/shared/flow/flow-edge.tsx index 0189f351..cc7a9a55 100644 --- a/web/src/components/shared/flow/flow-edge.tsx +++ b/web/src/components/shared/flow/flow-edge.tsx @@ -1,4 +1,12 @@ -import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type Edge, type EdgeProps } from '@xyflow/react' +import { + BaseEdge, + EdgeLabelRenderer, + getSmoothStepPath, + type Edge, + type EdgeProps, + type Position, + type XYPosition, +} from '@xyflow/react' import { AddNodeButton } from './add-node-button' import { useFlowInsert } from './flow-insert-context' @@ -7,6 +15,11 @@ export type FlowEdgeData = { label?: string /** Present = the edge offers an add button; the text is that button's accessible name. */ insertLabel?: string + /** + * Bend points the layout routed the edge through (see `layoutFlow`), for an + * edge that skips a rank and would otherwise be drawn behind a node. + */ + route?: readonly XYPosition[] } export type FlowEdgeType = Edge @@ -30,14 +43,12 @@ export function FlowEdge({ data, }: EdgeProps) { const onInsert = useFlowInsert() - const [path, labelX, labelY] = getSmoothStepPath({ - sourceX, - sourceY, - targetX, - targetY, + const [path, labelX, labelY] = edgePath({ + from: { x: sourceX, y: sourceY }, + to: { x: targetX, y: targetY }, + route: data?.route ?? [], sourcePosition, targetPosition, - borderRadius: 10, }) const insertLabel = data?.insertLabel const canInsert = insertLabel !== undefined && onInsert !== null @@ -68,3 +79,43 @@ export function FlowEdge({ ) } + +/** + * The edge's path and where its chip sits. Without a route it is one rounded + * orthogonal step; with one, a step through each bend point in turn (a path of + * several subpaths reads as one line, and the arrowhead lands on the last + * vertex), with the chip on the middle bend so it sits in the gap the route + * found rather than on a node. + */ +function edgePath({ + from, + to, + route, + sourcePosition, + targetPosition, +}: { + from: XYPosition + to: XYPosition + route: readonly XYPosition[] + sourcePosition: Position + targetPosition: Position +}): [path: string, labelX: number, labelY: number] { + const waypoints = [from, ...route, to] + const segments = waypoints.slice(1).map((end, index) => { + const start = waypoints[index] ?? from + return getSmoothStepPath({ + sourceX: start.x, + sourceY: start.y, + targetX: end.x, + targetY: end.y, + sourcePosition, + targetPosition, + borderRadius: 10, + }) + }) + const path = segments.map(([segment]) => segment).join(' ') + const middle = route[Math.floor(route.length / 2)] + if (middle) return [path, middle.x, middle.y] + const [, labelX, labelY] = segments[0] ?? ['', from.x, from.y] + return [path, labelX, labelY] +} diff --git a/web/src/components/shared/flow/flow-layout.ts b/web/src/components/shared/flow/flow-layout.ts index eca9a14d..1f569c04 100644 --- a/web/src/components/shared/flow/flow-layout.ts +++ b/web/src/components/shared/flow/flow-layout.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { graphlib, layout } from '@dagrejs/dagre' -import type { Edge, Node } from '@xyflow/react' +import type { Node, XYPosition } from '@xyflow/react' +import type { FlowEdgeType } from './flow-edge' import type { FlowNodeRegistry, FlowNodeSize } from './node-registry' /** The part of a registry layout needs; `handlesOf` is optional so a test can lay out bare boxes. */ @@ -15,46 +16,85 @@ export type FlowLayoutOptions = { rankSpacing?: number } +export type FlowLayout = { nodes: N[]; edges: FlowEdgeType[] } + const DEFAULTS: Required = { direction: 'TB', nodeSpacing: 56, rankSpacing: 72 } +/** Far above any ordinary edge's default weight of 1; see the relay edges in `layoutFlow`. */ +const RELAY_WEIGHT = 1000 + +// dagre writes each node's centre (`x`, `y`) back onto the label we give it, +// and each edge's route (`points`) onto its label. +type PlacedNode = FlowNodeSize & { x?: number; y?: number } +type PlacedEdge = { points?: XYPosition[]; weight?: number } + /** - * Positions every node with dagre and returns new nodes; the inputs are not - * touched. Positions are React Flow's top-left corner (dagre reports centres), - * and each node gets its declared `width`/`height` (and handle geometry) so - * React Flow renders it at the size the layout assumed instead of waiting to - * measure it. + * Positions every node with dagre and returns new nodes and edges; the inputs + * are not touched. Positions are React Flow's top-left corner (dagre reports + * centres), and each node gets its declared `width`/`height` (and handle + * geometry) so React Flow renders it at the size the layout assumed instead of + * waiting to measure it. * - * A node's children keep the order of its edges: emit a condition's "yes" - * edge before its "no" edge and "yes" lands on the left. dagre's own crossing - * minimisation would otherwise pick either side, so this is passed to it as an - * explicit ordering constraint. + * An edge that skips a rank (a branch jumping from step 1 to step 3) gets + * dagre's bend points as `data.route`. Drawn straight, it would run behind the + * node in between and read as going there; dagre routes it through the gap + * beside that node instead. + * + * A node with several exits keeps them in the order of its edges: emit a + * condition's "yes" edge before its "no" edge and the yes path stays on the + * left all the way down. dagre can only be told the order of nodes within one + * rank, and an exit that skips a rank has no node there — so every exit of a + * multi-exit node is routed through a relay point in the rank right below, and + * the relays are ordered. Without that, "yes → step 3" and "no → step 2" leave the diamond + * crossed. */ export function layoutFlow( nodes: readonly N[], - edges: readonly Edge[], + edges: readonly FlowEdgeType[], { sizeOf, handlesOf }: FlowNodeShapes, options: FlowLayoutOptions = {}, -): N[] { +): FlowLayout { // `??` per field, not an object spread: an explicit `undefined` must fall back // to the default rather than override it. const direction = options.direction ?? DEFAULTS.direction const nodeSpacing = options.nodeSpacing ?? DEFAULTS.nodeSpacing const rankSpacing = options.rankSpacing ?? DEFAULTS.rankSpacing - // dagre writes each node's centre (`x`, `y`) back onto the label we give it. - const graph = new graphlib.Graph() + // A multigraph, so two edges between the same pair (a condition whose Yes and + // No both go to one step) are laid out — and routed — separately. + const graph = new graphlib.Graph({ multigraph: true }) graph.setGraph({ rankdir: direction, nodesep: nodeSpacing, ranksep: rankSpacing }) graph.setDefaultEdgeLabel(() => ({})) for (const node of nodes) graph.setNode(node.id, { ...sizeOf(node.type) }) - for (const edge of edges) { - // An edge to a node that isn't in the graph would make dagre invent an - // unsized node for it; drop it here rather than lay out a phantom. - if (graph.hasNode(edge.source) && graph.hasNode(edge.target)) graph.setEdge(edge.source, edge.target) + // An edge to a node that isn't in the graph would make dagre invent an + // unsized node for it; leave it out rather than lay out a phantom. + const placeable = edges.filter((edge) => graph.hasNode(edge.source) && graph.hasNode(edge.target)) + const relayed = relayedEdges(placeable) + for (const edge of placeable) { + const relay = relayed.get(edge.id) + if (!relay) { + graph.setEdge(edge.source, edge.target, {}, edge.id) + continue + } + graph.setNode(relay, { width: 1, height: 1 }) + // Heavy, so the ranker keeps it one rank long: every relay of a node sits + // in the rank right below it, where the order constraint can apply. A + // light edge lets a relay drift down towards a distant target — into a + // different rank from its siblings — and the exits cross again. + graph.setEdge(edge.source, relay, { weight: RELAY_WEIGHT }, `${edge.id}:in`) + graph.setEdge(relay, edge.target, {}, `${edge.id}:out`) } - layout(graph, { constraints: siblingOrder(edges, graph.hasNode.bind(graph)) }) + // `disableOptimalOrderHeuristic` keeps dagre's initial order: a depth-first + // walk that visits each node's edges in the order given — exactly "yes before + // no", step by step. Its crossing-reduction sweeps were observed to undo that + // one rank below the relays (putting the "no" target left of the "yes" + // path's dummy node), re-crossing exits the constraint had just uncrossed. + // The flows drawn here are near-trees, where the walk order is already + // crossing-free. + layout(graph, { constraints: relayOrder(placeable, relayed), disableOptimalOrderHeuristic: true }) - return nodes.map((node) => { + const laidNodes = nodes.map((node) => { const { width, height } = sizeOf(node.type) const placed = graph.node(node.id) return { @@ -65,20 +105,47 @@ export function layoutFlow( position: { x: (placed.x ?? 0) - width / 2, y: (placed.y ?? 0) - height / 2 }, } }) + const laidEdges = edges.map((edge) => { + const relay = relayed.get(edge.id) + if (relay) { + const { x, y } = graph.node(relay) + const onward = graph.edge({ v: relay, w: edge.target, name: `${edge.id}:out` })?.points ?? [] + const route = [{ x: x ?? 0, y: y ?? 0 }, ...onward.slice(1, -1)] + return { ...edge, data: { ...edge.data, route } } + } + const points = graph.edge({ v: edge.source, w: edge.target, name: edge.id })?.points ?? [] + // Three points is dagre's route for adjacent ranks (leave, middle, arrive): + // nothing is in the way, and the edge's own orthogonal path is cleaner. + // Anything longer passed through dummy nodes between the ranks. + if (points.length <= 3) return edge + return { ...edge, data: { ...edge.data, route: points.slice(1, -1) } } + }) + return { nodes: laidNodes, edges: laidEdges } +} + +/** Relay node ids, by edge, for every edge leaving a node that has more than one. */ +function relayedEdges(edges: readonly FlowEdgeType[]): Map { + const outgoing = new Map() + for (const edge of edges) outgoing.set(edge.source, (outgoing.get(edge.source) ?? 0) + 1) + const relays = new Map() + for (const edge of edges) { + // A NUL never appears in a node id a caller chooses, so a relay id can't + // collide with a real node. + if ((outgoing.get(edge.source) ?? 0) > 1) relays.set(edge.id, `\u0000relay\u0000${edge.id}`) + } + return relays } -/** Left-to-right constraints between consecutive children of each node. */ -function siblingOrder(edges: readonly Edge[], exists: (id: string) => boolean): { left: string; right: string }[] { - const children = new Map() +/** Left-to-right constraints between consecutive relays of each node, in edge order. */ +function relayOrder(edges: readonly FlowEdgeType[], relays: ReadonlyMap): { left: string; right: string }[] { + const bySource = new Map() for (const edge of edges) { - if (!exists(edge.source) || !exists(edge.target)) continue - const list = children.get(edge.source) ?? [] - list.push(edge.target) - children.set(edge.source, list) + const relay = relays.get(edge.id) + if (relay) bySource.set(edge.source, [...(bySource.get(edge.source) ?? []), relay]) } const constraints: { left: string; right: string }[] = [] - for (const targets of children.values()) { - targets.reduce((left, right) => { + for (const ordered of bySource.values()) { + ordered.reduce((left, right) => { constraints.push({ left, right }) return right }) @@ -89,10 +156,10 @@ function siblingOrder(edges: readonly Edge[], exists: (id: string) => boolean): /** `layoutFlow`, recomputed only when the graph itself changes. */ export function useFlowLayout( nodes: readonly N[], - edges: readonly Edge[], + edges: readonly FlowEdgeType[], shapes: FlowNodeShapes, options?: FlowLayoutOptions, -): N[] { +): FlowLayout { const direction = options?.direction const nodeSpacing = options?.nodeSpacing const rankSpacing = options?.rankSpacing diff --git a/web/src/components/shared/flow/flow-node-frame.tsx b/web/src/components/shared/flow/flow-node-frame.tsx index 73a3abf8..a542e5c2 100644 --- a/web/src/components/shared/flow/flow-node-frame.tsx +++ b/web/src/components/shared/flow/flow-node-frame.tsx @@ -9,8 +9,10 @@ import { useFlowRegistry } from './flow-registry-context' * bottom, each named in text when its output has a label. Both come from the * registry by `type` — pass the `type` React Flow gives the node component. * - * Handles are only connectable when the canvas is (`connectable`) — otherwise - * they are drawn purely to show where edges attach. + * Connectability is split by direction, because what may be dragged FROM and + * what may be dropped ONTO differ: a read-only step may refuse to start a drag + * yet still accept a condition exit dropped on it. A handle that is neither is + * drawn purely to show where edges attach. * * `pointer-events-auto`: React Flow sets `pointer-events: none` on the wrapper * of a node that is neither selectable nor draggable, and it inherits — without @@ -18,12 +20,16 @@ import { useFlowRegistry } from './flow-registry-context' */ export function FlowNodeFrame({ type, - connectable = false, + inputConnectable = false, + outputsConnectable = false, className, children, }: { type: string - connectable?: boolean + /** Accepts a connection dropped on its entry handle. */ + inputConnectable?: boolean + /** Its exits can start a connection. */ + outputsConnectable?: boolean className?: string children: React.ReactNode }) { @@ -31,7 +37,9 @@ export function FlowNodeFrame({ const outputs = registry.outputsOf(type) return (
- {registry.hasInputOf(type) && } + {registry.hasInputOf(type) && ( + + )} {children} {outputs.map((output, index) => { const left = `${outputOffset(index, outputs.length) * 100}%` @@ -49,7 +57,8 @@ export function FlowNodeFrame({ type="source" position={Position.Bottom} id={output.id ?? undefined} - isConnectable={connectable} + isConnectable={outputsConnectable} + isConnectableEnd={false} style={{ left }} />
diff --git a/web/src/features/campaigns/__tests__/branch-draft.test.ts b/web/src/features/campaigns/__tests__/branch-draft.test.ts new file mode 100644 index 00000000..76558f1d --- /dev/null +++ b/web/src/features/campaigns/__tests__/branch-draft.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from 'vitest' +import type { StepBranch } from '../api' +import { + CONDITIONS, + describeBranch, + draftFromBranch, + toBranchRequest, + validateDraft, + withExit, + type ConditionDraft, + type DraftContext, +} from '../branch-draft' + +const context: DraftContext = { + stepId: 'a', + stepIds: new Set(['a', 'b', 'c']), + trackingEnabled: true, + htmlEverywhere: true, + replyLabels: [ + { key: 'interested', label: 'Interested', stopsEnrollment: true }, + { key: 'question', label: 'Question', stopsEnrollment: false }, + ], +} + +const draft = (fields: Partial): ConditionDraft => ({ ...draftFromBranch(null), ...fields }) +const fields = (d: ConditionDraft, c: DraftContext = context) => validateDraft(d, c).map((p) => p.field) + +const saved: StepBranch = { + step_id: 'a', + condition: 'replied', + within_days: 5, + reply_label_key: 'question', + yes_step_id: 'c', + no_step_id: null, + updated_at: '2026-09-23T00:00:00Z', +} + +describe('draftFromBranch', () => { + test('a new condition starts as "opened within 3 days", both paths ending', () => { + expect(draftFromBranch(null)).toEqual({ + condition: 'opened', + withinDays: '3', + replyLabelKey: '', + yesStepId: '', + noStepId: '', + }) + }) + test('an existing branch fills the form, nulls as empty', () => { + expect(draftFromBranch(saved)).toEqual({ + condition: 'replied', + withinDays: '5', + replyLabelKey: 'question', + yesStepId: 'c', + noStepId: '', + }) + }) +}) + +describe('validateDraft mirrors the server', () => { + test('a valid draft has no problems', () => { + expect(fields(draft({ yesStepId: 'b', noStepId: 'c' }))).toEqual([]) + }) + + test('within_days is a whole number from 1 to 90 (invalid_within_days)', () => { + for (const bad of ['', '0', '91', '2.5', 'abc']) expect(fields(draft({ withinDays: bad }))).toEqual(['withinDays']) + for (const ok of ['1', '90']) expect(fields(draft({ withinDays: ok }))).toEqual([]) + }) + + test('"always" needs no window at all', () => { + expect(fields(draft({ condition: 'always', withinDays: '' }))).toEqual([]) + }) + + test('open / click conditions need tracking and an HTML body (tracking_required)', () => { + for (const condition of ['opened', 'not_opened', 'clicked'] as const) { + expect(fields(draft({ condition }), { ...context, trackingEnabled: false })).toEqual(['condition']) + expect(fields(draft({ condition }), { ...context, htmlEverywhere: false })).toEqual(['condition']) + } + // Reply conditions don't read tracking at all. + expect(fields(draft({ condition: 'replied' }), { ...context, trackingEnabled: false })).toEqual([]) + // Unknown (still loading) is left to the server rather than guessed. + expect(fields(draft({}), { ...context, trackingEnabled: undefined, htmlEverywhere: undefined })).toEqual([]) + }) + + test('the tracking problem says what to do, in words', () => { + const [problem] = validateDraft(draft({}), { ...context, trackingEnabled: false }) + expect(problem?.message).toMatch(/turn it on from the Overview tab/) + }) + + test('a reply label that stops the sequence can never route (reply_label_stops_sequence)', () => { + const [problem] = validateDraft(draft({ condition: 'replied', replyLabelKey: 'interested' }), context) + expect(problem).toEqual({ field: 'replyLabel', message: expect.stringMatching(/“Interested” stop the sequence/) }) + expect(fields(draft({ condition: 'replied', replyLabelKey: 'question' }))).toEqual([]) + expect(fields(draft({ condition: 'not_replied', replyLabelKey: 'question' }))).toEqual([]) + }) + + test('a label that no longer exists is flagged (invalid_reply_label)', () => { + expect(fields(draft({ condition: 'replied', replyLabelKey: 'deleted' }))).toEqual(['replyLabel']) + }) + + test('an exit can’t point back at its own step (cycle) or at a step that’s gone (unknown_target)', () => { + expect(fields(draft({ yesStepId: 'a' }))).toEqual(['yes']) + expect(fields(draft({ noStepId: 'zzz' }))).toEqual(['no']) + }) + + test('"always" ignores a stale No exit instead of flagging it (no_exit_not_allowed is never sent)', () => { + expect(fields(draft({ condition: 'always', noStepId: 'a' }))).toEqual([]) + expect(toBranchRequest(draft({ condition: 'always', noStepId: 'b' })).no_step_id).toBeNull() + }) +}) + +describe('toBranchRequest', () => { + test('sends only what the condition uses', () => { + expect(toBranchRequest(draft({ condition: 'opened', replyLabelKey: 'question', yesStepId: 'b' }))).toEqual({ + condition: 'opened', + within_days: 3, + // A label can't ride along on a non-reply condition (invalid_reply_label). + reply_label_key: null, + yes_step_id: 'b', + no_step_id: null, + }) + expect(toBranchRequest(draft({ condition: 'always', withinDays: '9', yesStepId: 'c' }))).toEqual({ + condition: 'always', + within_days: null, + reply_label_key: null, + yes_step_id: 'c', + no_step_id: null, + }) + expect(toBranchRequest(draft({ condition: 'replied', replyLabelKey: 'question' })).reply_label_key).toBe('question') + }) + + test('withExit changes one exit and keeps the rest', () => { + expect(withExit(saved, 'no', 'b')).toEqual({ + condition: 'replied', + within_days: 5, + reply_label_key: 'question', + yes_step_id: 'c', + no_step_id: 'b', + }) + }) +}) + +describe('describeBranch', () => { + test('reads as the condition in words', () => { + expect(describeBranch({ condition: 'opened', within_days: 3, reply_label_key: null })).toBe('Opened within 3 days') + expect(describeBranch({ condition: 'not_replied', within_days: 1, reply_label_key: null })).toBe('No reply within 1 day') + expect(describeBranch(saved, 'Question')).toBe('Replied · Question within 5 days') + expect(describeBranch(saved)).toBe('Replied · question within 5 days') + expect(describeBranch({ condition: 'always', within_days: null, reply_label_key: null })).toBe('Always') + }) + + test('every contract condition has an option', () => { + expect(CONDITIONS.map((c) => c.value).sort()).toEqual( + ['always', 'clicked', 'not_opened', 'not_replied', 'opened', 'replied'].sort(), + ) + }) +}) diff --git a/web/src/features/campaigns/__tests__/branch-error.test.ts b/web/src/features/campaigns/__tests__/branch-error.test.ts new file mode 100644 index 00000000..f3c37960 --- /dev/null +++ b/web/src/features/campaigns/__tests__/branch-error.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'vitest' +import { branchErrorMessage } from '../branch-error' +import { cycleStepIds } from '../branch-loop' +import { reorderErrorMessage, stepErrorMessage } from '../step-error' + +const refused = (status: number, code: string, extra: Record = {}) => ({ + status, + data: { error: 'server words', code, ...extra }, +}) + +describe('branchErrorMessage', () => { + test.each([ + [400, 'invalid_condition', /isn’t one Inroad supports/], + [400, 'invalid_within_days', /whole number of days from 1 to 90/], + [400, 'invalid_reply_label', /reply label no longer exists/], + [400, 'reply_label_stops_sequence', /stop the sequence before any condition runs/], + [400, 'tracking_required', /tracking is on for this campaign.*Overview tab/], + [400, 'no_exit_not_allowed', /only one exit/], + [400, 'cycle', /would make a loop/], + [422, 'cycle', /steps in the loop are highlighted/], + [422, 'unknown_target', /step that no longer exists/], + [422, 'unknown_step', /This step no longer exists/], + ])('%i %s has its own copy', (status, code, copy) => { + expect(branchErrorMessage(refused(status, code))).toMatch(copy) + }) + + test('a 404 without a code, then the server’s prose, then a generic line', () => { + expect(branchErrorMessage({ status: 404, data: { error: 'step not found' } })).toBe('This step no longer exists.') + expect(branchErrorMessage({ status: 500, data: { error: 'the database is on fire' } })).toBe('the database is on fire') + expect(branchErrorMessage({ status: 'FETCH_ERROR', error: 'offline' })).toMatch(/Couldn’t save the condition/) + }) +}) + +describe('cycleStepIds', () => { + test('reads the loop a cycle names, in order', () => { + expect(cycleStepIds(refused(422, 'cycle', { step_ids: ['b', 'c', 'a'] }))).toEqual(['b', 'c', 'a']) + }) + test('null for any other failure, or a loop it can’t trust', () => { + expect(cycleStepIds(refused(422, 'unknown_target', { step_ids: ['b'] }))).toBeNull() + expect(cycleStepIds(refused(422, 'cycle'))).toBeNull() + expect(cycleStepIds(refused(422, 'cycle', { step_ids: [] }))).toBeNull() + expect(cycleStepIds(refused(422, 'cycle', { step_ids: [1, 2] }))).toBeNull() + expect(cycleStepIds(undefined)).toBeNull() + }) +}) + +test('a structural write that would close a loop says so, not "couldn’t reorder"', () => { + expect(reorderErrorMessage(refused(422, 'cycle'))).toMatch(/loop through a condition/) + expect(stepErrorMessage(refused(422, 'cycle'))).toMatch(/loop through a condition/) + // Unchanged for everything else. + expect(reorderErrorMessage({ status: 409, data: {} })).toMatch(/only allowed while the campaign is a draft/) +}) diff --git a/web/src/features/campaigns/__tests__/campaign-detail-layout.test.tsx b/web/src/features/campaigns/__tests__/campaign-detail-layout.test.tsx index 166cd0fd..1208f3fa 100644 --- a/web/src/features/campaigns/__tests__/campaign-detail-layout.test.tsx +++ b/web/src/features/campaigns/__tests__/campaign-detail-layout.test.tsx @@ -70,6 +70,7 @@ beforeEach(() => { // return BARE ARRAYS, and `listCampaigns`'s `providesTags` calls // `result.map`, so an object stub there throws inside RTK Query itself. if (url.includes('/steps')) return jsonResponse([]) + if (url.includes('/graph')) return jsonResponse({ campaign_id: CAMPAIGN_ID, entry_step_id: null, nodes: [] }) if (url.includes('/schedule')) return jsonResponse({ days: [], timezone: 'UTC' }) if (url.includes('/senders')) return jsonResponse({ mailbox_ids: [], rotation: 'round_robin' }) if (url.includes('/mailboxes')) return jsonResponse([]) diff --git a/web/src/features/campaigns/__tests__/fake-sequence-server.ts b/web/src/features/campaigns/__tests__/fake-sequence-server.ts new file mode 100644 index 00000000..bbd8b172 --- /dev/null +++ b/web/src/features/campaigns/__tests__/fake-sequence-server.ts @@ -0,0 +1,178 @@ +// A small stateful stand-in for the sequence endpoints, installed as `fetch`. +// Reorder, create, delete and the branch writes change its state, and every +// GET answers from that state, so a refetch is consistent with the mutations +// before it — the canvas is exercised through RTK Query exactly as in the app. +// Not a test file itself: shared by the canvas suites. +import { vi } from 'vitest' +import type { StepBranch, StepBranchRequest } from '../api' + +export type FakeStep = { + id: string + step_order: number + delay_seconds: number + subject: string + body_text?: string + body_html?: string +} +export type CapturedRequest = { method: string; url: string; body: unknown } +export type FakeReplyLabel = { key: string; label: string; stops_enrollment: boolean } + +export type FakeSequenceServer = { + steps: FakeStep[] + /** Branches by source step. */ + branches: Map + requests: CapturedRequest[] + campaign: { id: string; status: string; tracking_enabled: boolean } + replyLabels: FakeReplyLabel[] + /** When set, the reorder endpoint answers with this instead of reordering. */ + reorderFails: Response | null + /** When set, a branch write (PUT or DELETE) answers with this instead of saving. */ + branchFails: Response | null + /** When true, GET /graph answers 500. */ + graphFails: boolean + /** When set, GET /steps waits on it — "the refetch hasn't come back yet". */ + listGate: Promise | null +} + +export function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } }) +} + +function renumber(list: FakeStep[]): FakeStep[] { + return list.map((step, index) => ({ ...step, step_order: index + 1 })) +} + +function graphOf(server: FakeSequenceServer) { + return { + campaign_id: server.campaign.id, + entry_step_id: server.steps[0]?.id ?? null, + nodes: server.steps.map((step, index) => ({ + step_id: step.id, + step_order: step.step_order, + default_next_step_id: server.steps[index + 1]?.id ?? null, + branch: server.branches.get(step.id) ?? null, + })), + } +} + +function labelList(server: FakeSequenceServer) { + return { + labels: server.replyLabels.map((label, index) => ({ + id: `rl-${label.key}`, + key: label.key, + label: label.label, + color: '#888888', + position: index, + is_builtin: true, + stops_enrollment: label.stops_enrollment, + is_automated: false, + suppresses_contact: false, + captures_deal: false, + defers_enrollment: false, + created_at: '2026-09-01T00:00:00Z', + updated_at: '2026-09-01T00:00:00Z', + })), + } +} + +export function installFakeSequenceServer(steps: FakeStep[]): FakeSequenceServer { + const server: FakeSequenceServer = { + steps, + branches: new Map(), + requests: [], + campaign: { id: 'c-1', status: 'draft', tracking_enabled: true }, + replyLabels: [], + reorderFails: null, + branchFails: null, + graphFails: false, + listGate: null, + } + + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const isRequest = input instanceof Request + const url = isRequest ? input.url : typeof input === 'string' ? input : (input as URL).href + const method = (isRequest ? input.method : (init?.method ?? 'GET')).toUpperCase() + const text = isRequest ? await input.clone().text() : typeof init?.body === 'string' ? init.body : '' + const body = text ? (JSON.parse(text) as unknown) : undefined + server.requests.push({ method, url, body }) + + if (url.endsWith('/reply-labels')) return jsonResponse(labelList(server)) + if (url.endsWith('/graph')) { + return server.graphFails ? jsonResponse({ error: 'boom' }, 500) : jsonResponse(graphOf(server)) + } + const branchPath = /\/steps\/([^/]+)\/branch$/.exec(url) + if (branchPath?.[1]) { + if (server.branchFails) return server.branchFails + const stepId = branchPath[1] + if (method === 'DELETE') { + server.branches.delete(stepId) + return new Response(null, { status: 204 }) + } + const request = body as StepBranchRequest + const saved: StepBranch = { + step_id: stepId, + condition: request.condition, + within_days: request.within_days ?? null, + reply_label_key: request.reply_label_key ?? null, + yes_step_id: request.yes_step_id ?? null, + no_step_id: request.no_step_id ?? null, + updated_at: '2026-09-23T00:00:00Z', + } + server.branches.set(stepId, saved) + return jsonResponse(saved) + } + if (url.endsWith('/steps/s-1/variants')) { + return jsonResponse([ + { id: 'v-1', step_id: 's-1', label: 'B', weight: 50, subject: '', body_text: '', body_html: '

b

' }, + { id: 'v-2', step_id: 's-1', label: 'C', weight: 50, subject: '', body_text: '', body_html: '

c

' }, + ]) + } + if (url.endsWith('/variants')) return jsonResponse([]) + if (url.endsWith('/steps/reorder')) { + if (server.reorderFails) return server.reorderFails + const { step_ids } = body as { step_ids: string[] } + server.steps = renumber(step_ids.flatMap((id) => server.steps.filter((step) => step.id === id))) + return jsonResponse(server.steps) + } + if (/\/steps\/[^/]+$/.test(url) && method === 'PUT') return jsonResponse(server.steps[0]) + if (/\/steps\/[^/]+$/.test(url) && method === 'DELETE') { + const id = url.split('/').at(-1) + server.steps = renumber(server.steps.filter((step) => step.id !== id)) + return new Response(null, { status: 204 }) + } + if (url.endsWith('/steps') && method === 'POST') { + const created: FakeStep = { + id: 's-new', + step_order: server.steps.length + 1, + delay_seconds: 0, + subject: (body as { subject?: string }).subject ?? '', + } + server.steps = [...server.steps, created] + return jsonResponse(created) + } + if (url.endsWith('/steps')) { + if (server.listGate) await server.listGate + return jsonResponse(server.steps) + } + if (url.endsWith(`/campaigns/${server.campaign.id}`)) return jsonResponse(server.campaign) + return jsonResponse({ error: `unhandled ${method} ${url}` }, 404) + }), + ) + return server +} + +export function lastRequest( + server: FakeSequenceServer, + predicate: (request: CapturedRequest) => boolean, +): CapturedRequest | undefined { + return [...server.requests].reverse().find(predicate) +} + +/** The bodies sent to URLs ending in `suffix`, in order. */ +export function bodiesTo(server: FakeSequenceServer, suffix: string, method?: string): unknown[] { + return server.requests + .filter((request) => request.url.endsWith(suffix) && (method === undefined || request.method === method)) + .map((request) => request.body) +} diff --git a/web/src/features/campaigns/__tests__/sequence-canvas-conditions.test.tsx b/web/src/features/campaigns/__tests__/sequence-canvas-conditions.test.tsx new file mode 100644 index 00000000..373f3c08 --- /dev/null +++ b/web/src/features/campaigns/__tests__/sequence-canvas-conditions.test.tsx @@ -0,0 +1,307 @@ +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import type { Connection, Edge } from '@xyflow/react' +import { afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest' +import { renderWithProviders } from '@/test/render-with-providers' +import { installReactFlowDom } from '@/test/react-flow-dom' +import type { StepBranch } from '../api' +import { SequenceEditor } from '../sequence-editor' +import { bodiesTo, installFakeSequenceServer, jsonResponse, type FakeSequenceServer } from './fake-sequence-server' + +// Conditions on the flow canvas, through the real React Flow + dagre pipeline +// against a stateful fake of the sequence endpoints (branch writes included). +// As in sequence-canvas.test.tsx, the handlers the canvas gives React Flow are +// captured so a drop can be driven without jsdom geometry. +const canvasProps = vi.hoisted(() => ({ + current: undefined as + | { onConnect?: (connection: Connection) => void; isValidConnection?: (edge: Edge | Connection) => boolean } + | undefined, +})) +vi.mock('@/components/shared/flow/flow-canvas', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + FlowCanvas: (props: Parameters[0]) => { + canvasProps.current = props + return + }, + } +}) + +beforeAll(async () => { + await import('../sequence-canvas') +}, 30_000) + +beforeAll(() => { + installReactFlowDom() + const proto = Element.prototype as unknown as Record + proto.hasPointerCapture ??= () => false + proto.setPointerCapture ??= () => {} + proto.releasePointerCapture ??= () => {} + proto.scrollIntoView ??= () => {} +}) + +let server: FakeSequenceServer + +beforeEach(() => { + server = installFakeSequenceServer([ + { id: 's-1', step_order: 1, delay_seconds: 0, subject: 'Intro', body_html: '

Hi

' }, + { id: 's-2', step_order: 2, delay_seconds: 2 * 86400, subject: 'Bump' }, + { id: 's-3', step_order: 3, delay_seconds: 86400, subject: 'Case study' }, + ]) + server.replyLabels = [ + { key: 'interested', label: 'Interested', stops_enrollment: true }, + { key: 'question', label: 'Question', stops_enrollment: false }, + ] + canvasProps.current = undefined +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +function branch(stepId: string, fields: Partial): StepBranch { + return { + step_id: stepId, + condition: 'opened', + within_days: 3, + reply_label_key: null, + yes_step_id: null, + no_step_id: null, + updated_at: '2026-09-23T00:00:00Z', + ...fields, + } +} + +async function renderCanvas(status = 'draft') { + server.campaign.status = status + renderWithProviders() + return screen.findByRole('region', { name: 'Sequence flow' }) +} + +const branchWrites = (method: string) => bodiesTo(server, '/branch', method) + +async function openNewCondition(canvas: HTMLElement, position: number) { + const trigger = within(canvas).getByRole('button', { name: `Add a condition after step ${position}` }) + trigger.focus() + fireEvent.click(trigger) + return screen.findByRole('complementary', { name: `Condition after step ${position}` }) +} + +// --- Rendering ---------------------------------------------------------- + +test('a linear sequence draws exactly as before: no condition nodes, one Stop', async () => { + const canvas = await renderCanvas() + expect(within(canvas).queryByRole('button', { name: /^Edit the condition/ })).not.toBeInTheDocument() + expect(within(canvas).getAllByText('Stop')).toHaveLength(1) + // Every step offers a condition. + for (const position of [1, 2, 3]) { + expect(within(canvas).getByRole('button', { name: `Add a condition after step ${position}` })).toBeEnabled() + } +}) + +test('a branched sequence draws the IF with labelled exits and a Stop per unconnected exit', async () => { + server.branches.set('s-1', branch('s-1', { yes_step_id: 's-3' })) + const canvas = await renderCanvas() + + const condition = await within(canvas).findByRole('button', { + name: 'Edit the condition after step 1: Opened within 3 days', + }) + expect(condition).toBeInTheDocument() + expect(within(canvas).getByText('Yes')).toBeInTheDocument() + expect(within(canvas).getByText('No')).toBeInTheDocument() + // No → ends here; step 3 → its own end. + expect(within(canvas).getAllByText('Stop')).toHaveLength(2) + // The step that already has one offers no second condition. + expect(within(canvas).queryByRole('button', { name: 'Add a condition after step 1' })).not.toBeInTheDocument() + // Step 2 is only reachable by falling through from step 1 — which the condition replaced. + expect(within(canvas).getByText('Not reached')).toBeInTheDocument() +}) + +// --- Adding and editing ------------------------------------------------------ + +test('adding a condition: pick the condition, window, label and exits; it saves and takes focus', async () => { + const canvas = await renderCanvas() + const panel = await openNewCondition(canvas, 1) + + fireEvent.change(within(panel).getByLabelText('If they…'), { target: { value: 'replied' } }) + fireEvent.change(within(panel).getByLabelText('Within (days)'), { target: { value: '5' } }) + + // Only labels that don't stop the sequence are offered, and the editor says why. + const labelSelect = within(panel).getByLabelText('With the reply label') + const offered = within(labelSelect).getAllByRole('option').map((option) => option.textContent) + expect(offered).toEqual(['Any reply that doesn’t stop the sequence', 'Question']) + expect(within(panel).getByText(/1 label stops the sequence and isn’t offered/)).toBeInTheDocument() + fireEvent.change(labelSelect, { target: { value: 'question' } }) + + fireEvent.change(within(panel).getByLabelText('Yes — go to'), { target: { value: 's-3' } }) + // No stays "End the sequence". + fireEvent.click(within(panel).getByRole('button', { name: 'Add condition' })) + + await waitFor(() => + expect(branchWrites('PUT')).toEqual([ + { condition: 'replied', within_days: 5, reply_label_key: 'question', yes_step_id: 's-3', no_step_id: null }, + ]), + ) + const node = await within(canvas).findByRole('button', { + name: 'Edit the condition after step 1: Replied · Question within 5 days', + }) + expect(screen.queryByRole('complementary')).not.toBeInTheDocument() + await waitFor(() => expect(node).toHaveFocus()) +}) + +test('the editor refuses what the server would, and says why before anything is sent', async () => { + server.campaign.tracking_enabled = false + const canvas = await renderCanvas() + const panel = await openNewCondition(canvas, 1) + const save = within(panel).getByRole('button', { name: 'Add condition' }) + + // "Opened" is the default; with tracking off it can never be true. + expect(await within(panel).findByText(/Tracking is off for this campaign/)).toBeInTheDocument() + expect(save).toBeDisabled() + + fireEvent.change(within(panel).getByLabelText('If they…'), { target: { value: 'not_replied' } }) + expect(save).toBeEnabled() + + fireEvent.change(within(panel).getByLabelText('Within (days)'), { target: { value: '91' } }) + expect(within(panel).getByText('A whole number of days from 1 to 90.')).toBeInTheDocument() + expect(save).toBeDisabled() + + // "Always" has no window and a single exit. + fireEvent.change(within(panel).getByLabelText('If they…'), { target: { value: 'always' } }) + expect(within(panel).queryByLabelText('Within (days)')).not.toBeInTheDocument() + expect(within(panel).queryByLabelText('No — go to')).not.toBeInTheDocument() + expect(within(panel).getByLabelText('Then go to')).toBeInTheDocument() + expect(save).toBeEnabled() + expect(branchWrites('PUT')).toEqual([]) +}) + +test('an exit can’t name its own step', async () => { + const canvas = await renderCanvas() + const panel = await openNewCondition(canvas, 2) + const options = within(within(panel).getByLabelText('Yes — go to')).getAllByRole('option') + expect(options.map((option) => option.textContent)).toEqual([ + 'End the sequence', + 'Step 1 — Intro', + 'Step 3 — Case study', + ]) +}) + +test('a refusal the client couldn’t foresee shows the server’s code as plain copy', async () => { + server.branchFails = jsonResponse({ error: 'no', code: 'tracking_required' }, 400) + const canvas = await renderCanvas() + const panel = await openNewCondition(canvas, 1) + fireEvent.click(within(panel).getByRole('button', { name: 'Add condition' })) + const alert = await within(panel).findByRole('alert') + expect(alert).toHaveTextContent(/tracking is on for this campaign.*Overview tab/) + // The panel stays open so the user can change the condition. + expect(screen.getByRole('complementary', { name: 'Condition after step 1' })).toBeInTheDocument() +}) + +test('a loop the server refuses is marked on the steps in it', async () => { + server.branchFails = jsonResponse({ error: 'loop', code: 'cycle', step_ids: ['s-1', 's-2', 's-3'] }, 422) + const canvas = await renderCanvas() + const panel = await openNewCondition(canvas, 3) + fireEvent.change(within(panel).getByLabelText('If they…'), { target: { value: 'always' } }) + fireEvent.change(within(panel).getByLabelText('Then go to'), { target: { value: 's-1' } }) + fireEvent.click(within(panel).getByRole('button', { name: 'Add condition' })) + + expect(await within(panel).findByRole('alert')).toHaveTextContent(/would make a loop/) + await waitFor(() => expect(within(canvas).getAllByText('In a loop')).toHaveLength(3)) +}) + +test('removing a condition returns the step to falling through, and focus to "Add a condition"', async () => { + server.branches.set('s-1', branch('s-1', { yes_step_id: 's-3' })) + const canvas = await renderCanvas() + fireEvent.click(await within(canvas).findByRole('button', { name: /^Edit the condition after step 1/ })) + const panel = await screen.findByRole('complementary', { name: 'Condition after step 1' }) + // The saved branch fills the form. + expect(within(panel).getByLabelText('Yes — go to')).toHaveValue('s-3') + fireEvent.click(within(panel).getByRole('button', { name: 'Remove condition' })) + + await waitFor(() => expect(bodiesTo(server, '/steps/s-1/branch', 'DELETE')).toHaveLength(1)) + const add = await within(canvas).findByRole('button', { name: 'Add a condition after step 1' }) + await waitFor(() => expect(add).toHaveFocus()) + expect(within(canvas).queryByText('Not reached')).not.toBeInTheDocument() +}) + +// --- Dragging an exit ------------------------------------------------------ + +const drag = (source: string, target: string, sourceHandle: string | null): Connection => ({ + source, + target, + sourceHandle, + targetHandle: null, +}) + +test('dragging a condition’s No exit onto a step sets that exit and keeps the rest', async () => { + server.branches.set('s-1', branch('s-1', { yes_step_id: 's-3' })) + await renderCanvas() + await screen.findByRole('button', { name: /^Edit the condition after step 1/ }) + const props = canvasProps.current + expect(props?.isValidConnection?.(drag('cond:s-1', 's-2', 'no'))).toBe(true) + expect(props?.isValidConnection?.(drag('cond:s-1', 's-1', 'no'))).toBe(false) + + await act(async () => props?.onConnect?.(drag('cond:s-1', 's-2', 'no'))) + await waitFor(() => + expect(branchWrites('PUT')).toEqual([ + { condition: 'opened', within_days: 3, reply_label_key: null, yes_step_id: 's-3', no_step_id: 's-2' }, + ]), + ) + // The step that now owns its routing can't also be dragged as a reorder. + expect(props?.isValidConnection?.(drag('s-1', 's-3', null))).toBe(false) +}) + +test('a refused drag shows why in the banner', async () => { + server.branches.set('s-1', branch('s-1', {})) + server.branchFails = jsonResponse({ error: 'gone', code: 'unknown_target' }, 422) + await renderCanvas() + await screen.findByRole('button', { name: /^Edit the condition after step 1/ }) + await act(async () => canvasProps.current?.onConnect?.(drag('cond:s-1', 's-2', 'yes'))) + expect(await screen.findByRole('alert')).toHaveTextContent(/points at a step that no longer exists/) +}) + +// --- Status --------------------------------------------------------------- + +test('a running campaign can add and route conditions but not change structure', async () => { + server.branches.set('s-2', branch('s-2', {})) + const canvas = await renderCanvas('running') + await screen.findByRole('button', { name: /^Edit the condition after step 2/ }) + + // Structure is locked… + expect(within(canvas).queryByRole('button', { name: /^Move step/ })).not.toBeInTheDocument() + expect(within(canvas).queryByRole('button', { name: /^Add a step/ })).not.toBeInTheDocument() + expect(canvasProps.current?.isValidConnection?.(drag('s-1', 's-3', null))).toBe(false) + // …routing isn't: the condition's exits can be dragged, and another condition added. + expect(canvas.querySelectorAll('.react-flow__node[data-id="cond:s-2"] .react-flow__handle.source.connectable')).toHaveLength(2) + expect(canvasProps.current?.isValidConnection?.(drag('cond:s-2', 's-1', 'yes'))).toBe(true) + + const panel = await openNewCondition(canvas, 1) + fireEvent.change(within(panel).getByLabelText('If they…'), { target: { value: 'not_replied' } }) + fireEvent.click(within(panel).getByRole('button', { name: 'Add condition' })) + await waitFor(() => expect(bodiesTo(server, '/steps/s-1/branch', 'PUT')).toHaveLength(1)) +}) + +// --- Degraded --------------------------------------------------------------- + +test('if the routing can’t load, the flow falls back to the steps in order and offers no conditions', async () => { + server.graphFails = true + const canvas = await renderCanvas() + expect(await screen.findByRole('alert')).toHaveTextContent(/Couldn’t load this sequence’s conditions/) + expect(within(canvas).queryByRole('button', { name: /^Add a condition/ })).not.toBeInTheDocument() + // The steps are all still there, linked in order. + expect(within(canvas).getByRole('button', { name: 'Edit step 3: Case study' })).toBeInTheDocument() + + server.graphFails = false + server.branches.set('s-1', branch('s-1', {})) + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + expect(await within(canvas).findByRole('button', { name: /^Edit the condition after step 1/ })).toBeInTheDocument() +}) + +test('the list view says when there are conditions it can’t show', async () => { + server.branches.set('s-1', branch('s-1', {})) + await renderCanvas() + await screen.findByRole('button', { name: /^Edit the condition after step 1/ }) + fireEvent.click(screen.getByRole('button', { name: 'List' })) + expect(screen.getByRole('note')).toHaveTextContent(/This sequence has conditions/) +}) diff --git a/web/src/features/campaigns/__tests__/sequence-canvas.test.tsx b/web/src/features/campaigns/__tests__/sequence-canvas.test.tsx index 3a1b867a..07f79f99 100644 --- a/web/src/features/campaigns/__tests__/sequence-canvas.test.tsx +++ b/web/src/features/campaigns/__tests__/sequence-canvas.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest' import { renderWithProviders } from '@/test/render-with-providers' import { installReactFlowDom } from '@/test/react-flow-dom' import { SequenceEditor } from '../sequence-editor' +import { bodiesTo, installFakeSequenceServer, jsonResponse, lastRequest, type FakeSequenceServer } from './fake-sequence-server' // The canvas is the editor's default view, rendered through the real React // Flow + dagre pipeline against a stubbed `fetch` — the same seam the list's @@ -47,80 +48,15 @@ beforeAll(() => { proto.scrollIntoView ??= () => {} }) -type Step = { id: string; step_order: number; delay_seconds: number; subject: string; body_text?: string } -type CapturedRequest = { method: string; url: string; body: unknown } - -// A small stateful server: reorder, create and delete change `steps`, and a -// GET returns whatever `steps` is now, so refetches are consistent with the -// mutations before them. -let steps: Step[] -let requests: CapturedRequest[] -let reorderFails: Response | null -/** When set, GET /steps waits on it — "the refetch hasn't come back yet". */ -let listGate: Promise | null - -function jsonResponse(data: unknown, status = 200): Response { - return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } }) -} - -function renumber(list: Step[]): Step[] { - return list.map((step, index) => ({ ...step, step_order: index + 1 })) -} +let server: FakeSequenceServer beforeEach(() => { - requests = [] - steps = [ - { id: 's-1', step_order: 1, delay_seconds: 0, subject: 'Intro', body_text: 'Hello there friend' }, + server = installFakeSequenceServer([ + { id: 's-1', step_order: 1, delay_seconds: 0, subject: 'Intro', body_text: 'Hello there friend', body_html: '

Hi

' }, { id: 's-2', step_order: 2, delay_seconds: 259200, subject: '', body_text: 'Following up now' }, { id: 's-3', step_order: 3, delay_seconds: 86400, subject: 'Last call' }, - ] - reorderFails = null - listGate = null + ]) canvasProps.current = undefined - - vi.stubGlobal( - 'fetch', - vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const isRequest = input instanceof Request - const url = isRequest ? input.url : typeof input === 'string' ? input : (input as URL).href - const method = (isRequest ? input.method : (init?.method ?? 'GET')).toUpperCase() - const text = isRequest ? await input.clone().text() : typeof init?.body === 'string' ? init.body : '' - const body = text ? (JSON.parse(text) as unknown) : undefined - requests.push({ method, url, body }) - - if (url.endsWith('/steps/s-1/variants')) { - return jsonResponse([ - { id: 'v-1', step_id: 's-1', label: 'B', weight: 50, subject: '', body_text: '', body_html: '' }, - { id: 'v-2', step_id: 's-1', label: 'C', weight: 50, subject: '', body_text: '', body_html: '' }, - ]) - } - if (url.endsWith('/variants')) return jsonResponse([]) - if (url.endsWith('/steps/reorder')) { - if (reorderFails) return reorderFails - const { step_ids } = body as { step_ids: string[] } - steps = renumber(step_ids.flatMap((id) => steps.filter((step) => step.id === id))) - return jsonResponse(steps) - } - if (/\/steps\/[^/]+$/.test(url) && method === 'PUT') return jsonResponse(steps[0]) - if (/\/steps\/[^/]+$/.test(url) && method === 'DELETE') { - const id = url.split('/').at(-1) - steps = renumber(steps.filter((step) => step.id !== id)) - return new Response(null, { status: 204 }) - } - if (url.endsWith('/steps') && method === 'POST') { - const created: Step = { - id: 's-new', - step_order: steps.length + 1, - delay_seconds: 0, - subject: (body as { subject?: string }).subject ?? '', - } - steps = [...steps, created] - return jsonResponse(created) - } - if (listGate) await listGate - return jsonResponse(steps) - }), - ) }) afterEach(() => { @@ -128,10 +64,7 @@ afterEach(() => { vi.restoreAllMocks() }) -function lastRequest(predicate: (r: CapturedRequest) => boolean): CapturedRequest | undefined { - return [...requests].reverse().find(predicate) -} -const reorderBodies = () => requests.filter((r) => r.url.endsWith('/steps/reorder')).map((r) => r.body) +const reorderBodies = () => bodiesTo(server, '/steps/reorder') async function renderCanvas(status = 'draft') { renderWithProviders() @@ -181,7 +114,7 @@ test('a step with variants shows how many, a step without shows nothing', async }) test('a short sequence gets a canvas sized to it, not the full cap', async () => { - steps = [{ id: 's-1', step_order: 1, delay_seconds: 0, subject: 'Intro' }] + server.steps = [{ id: 's-1', step_order: 1, delay_seconds: 0, subject: 'Intro' }] const canvas = await renderCanvas() const shortHeight = Number.parseFloat(canvas.parentElement?.style.height ?? '') expect(shortHeight).toBeGreaterThan(320) @@ -189,14 +122,12 @@ test('a short sequence gets a canvas sized to it, not the full cap', async () => }) test('a twelve-step sequence stops growing at the cap', async () => { - steps = renumber( - Array.from({ length: 12 }, (_, index) => ({ - id: `s-${index + 1}`, - step_order: 0, - delay_seconds: 86400, - subject: `Touch ${index + 1}`, - })), - ) + server.steps = Array.from({ length: 12 }, (_, index) => ({ + id: `s-${index + 1}`, + step_order: index + 1, + delay_seconds: 86400, + subject: `Touch ${index + 1}`, + })) const canvas = await renderCanvas() expect(canvas.parentElement?.style.height).toBe('720px') // Every step is still in the DOM and reachable by Tab (the canvas pans to a @@ -218,7 +149,7 @@ test('clicking a step opens the existing step form beside the canvas and saves t submitForm(panel) await waitFor(() => - expect(lastRequest((r) => r.method === 'PUT' && r.url.endsWith('/campaigns/c-1/steps/s-1'))?.body).toMatchObject({ + expect(lastRequest(server, (r) => r.method === 'PUT' && r.url.endsWith('/campaigns/c-1/steps/s-1'))?.body).toMatchObject({ subject: 'Intro v2', }), ) @@ -250,7 +181,7 @@ test('inserting on an edge mid-sequence creates the step, places it, and focuses submitForm(panel) await waitFor(() => expect(reorderBodies().at(-1)).toEqual({ step_ids: ['s-1', 's-new', 's-2', 's-3'] })) - expect(lastRequest((r) => r.method === 'POST' && r.url.endsWith('/campaigns/c-1/steps'))?.body).toMatchObject({ + expect(lastRequest(server, (r) => r.method === 'POST' && r.url.endsWith('/campaigns/c-1/steps'))?.body).toMatchObject({ subject: 'Inserted', }) // The "+" that opened the panel is gone (its edge was split), so focus goes @@ -264,7 +195,7 @@ test('inserting before Stop just appends — no reorder request', async () => { const panel = await screen.findByRole('complementary', { name: 'New step after step 3' }) submitForm(panel) - await waitFor(() => expect(lastRequest((r) => r.method === 'POST' && r.url.endsWith('/campaigns/c-1/steps'))).toBeDefined()) + await waitFor(() => expect(lastRequest(server, (r) => r.method === 'POST' && r.url.endsWith('/campaigns/c-1/steps'))).toBeDefined()) await waitFor(() => expect(screen.queryByRole('complementary')).not.toBeInTheDocument()) expect(reorderBodies()).toEqual([]) }) @@ -278,11 +209,11 @@ test('a double click on save creates the step once', async () => { fireEvent.click(save) await waitFor(() => expect(screen.queryByRole('complementary')).not.toBeInTheDocument()) - expect(requests.filter((r) => r.method === 'POST' && r.url.endsWith('/campaigns/c-1/steps'))).toHaveLength(1) + expect(server.requests.filter((r) => r.method === 'POST' && r.url.endsWith('/campaigns/c-1/steps'))).toHaveLength(1) }) test('a failed placement says the step exists but landed at the end', async () => { - reorderFails = jsonResponse({ error: 'boom' }, 500) + server.reorderFails = jsonResponse({ error: 'boom' }, 500) const canvas = await renderCanvas() fireEvent.click(await within(canvas).findByRole('button', { name: 'Add a step at the start' })) const panel = await screen.findByRole('complementary', { name: 'New first step' }) @@ -338,7 +269,7 @@ test('a second move before the refetch lands builds on the first, not on the sta // From here on, the refetch the reorder's invalidation triggers never // returns — only the reorder response itself can update the canvas. let release = () => {} - listGate = new Promise((resolve) => { + server.listGate = new Promise((resolve) => { release = resolve }) @@ -367,7 +298,7 @@ test('a blank-subject follow-up can’t be moved into first place', async () => }) test('a 409 on reorder surfaces the draft-only copy in the banner', async () => { - reorderFails = jsonResponse({ error: 'campaign is not a draft' }, 409) + server.reorderFails = jsonResponse({ error: 'campaign is not a draft' }, 409) const canvas = await renderCanvas() fireEvent.click(within(canvas).getByRole('button', { name: 'Move step 3 up' })) expect(await screen.findByRole('alert')).toHaveTextContent('Reorder is only allowed while the campaign is a draft.') @@ -406,9 +337,9 @@ test('connections that mean nothing, or would open on a blank subject, are refus expect(reorderBodies()).toEqual([]) }) -test('handles are connectable on a draft and not on a running campaign', async () => { +test('on a draft, steps and Start can start a drag', async () => { const draft = await renderCanvas('draft') - expect(draft.querySelectorAll('.react-flow__handle.connectable').length).toBeGreaterThan(0) + expect(draft.querySelectorAll('.react-flow__handle.source.connectable').length).toBeGreaterThan(0) }) test('a running campaign keeps edit and A/B but offers no structural change', async () => { @@ -418,9 +349,10 @@ test('a running campaign keeps edit and A/B but offers no structural change', as expect(within(canvas).getByRole('button', { name: /delete step 1 \(disabled/i })).toBeDisabled() expect(within(canvas).queryByRole('button', { name: /^add a step/i })).not.toBeInTheDocument() expect(within(canvas).queryByRole('button', { name: /^move step/i })).not.toBeInTheDocument() - // Nothing to drag: no handler, and no handle React Flow treats as connectable. - expect(canvasProps.current?.onConnect).toBeUndefined() - expect(canvas.querySelectorAll('.react-flow__handle.connectable')).toHaveLength(0) + // Nothing a step or Start can start a drag from — a reorder is structure — + // and such a drop is refused even if one arrived. + expect(canvas.querySelectorAll('.react-flow__handle.source.connectable')).toHaveLength(0) + expect(canvasProps.current?.isValidConnection?.(connection('s-1', 's-3'))).toBe(false) // The waits still read — they describe the sequence, not an action. expect(await within(canvas).findByText('Wait 3 days')).toBeInTheDocument() }) @@ -433,7 +365,7 @@ test('delete goes through the shared confirmation and deleteStep', async () => { const dialog = await screen.findByRole('alertdialog') fireEvent.click(within(dialog).getByRole('button', { name: /^delete step$/i })) await waitFor(() => - expect(lastRequest((r) => r.method === 'DELETE' && r.url.endsWith('/campaigns/c-1/steps/s-2'))).toBeDefined(), + expect(lastRequest(server, (r) => r.method === 'DELETE' && r.url.endsWith('/campaigns/c-1/steps/s-2'))).toBeDefined(), ) await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) }) diff --git a/web/src/features/campaigns/__tests__/sequence-editor.test.tsx b/web/src/features/campaigns/__tests__/sequence-editor.test.tsx index 999ca318..bd9db019 100644 --- a/web/src/features/campaigns/__tests__/sequence-editor.test.tsx +++ b/web/src/features/campaigns/__tests__/sequence-editor.test.tsx @@ -118,6 +118,19 @@ beforeEach(() => { if (url.endsWith('/steps') && method === 'POST') { return jsonResponse({ id: 's-3', step_order: 3, delay_seconds: 0, subject: 'New' }) } + // The routing: this suite covers the list, so the sequence is linear. + if (url.endsWith('/graph')) { + return jsonResponse({ + campaign_id: 'c-1', + entry_step_id: steps[0]?.id ?? null, + nodes: steps.map((step, index) => ({ + step_id: step.id, + step_order: step.step_order, + default_next_step_id: steps[index + 1]?.id ?? null, + branch: null, + })), + }) + } return stepsResponder() }), ) diff --git a/web/src/features/campaigns/__tests__/sequence-graph.test.ts b/web/src/features/campaigns/__tests__/sequence-graph.test.ts index 99296626..e37f5cc0 100644 --- a/web/src/features/campaigns/__tests__/sequence-graph.test.ts +++ b/web/src/features/campaigns/__tests__/sequence-graph.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from 'vitest' import { START_NODE_ID, + branchesByStep, buildSequenceGraph, + exitForConnection, isMeaningfulConnection, leadsWithSubject, moveStep, @@ -10,6 +12,7 @@ import { placeAfter, } from '../sequence-graph' import { sequenceNodeRegistry } from '../sequence-node-registry' +import type { StepBranch } from '../api' import type { StepWithId } from '../step-card' const steps: StepWithId[] = [ @@ -18,9 +21,34 @@ const steps: StepWithId[] = [ ] const outputsOf = sequenceNodeRegistry.outputsOf +/** The linear defaults; each test overrides what it's about. */ +function options(overrides: Partial[1]> = {}) { + return { + canModifyStructure: true, + canEditBranches: true, + branches: new Map(), + loopStepIds: new Set(), + outputsOf, + ...overrides, + } +} + +function branch(stepId: string, fields: Partial): StepBranch { + return { + step_id: stepId, + condition: 'opened', + within_days: 3, + reply_label_key: null, + yes_step_id: null, + no_step_id: null, + updated_at: '2026-09-23T00:00:00Z', + ...fields, + } +} + describe('buildSequenceGraph', () => { test('Start → each step in order → one Stop after the last', () => { - const { nodes, edges } = buildSequenceGraph(steps, { canModifyStructure: true, outputsOf }) + const { nodes, edges } = buildSequenceGraph(steps, options()) expect(nodes.map((n) => `${n.type}:${n.id}`)).toEqual([ 'start:start', 'step:s-1', @@ -35,23 +63,23 @@ describe('buildSequenceGraph', () => { }) test('the edge into a step carries its wait; the edge into Stop carries none', () => { - const { edges } = buildSequenceGraph(steps, { canModifyStructure: true, outputsOf }) + const { edges } = buildSequenceGraph(steps, options()) expect(edges.map((e) => e.data?.label)).toEqual(['No wait', 'Wait 3 days', undefined]) }) test('every edge offers an insert while the structure is editable, named by position', () => { - const editable = buildSequenceGraph(steps, { canModifyStructure: true, outputsOf }) + const editable = buildSequenceGraph(steps, options()) expect(editable.edges.map((e) => e.data?.insertLabel)).toEqual([ 'Add a step at the start', 'Add a step after step 1', 'Add a step after step 2', ]) - const locked = buildSequenceGraph(steps, { canModifyStructure: false, outputsOf }) + const locked = buildSequenceGraph(steps, options({ canModifyStructure: false })) expect(locked.edges.every((e) => e.data?.insertLabel === undefined)).toBe(true) }) test('a step node knows its position and the thread subject for blank follow-ups', () => { - const { nodes } = buildSequenceGraph(steps, { canModifyStructure: false, outputsOf }) + const { nodes } = buildSequenceGraph(steps, options({ canModifyStructure: false })) const second = nodes.find((n) => n.id === 's-2') expect(second?.type === 'step' && second.data).toMatchObject({ position: 2, @@ -61,13 +89,147 @@ describe('buildSequenceGraph', () => { }) test('with no steps, Start runs straight to Stop', () => { - const { nodes, edges } = buildSequenceGraph([], { canModifyStructure: true, outputsOf }) + const { nodes, edges } = buildSequenceGraph([], options()) expect(nodes.map((n) => n.type)).toEqual(['start', 'stop']) expect(edges).toHaveLength(1) expect(edges[0]?.data?.insertLabel).toBe('Add a step at the start') }) }) +describe('buildSequenceGraph with conditions', () => { + const four: StepWithId[] = [ + { id: 'a', step_order: 1, delay_seconds: 0, subject: 'Intro' }, + { id: 'b', step_order: 2, delay_seconds: 86400, subject: 'Bump' }, + { id: 'c', step_order: 3, delay_seconds: 2 * 86400, subject: 'Case study' }, + { id: 'd', step_order: 4, delay_seconds: 0, subject: 'Breakup' }, + ] + const summary = (graph: ReturnType) => + graph.edges.map((e) => `${e.source}${e.sourceHandle ? `:${e.sourceHandle}` : ''}->${e.target}`) + + test('a step with a condition routes through its IF node; each exit goes where the branch says', () => { + const graph = buildSequenceGraph( + four, + options({ branches: new Map([['a', branch('a', { yes_step_id: 'c', no_step_id: 'b' })]]) }), + ) + // The condition sits right after its step. + expect(graph.nodes.map((n) => n.id).slice(0, 4)).toEqual(['start', 'a', 'cond:a', 'b']) + expect(graph.nodes.find((n) => n.id === 'cond:a')?.type).toBe('condition') + expect(summary(graph)).toEqual([ + 'start->a', + 'a->cond:a', + // Yes is emitted first, so the layout puts it on the left. + 'cond:a:yes->c', + 'cond:a:no->b', + 'b->c', + 'c->d', + 'd->stop:d:out', + ]) + // The wait on an exit is the target's own (it starts once the answer is known). + expect(graph.edges.find((e) => e.id === 'cond:a:yes->c')?.data?.label).toBe('Wait 2 days') + }) + + test('an exit left empty ends that path at its own Stop', () => { + const graph = buildSequenceGraph(four, options({ branches: new Map([['b', branch('b', { yes_step_id: 'd' })]]) })) + expect(summary(graph)).toContain('cond:b:no->stop:cond:b:no') + expect(summary(graph)).toContain('cond:b:yes->d') + // A condition's end can't be "inserted into": its exits are chosen, not placed. + expect(graph.edges.find((e) => e.target === 'stop:cond:b:no')?.data?.insertLabel).toBeUndefined() + // Step d's own fall-through end still can. + expect(graph.edges.find((e) => e.target === 'stop:d:out')?.data?.insertLabel).toBe('Add a step after step 4') + }) + + test('both exits empty: two Stops, one per exit', () => { + const graph = buildSequenceGraph(four, options({ branches: new Map([['d', branch('d', {})]]) })) + const stops = graph.nodes.filter((n) => n.type === 'stop').map((n) => n.id) + expect(stops).toEqual(['stop:cond:d:yes', 'stop:cond:d:no']) + }) + + test('two paths can converge on one step', () => { + const graph = buildSequenceGraph( + four, + options({ branches: new Map([['a', branch('a', { yes_step_id: 'd', no_step_id: 'd' })]]) }), + ) + expect(graph.edges.filter((e) => e.target === 'd').map((e) => e.sourceHandle)).toEqual(['yes', 'no', null]) + }) + + test('"always" is its own kind with one exit', () => { + const graph = buildSequenceGraph( + four, + options({ branches: new Map([['a', branch('a', { condition: 'always', within_days: null, yes_step_id: 'd' })]]) }), + ) + expect(graph.nodes.find((n) => n.id === 'cond:a')?.type).toBe('always') + expect(summary(graph).filter((e) => e.startsWith('cond:a'))).toEqual(['cond:a:yes->d']) + }) + + test('steps no path reaches are marked, and inserts appear only on fall-through edges', () => { + const graph = buildSequenceGraph( + four, + options({ branches: new Map([['a', branch('a', { condition: 'always', within_days: null, yes_step_id: 'd' })]]) }), + ) + const reach = Object.fromEntries(graph.nodes.flatMap((n) => (n.type === 'step' ? [[n.id, n.data.reachable]] : []))) + expect(reach).toEqual({ a: true, b: false, c: false, d: true }) + const inserts = graph.edges.filter((e) => e.data?.insertLabel).map((e) => e.data?.insertLabel) + // No "+" between a and its condition, nor on the condition's exit. + expect(inserts).toEqual([ + 'Add a step at the start', + 'Add a step after step 2', + 'Add a step after step 3', + 'Add a step after step 4', + ]) + }) + + test('an exit naming a step not in the list ends the path, as the send path does', () => { + const graph = buildSequenceGraph(four, options({ branches: new Map([['a', branch('a', { yes_step_id: 'gone' })]]) })) + expect(summary(graph)).toContain('cond:a:yes->stop:cond:a:yes') + }) + + test('loop steps are flagged on both the step and its condition', () => { + const graph = buildSequenceGraph( + four, + options({ + branches: new Map([['c', branch('c', { condition: 'always', within_days: null, yes_step_id: 'a' })]]), + loopStepIds: new Set(['a', 'b', 'c']), + }), + ) + const flagged = graph.nodes.flatMap((n) => ('inLoop' in n.data && n.data.inLoop ? [n.id] : [])) + expect(flagged).toEqual(['a', 'b', 'c', 'cond:c']) + }) + + test('branchesByStep keeps only steps that have one', () => { + const map = branchesByStep([ + { step_id: 'a', branch: branch('a', {}) }, + { step_id: 'b', branch: null }, + ]) + expect([...map.keys()]).toEqual(['a']) + expect(branchesByStep(undefined).size).toBe(0) + }) +}) + +describe('exitForConnection', () => { + const ids = new Set(['a', 'b', 'c']) + const branches = new Map([ + ['a', branch('a', {})], + ['b', branch('b', { condition: 'always', within_days: null })], + ]) + const drag = (source: string, target: string, sourceHandle: string | null) => + exitForConnection({ source, target, sourceHandle }, branches, ids) + + test('a drag from a condition exit onto another step sets that exit', () => { + expect(drag('cond:a', 'c', 'yes')).toEqual({ stepId: 'a', exit: 'yes', target: 'c' }) + expect(drag('cond:a', 'b', 'no')).toEqual({ stepId: 'a', exit: 'no', target: 'b' }) + }) + + test('refuses its own step, non-steps, a missing branch, and the No exit "always" does not have', () => { + expect(drag('cond:a', 'a', 'yes')).toBeNull() + expect(drag('cond:a', 'stop:x', 'yes')).toBeNull() + expect(drag('cond:c', 'a', 'yes')).toBeNull() + expect(drag('cond:b', 'a', 'no')).toBeNull() + expect(drag('cond:b', 'a', 'yes')).toEqual({ stepId: 'b', exit: 'yes', target: 'a' }) + // A drag from a step or Start is a reorder, not an exit. + expect(drag('a', 'c', null)).toBeNull() + }) +}) + describe('step order', () => { const order = ['a', 'b', 'c'] diff --git a/web/src/features/campaigns/api.ts b/web/src/features/campaigns/api.ts index ab51f6cc..79c0c4af 100644 --- a/web/src/features/campaigns/api.ts +++ b/web/src/features/campaigns/api.ts @@ -22,6 +22,16 @@ export type { CampaignEnrollment } from '@/store/api' // Step shapes are generated too; re-export so the sequence editor derives its // form/card types from the contract rather than hand-duplicating them. export type { SequenceStep, StepRequest } from '@/store/api' +// Branching shapes: the graph is the campaign's routing, a branch the router +// attached to one step. Re-exported so the canvas derives from the contract. +export type { + BranchValidationError, + CampaignGraph, + CampaignGraphNode, + StepBranch, + StepBranchCondition, + StepBranchRequest, +} from '@/store/api' // A/B variant shapes come from the contract too, so the editor's draft state // is typed by the same definition the API validates against. export type { StepVariant, StepVariantRequest } from '@/store/api' @@ -58,7 +68,7 @@ export type { CampaignDeliverability, CampaignGuardrails, CampaignPauseEvent } f export type { CampaignPreflight, CampaignPreflightCheck, TestSendRequest, TestSendResponse } from '@/store/api' const campaignApi = api.enhanceEndpoints({ - addTagTypes: ['Campaign', 'Step', 'Schedule', 'SenderPool', 'Guardrails', 'Variant'], + addTagTypes: ['Campaign', 'Step', 'Graph', 'Schedule', 'SenderPool', 'Guardrails', 'Variant'], endpoints: { listCampaigns: { providesTags: (result) => @@ -154,17 +164,43 @@ const campaignApi = api.enhanceEndpoints({ { type: 'Step', id: arg.id }, ], }, + // Structural step writes also invalidate the graph: a create or reorder + // moves fall-through edges, and a delete nulls the branch exits that + // pointed at the removed step (ON DELETE SET NULL). A content edit changes + // neither, so `updateStep` leaves the graph alone. createStep: { - invalidatesTags: (_result, _error, arg) => [{ type: 'Step', id: arg.id }], + invalidatesTags: (_result, _error, arg) => [ + { type: 'Step', id: arg.id }, + { type: 'Graph', id: arg.id }, + ], }, updateStep: { invalidatesTags: (_result, _error, arg) => [{ type: 'Step', id: arg.id }], }, deleteStep: { - invalidatesTags: (_result, _error, arg) => [{ type: 'Step', id: arg.id }], + invalidatesTags: (_result, _error, arg) => [ + { type: 'Step', id: arg.id }, + { type: 'Graph', id: arg.id }, + ], }, reorderSteps: { - invalidatesTags: (_result, _error, arg) => [{ type: 'Step', id: arg.id }], + invalidatesTags: (_result, _error, arg) => [ + { type: 'Step', id: arg.id }, + { type: 'Graph', id: arg.id }, + ], + }, + // --- Branching --------------------------------------------------------- + // + // A branch write changes routing only — never a step's content — so it + // invalidates the graph and not the step list. + getCampaignGraph: { + providesTags: (_result, _error, arg) => [{ type: 'Graph', id: arg.id }], + }, + setStepBranch: { + invalidatesTags: (_result, _error, arg) => [{ type: 'Graph', id: arg.id }], + }, + deleteStepBranch: { + invalidatesTags: (_result, _error, arg) => [{ type: 'Graph', id: arg.id }], }, // The sending schedule gets its own tag rather than reusing `Campaign`: a // schedule save shouldn't refetch metrics and steps, and a campaign mutation @@ -211,6 +247,9 @@ export const { useUpdateStepMutation, useDeleteStepMutation, useReorderStepsMutation, + useGetCampaignGraphQuery, + useSetStepBranchMutation, + useDeleteStepBranchMutation, useListStepVariantsQuery, useCreateStepVariantMutation, useUpdateStepVariantMutation, diff --git a/web/src/features/campaigns/branch-draft.ts b/web/src/features/campaigns/branch-draft.ts new file mode 100644 index 00000000..843e69c2 --- /dev/null +++ b/web/src/features/campaigns/branch-draft.ts @@ -0,0 +1,181 @@ +// The condition editor's model, and the checks it runs before a save. Pure: no +// React, no RTK. The rules mirror the server's (internal/platform/seqgraph +// `ValidateShape`, internal/app/sequencestep `checkReplyLabel` / +// `checkTrackable`), so an editor that says "fine" is one the API will accept — +// and where the client can't know (a label deleted a moment ago), the server's +// typed error still has the last word (see branch-error.ts). +import type { StepBranch, StepBranchCondition, StepBranchRequest } from './api' + +export const MIN_WITHIN_DAYS = 1 +export const MAX_WITHIN_DAYS = 90 +const DEFAULT_WITHIN_DAYS = 3 + +type ConditionMeta = { + value: StepBranchCondition + /** The select's option text. */ + label: string + /** Reads opens or clicks, which only exist with tracking and an HTML body. */ + needsTracking: boolean + /** Reads replies, so it may be narrowed to one reply label. */ + isReply: boolean +} + +export const CONDITIONS: readonly ConditionMeta[] = [ + { value: 'opened', label: 'Opened this email', needsTracking: true, isReply: false }, + { value: 'not_opened', label: 'Did not open this email', needsTracking: true, isReply: false }, + { value: 'clicked', label: 'Clicked a link in this email', needsTracking: true, isReply: false }, + { value: 'replied', label: 'Replied', needsTracking: false, isReply: true }, + { value: 'not_replied', label: 'Did not reply', needsTracking: false, isReply: true }, + { value: 'always', label: 'Always (no condition — jump to a step)', needsTracking: false, isReply: false }, +] + +export function conditionMeta(condition: StepBranchCondition): ConditionMeta { + const meta = CONDITIONS.find((entry) => entry.value === condition) + if (!meta) throw new Error(`unknown condition ${condition}`) + return meta +} + +/** + * The editor's state. Strings throughout because they are form values: `''` + * means "none" — no label narrowing, or an exit that ends the path. + */ +export type ConditionDraft = { + condition: StepBranchCondition + withinDays: string + replyLabelKey: string + yesStepId: string + noStepId: string +} + +/** A new condition starts as the reference flow's first one: opened within 3 days. */ +export function draftFromBranch(branch: StepBranch | null): ConditionDraft { + if (!branch) { + return { condition: 'opened', withinDays: String(DEFAULT_WITHIN_DAYS), replyLabelKey: '', yesStepId: '', noStepId: '' } + } + return { + condition: branch.condition, + withinDays: branch.within_days === null ? String(DEFAULT_WITHIN_DAYS) : String(branch.within_days), + replyLabelKey: branch.reply_label_key ?? '', + yesStepId: branch.yes_step_id ?? '', + noStepId: branch.no_step_id ?? '', + } +} + +/** A reply label as the editor needs it. */ +export type RouteableLabel = { key: string; label: string; stopsEnrollment: boolean } + +export type DraftContext = { + /** The step the condition hangs off. */ + stepId: string + /** Every step of the campaign — exits may only name one of these. */ + stepIds: ReadonlySet + /** `undefined` while unknown: the check is then left to the server. */ + trackingEnabled: boolean | undefined + /** The step and every A/B variant carry an HTML body; `undefined` while unknown. */ + htmlEverywhere: boolean | undefined + /** The workspace's reply labels; `undefined` while loading. */ + replyLabels: readonly RouteableLabel[] | undefined +} + +export type DraftField = 'condition' | 'withinDays' | 'replyLabel' | 'yes' | 'no' +export type DraftProblem = { field: DraftField; message: string } + +export const TRACKING_REQUIRED_HINT = + 'Opens and clicks are only recorded when tracking is on for this campaign and this step — and each of its A/B variants — has an HTML body.' + +/** Everything that would make the server refuse this draft, per field. */ +export function validateDraft(draft: ConditionDraft, context: DraftContext): DraftProblem[] { + const problems: DraftProblem[] = [] + const meta = conditionMeta(draft.condition) + + if (draft.condition !== 'always') { + const days = Number(draft.withinDays) + if (!Number.isInteger(days) || days < MIN_WITHIN_DAYS || days > MAX_WITHIN_DAYS) { + problems.push({ + field: 'withinDays', + message: `A whole number of days from ${MIN_WITHIN_DAYS} to ${MAX_WITHIN_DAYS}.`, + }) + } + } + + if (meta.needsTracking) { + if (context.trackingEnabled === false) { + problems.push({ + field: 'condition', + message: `Tracking is off for this campaign — turn it on from the Overview tab. ${TRACKING_REQUIRED_HINT}`, + }) + } else if (context.htmlEverywhere === false) { + problems.push({ field: 'condition', message: `This step has no HTML body. ${TRACKING_REQUIRED_HINT}` }) + } + } + + if (meta.isReply && draft.replyLabelKey !== '' && context.replyLabels) { + const label = context.replyLabels.find((entry) => entry.key === draft.replyLabelKey) + if (!label) { + problems.push({ field: 'replyLabel', message: 'That reply label no longer exists.' }) + } else if (label.stopsEnrollment) { + problems.push({ + field: 'replyLabel', + message: `Replies labelled “${label.label}” stop the sequence, so they can never be routed.`, + }) + } + } + + const exits: [DraftField, string][] = [['yes', draft.yesStepId]] + if (draft.condition !== 'always') exits.push(['no', draft.noStepId]) + for (const [field, target] of exits) { + if (target === '') continue + if (target === context.stepId) { + problems.push({ field, message: 'A step can’t route back to itself — that would be a loop.' }) + } else if (!context.stepIds.has(target)) { + problems.push({ field, message: 'That step no longer exists.' }) + } + } + return problems +} + +/** + * The request for a draft. Fields the condition doesn't use are sent as null + * rather than whatever the form still holds, so switching a reply condition to + * "opened" can't smuggle a label along, and "always" never carries a window or + * a no exit. + */ +export function toBranchRequest(draft: ConditionDraft): StepBranchRequest { + const always = draft.condition === 'always' + const meta = conditionMeta(draft.condition) + return { + condition: draft.condition, + within_days: always ? null : Number(draft.withinDays), + reply_label_key: meta.isReply && draft.replyLabelKey !== '' ? draft.replyLabelKey : null, + yes_step_id: draft.yesStepId === '' ? null : draft.yesStepId, + no_step_id: always || draft.noStepId === '' ? null : draft.noStepId, + } +} + +/** The request that changes one exit of an existing branch and keeps the rest. */ +export function withExit(branch: StepBranch, exit: 'yes' | 'no', target: string): StepBranchRequest { + return { + condition: branch.condition, + within_days: branch.within_days, + reply_label_key: branch.reply_label_key, + yes_step_id: exit === 'yes' ? target : branch.yes_step_id, + no_step_id: exit === 'no' ? target : branch.no_step_id, + } +} + +const SUMMARY: Record = { + opened: 'Opened', + not_opened: 'Not opened', + clicked: 'Clicked', + replied: 'Replied', + not_replied: 'No reply', + always: 'Always', +} + +/** The condition node's text: "Opened within 3 days", "Replied · Interested within 5 days". */ +export function describeBranch(branch: Pick, labelName?: string): string { + if (branch.condition === 'always') return 'Always' + const narrowed = branch.reply_label_key ? ` · ${labelName ?? branch.reply_label_key}` : '' + const days = branch.within_days ?? 0 + return `${SUMMARY[branch.condition]}${narrowed} within ${days} day${days === 1 ? '' : 's'}` +} diff --git a/web/src/features/campaigns/branch-error.ts b/web/src/features/campaigns/branch-error.ts new file mode 100644 index 00000000..b7cfad57 --- /dev/null +++ b/web/src/features/campaigns/branch-error.ts @@ -0,0 +1,33 @@ +// Human copy for a failed branch write (PUT/DELETE /steps/{id}/branch). Every +// failure narrows through `@/lib/rtk-error`. The loop a `cycle` names is read +// by branch-loop.ts. +import { errorCode, httpStatus, serverDetail } from '@/lib/rtk-error' +import type { BranchValidationError } from './api' +import { TRACKING_REQUIRED_HINT } from './branch-draft' + +type BranchErrorCode = BranchValidationError['code'] + +const COPY: Record = { + invalid_condition: 'That condition isn’t one Inroad supports. Pick one from the list.', + invalid_within_days: 'The window must be a whole number of days from 1 to 90.', + invalid_reply_label: 'That reply label no longer exists. Pick another, or route on any reply.', + reply_label_stops_sequence: + 'Replies with that label stop the sequence before any condition runs, so it could never route anyone. Pick a label that doesn’t stop the sequence.', + tracking_required: `${TRACKING_REQUIRED_HINT} Turn tracking on from this campaign’s Overview tab, or route on replies instead.`, + no_exit_not_allowed: '“Always” has only one exit — there is no “No” path to set.', + unknown_step: 'This step no longer exists.', + unknown_target: 'An exit points at a step that no longer exists. Pick again.', + cycle: + 'That would make a loop: someone could be sent back to a step they already received. The steps in the loop are highlighted — change one of their exits.', +} + +function isBranchErrorCode(code: string | undefined): code is BranchErrorCode { + return code !== undefined && code in COPY +} + +export function branchErrorMessage(error: unknown): string { + const code = errorCode(error) + if (isBranchErrorCode(code)) return COPY[code] + if (httpStatus(error) === 404) return 'This step no longer exists.' + return serverDetail(error) ?? 'Couldn’t save the condition. Please try again.' +} diff --git a/web/src/features/campaigns/branch-loop.ts b/web/src/features/campaigns/branch-loop.ts new file mode 100644 index 00000000..cab653f8 --- /dev/null +++ b/web/src/features/campaigns/branch-loop.ts @@ -0,0 +1,18 @@ +// The loop a 422 `cycle` names — from a branch write, a step delete or a +// reorder, which all answer the same shape. Its own module, apart from the +// condition copy in branch-error.ts, because the eagerly loaded sequence editor +// needs this (a delete can close a loop) and nothing else of branching: keeping +// it separate keeps the condition editor's copy and rules in the lazy canvas +// chunk. +import { errorCode, isFetchBaseQueryError } from '@/lib/rtk-error' + +/** + * The steps on the loop, in path order; `null` for any other failure, or a + * cycle that didn't list its steps in a shape that can be trusted. + */ +export function cycleStepIds(error: unknown): string[] | null { + if (errorCode(error) !== 'cycle' || !isFetchBaseQueryError(error)) return null + const ids = (error.data as { step_ids?: unknown }).step_ids + if (!Array.isArray(ids) || !ids.every((id): id is string => typeof id === 'string')) return null + return ids.length > 0 ? ids : null +} diff --git a/web/src/features/campaigns/condition-editor.tsx b/web/src/features/campaigns/condition-editor.tsx new file mode 100644 index 00000000..58dc3e04 --- /dev/null +++ b/web/src/features/campaigns/condition-editor.tsx @@ -0,0 +1,371 @@ +import { useId, useMemo, useState } from 'react' +import { Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select } from '@/components/ui/select' +// Read-only cross-feature hook (the documented exception): which reply labels +// exist, and which of them stop the sequence, belongs to the reply-labels +// feature. Hooks only — no reply-labels UI or state is imported. +import { useListReplyLabelsQuery } from '@/features/reply-labels/api' +import { + useDeleteStepBranchMutation, + useGetCampaignQuery, + useListStepVariantsQuery, + useSetStepBranchMutation, + type StepBranch, +} from './api' +import { + CONDITIONS, + MAX_WITHIN_DAYS, + MIN_WITHIN_DAYS, + conditionMeta, + draftFromBranch, + toBranchRequest, + validateDraft, + type ConditionDraft, + type DraftField, + type RouteableLabel, +} from './branch-draft' +import { branchErrorMessage } from './branch-error' +import { cycleStepIds } from './branch-loop' +import type { StepWithId } from './step-card' + +export type ConditionEditorProps = { + campaignId: string + step: StepWithId + /** 1-based. */ + position: number + /** Every step in order — the exits can name any of them but this one. */ + steps: readonly StepWithId[] + /** The router already on this step, or null when adding one. */ + branch: StepBranch | null + onSaved: () => void + onDeleted: () => void + onCancel: () => void + /** The loop a refused write named, or `null` to clear the highlight. */ + onLoop: (stepIds: string[] | null) => void +} + +/** + * The router on one step: what to watch for, for how long, and where each + * answer goes. Branch writes are allowed on a running campaign — they change + * routing, not the steps — so nothing here is gated on status. + * + * Every rule the server enforces is checked here first (`validateDraft`) and + * explained next to the field it concerns; the server's typed error still has + * the last word for what the client can't know. + */ +export function ConditionEditor({ + campaignId, + step, + position, + steps, + branch, + onSaved, + onDeleted, + onCancel, + onLoop, +}: ConditionEditorProps) { + const [draft, setDraft] = useState(() => draftFromBranch(branch)) + const [save, saveState] = useSetStepBranchMutation() + const [remove, removeState] = useDeleteStepBranchMutation() + const busy = saveState.isLoading || removeState.isLoading + const failure = saveState.error ?? removeState.error + + const { data: campaign } = useGetCampaignQuery({ id: campaignId }) + const { data: variants } = useListStepVariantsQuery({ id: campaignId, stepId: step.id }) + const labelsQuery = useListReplyLabelsQuery() + const labels = useMemo( + () => + labelsQuery.data?.labels.map((label) => ({ + key: label.key, + label: label.label, + stopsEnrollment: label.stops_enrollment, + })), + [labelsQuery.data], + ) + + const stepIds = useMemo(() => new Set(steps.map((candidate) => candidate.id)), [steps]) + const problems = validateDraft(draft, { + stepId: step.id, + stepIds, + trackingEnabled: campaign?.tracking_enabled, + htmlEverywhere: + variants === undefined ? undefined : hasHtml(step.body_html) && variants.every((variant) => hasHtml(variant.body_html)), + replyLabels: labels, + }) + const problemFor = (field: DraftField) => problems.find((problem) => problem.field === field)?.message + + const meta = conditionMeta(draft.condition) + const always = draft.condition === 'always' + const routeable = labels?.filter((label) => !label.stopsEnrollment) ?? [] + const stoppingCount = (labels?.length ?? 0) - routeable.length + const exitOptions = steps.filter((candidate) => candidate.id !== step.id) + + const ids = { + condition: useId(), + days: useId(), + label: useId(), + yes: useId(), + no: useId(), + conditionHint: useId(), + daysHint: useId(), + labelHint: useId(), + yesHint: useId(), + noHint: useId(), + } + + function update(patch: Partial) { + setDraft((current) => ({ ...current, ...patch })) + } + + async function onSubmit(event: React.FormEvent) { + event.preventDefault() + if (problems.length > 0 || busy) return + onLoop(null) + const result = await save({ id: campaignId, stepId: step.id, stepBranchRequest: toBranchRequest(draft) }) + if ('error' in result) { + onLoop(cycleStepIds(result.error)) + return + } + onSaved() + } + + async function onRemove() { + onLoop(null) + const result = await remove({ id: campaignId, stepId: step.id }) + if ('error' in result) { + onLoop(cycleStepIds(result.error)) + return + } + onDeleted() + } + + return ( +
void onSubmit(event)} noValidate className="grid gap-4 p-5"> +

+ Decides where someone goes after step {position} is sent. The window counts from that send, and only human + opens and clicks count. The next step’s own wait starts once the answer is known. +

+ + + + + + {!always && ( + + update({ withinDays: event.target.value })} + /> + + )} + + {meta.isReply && ( + + + + )} + + update({ yesStepId })} + /> + {!always && ( + update({ noStepId })} + /> + )} +

+ You can also drag from the condition’s {always ? 'exit' : 'Yes or No handle'} onto a step. +

+ + {failure && ( +

+ {branchErrorMessage(failure)} +

+ )} + +
+ {branch && ( + + )} + + + +
+ + ) +} + +function hasHtml(body: string | undefined): boolean { + return (body ?? '').trim() !== '' +} + +function replyLabelHint(stoppingCount: number, failed: boolean): string { + const rule = + 'A reply whose label stops the sequence — every default label does — ends it before this condition is checked, so only labels that don’t stop it can be routed on. To route on one, turn off “Stops the sequence” for it in Settings → Reply labels.' + if (failed) return `Couldn’t load reply labels, so only “any reply” is offered. ${rule}` + if (stoppingCount === 0) return rule + return `${stoppingCount} label${stoppingCount === 1 ? '' : 's'} stop${stoppingCount === 1 ? 's' : ''} the sequence and ${stoppingCount === 1 ? 'isn’t' : 'aren’t'} offered. ${rule}` +} + +/** One labelled control, its hint, and — when the draft breaks a rule — why, in words. */ +function Field({ + id, + label, + hint, + hintId, + problem, + children, +}: { + id: string + label: string + hint?: string + hintId: string + problem: string | undefined + children: React.ReactNode +}) { + return ( +
+ + {children} + + {problem ?? hint} + +
+ ) +} + +function ExitSelect({ + id, + hintId, + label, + value, + options, + allSteps, + problem, + onChange, +}: { + id: string + hintId: string + label: string + value: string + options: readonly StepWithId[] + allSteps: readonly StepWithId[] + problem: string | undefined + onChange: (stepId: string) => void +}) { + return ( + + + + ) +} + +function stepOptionLabel(step: StepWithId, steps: readonly StepWithId[]): string { + const position = steps.findIndex((candidate) => candidate.id === step.id) + 1 + const subject = step.subject?.trim() || (position > 1 ? 'same-thread follow-up' : 'no subject yet') + return `Step ${position} — ${subject}` +} diff --git a/web/src/features/campaigns/condition-flow-nodes.tsx b/web/src/features/campaigns/condition-flow-nodes.tsx new file mode 100644 index 00000000..625092c3 --- /dev/null +++ b/web/src/features/campaigns/condition-flow-nodes.tsx @@ -0,0 +1,85 @@ +import type { NodeProps } from '@xyflow/react' +import { FlowNodeFrame } from '@/components/shared/flow/flow-node-frame' +import { cn } from '@/lib/utils' +// Read-only cross-feature hook (the documented exception): a condition node +// names its reply label by the label's display name, which the reply-labels +// feature owns. Hooks only — no reply-labels UI or state is imported. +import { useListReplyLabelsQuery } from '@/features/reply-labels/api' +import { describeBranch } from './branch-draft' +import { focusKey, useFocusRequest, useSequenceCanvasActions } from './sequence-canvas-actions' +import type { ConditionFlowNode } from './sequence-graph' +import { NodeFlag } from './node-flag' + +// The shapes, in a 0–100 box stretched to the node. The condition's diamond is +// cut flat at the bottom so its two exits — at 1/3 and 2/3 of the width, where +// the registry puts the handles — leave from its corners rather than floating +// under a slanted edge. +const CONDITION_SHAPE = '50,1 99,50 66.7,99 33.3,99 1,50' +const ALWAYS_SHAPE = '50,1 99,50 50,99 1,50' + +/** + * The IF after a step: its condition in words, "Yes" / "No" under its exits + * (drawn by the frame from the registry's output labels), and the whole face + * one button that opens the condition editor. + */ +export function ConditionNode(props: NodeProps) { + return +} + +/** An unconditional jump: "always go to …". One exit. */ +export function AlwaysNode(props: NodeProps) { + return +} + +function RouterNode({ + type, + data, + shape, + eyebrow, +}: NodeProps & { shape: string; eyebrow: string }) { + const { branch, position, inLoop } = data + const actions = useSequenceCanvasActions() + const ref = useFocusRequest(focusKey(branch.step_id, 'condition')) + const labelKey = branch.reply_label_key + const { data: labels } = useListReplyLabelsQuery(undefined, { skip: !labelKey }) + const labelName = labels?.labels.find((label) => label.key === labelKey)?.label + const summary = describeBranch(branch, labelName) + const isEditing = actions.editingConditionStepId === branch.step_id + + return ( + // Conditions change routing, not structure, so their exits can be dragged + // on a running campaign too. + + + + + ) +} diff --git a/web/src/features/campaigns/node-flag.tsx b/web/src/features/campaigns/node-flag.tsx new file mode 100644 index 00000000..003de3ba --- /dev/null +++ b/web/src/features/campaigns/node-flag.tsx @@ -0,0 +1,18 @@ +import { cn } from '@/lib/utils' + +/** + * A small text flag on a canvas node ("In a loop", "Not reached"). Always words, + * so the state never rests on the tint alone. + */ +export function NodeFlag({ tone, children }: { tone: 'danger' | 'muted'; children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/web/src/features/campaigns/sequence-canvas-actions.ts b/web/src/features/campaigns/sequence-canvas-actions.ts index ec46c6cc..183c6812 100644 --- a/web/src/features/campaigns/sequence-canvas-actions.ts +++ b/web/src/features/campaigns/sequence-canvas-actions.ts @@ -1,9 +1,10 @@ -import { createContext, useContext, useEffect, useRef } from 'react' +import { createContext, useCallback, useContext } from 'react' import type { StepWithId } from './step-card' /** - * What the canvas's side panel shows: one step's editor, or a new step's - * anchored after a step (`null` = first). Owned by the sequence editor, so its + * What the canvas's side panel shows: one step's editor, a new step's anchored + * after a step (`null` = first), or the condition editor for the router on one + * step (new or existing). Owned by the sequence editor, so its * section-bar "Add step" opens the same panel as an edge's "+" and the two can * never both be open. `returnFocus` is whatever had focus when the panel * opened; closing without a better target hands focus back to it. @@ -11,6 +12,7 @@ import type { StepWithId } from './step-card' export type SequencePanel = | { kind: 'edit'; stepId: string; returnFocus: HTMLElement | null } | { kind: 'add'; afterId: string | null; returnFocus: HTMLElement | null } + | { kind: 'condition'; stepId: string; returnFocus: HTMLElement | null } /** The element that currently has focus, for `SequencePanel.returnFocus`. */ export function currentFocus(): HTMLElement | null { @@ -20,7 +22,7 @@ export function currentFocus(): HTMLElement | null { export type MoveBlock = { reason: string | null } /** A node control that can be asked for focus: `${stepId}:${control}`. */ -export type StepControl = 'edit' | 'up' | 'down' +export type StepControl = 'edit' | 'up' | 'down' | 'add-condition' | 'condition' export const focusKey = (stepId: string, control: StepControl) => `${stepId}:${control}` /** @@ -33,6 +35,10 @@ export type SequenceCanvasActions = { /** The step whose editor is open, so its node can show it's the one being edited. */ editingStepId: string | null editStep: (stepId: string) => void + /** Opens the condition editor for the router on `stepId` (creating one if it has none). */ + editCondition: (stepId: string) => void + /** The step whose condition editor is open. */ + editingConditionStepId: string | null openVariants: (step: StepWithId, position: number) => void requestDelete: (step: StepWithId) => void moveStep: (stepId: string, delta: -1 | 1) => void @@ -58,17 +64,22 @@ export function useSequenceCanvasActions(): SequenceCanvasActions { } /** - * Focuses the returned element when the canvas asks for `key`. Waits while the - * control is disabled (a reorder in flight disables every move button), so a - * request made at click time lands once the control is usable again. + * A ref that focuses its element when the canvas asks for `key`. A callback + * ref rather than an effect, because the control is often not there yet when + * the request is made — the button a save creates only mounts once the refetch + * lands — and a callback ref runs when the element attaches, whenever that is. + * Waits while the control is disabled (a reorder in flight disables every move + * button): the ref is re-created when it becomes usable, and runs again. */ export function useFocusRequest(key: string, disabled = false) { - const ref = useRef(null) const { focusRequest, clearFocusRequest } = useSequenceCanvasActions() - useEffect(() => { - if (focusRequest !== key || disabled || !ref.current) return - ref.current.focus() - clearFocusRequest() - }, [clearFocusRequest, disabled, focusRequest, key]) - return ref + const wanted = focusRequest === key && !disabled + return useCallback( + (element: E | null) => { + if (!wanted || !element) return + element.focus() + clearFocusRequest() + }, + [clearFocusRequest, wanted], + ) } diff --git a/web/src/features/campaigns/sequence-canvas.tsx b/web/src/features/campaigns/sequence-canvas.tsx index 4a4a507a..6d574ba5 100644 --- a/web/src/features/campaigns/sequence-canvas.tsx +++ b/web/src/features/campaigns/sequence-canvas.tsx @@ -3,7 +3,17 @@ import type { Connection, Edge } from '@xyflow/react' import { FlowCanvas } from '@/components/shared/flow/flow-canvas' import { flowContentHeight, useFlowLayout } from '@/components/shared/flow/flow-layout' import type { FlowInsertTarget } from '@/components/shared/flow/flow-insert-context' -import { useReorderStepsMutation, type SequenceStep } from './api' +import { + useReorderStepsMutation, + useSetStepBranchMutation, + type CampaignGraph, + type SequenceStep, + type StepBranch, +} from './api' +import { withExit } from './branch-draft' +import { branchErrorMessage } from './branch-error' +import { cycleStepIds } from './branch-loop' +import { ConditionEditor } from './condition-editor' import { SequenceCanvasActionsContext, currentFocus, @@ -15,7 +25,9 @@ import { import { START_NODE_ID, applyOrder, + branchesByStep, buildSequenceGraph, + exitForConnection, isMeaningfulConnection, leadsWithSubject, moveStep, @@ -38,41 +50,61 @@ const MIN_CANVAS_HEIGHT = 320 const MAX_CANVAS_HEIGHT = 720 const CANVAS_PADDING = 96 +// Frozen in use: nothing ever writes to it, it only stands in for "no routing known". +const NO_BRANCHES: ReadonlyMap = new Map() + export type SequenceCanvasProps = { campaignId: string /** Server truth, sorted by `step_order`. */ steps: StepWithId[] canModifyStructure: boolean + /** + * The routing. `undefined` together with `graphFailed` means it couldn't be + * loaded: the flow then draws the steps in order and offers no condition + * editing, because a save could silently replace a branch nobody can see. + */ + graph: CampaignGraph | undefined + graphFailed: boolean + /** The loop the server last refused, highlighted on its nodes. */ + loopStepIds: readonly string[] | null + onLoop: (stepIds: string[] | null) => void /** Owned by the editor, so its section-bar "Add step" opens this same panel. */ panel: SequencePanel | null onPanelChange: (panel: SequencePanel | null) => void onDelete: (step: StepWithId) => void onVariants: (target: { step: StepWithId; position: number }) => void - /** Lifts a reorder failure to the editor's banner; `null` clears it. */ - onReorderError: (message: string | null) => void + /** Lifts a failed gesture to the editor's banner; `null` clears it. */ + onNotice: (message: string | null) => void } /** - * The sequence as a flow: Start → steps → Stop, laid out by dagre. Code-split - * behind `React.lazy` in `sequence-editor.tsx`, so `@xyflow/react` and dagre - * only download when the canvas is actually shown. + * The sequence as a flow: Start → steps (and the conditions hanging off them) + * → Stop, laid out by dagre. Code-split behind `React.lazy` in + * `sequence-editor.tsx`, so `@xyflow/react` and dagre only download when the + * canvas is actually shown. * - * Editing reuses the list's `StepForm` in a side panel rather than a second - * form. Structural gestures (insert on an edge, move up/down, drag a - * connection) all reduce to the one reorder endpoint; the create endpoint - * always appends, so inserting mid-sequence is create-then-place. + * Two kinds of edit, gated differently, as the server gates them: + * - STRUCTURE (insert on an edge, move up/down, drag from Start or a step) all + * reduce to the reorder endpoint and are draft-only. + * - ROUTING (add / edit / remove a condition, drag a condition's exit onto a + * step) goes through the branch endpoints and is allowed while running. */ export default function SequenceCanvas({ campaignId, steps: serverSteps, canModifyStructure, + graph: routing, + graphFailed, + loopStepIds, + onLoop, panel, onPanelChange, onDelete, onVariants, - onReorderError, + onNotice, }: SequenceCanvasProps) { const [reorderSteps, reorderState] = useReorderStepsMutation() + const [setBranch] = useSetStepBranchMutation() const [focusRequest, setFocusRequest] = useState(null) const clearFocusRequest = useCallback(() => setFocusRequest(null), []) @@ -93,11 +125,22 @@ export default function SequenceCanvas({ latestServerSteps.current = serverSteps }, [serverSteps]) - const graph = useMemo( - () => buildSequenceGraph(steps, { canModifyStructure, outputsOf: sequenceNodeRegistry.outputsOf }), - [steps, canModifyStructure], + const canEditBranches = !graphFailed && routing !== undefined + const branches = useMemo(() => (graphFailed ? NO_BRANCHES : branchesByStep(routing?.nodes)), [graphFailed, routing]) + const loop = useMemo(() => new Set(loopStepIds ?? []), [loopStepIds]) + + const flow = useMemo( + () => + buildSequenceGraph(steps, { + canModifyStructure, + canEditBranches, + branches, + loopStepIds: loop, + outputsOf: sequenceNodeRegistry.outputsOf, + }), + [steps, canModifyStructure, canEditBranches, branches, loop], ) - const nodes = useFlowLayout(graph.nodes, graph.edges, sequenceNodeRegistry) + const { nodes, edges } = useFlowLayout(flow.nodes, flow.edges, sequenceNodeRegistry) const order = useMemo(() => steps.map((step) => step.id), [steps]) const stepIds = useMemo(() => new Set(order), [order]) const subjectOf = useMemo(() => { @@ -116,17 +159,21 @@ export default function SequenceCanvas({ const reorder = useCallback( async (next: string[], failurePrefix?: string) => { if (!orderChanged(latestOrder.current, next)) return - onReorderError(null) + onNotice(null) + onLoop(null) const result = await reorderSteps({ id: campaignId, reorderStepsRequest: { step_ids: next } }) if ('error' in result) { const message = reorderErrorMessage(result.error) - onReorderError(failurePrefix ? `${failurePrefix} ${message}` : message) + onNotice(failurePrefix ? `${failurePrefix} ${message}` : message) + // With conditions in play a new order can close a loop through the + // fall-through; the server names it, so point at it. + onLoop(cycleStepIds(result.error)) return } const confirmedOrder = result.data.flatMap((step) => (step.id ? [step.id] : [])) setConfirmed({ base: latestServerSteps.current, order: confirmedOrder }) }, - [campaignId, onReorderError, reorderSteps], + [campaignId, onLoop, onNotice, reorderSteps], ) const moveBlock = useCallback( @@ -159,11 +206,13 @@ export default function SequenceCanvas({ () => ({ campaignId, editingStepId: panel?.kind === 'edit' ? panel.stepId : null, + editingConditionStepId: panel?.kind === 'condition' ? panel.stepId : null, editStep: (stepId) => onPanelChange({ kind: 'edit', stepId, returnFocus: currentFocus() }), + editCondition: (stepId) => onPanelChange({ kind: 'condition', stepId, returnFocus: currentFocus() }), openVariants: (step, position) => onVariants({ step, position }), requestDelete: (step) => { - // Deleting the step being edited would leave the panel editing nothing. - if (panel?.kind === 'edit' && panel.stepId === step.id) onPanelChange(null) + // Deleting the step a panel is about would leave it editing nothing. + if (panel && panel.kind !== 'add' && panel.stepId === step.id) onPanelChange(null) onDelete(step) }, moveStep: move, @@ -197,26 +246,59 @@ export default function SequenceCanvas({ [onPanelChange], ) - // A drag is meaningful only if it would also leave a subject on step 1 — - // the same rule the move buttons and insert-at-start follow. - const connectionOrder = useCallback( + // What a drag means depends on where it starts. From a condition's exit it + // sets that exit (routing, allowed while running). From Start or a step it + // is a reorder (structure, draft-only) — and only from a step whose next is + // still the fall-through, since a condition owns the rest; the reorder must + // also leave a subject on step 1, the rule the move buttons follow. + const exitFor = useCallback( + (connection: Pick) => + canEditBranches ? exitForConnection(connection, branches, stepIds) : null, + [branches, canEditBranches, stepIds], + ) + const reorderFor = useCallback( (connection: Pick): string[] | null => { + if (!canModifyStructure || branches.has(connection.source)) return null if (!isMeaningfulConnection(connection, stepIds)) return null const next = orderForConnection(order, connection.source, connection.target) return leadsWithSubject(next, subjectOf) ? next : null }, - [order, stepIds, subjectOf], + [branches, canModifyStructure, order, stepIds, subjectOf], + ) + + const setExit = useCallback( + async (connection: Connection) => { + const exit = exitFor(connection) + const branch = exit ? branches.get(exit.stepId) : undefined + if (!exit || !branch) return + onNotice(null) + onLoop(null) + const result = await setBranch({ + id: campaignId, + stepId: exit.stepId, + stepBranchRequest: withExit(branch, exit.exit, exit.target), + }) + if ('error' in result) { + onNotice(branchErrorMessage(result.error)) + onLoop(cycleStepIds(result.error)) + } + }, + [branches, campaignId, exitFor, onLoop, onNotice, setBranch], ) const onConnect = useCallback( (connection: Connection) => { - const next = connectionOrder(connection) + if (exitFor(connection)) { + void setExit(connection) + return + } + const next = reorderFor(connection) if (next) void reorder(next) }, - [connectionOrder, reorder], + [exitFor, reorder, reorderFor, setExit], ) const isValidConnection = useCallback( - (connection: Connection | Edge) => connectionOrder(connection) !== null, - [connectionOrder], + (connection: Connection | Edge) => exitFor(connection) !== null || reorderFor(connection) !== null, + [exitFor, reorderFor], ) /** Closes the panel, returning focus to what opened it unless a better target is named. */ @@ -237,7 +319,7 @@ export default function SequenceCanvas({ closePanel(focusKey(saved.id, 'edit')) const appended = [...latestOrder.current.filter((id) => id !== saved.id), saved.id] if (afterId !== null && !appended.includes(afterId)) { - onReorderError(`${PLACEMENT_FAILED} The step it was meant to follow was removed.`) + onNotice(`${PLACEMENT_FAILED} The step it was meant to follow was removed.`) return } const placed = placeAfter(appended, afterId, saved.id) @@ -245,8 +327,8 @@ export default function SequenceCanvas({ void reorder(placed, PLACEMENT_FAILED) } - const editing = panel?.kind === 'edit' ? steps.find((step) => step.id === panel.stepId) : undefined - const editingPosition = editing ? steps.indexOf(editing) + 1 : 0 + const panelStep = panel && panel.kind !== 'add' ? steps.find((step) => step.id === panel.stepId) : undefined + const panelPosition = panelStep ? steps.indexOf(panelStep) + 1 : 0 const height = Math.min(MAX_CANVAS_HEIGHT, Math.max(MIN_CANVAS_HEIGHT, flowContentHeight(nodes) + CANVAS_PADDING)) return ( @@ -256,25 +338,44 @@ export default function SequenceCanvas({ - {editing && ( - closePanel()}> + {panel?.kind === 'edit' && panelStep && ( + closePanel()}> closePanel()} onCancel={() => closePanel()} /> )} + {panel?.kind === 'condition' && panelStep && canEditBranches && ( + closePanel()} + > + closePanel(focusKey(panelStep.id, 'condition'))} + onDeleted={() => closePanel(focusKey(panelStep.id, 'add-condition'))} + onCancel={() => closePanel()} + onLoop={onLoop} + /> + + )} {panel?.kind === 'add' && canModifyStructure && ( void; children: React.ReactNode }) { const headingRef = useRef(null) diff --git a/web/src/features/campaigns/sequence-editor.tsx b/web/src/features/campaigns/sequence-editor.tsx index 1db57d98..28baaa2e 100644 --- a/web/src/features/campaigns/sequence-editor.tsx +++ b/web/src/features/campaigns/sequence-editor.tsx @@ -15,8 +15,10 @@ import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { SectionBar, EmptyBlock } from '@/components/layout/page' import { NoticeBanner } from '@/components/shared/notice-banner' +import { QueryErrorBanner } from '@/components/shared/record-page' import { httpStatus } from '@/lib/rtk-error' -import { useListStepsQuery, useDeleteStepMutation, type SequenceStep } from './api' +import { useListStepsQuery, useDeleteStepMutation, useGetCampaignGraphQuery, type SequenceStep } from './api' +import { cycleStepIds } from './branch-loop' import { StepCard, type StepWithId } from './step-card' import { VariantsDialog } from './variants-dialog' import { StepForm } from './step-form' @@ -49,12 +51,18 @@ function hasId(step: SequenceStep): step is StepWithId { * list of step cards, both with add / edit / delete / reorder. The editor owns * what both views share — the query, the delete confirmation, the variants * dialog, the reorder banner, the canvas's side panel — so switching views - * loses none of it. Structural edits (add, delete, reorder) are draft-only; content edit is available in any status (live-reference). Owns its - * own loading / empty / error states so the parent mounts it unconditionally. + * loses none of it. Structural edits (add, delete, reorder) are draft-only; + * content edits and conditions are available in any status. Owns its own + * loading / empty / error states so the parent mounts it unconditionally. */ export function SequenceEditor({ campaignId, status }: { campaignId: string; status: string | undefined }) { const isDraft = status === 'draft' const { data, isLoading, error, refetch } = useListStepsQuery({ id: campaignId }) + // The routing (conditions). Its own query: the step list stays the source of + // content, and a failure here degrades the flow to the linear view instead + // of hiding the sequence. + const graphQuery = useGetCampaignGraphQuery({ id: campaignId }) + const hasBranches = graphQuery.data?.nodes.some((node) => node.branch !== null) ?? false // The step whose A/B variants are open, plus its 1-based position for the // dialog title. Held here rather than in the card so only one dialog can be // mounted at a time. @@ -64,7 +72,10 @@ export function SequenceEditor({ campaignId, status }: { campaignId: string; sta const [adding, setAdding] = useState(false) const [editingId, setEditingId] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) - const [reorderError, setReorderError] = useState(null) + const [notice, setNotice] = useState(null) + // The loop the server last refused (a branch write, a reorder, a delete), + // marked on the flow's nodes until the next attempt. + const [loop, setLoop] = useState(null) const [view, setView] = useState('canvas') const [canvasPanel, setCanvasPanel] = useState(null) @@ -104,10 +115,13 @@ export function SequenceEditor({ campaignId, status }: { campaignId: string; sta async function confirmDelete() { if (!pendingDelete) return + setLoop(null) const result = await deleteStep({ id: campaignId, stepId: pendingDelete.id }) // Close only on success; on error keep the dialog OPEN so the rendered - // delete error is visible and the user can retry. - if (!('error' in result)) setPendingDelete(null) + // delete error is visible and the user can retry. A delete re-links the + // fall-through, which can close a loop through a condition — mark it. + if ('error' in result) setLoop(cycleStepIds(result.error)) + else setPendingDelete(null) } return ( @@ -141,7 +155,20 @@ export function SequenceEditor({ campaignId, status }: { campaignId: string; sta )} - {reorderError && } + {notice && } + {canvasShown && graphQuery.isError && ( + void graphQuery.refetch()} + retrying={graphQuery.isFetching} + /> + )} + {view === 'list' && hasBranches && ( +

+ This sequence has conditions. The list shows steps in order only — switch to Flow to see where each path goes. +

+ )} {isDraft && adding && ( ) : view === 'canvas' ? ( - }> - - + // Wait for the routing too, so a branched sequence never flashes its + // linear fall-back first. + graphQuery.isLoading ? ( + + ) : ( + }> + + + ) ) : isDraft ? ( diff --git a/web/src/features/campaigns/sequence-flow-nodes.tsx b/web/src/features/campaigns/sequence-flow-nodes.tsx index 640872a2..b4b9600d 100644 --- a/web/src/features/campaigns/sequence-flow-nodes.tsx +++ b/web/src/features/campaigns/sequence-flow-nodes.tsx @@ -1,11 +1,12 @@ import type { NodeProps } from '@xyflow/react' -import { ArrowDown, ArrowUp, CirclePlay, FlaskConical, Mail, Square, Trash2 } from 'lucide-react' +import { ArrowDown, ArrowUp, CirclePlay, FlaskConical, Mail, Split, Square, Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { FlowNodeFrame } from '@/components/shared/flow/flow-node-frame' import { cn } from '@/lib/utils' import { useListStepVariantsQuery } from './api' import type { StartFlowNode, StepFlowNode, StopFlowNode } from './sequence-graph' +import { NodeFlag } from './node-flag' import { focusKey, useFocusRequest, useSequenceCanvasActions } from './sequence-canvas-actions' const DRAFT_ONLY_HINT = 'Structural changes are draft-only' @@ -13,9 +14,10 @@ const DRAFT_ONLY_HINT = 'Structural changes are draft-only' const terminalClass = 'flex h-full w-full items-center justify-center gap-2 rounded-full border border-border-strong bg-surface font-mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground' -export function StartNode({ type, data, isConnectable }: NodeProps) { +export function StartNode({ type, data }: NodeProps) { + // Dragging from Start makes the dropped step first — a reorder, so draft-only. return ( - 0}> + 0}>