From 11b56b7031aac135c42ce81c09996cb616cff6fb Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 9 Oct 2026 06:03:21 +0200 Subject: [PATCH 1/2] feat: file Unibox forwards into the conversation they were forwarded from via email_tasks.forward_thread_id and unibox_emails.filed_forward, sent with no provider thread or In-Reply-To and skipped by reply threading --- docs/content/docs/api/reference/unibox.mdx | 4 +- docs/content/docs/guides/unibox.mdx | 2 + internal/api/handler/unibox.go | 5 +- internal/api/handler/unibox_forward_test.go | 61 ++++++++ internal/app/consumer/event_send_result.go | 18 +++ internal/app/emailsend/forward_test.go | 36 +++++ internal/app/emailsend/service.go | 41 +++--- internal/app/unibox/forward.go | 13 +- .../000275_unibox_forward_thread.down.sql | 2 + .../000275_unibox_forward_thread.up.sql | 8 ++ internal/models/unibox.go | 13 +- internal/repository/pg_task.go | 24 ++-- internal/repository/pg_unibox.go | 88 +++++++++++- .../repository/unibox_forward_live_test.go | 136 ++++++++++++++++++ 14 files changed, 405 insertions(+), 46 deletions(-) create mode 100644 internal/infrastructure/db/migrations/000275_unibox_forward_thread.down.sql create mode 100644 internal/infrastructure/db/migrations/000275_unibox_forward_thread.up.sql create mode 100644 internal/repository/unibox_forward_live_test.go diff --git a/docs/content/docs/api/reference/unibox.mdx b/docs/content/docs/api/reference/unibox.mdx index 09a03df8c..27abca103 100644 --- a/docs/content/docs/api/reference/unibox.mdx +++ b/docs/content/docs/api/reference/unibox.mdx @@ -319,7 +319,7 @@ Sends or schedules a reply or a forward from any mailbox in the organization. Th | `body_html` | string | No | HTML body. | | `body_plain` | string | No | Plain-text body. Leave it empty with `body_html` set and the plain-text part is rendered from the HTML, link destinations included. | | `in_reply_to` | string[] | No | Message-ID(s) this reply threads under. When omitted, the newest Message-ID in `thread_id` is used. | -| `thread_id` | string | No | The conversation being answered. | +| `thread_id` | string | No | The conversation being answered. Not used with `forward_message_id`; see [Forwarding a message](#forwarding-a-message). | | `send_mode` | string | No | `instant` (default), `smart` (next mailbox gap), or `scheduled` (use `scheduled_at`). | | `scheduled_at` | string | No | RFC 3339 timestamp. Required when `send_mode` is `scheduled`; must be in the future. | | `forward_message_id` | string | No | UUID of a stored message (the `id` from `GET /unibox/:id`) to forward. See [Forwarding a message](#forwarding-a-message). | @@ -360,6 +360,8 @@ Set `forward_message_id` to forward a message from the inbox. Warmbly attaches t The Date line is written in the sending mailbox's time zone (its own, else the workspace's, else UTC). The original HTML keeps its formatting: its stylesheet is folded into inline styles and it is sanitized like a message opened in Unibox, so scripts, event handlers and Warmbly's own open-tracking pixels are dropped. When the original has only a text part, it is forwarded as text. Links in the forwarded message are never rewritten for click tracking; only your note is tracked, when the mailbox tracks direct mail. Forwarding one of your own tracked sends (from Sent, say) swaps its tracked links back to their real destinations, so the new recipient's clicks are not counted against the original send. +A forward goes out as a new conversation for its recipient: no provider thread, and no In-Reply-To or References unless you set `in_reply_to` yourself. A `thread_id` sent with it is not used for either. Inside Unibox the forward belongs to the conversation of the message it forwards (that message's `thread_id`), whichever mailbox sends it: it is listed with that conversation's scheduled sends while queued, and once the sending mailbox's Sent folder syncs, the sent copy is returned by `GET /unibox/thread` for that conversation, stored once. A forward in a conversation is never picked as the parent Message-ID for a later reply into it. + Two limits: - attachments on the original are not forwarded diff --git a/docs/content/docs/guides/unibox.mdx b/docs/content/docs/guides/unibox.mdx index 9d3a62c1a..7bb0f549b 100644 --- a/docs/content/docs/guides/unibox.mdx +++ b/docs/content/docs/guides/unibox.mdx @@ -221,6 +221,8 @@ A reply from a different mailbox keeps the conversation's subject and its In-Rep The composer shows the message it will forward under your note; expand **Forwarded message** to read it before you send. Attachments on the original are not forwarded. If only a preview of the original is stored (the thread shows "Only a preview of this message is stored"), the composer says so, and the forward carries that preview. Forwarding one of your own tracked sends puts its links back to their real destinations, so the new recipient's clicks do not count against the original send. +Once sent, the forward shows in the conversation you forwarded from, as the next outgoing message with its forwarded block, whichever mailbox sent it. While it waits to go out (the undo window, or a scheduled time) it is listed with the conversation's scheduled sends. It appears once the sending mailbox's Sent folder syncs, and that sync files the copy into this conversation rather than adding a second one. Only Warmbly files it there: the recipient gets an ordinary forward that starts a conversation of its own, with no In-Reply-To or References pointing at the original, and a later reply in the conversation still answers its last message from the other side, never the forward. + ### Pausing their follow-ups The contact panel lists every campaign the sender is a lead of, with their status in it and what happens next: the next step and when, the hold and when it lifts, or why the flow ended. A campaign with a step still to send has **Pause**, which opens the same dialog as the campaign's Leads list (until a date, or until you resume them, with an optional note). A held lead has **Resume** instead; for a contact held because they are [copied on another lead's emails](/guides/campaigns/#copying-colleagues-on-one-lead) it asks first, since it starts a second thread to them. Neither appears on a lead whose flow has ended, and both need the **Manage campaigns** permission. See [Out of office and pausing one lead](/guides/campaigns/#out-of-office-and-pausing-one-lead). diff --git a/internal/api/handler/unibox.go b/internal/api/handler/unibox.go index c4443d4c7..a5542bf54 100644 --- a/internal/api/handler/unibox.go +++ b/internal/api/handler/unibox.go @@ -612,9 +612,10 @@ func (h *Handler) UniboxReply(c *gin.Context) { // The composer only knows the provider thread id, but a thread id is // meaningless outside the sending mailbox: the recipient's client threads // on In-Reply-To/References. Resolve the parent Message-ID here so a reply - // nests for them too, without needing a client change. + // nests for them too, without needing a client change. A forward answers + // nobody: it is filed into the forwarded message's conversation instead. inReplyTo := req.InReplyTo - if len(inReplyTo) == 0 && req.ThreadID != "" { + if len(inReplyTo) == 0 && req.ThreadID != "" && forward == nil { if parentMessageID, xerr := h.UniboxService.LatestMessageIDInThread(c.Request.Context(), *orgID, req.ThreadID); xerr == nil && mailhdr.ValidMessageID(parentMessageID) { inReplyTo = []string{parentMessageID} } diff --git a/internal/api/handler/unibox_forward_test.go b/internal/api/handler/unibox_forward_test.go index cc8deac58..345829652 100644 --- a/internal/api/handler/unibox_forward_test.go +++ b/internal/api/handler/unibox_forward_test.go @@ -91,3 +91,64 @@ func TestUniboxReplyForwardNeedsReadAccess(t *testing.T) { }) } } + +type fakeForwardThreadUnibox struct { + fakeForwardUnibox + parentLookups int +} + +func (f *fakeForwardThreadUnibox) LatestMessageIDInThread(context.Context, uuid.UUID, string) (string, *errx.Error) { + f.parentLookups++ + return "", nil +} + +// A forward from an open conversation answers nobody, so no In-Reply-To is +// resolved from the thread it was forwarded from; a reply still gets one. +func TestUniboxReplyForwardFromAThreadIsNoReply(t *testing.T) { + gin.SetMode(gin.TestMode) + sender := uuid.New() + + for _, tc := range []struct { + name string + forward bool + parent bool + }{ + {"forward", true, false}, + {"reply", false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + ub := &fakeForwardThreadUnibox{fakeForwardUnibox: fakeForwardUnibox{mailbox: sender}} + send := &fakeForwardSend{} + h := &Handler{UniboxService: ub, EmailSendService: send, AuditService: audit.NewNoOpService()} + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(middleware.UserIDKey, uuid.NewString()) + c.Set(middleware.OrganizationIDKey, uuid.New()) + c.Set(middleware.AuthTypeKey, middleware.AuthTypeAPIKey) + c.Set(middleware.APIKeyPermissionsKey, models.APIPermWriteUnibox|models.APIPermReadUnibox) + }) + router.POST("/unibox/reply", h.UniboxReply) + + body := `{"email_account_id":"` + sender.String() + `","to":["team@example.com"],"subject":"Fwd: Pricing","body_plain":"FYI","thread_id":"conversation-1"` + if tc.forward { + body += `,"forward_message_id":"` + uuid.NewString() + `"` + } + body += `}` + req := httptest.NewRequest(http.MethodPost, "/unibox/reply", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + if got := len(send.req.InReplyTo) > 0; got != tc.parent { + t.Fatalf("in_reply_to %v, want a parent: %v", send.req.InReplyTo, tc.parent) + } + if (ub.parentLookups > 0) != tc.parent { + t.Fatalf("parent looked up %d times", ub.parentLookups) + } + }) + } +} diff --git a/internal/app/consumer/event_send_result.go b/internal/app/consumer/event_send_result.go index ec38c459d..eac98c6a6 100644 --- a/internal/app/consumer/event_send_result.go +++ b/internal/app/consumer/event_send_result.go @@ -72,6 +72,8 @@ func (s *JobsService) applyEmailSent(ctx context.Context, result models.SendEmai } } switch task.TaskType { + case "email": + return s.fileSentForward(ctx, task.ID) case "campaign": s.repairCampaignSendStamp(ctx, task) // Anchor the graduation ramp on the mailbox's first CONFIRMED cold @@ -102,6 +104,22 @@ func (s *JobsService) applyEmailSent(ctx context.Context, result models.SendEmai return nil } +// fileSentForward files a forward's sent copy that Sent sync stored before its +// Message-ID was known; a copy stored after is filed by the insert itself. +func (s *JobsService) fileSentForward(ctx context.Context, taskID uuid.UUID) error { + if s.UniboxRepository == nil { + return nil + } + filed, err := s.UniboxRepository.FileSentForward(ctx, taskID) + if err != nil { + return err + } + for i := range filed { + s.publishEmailUpdated(ctx, filed[i].UserID, &filed[i].Message) + } + return nil +} + // repairCampaignSendStamp completes a reserved campaign step the control plane // never stamped. A no-op in the normal case, where the stamp is already there. func (s *JobsService) repairCampaignSendStamp(ctx context.Context, task *repository.Task) { diff --git a/internal/app/emailsend/forward_test.go b/internal/app/emailsend/forward_test.go index b2dfc7b27..ab2260161 100644 --- a/internal/app/emailsend/forward_test.go +++ b/internal/app/emailsend/forward_test.go @@ -192,6 +192,42 @@ func TestSendEmailStoresTheForwardedMessage(t *testing.T) { } } +// A forward is filed into the forwarded message's conversation in Unibox but +// goes out as a new conversation: no provider thread, even when one is named. +func TestSendEmailFilesAForwardWithoutThreadingIt(t *testing.T) { + orgID := uuid.New() + tasks := &fakeSendTaskRepo{} + svc := &emailSendService{ + taskRepo: tasks, + emailRepo: &fakeSendEmailRepo{account: &models.Email{OrganizationID: &orgID}}, + } + + fwd := forwardedFixture() + fwd.ThreadID = "conversation-1" + if _, xerr := svc.SendEmail(context.Background(), uuid.New(), orgID, uuid.New(), &SendEmailRequest{ + To: []string{"team@example.com"}, Subject: "Fwd: Pricing details", ThreadID: "conversation-1", Forward: fwd, + }); xerr != nil { + t.Fatal(xerr) + } + et := tasks.stored + if et.ThreadID != nil { + t.Fatalf("forward carries thread %q, want none on the wire", *et.ThreadID) + } + if et.ForwardThreadID == nil || *et.ForwardThreadID != "conversation-1" { + t.Fatalf("forward filed into %v, want conversation-1", et.ForwardThreadID) + } + + // A reply keeps its thread and is filed nowhere else. + if _, xerr := svc.SendEmail(context.Background(), uuid.New(), orgID, uuid.New(), &SendEmailRequest{ + To: []string{"them@example.com"}, Subject: "Re: x", BodyPlain: "Thanks", ThreadID: "conversation-1", + }); xerr != nil { + t.Fatal(xerr) + } + if et := tasks.stored; et.ThreadID == nil || *et.ThreadID != "conversation-1" || et.ForwardThreadID != nil { + t.Fatalf("reply: thread %v forward thread %v", et.ThreadID, et.ForwardThreadID) + } +} + type fakeTicketRepo struct { repository.TrackedLinkRepository links map[uuid.UUID]string diff --git a/internal/app/emailsend/service.go b/internal/app/emailsend/service.go index 3bda2c03b..0789487d6 100644 --- a/internal/app/emailsend/service.go +++ b/internal/app/emailsend/service.go @@ -282,8 +282,14 @@ func (s *emailSendService) SendEmail(ctx context.Context, userID, orgID, account ScheduledAt: &scheduledAt, } - var threadID *string - if req.ThreadID != "" { + // A forward starts a new conversation on the wire and is only filed into the + // one it was forwarded from inside Unibox. + var threadID, forwardThreadID *string + if req.Forward != nil { + if req.Forward.ThreadID != "" { + forwardThreadID = &req.Forward.ThreadID + } + } else if req.ThreadID != "" { threadID = &req.ThreadID } @@ -303,21 +309,22 @@ func (s *emailSendService) SendEmail(ctx context.Context, userID, orgID, account bodyHTML, tracked := s.applyDirectTracking(ctx, account, taskID, bodyHTML, bodyHTML != "" || forwardedHTML != "") emailTask := &repository.EmailTask{ - TaskID: taskID, - To: req.To, - CC: req.CC, - BCC: req.BCC, - InReplyTo: req.InReplyTo, - Subject: req.Subject, - Body: bodyPlain, - BodyHTML: bodyHTML, - BodyPlain: bodyPlain, - ThreadID: threadID, - SendMode: sendMode, - Encrypted: false, - Tracked: tracked, - ForwardedHTML: forwardedHTML, - ForwardedPlain: forwardedPlain, + TaskID: taskID, + To: req.To, + CC: req.CC, + BCC: req.BCC, + InReplyTo: req.InReplyTo, + Subject: req.Subject, + Body: bodyPlain, + BodyHTML: bodyHTML, + BodyPlain: bodyPlain, + ThreadID: threadID, + SendMode: sendMode, + Encrypted: false, + Tracked: tracked, + ForwardedHTML: forwardedHTML, + ForwardedPlain: forwardedPlain, + ForwardThreadID: forwardThreadID, } if err := s.taskRepo.CreateEmailTaskFull(ctx, task, emailTask); err != nil { diff --git a/internal/app/unibox/forward.go b/internal/app/unibox/forward.go index bd96c549a..a60f56d19 100644 --- a/internal/app/unibox/forward.go +++ b/internal/app/unibox/forward.go @@ -24,12 +24,13 @@ func (s *uniboxService) ForwardSource(ctx context.Context, orgID, id uuid.UUID) } out := &models.ForwardedMessage{ - EmailID: msg.EmailID, - From: msg.FromAddr, - To: msg.ToAddr, - CC: msg.CC, - Subject: msg.Subject, - Date: msg.SentDate, + EmailID: msg.EmailID, + ThreadID: msg.ThreadID, + From: msg.FromAddr, + To: msg.ToAddr, + CC: msg.CC, + Subject: msg.Subject, + Date: msg.SentDate, } if out.Date.IsZero() { out.Date = msg.InternalDate diff --git a/internal/infrastructure/db/migrations/000275_unibox_forward_thread.down.sql b/internal/infrastructure/db/migrations/000275_unibox_forward_thread.down.sql new file mode 100644 index 000000000..83f977da4 --- /dev/null +++ b/internal/infrastructure/db/migrations/000275_unibox_forward_thread.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE unibox_emails DROP COLUMN IF EXISTS filed_forward; +ALTER TABLE email_tasks DROP COLUMN IF EXISTS forward_thread_id; diff --git a/internal/infrastructure/db/migrations/000275_unibox_forward_thread.up.sql b/internal/infrastructure/db/migrations/000275_unibox_forward_thread.up.sql new file mode 100644 index 000000000..c117e2525 --- /dev/null +++ b/internal/infrastructure/db/migrations/000275_unibox_forward_thread.up.sql @@ -0,0 +1,8 @@ +-- The Unibox conversation a forward was sent from. Only Warmbly files it there; +-- on the wire the forward stays a new conversation (no thread, no In-Reply-To). +ALTER TABLE email_tasks ADD COLUMN IF NOT EXISTS forward_thread_id text; + +-- A sent forward filed into the conversation it was forwarded from. It is shown +-- there but is not part of that conversation for the recipient, so reply +-- threading skips it. +ALTER TABLE unibox_emails ADD COLUMN IF NOT EXISTS filed_forward boolean NOT NULL DEFAULT false; diff --git a/internal/models/unibox.go b/internal/models/unibox.go index 2778821ac..8c0849a40 100644 --- a/internal/models/unibox.go +++ b/internal/models/unibox.go @@ -56,11 +56,14 @@ type EmailMessage struct { // used for sending to the user type ForwardedMessage struct { // EmailID is the mailbox the message belongs to. EmailID uuid.UUID - From []string - To []string - CC []string - Subject string - Date time.Time + // ThreadID is the Unibox conversation the message is in, where the forward + // is filed once sent. + ThreadID string + From []string + To []string + CC []string + Subject string + Date time.Time // BodyHTML is the stored HTML, unsanitized; empty when the message has none. BodyHTML string // BodyPlain is the preview snippet when the full body is not stored. diff --git a/internal/repository/pg_task.go b/internal/repository/pg_task.go index 1862e9888..660df79a2 100644 --- a/internal/repository/pg_task.go +++ b/internal/repository/pg_task.go @@ -64,6 +64,9 @@ type EmailTask struct { // appended after the body and signature when the send goes out. ForwardedHTML string ForwardedPlain string + // ForwardThreadID is the Unibox conversation a forward was sent from. Its + // sent copy is filed there; ThreadID stays nil so it goes out as no reply. + ForwardThreadID *string } // TaskFailure represents a task failure record @@ -280,8 +283,8 @@ func (r *taskRepository) CreateWarmupTask(ctx context.Context, warmupTask *Warmu // CreateEmailTask creates email-specific task data func (r *taskRepository) CreateEmailTask(ctx context.Context, emailTask *EmailTask) error { query := ` - INSERT INTO email_tasks (task_id, to_addrs, cc, bcc, in_reply_to, subject, body, body_html, body_plain, thread_id, send_mode, encrypted, tracked, forwarded_html, forwarded_plain) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + INSERT INTO email_tasks (task_id, to_addrs, cc, bcc, in_reply_to, subject, body, body_html, body_plain, thread_id, send_mode, encrypted, tracked, forwarded_html, forwarded_plain, forward_thread_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) ` sendMode := emailTask.SendMode @@ -305,6 +308,7 @@ func (r *taskRepository) CreateEmailTask(ctx context.Context, emailTask *EmailTa emailTask.Tracked, emailTask.ForwardedHTML, emailTask.ForwardedPlain, + emailTask.ForwardThreadID, ) return err @@ -660,8 +664,8 @@ func (r *taskRepository) CreateEmailTaskFull(ctx context.Context, task *Task, em } etQuery := ` - INSERT INTO email_tasks (task_id, to_addrs, cc, bcc, in_reply_to, subject, body, body_html, body_plain, thread_id, send_mode, encrypted, tracked, forwarded_html, forwarded_plain) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + INSERT INTO email_tasks (task_id, to_addrs, cc, bcc, in_reply_to, subject, body, body_html, body_plain, thread_id, send_mode, encrypted, tracked, forwarded_html, forwarded_plain, forward_thread_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) ` _, err = tx.Exec(ctx, etQuery, emailTask.TaskID, @@ -679,6 +683,7 @@ func (r *taskRepository) CreateEmailTaskFull(ctx context.Context, task *Task, em emailTask.Tracked, emailTask.ForwardedHTML, emailTask.ForwardedPlain, + emailTask.ForwardThreadID, ) if err != nil { return err @@ -1202,7 +1207,7 @@ func (r *taskRepository) ListScheduledInOrg(ctx context.Context, orgID uuid.UUID -- A forward with no note previews the message it forwards. CASE WHEN btrim(et.body_plain) = '' THEN et.forwarded_plain ELSE et.body_plain END, et.body_html, - et.thread_id + COALESCE(et.thread_id, et.forward_thread_id) FROM tasks t INNER JOIN email_tasks et ON et.task_id = t.id INNER JOIN email_accounts ea ON ea.id = t.email_account_id @@ -1274,14 +1279,14 @@ func (r *taskRepository) ListScheduledInOrgByThread(ctx context.Context, orgID u -- A forward with no note previews the message it forwards. CASE WHEN btrim(et.body_plain) = '' THEN et.forwarded_plain ELSE et.body_plain END, et.body_html, - et.thread_id + COALESCE(et.thread_id, et.forward_thread_id) FROM tasks t INNER JOIN email_tasks et ON et.task_id = t.id INNER JOIN email_accounts ea ON ea.id = t.email_account_id WHERE ea.organization_id = $1 AND t.task_type = 'email' AND t.status = 'pending' - AND et.thread_id = $2 + AND (et.thread_id = $2 OR et.forward_thread_id = $2) AND (COALESCE(cardinality($4::uuid[]), 0) = 0 OR ea.id = ANY($4::uuid[])) ORDER BY t.scheduled_at ASC NULLS LAST, t.created_at ASC LIMIT $3 @@ -1373,7 +1378,8 @@ func (r *taskRepository) CancelScheduledInOrg(ctx context.Context, taskID, orgID // ProviderThreadForMailbox returns the provider thread the mailbox holds for a // unibox conversation: the conversation's own id when the mailbox has a message // in it (synced, or sent and not yet synced back), else the thread its latest -// earlier reply into the conversation landed in, else "". +// earlier reply into the conversation landed in, else "". A forward filed into +// the conversation is not in its provider thread, so it does not count. func (r *taskRepository) ProviderThreadForMailbox(ctx context.Context, emailAccountID uuid.UUID, threadID string) (string, error) { threadID = strings.TrimSpace(threadID) if threadID == "" { @@ -1382,7 +1388,7 @@ func (r *taskRepository) ProviderThreadForMailbox(ctx context.Context, emailAcco var handle string err := r.db.QueryRow(ctx, ` SELECT CASE - WHEN EXISTS (SELECT 1 FROM unibox_emails WHERE email_id = $1 AND thread_id = $2) + WHEN EXISTS (SELECT 1 FROM unibox_emails WHERE email_id = $1 AND thread_id = $2 AND NOT filed_forward) OR EXISTS (SELECT 1 FROM tasks WHERE email_account_id = $1 AND status = 'completed' AND thread_id = $2) THEN $2 ELSE COALESCE(( diff --git a/internal/repository/pg_unibox.go b/internal/repository/pg_unibox.go index 1f011dbcd..724c92636 100644 --- a/internal/repository/pg_unibox.go +++ b/internal/repository/pg_unibox.go @@ -132,6 +132,10 @@ type UniboxRepository interface { // a reply that arrives with only a provider thread id can still carry the // In-Reply-To header the recipient's mail client threads on. LatestMessageIDInThread(ctx context.Context, orgID uuid.UUID, threadID string) (string, error) + // FileSentForward files the sending mailbox's stored copy of a Unibox + // forward into the conversation it was forwarded from, for a copy synced + // before the send's Message-ID was known. Returns the rows it moved. + FileSentForward(ctx context.Context, taskID uuid.UUID) ([]FiledSentForward, error) // ListMissingBodyText pages through messages stored before bodies were // indexed for search, oldest id first from afterID. SetBodyText writes the @@ -230,17 +234,21 @@ func ownAddressSQL(arrayExpr, orgArg string) string { )`, arrayExpr, bareAddrSQL("own.addr"), orgArg) } +// CreateEntry stores a synced message. A mailbox's copy of a forward it sent +// from Unibox is filed into the conversation it was forwarded from, and e +// carries the thread it was stored under. func (r *uniboxRepository) CreateEntry(ctx context.Context, userID uuid.UUID, e *models.EmailMessageStoreData) error { query := ` + WITH fwd AS (` + sentForwardThreadSQL + `) INSERT INTO unibox_emails ( id, user_id, email_id, mailbox, folder_path, thread_id, message_id, gmail_id, parent_id, uid, mod_seq, flags, bcc, cc, from_addr, in_reply_to, reply_to, to_addr, subject, size, internal_date, sent_date, snippet, seen, created_at, updated_at, body_text, folder, - provider_folder, automated + provider_folder, automated, filed_forward ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, + $1, $2, $3, $4, $5, COALESCE((SELECT thread_id FROM fwd), $6), $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, @@ -252,14 +260,17 @@ func (r *uniboxRepository) CreateEntry(ctx context.Context, userID uuid.UUID, e WHERE r.organization_id = (SELECT organization_id FROM email_accounts WHERE id = $3) AND r.message_id = $7 AND $7 <> '' AND r.automated - ) + ), + EXISTS (SELECT 1 FROM fwd) ) ON CONFLICT (id) DO NOTHING + RETURNING thread_id ` // The array columns are NOT NULL. A nil Go slice binds as SQL NULL, so a // message with no In-Reply-To (any thread root) would fail the insert. - _, err := r.db.Exec(ctx, query, + var threadID string + err := r.db.QueryRow(ctx, query, e.ID, userID, e.EmailID, e.Mailbox, e.FolderPath, e.ThreadID, e.MessageID, e.GmailID, e.ParentID, e.UID, e.ModSeq, textArray(e.Flags), textArray(e.BCC), textArray(e.CC), textArray(e.FromAddr), @@ -269,10 +280,73 @@ func (r *uniboxRepository) CreateEntry(ctx context.Context, userID uuid.UUID, e // $28 is both columns: a message starts out where the provider put it, // and only diverges once someone files it in Warmbly. models.NormalizeFolder(e.Folder, e.Flags), - ) - return err + ).Scan(&threadID) + if errors.Is(err, pgx.ErrNoRows) { + // Already stored: the conflict leaves the row as it was. + return nil + } + if err != nil { + return err + } + e.ThreadID = threadID + return nil } +// FiledSentForward is a stored copy FileSentForward moved, with its mailbox owner. +type FiledSentForward struct { + UserID uuid.UUID + Message models.EmailMessageStoreData +} + +func (r *uniboxRepository) FileSentForward(ctx context.Context, taskID uuid.UUID) ([]FiledSentForward, error) { + // Only the Sent copy can have raced the result, and (email_id, folder) is indexed. + rows, err := r.db.Query(ctx, ` + UPDATE unibox_emails ue + SET thread_id = et.forward_thread_id, + filed_forward = true, + updated_at = NOW() + FROM tasks t + JOIN email_tasks et ON et.task_id = t.id + WHERE t.id = $1 + AND t.task_type = 'email' + AND t.message_id <> '' + AND COALESCE(et.forward_thread_id, '') <> '' + AND ue.email_id = t.email_account_id + AND ue.folder = $2 + AND ue.message_id IN (t.message_id, btrim(t.message_id, '<> '), '<' || btrim(t.message_id, '<> ') || '>') + AND NOT (ue.filed_forward AND ue.thread_id = et.forward_thread_id) + RETURNING ue.id, ue.user_id, ue.email_id, ue.thread_id, ue.subject, ue.from_addr, ue.snippet, ue.folder, ue.flags + `, taskID, models.FolderSent) + if err != nil { + return nil, err + } + defer rows.Close() + var out []FiledSentForward + for rows.Next() { + var f FiledSentForward + m := &f.Message + if err := rows.Scan(&m.ID, &f.UserID, &m.EmailID, &m.ThreadID, &m.Subject, &m.FromAddr, &m.Snippet, &m.Folder, &m.Flags); err != nil { + return nil, err + } + out = append(out, f) + } + return out, rows.Err() +} + +// sentForwardThreadSQL finds the conversation a mailbox's message ($3, $7) was +// forwarded from, when it is the mailbox's own copy of a Unibox forward. +const sentForwardThreadSQL = ` + SELECT et.forward_thread_id AS thread_id + FROM tasks t + JOIN email_tasks et ON et.task_id = t.id + WHERE $7 <> '' + AND t.email_account_id = $3 + AND t.task_type = 'email' + AND t.message_id IN ($7, btrim($7, '<> '), '<' || btrim($7, '<> ') || '>') + AND COALESCE(et.forward_thread_id, '') <> '' + ORDER BY t.created_at DESC + LIMIT 1` + // textArray coalesces a nil slice to an empty one so it binds as '{}' rather // than NULL. func textArray(v []string) []string { @@ -1459,6 +1533,8 @@ func (r *uniboxRepository) LatestMessageIDInThread(ctx context.Context, orgID uu FROM unibox_emails WHERE thread_id = $2 AND message_id <> '' + -- A filed forward never reached the people in the conversation. + AND NOT filed_forward AND email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1) ORDER BY internal_date DESC LIMIT 1 diff --git a/internal/repository/unibox_forward_live_test.go b/internal/repository/unibox_forward_live_test.go new file mode 100644 index 000000000..4099575b1 --- /dev/null +++ b/internal/repository/unibox_forward_live_test.go @@ -0,0 +1,136 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/models" +) + +// A forward sent from a Unibox conversation is filed into it, from either +// mailbox and whichever of the Sent sync and the send result lands first, while +// reply threading never treats it as part of the conversation. +// +// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \ +// go test ./internal/repository/ -run LiveUniboxForward -v +func TestLiveUniboxForwardIsFiledIntoItsConversation(t *testing.T) { + handle, pool := liveContactDB(t) + requireSchemaVersion(t, pool, 275) + f := newRoutedPairsFixture(t, pool, 0) + ctx := context.Background() + unibox := NewUniboxRepository(handle) + tasks := NewTaskRepository(pool) + + other := uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO email_accounts (id, user_id, organization_id, email, name, + signature_plain, signature_html, provider, status, campaign_limit, min_wait_time, timezone) + VALUES ($1, $2, $3, $4, 'Other', '', '', 'smtp_imap', 'active', 50, 0, 'UTC')`, + other, f.owner, f.org, "fwd-other-"+other.String()[:8]+"@test.local"); err != nil { + t.Fatalf("second mailbox: %v", err) + } + var taskIDs []uuid.UUID + t.Cleanup(func() { + c := context.Background() + for _, step := range []struct { + sql string + arg any + }{ + {`DELETE FROM unibox_emails WHERE email_id = ANY($1)`, []uuid.UUID{f.mailbox, other}}, + {`DELETE FROM email_tasks WHERE task_id = ANY($1)`, taskIDs}, + {`DELETE FROM tasks WHERE id = ANY($1)`, taskIDs}, + {`DELETE FROM email_accounts WHERE id = $1`, other}, + } { + if _, err := pool.Exec(c, step.sql, step.arg); err != nil { + t.Errorf("cleanup %q: %v", step.sql, err) + } + } + }) + + const conversation = "conv-forward-1" + store := func(mailbox uuid.UUID, threadID, messageID, folder string, at time.Time) *models.EmailMessageStoreData { + t.Helper() + m := &models.EmailMessageStoreData{ + ID: uuid.New(), EmailID: mailbox, ThreadID: threadID, MessageID: messageID, Folder: folder, + Subject: "Packaging", InternalDate: at, SentDate: at, CreatedAt: at, UpdatedAt: at, + } + if err := unibox.CreateEntry(ctx, f.owner, m); err != nil { + t.Fatalf("store %s: %v", messageID, err) + } + return m + } + forward := func(mailbox uuid.UUID, status, messageID string) uuid.UUID { + t.Helper() + id := uuid.New() + taskIDs = append(taskIDs, id) + at := time.Now().Add(time.Hour) + thread := conversation + if err := tasks.CreateEmailTaskFull(ctx, + &Task{ID: id, TaskType: "email", EmailAccountID: mailbox, Status: status, MessageID: messageID, ScheduledAt: &at}, + &EmailTask{TaskID: id, To: []string{"team@example.com"}, Subject: "Fwd: Packaging", ForwardedPlain: "forwarded", ForwardThreadID: &thread}, + ); err != nil { + t.Fatalf("forward task: %v", err) + } + return id + } + + base := time.Now().Add(-time.Hour) + store(f.mailbox, conversation, "", models.FolderInbox, base) + + // The send result came first: the Sent copy is filed as it is stored, from + // another mailbox and with the Message-ID written without brackets. + forward(other, "completed", "") + sent := store(other, "other-own-thread", "fwd-1@test.local", models.FolderSent, base.Add(time.Minute)) + if sent.ThreadID != conversation { + t.Fatalf("sent copy stored under %q, want %q", sent.ThreadID, conversation) + } + + // The Sent copy came first: the send result files it. + late := forward(f.mailbox, "completed", "") + early := store(f.mailbox, "own-thread-2", "", models.FolderSent, base.Add(2*time.Minute)) + if early.ThreadID != "own-thread-2" { + t.Fatalf("copy with no known send filed into %q", early.ThreadID) + } + if err := tasks.UpdateTaskMessageID(ctx, late, ""); err != nil { + t.Fatal(err) + } + filed, err := unibox.FileSentForward(ctx, late) + if err != nil { + t.Fatal(err) + } + if len(filed) != 1 || filed[0].Message.ID != early.ID || filed[0].Message.ThreadID != conversation || filed[0].UserID != f.owner { + t.Fatalf("filed %+v", filed) + } + if again, err := unibox.FileSentForward(ctx, late); err != nil || len(again) != 0 { + t.Fatalf("second filing moved %d rows (err %v)", len(again), err) + } + + thread, err := unibox.GetByThread(ctx, f.org, uuid.Nil, conversation, 10, "") + if err != nil { + t.Fatal(err) + } + if len(thread.Data) != 3 { + t.Fatalf("conversation holds %d messages, want the reply and both forwards", len(thread.Data)) + } + + // A reply still answers the prospect, and a mailbox whose only message in the + // conversation is a forward has no provider thread in it. + parent, err := unibox.LatestMessageIDInThread(ctx, f.org, conversation) + if err != nil || parent != "" { + t.Fatalf("reply parent %q (err %v), want the prospect's message", parent, err) + } + if own, err := tasks.ProviderThreadForMailbox(ctx, other, conversation); err != nil || own != "" { + t.Fatalf("provider thread %q (err %v), want none", own, err) + } + + // A queued forward is listed with the conversation's scheduled sends. + queued := forward(other, "pending", "") + items, err := tasks.ListScheduledInOrgByThread(ctx, f.org, conversation, nil, 10) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].TaskID != queued || items[0].ThreadID == nil || *items[0].ThreadID != conversation { + t.Fatalf("scheduled in conversation: %+v", items) + } +} From 6d885883505d74b8c7d8c30525974d4b830a4f53 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 9 Oct 2026 06:04:31 +0200 Subject: [PATCH 2/2] feat: filter the Analytics dashboard by campaigns and campaign folders (campaign_ids/folder_ids on GET /analytics/dashboard, resolved server-side to one deduplicated org-scoped set), with a picker on web, a filter sheet on iOS, CLI flags, an AI tool argument, and API, OpenAPI and guide docs --- cmd/cli/specs.go | 11 +- cmd/warmblyctl/api_resources.go | 2 +- docs/content/docs/api/mcp.mdx | 2 +- docs/content/docs/api/reference/analytics.mdx | 22 ++ docs/content/docs/guides/analytics.mdx | 12 +- docs/public/openapi.json | 68 +++++- internal/api/handler/analytics.go | 44 +++- internal/api/handler/analytics_range_test.go | 41 ++++ internal/app/aitools/tools_analytics.go | 28 ++- internal/app/analytics/service.go | 26 ++- internal/app/analytics/service_test.go | 83 ++++++- internal/models/analytics.go | 36 +++- .../repository/dashboard_scope_live_test.go | 171 +++++++++++++++ .../repository/engagement_origin_live_test.go | 2 +- internal/repository/pg_analytics.go | 125 +++++++++-- internal/repository/reply_inbox_live_test.go | 2 +- .../usage_overview_scope_live_test.go | 6 +- .../AnalyticsCampaignFilterSheet.swift | 202 ++++++++++++++++++ .../Features/Analytics/AnalyticsModels.swift | 32 ++- .../Analytics/AnalyticsRootView.swift | 65 +++++- .../Features/Analytics/AnalyticsStore.swift | 52 ++++- .../app/app/analytics/browseState.test.tsx | 3 + web/src/app/app/analytics/page.tsx | 65 +++++- .../app/analytics/CampaignScopePicker.tsx | 129 +++++++++++ .../app/analytics/DirectMailSection.tsx | 8 +- .../app/analytics/campaignScope.test.ts | 30 +++ .../components/app/analytics/campaignScope.ts | 37 ++++ .../api/client/app/analytics/getDashboard.ts | 8 +- .../api/hooks/app/analytics/useDashboard.ts | 21 +- .../models/app/analytics/DashboardOverview.ts | 24 ++- web/src/lib/browse-accounts-analytics.ts | 4 + 31 files changed, 1285 insertions(+), 76 deletions(-) create mode 100644 internal/repository/dashboard_scope_live_test.go create mode 100644 ios/Warmbly/Features/Analytics/AnalyticsCampaignFilterSheet.swift create mode 100644 web/src/components/app/analytics/CampaignScopePicker.tsx create mode 100644 web/src/components/app/analytics/campaignScope.test.ts create mode 100644 web/src/components/app/analytics/campaignScope.ts diff --git a/cmd/cli/specs.go b/cmd/cli/specs.go index c4ae0912a..7b7c0d147 100644 --- a/cmd/cli/specs.go +++ b/cmd/cli/specs.go @@ -1741,7 +1741,16 @@ func analyticsSpec() resource { Short: "The numbers: sends, opens, replies, deliverability, warmup", Group: groupData, Endpoints: []endpoint{ - {Name: "dashboard", Short: "The headline numbers", Method: http.MethodGet, Path: "/analytics/dashboard"}, + { + Name: "dashboard", Short: "The headline numbers, for the workspace or chosen campaigns and folders", + Method: http.MethodGet, Path: "/analytics/dashboard", + Example: " $ warmbly analytics dashboard --period 30d --folders --campaigns ,", + Flag: []flagSpec{ + {Name: "period", Help: "7d, 30d or 90d", Query: true}, + {Name: "campaigns", Help: "Only these campaigns, comma-separated ids", Query: true, Key: "campaign_ids"}, + {Name: "folders", Help: "Only the campaigns in these folders, comma-separated ids", Query: true, Key: "folder_ids"}, + }, + }, {Name: "deliverability", Short: "Bounces, complaints and placement", Method: http.MethodGet, Path: "/analytics/deliverability"}, { Name: "warmup", Short: "Warmup analytics over a date range", diff --git a/cmd/warmblyctl/api_resources.go b/cmd/warmblyctl/api_resources.go index c9bdc2eeb..654a94cdc 100644 --- a/cmd/warmblyctl/api_resources.go +++ b/cmd/warmblyctl/api_resources.go @@ -141,7 +141,7 @@ var apiSpecs = []apiSpec{ {name: "inbox cancel-scheduled", summary: "Cancel a scheduled send", method: "DELETE", path: "/unibox/scheduled/{id}"}, // Analytics and audit. - {name: "analytics dashboard", summary: "The dashboard numbers", method: "GET", path: "/analytics/dashboard"}, + {name: "analytics dashboard", summary: "The dashboard numbers, optionally for chosen campaigns and folders", method: "GET", path: "/analytics/dashboard", query: []string{"period", "campaign_ids", "folder_ids"}}, {name: "analytics deliverability", summary: "Bounces, complaints and placement", method: "GET", path: "/analytics/deliverability"}, {name: "analytics warmup", summary: "Warmup analytics", method: "GET", path: "/analytics/warmup"}, {name: "analytics accounts", summary: "Per-mailbox analytics", method: "GET", path: "/analytics/accounts"}, diff --git a/docs/content/docs/api/mcp.mdx b/docs/content/docs/api/mcp.mdx index 1ef9f4484..750aa41cc 100644 --- a/docs/content/docs/api/mcp.mdx +++ b/docs/content/docs/api/mcp.mdx @@ -109,7 +109,7 @@ Every tool is gated by its API permission. `tools/list` returns only the tools y | `create_campaign_draft` | Create a draft campaign | `WRITE_CAMPAIGNS` | | `set_campaign_status` | Start or stop a campaign | `SEND_CAMPAIGNS` | | `set_lead_hold` | Hold or resume one contact's flow in one campaign, without unsubscribing them | `WRITE_CAMPAIGNS` | -| `get_dashboard_analytics` | Org-wide sending stats for a period | `READ_ANALYTICS` | +| `get_dashboard_analytics` | Sending stats for a period, org-wide or for chosen campaigns and campaign folders | `READ_ANALYTICS` | | `list_mailboxes` | Sender mailboxes with health + warmup state | `READ_ANALYTICS` | | `list_placement_tests` / `get_placement_test` | Inbox placement tests and where each copy landed | `READ_ANALYTICS` | | `run_placement_test` | Send a step or template to the seed inboxes from one mailbox | `SEND_CAMPAIGNS` | diff --git a/docs/content/docs/api/reference/analytics.mdx b/docs/content/docs/api/reference/analytics.mdx index b3dfa6b23..d52c7bea4 100644 --- a/docs/content/docs/api/reference/analytics.mdx +++ b/docs/content/docs/api/reference/analytics.mdx @@ -18,6 +18,28 @@ Returns the main dashboard overview for the active organization: aggregate stats | Parameter | In | Type | Description | | --- | --- | --- | --- | | `period` | query | string | One of `7d`, `30d`, `90d`, measured as UTC calendar days including today. Defaults to `7d`; any other value falls back to `7d`. | +| `campaign_ids` | query | string | Optional. Comma-separated campaign ids (or the parameter repeated) to narrow the campaign sections to. At most `100`. | +| `folder_ids` | query | string | Optional. Comma-separated campaign folder ids. Each one stands for the campaigns in that folder when the request is made. At most `100`. | + +### Filtering by campaign or folder + +Without `campaign_ids` and `folder_ids` the dashboard covers every campaign in the workspace. With either, the server resolves them to one set: the named campaigns plus every campaign currently in a named folder. A campaign named both ways, or sitting in two named folders, is counted once. + +The filter narrows everything built from campaign sends: `overall_stats` (sends, opens, clicks, replies, bounces, their rates and `active_campaigns`), `daily_trend`, `top_campaigns` and `recent_activity`. It does not narrow `account_health`, `overall_stats.active_accounts` or `capacity_today`, which describe the workspace's mailboxes, or [direct mail](/guides/analytics/#direct-mail), which belongs to no campaign. + +An id that does not belong to the active organization, or no longer exists, is dropped and never widens the filter: if nothing named belongs to the workspace, every campaign figure is zero. A malformed id, or more than `100` of either, is a `400`. A filtered response echoes what it was computed for in `scope`: + +```json +{ + "scope": { + "campaigns": [{ "id": "b1f2c3d4-0000-0000-0000-000000000001", "name": "Client A - Dentists" }], + "folders": [{ "id": "f01d0000-0000-0000-0000-000000000004", "name": "Client A" }], + "campaign_count": 6 + } +} +``` + +`campaigns` and `folders` list the requested ones that belong to the workspace; `campaign_count` is how many distinct campaigns they cover together. `scope` is absent when the request was not filtered. ### Response diff --git a/docs/content/docs/guides/analytics.mdx b/docs/content/docs/guides/analytics.mdx index 5701751df..9db6ab3ed 100644 --- a/docs/content/docs/guides/analytics.mdx +++ b/docs/content/docs/guides/analytics.mdx @@ -32,7 +32,7 @@ Seven rules govern the counts: ## Workspace dashboard -Live, scoped by `7d`, `30d`, or `90d` tabs. +Live, scoped by `7d`, `30d`, or `90d` tabs, and by default covering every campaign in the workspace. | Section | Shows | |---------|-------| @@ -45,6 +45,16 @@ Live, scoped by `7d`, `30d`, or `90d` tabs. Sending totals, trends, comparisons, and top-campaign rates count email steps only. Completed wait and action steps move a sequence forward but never inflate the number of messages sent. +### Filtering by campaign or folder + +The **All campaigns** menu next to the period tabs narrows the dashboard to the campaigns you pick. Choose one campaign, several, one or more [campaign folders](/guides/campaigns/), or a mix. The figures add the selected campaigns together, and a campaign picked directly and through a folder, or sitting in two picked folders, is counted once. A folder stands for the campaigns in it right now, so moving a campaign in or out of the folder changes what the dashboard counts the next time it loads. The period tabs keep working on top of the filter, and the selection is remembered in your browser tab like the period. + +| Follows the filter | Stays workspace-wide | +| --- | --- | +| Headline rates, Email performance chart, Breakdown, Top campaigns, Recent activity | Account health and Direct mail, marked **Workspace-wide** and **Not filtered by campaign** while a filter is on, because neither is campaign data | + +A campaign or folder that is deleted while selected drops out of the filter on its own. The mobile app's Analytics overview has the same menu under its period picker; there the filter changes the overview's campaign figures and leaves the Deliverability, Warmup and Accounts sections as they are. Through the API, pass `campaign_ids` and `folder_ids` to [`GET /analytics/dashboard`](/api/reference/analytics/#get-dashboard-analytics). + ## Direct mail The rest of this page measures campaigns. **Direct mail** measures what you write by hand in the [unified inbox](/guides/unified-inbox/), which most workspaces do far more of than they expect. diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 05c0af5a7..2b41efce5 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -15112,7 +15112,7 @@ "analytics" ], "summary": "Get dashboard analytics", - "description": "Org-wide dashboard overview: aggregate stats, recent activity, top campaigns, account health, and a daily trend series.", + "description": "Org-wide dashboard overview: aggregate stats, recent activity, top campaigns, account health, and a daily trend series. campaign_ids and folder_ids narrow the campaign sections; account health stays org-wide.", "security": [ { "bearerAuth": [] @@ -15133,6 +15133,24 @@ ], "default": "7d" } + }, + { + "name": "campaign_ids", + "in": "query", + "required": false, + "description": "Comma-separated campaign ids to narrow the campaign sections to, unioned with folder_ids and counted once each. Ids outside the organization are dropped. At most 100.", + "schema": { + "type": "string" + } + }, + { + "name": "folder_ids", + "in": "query", + "required": false, + "description": "Comma-separated campaign folder ids; each stands for the campaigns in that folder at request time. Ids outside the organization are dropped. At most 100.", + "schema": { + "type": "string" + } } ], "responses": { @@ -36358,6 +36376,54 @@ "items": { "$ref": "#/components/schemas/DailyTrendPoint" } + }, + "scope": { + "$ref": "#/components/schemas/DashboardScope" + } + } + }, + "DashboardScope": { + "type": "object", + "description": "The campaign filter a dashboard response was computed for. Absent when the request was not filtered.", + "required": [ + "campaigns", + "folders", + "campaign_count" + ], + "properties": { + "campaigns": { + "type": "array", + "description": "Requested campaigns that belong to the organization.", + "items": { + "$ref": "#/components/schemas/DashboardScopeItem" + } + }, + "folders": { + "type": "array", + "description": "Requested campaign folders that belong to the organization.", + "items": { + "$ref": "#/components/schemas/DashboardScopeItem" + } + }, + "campaign_count": { + "type": "integer", + "description": "Distinct campaigns the campaigns and folders cover together." + } + } + }, + "DashboardScopeItem": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" } } }, diff --git a/internal/api/handler/analytics.go b/internal/api/handler/analytics.go index db5906b35..103419741 100644 --- a/internal/api/handler/analytics.go +++ b/internal/api/handler/analytics.go @@ -1,6 +1,7 @@ package handler import ( + "fmt" "net/http" "strconv" "strings" @@ -347,8 +348,12 @@ func (h *Handler) GetRealtimeInfo(c *gin.Context) { }) } +// maxDashboardScopeIDs bounds each of campaign_ids and folder_ids; a folder +// stands in for any number of campaigns, so a larger set is never needed. +const maxDashboardScopeIDs = 100 + // GetDashboardAnalytics returns main dashboard analytics overview -// GET /analytics/dashboard?period=7d +// GET /analytics/dashboard?period=7d[&campaign_ids=a,b][&folder_ids=c] func (h *Handler) GetDashboardAnalytics(c *gin.Context) { orgID := middleware.GetOrganizationID(c) if orgID == nil { @@ -362,7 +367,18 @@ func (h *Handler) GetDashboardAnalytics(c *gin.Context) { period = "7d" } - analytics, xerr := h.AnalyticsService.GetDashboardAnalytics(c.Request.Context(), *orgID, period) + var filter models.DashboardFilter + var xerr *errx.Error + if filter.CampaignIDs, xerr = uuidListQuery(c, "campaign_ids", maxDashboardScopeIDs); xerr != nil { + errx.Handle(c, xerr) + return + } + if filter.FolderIDs, xerr = uuidListQuery(c, "folder_ids", maxDashboardScopeIDs); xerr != nil { + errx.Handle(c, xerr) + return + } + + analytics, xerr := h.AnalyticsService.GetDashboardAnalytics(c.Request.Context(), *orgID, period, filter) if xerr != nil { errx.Handle(c, xerr) return @@ -481,6 +497,30 @@ func (h *Handler) CompareCampaigns(c *gin.Context) { c.JSON(http.StatusOK, comparison) } +// uuidListQuery reads a comma-separated or repeated query parameter of +// UUIDs, deduplicated in order. A malformed id or more than max is a 400. +func uuidListQuery(c *gin.Context, key string, max int) ([]uuid.UUID, *errx.Error) { + var ids []uuid.UUID + seen := make(map[uuid.UUID]struct{}) + for _, raw := range c.QueryArray(key) { + for _, part := range splitAndTrim(raw) { + id, err := uuid.Parse(part) + if err != nil { + return nil, errx.New(errx.BadRequest, key+" must be a comma-separated list of UUIDs") + } + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + } + if len(ids) > max { + return nil, errx.New(errx.BadRequest, fmt.Sprintf("%s accepts at most %d ids", key, max)) + } + return ids, nil +} + // splitAndTrim splits a comma-separated string and trims whitespace func splitAndTrim(s string) []string { if s == "" { diff --git a/internal/api/handler/analytics_range_test.go b/internal/api/handler/analytics_range_test.go index f42c1be5d..598198037 100644 --- a/internal/api/handler/analytics_range_test.go +++ b/internal/api/handler/analytics_range_test.go @@ -56,3 +56,44 @@ func TestOptionalDayRange(t *testing.T) { }) } } + +// GET /analytics/dashboard takes campaign_ids and folder_ids as comma lists +// or repeated keys, deduplicated, refusing a malformed id (issue #869). +func TestUUIDListQuery(t *testing.T) { + gin.SetMode(gin.TestMode) + a, b := "0b8f6a52-6f0e-4c3e-9a51-0d3f1b0f6a01", "0b8f6a52-6f0e-4c3e-9a51-0d3f1b0f6a02" + for _, tc := range []struct { + query string + want int + wantErr bool + }{ + {query: "", want: 0}, + {query: "campaign_ids=" + a, want: 1}, + {query: "campaign_ids=" + a + "," + b, want: 2}, + {query: "campaign_ids=" + a + "&campaign_ids=" + b, want: 2}, + {query: "campaign_ids=" + a + ",%20" + a + ",", want: 1}, + {query: "campaign_ids=" + a + ",nope", wantErr: true}, + {query: "campaign_ids=" + a + "," + b + "," + a, want: 2}, + } { + t.Run(tc.query, func(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/analytics/dashboard?"+tc.query, nil) + ids, xerr := uuidListQuery(c, "campaign_ids", 2) + if tc.wantErr { + if xerr == nil { + t.Fatalf("ids = %v, want a 400", ids) + } + return + } + if xerr != nil || len(ids) != tc.want { + t.Fatalf("ids = %v, err = %v; want %d ids", ids, xerr, tc.want) + } + }) + } + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/analytics/dashboard?campaign_ids="+a+","+b, nil) + if ids, xerr := uuidListQuery(c, "campaign_ids", 1); xerr == nil { + t.Fatalf("ids = %v past the limit, want a 400", ids) + } +} diff --git a/internal/app/aitools/tools_analytics.go b/internal/app/aitools/tools_analytics.go index 539522eb9..003e17c63 100644 --- a/internal/app/aitools/tools_analytics.go +++ b/internal/app/aitools/tools_analytics.go @@ -11,9 +11,11 @@ import ( func (d Deps) registerAnalyticsTools(r *Registry) { r.Register(Tool{ Name: "get_dashboard_analytics", - Description: "Org-wide sending analytics for a period: totals (sent, opens, clicks, replies, bounces), rates, top campaigns, and account-health summary. Use this for \"how are we doing\" questions.", + Description: "Sending analytics for a period: totals (sent, opens, clicks, replies, bounces), rates, top campaigns, and account-health summary. Org-wide by default, or for chosen campaigns and campaign folders together. Use this for \"how are we doing\" questions.", InputSchema: objectSchema(map[string]any{ - "period": enumProp("Aggregation window (default 30d).", "7d", "30d", "90d"), + "period": enumProp("Aggregation window (default 30d).", "7d", "30d", "90d"), + "campaign_ids": arrProp("Only these campaigns. Combined with folder_ids, each campaign counts once.", strProp("Campaign id")), + "folder_ids": arrProp("Only the campaigns currently in these campaign folders.", strProp("Folder id")), }), Risk: generation.RiskRead, RequiredOrgPerm: models.PermViewAnalytics, @@ -34,7 +36,9 @@ func (d Deps) registerAnalyticsTools(r *Registry) { func (d Deps) getDashboardAnalytics(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) { in, err := decodeArgs[struct { - Period string `json:"period"` + Period string `json:"period"` + CampaignIDs []string `json:"campaign_ids"` + FolderIDs []string `json:"folder_ids"` }](args) if err != nil { return "", err @@ -43,7 +47,22 @@ func (d Deps) getDashboardAnalytics(ctx context.Context, inv Invocation, args js if period == "" { period = "30d" } - a, xerr := d.Analytics.GetDashboardAnalytics(ctx, inv.OrgID, period) + var filter models.DashboardFilter + for _, raw := range in.CampaignIDs { + id, perr := parseUUIDArg(raw) + if perr != nil { + return "", perr + } + filter.CampaignIDs = append(filter.CampaignIDs, id) + } + for _, raw := range in.FolderIDs { + id, perr := parseUUIDArg(raw) + if perr != nil { + return "", perr + } + filter.FolderIDs = append(filter.FolderIDs, id) + } + a, xerr := d.Analytics.GetDashboardAnalytics(ctx, inv.OrgID, period, filter) if xerr != nil { return "", fromErrx(xerr) } @@ -72,6 +91,7 @@ func (d Deps) getDashboardAnalytics(ctx context.Context, inv Invocation, args js }, "top_campaigns": top, "account_health": a.AccountHealth, + "scope": a.Scope, }) } diff --git a/internal/app/analytics/service.go b/internal/app/analytics/service.go index 78ebd5d66..a0c072046 100644 --- a/internal/app/analytics/service.go +++ b/internal/app/analytics/service.go @@ -45,7 +45,9 @@ type AnalyticsService interface { GetUsageOverview(ctx context.Context, orgID, userID uuid.UUID, period string) (*models.UsageOverview, *errx.Error) // Dashboard analytics - GetDashboardAnalytics(ctx context.Context, orgID uuid.UUID, period string) (*models.DashboardAnalytics, *errx.Error) + // GetDashboardAnalytics narrows the campaign-derived sections to filter + // when it names anything; account health stays workspace-wide. + GetDashboardAnalytics(ctx context.Context, orgID uuid.UUID, period string, filter models.DashboardFilter) (*models.DashboardAnalytics, *errx.Error) GetDirectMailAnalytics(ctx context.Context, orgID uuid.UUID, period string) (*models.DirectMailAnalytics, *errx.Error) GetCampaignHourlyStats(ctx context.Context, orgID, campaignID uuid.UUID, date time.Time) ([]models.CampaignHourlyStats, *errx.Error) CompareCampaigns(ctx context.Context, orgID uuid.UUID, campaignIDs []uuid.UUID, from, to time.Time) (*models.CampaignComparison, *errx.Error) @@ -714,7 +716,7 @@ func (s *analyticsService) warmupPartnerLimit(ctx context.Context, email *models // Dashboard Analytics implementations -func (s *analyticsService) GetDashboardAnalytics(ctx context.Context, orgID uuid.UUID, period string) (*models.DashboardAnalytics, *errx.Error) { +func (s *analyticsService) GetDashboardAnalytics(ctx context.Context, orgID uuid.UUID, period string, filter models.DashboardFilter) (*models.DashboardAnalytics, *errx.Error) { // Calculate date range from period to := time.Now().UTC() startOfToday := time.Date(to.Year(), to.Month(), to.Day(), 0, 0, 0, 0, time.UTC) @@ -732,20 +734,31 @@ func (s *analyticsService) GetDashboardAnalytics(ctx context.Context, orgID uuid period = "7d" } + // The one place a filter becomes a campaign set, so every section reads the same one. + var view *models.DashboardScope + var scope *models.CampaignScope + if !filter.Empty() { + var xerr *errx.Error + view, scope, xerr = s.analyticsRepo.ResolveDashboardScope(ctx, orgID, filter) + if xerr != nil { + return nil, xerr + } + } + // Get overall stats - overallStats, xerr := s.analyticsRepo.GetDashboardOverallStats(ctx, orgID, from, to) + overallStats, xerr := s.analyticsRepo.GetDashboardOverallStats(ctx, orgID, from, to, scope) if xerr != nil { return nil, xerr } // Get recent activity - recentActivity, xerr := s.analyticsRepo.GetRecentActivity(ctx, orgID, 20) + recentActivity, xerr := s.analyticsRepo.GetRecentActivity(ctx, orgID, 20, scope) if xerr != nil { recentActivity = make([]models.RecentActivityItem, 0) } // Get top campaigns - topCampaigns, xerr := s.analyticsRepo.GetTopCampaigns(ctx, orgID, from, to, 5, "emails_sent") + topCampaigns, xerr := s.analyticsRepo.GetTopCampaigns(ctx, orgID, from, to, 5, "emails_sent", scope) if xerr != nil { topCampaigns = make([]models.TopCampaignStats, 0) } @@ -757,7 +770,7 @@ func (s *analyticsService) GetDashboardAnalytics(ctx context.Context, orgID uuid } // Get daily trend - dailyTrend, xerr := s.analyticsRepo.GetDashboardDailyTrend(ctx, orgID, from, to) + dailyTrend, xerr := s.analyticsRepo.GetDashboardDailyTrend(ctx, orgID, from, to, scope) if xerr != nil { dailyTrend = make([]models.DashboardDailyStats, 0) } @@ -769,6 +782,7 @@ func (s *analyticsService) GetDashboardAnalytics(ctx context.Context, orgID uuid TopCampaigns: topCampaigns, AccountHealth: *accountHealth, DailyTrend: dailyTrend, + Scope: view, }, nil } diff --git a/internal/app/analytics/service_test.go b/internal/app/analytics/service_test.go index 9c6e1bee1..fd836e56d 100644 --- a/internal/app/analytics/service_test.go +++ b/internal/app/analytics/service_test.go @@ -53,18 +53,38 @@ type dashboardAnalyticsRepoStub struct { overallTo time.Time trendFrom time.Time trendTo time.Time + // resolved is what ResolveDashboardScope answers; scopes records what + // each section was then asked for, keyed by section. + resolved *models.CampaignScope + resolves int + scopes map[string]*models.CampaignScope } -func (s *dashboardAnalyticsRepoStub) GetDashboardOverallStats(_ context.Context, _ uuid.UUID, from, to time.Time) (*models.DashboardOverallStats, *errx.Error) { +func (s *dashboardAnalyticsRepoStub) record(section string, scope *models.CampaignScope) { + if s.scopes == nil { + s.scopes = map[string]*models.CampaignScope{} + } + s.scopes[section] = scope +} + +func (s *dashboardAnalyticsRepoStub) ResolveDashboardScope(_ context.Context, _ uuid.UUID, _ models.DashboardFilter) (*models.DashboardScope, *models.CampaignScope, *errx.Error) { + s.resolves++ + return &models.DashboardScope{CampaignCount: len(s.resolved.CampaignIDs)}, s.resolved, nil +} + +func (s *dashboardAnalyticsRepoStub) GetDashboardOverallStats(_ context.Context, _ uuid.UUID, from, to time.Time, scope *models.CampaignScope) (*models.DashboardOverallStats, *errx.Error) { s.overallFrom, s.overallTo = from, to + s.record("overall", scope) return &models.DashboardOverallStats{}, nil } -func (*dashboardAnalyticsRepoStub) GetRecentActivity(context.Context, uuid.UUID, int) ([]models.RecentActivityItem, *errx.Error) { +func (s *dashboardAnalyticsRepoStub) GetRecentActivity(_ context.Context, _ uuid.UUID, _ int, scope *models.CampaignScope) ([]models.RecentActivityItem, *errx.Error) { + s.record("recent", scope) return []models.RecentActivityItem{}, nil } -func (*dashboardAnalyticsRepoStub) GetTopCampaigns(context.Context, uuid.UUID, time.Time, time.Time, int, string) ([]models.TopCampaignStats, *errx.Error) { +func (s *dashboardAnalyticsRepoStub) GetTopCampaigns(_ context.Context, _ uuid.UUID, _, _ time.Time, _ int, _ string, scope *models.CampaignScope) ([]models.TopCampaignStats, *errx.Error) { + s.record("top", scope) return []models.TopCampaignStats{}, nil } @@ -72,8 +92,9 @@ func (*dashboardAnalyticsRepoStub) GetAccountHealthSummary(context.Context, uuid return &models.AccountHealthSummary{}, nil } -func (s *dashboardAnalyticsRepoStub) GetDashboardDailyTrend(_ context.Context, _ uuid.UUID, from, to time.Time) ([]models.DashboardDailyStats, *errx.Error) { +func (s *dashboardAnalyticsRepoStub) GetDashboardDailyTrend(_ context.Context, _ uuid.UUID, from, to time.Time, scope *models.CampaignScope) ([]models.DashboardDailyStats, *errx.Error) { s.trendFrom, s.trendTo = from, to + s.record("trend", scope) return []models.DashboardDailyStats{}, nil } @@ -139,7 +160,7 @@ func TestDashboardUsesTheSameSevenCalendarDaysForCardsAndTrend(t *testing.T) { repo := &dashboardAnalyticsRepoStub{} svc := &analyticsService{analyticsRepo: repo} - got, xerr := svc.GetDashboardAnalytics(context.Background(), uuid.New(), "7d") + got, xerr := svc.GetDashboardAnalytics(context.Background(), uuid.New(), "7d", models.DashboardFilter{}) if xerr != nil { t.Fatalf("GetDashboardAnalytics: %v", xerr) } @@ -156,3 +177,55 @@ func TestDashboardUsesTheSameSevenCalendarDaysForCardsAndTrend(t *testing.T) { t.Fatalf("range starts %d whole days before today, want 6", days) } } + +func TestDashboardWithoutFilterStaysWorkspaceWide(t *testing.T) { + repo := &dashboardAnalyticsRepoStub{} + svc := &analyticsService{analyticsRepo: repo} + + got, xerr := svc.GetDashboardAnalytics(context.Background(), uuid.New(), "30d", models.DashboardFilter{}) + if xerr != nil { + t.Fatalf("GetDashboardAnalytics: %v", xerr) + } + if repo.resolves != 0 || got.Scope != nil { + t.Fatalf("an empty filter resolved %d times and echoed %+v; want the workspace with no scope", repo.resolves, got.Scope) + } + for section, scope := range repo.scopes { + if scope != nil { + t.Errorf("%s narrowed to %+v without a filter", section, scope) + } + } +} + +func TestDashboardFilterNarrowsEveryCampaignSectionToOneSet(t *testing.T) { + for _, tc := range []struct { + name string + ids []uuid.UUID + }{ + {"campaigns", []uuid.UUID{uuid.New(), uuid.New()}}, + // Foreign or deleted ids resolve to nothing, which must not read as no filter. + {"nothing in the workspace", []uuid.UUID{}}, + } { + t.Run(tc.name, func(t *testing.T) { + resolved := &models.CampaignScope{CampaignIDs: tc.ids} + repo := &dashboardAnalyticsRepoStub{resolved: resolved} + svc := &analyticsService{analyticsRepo: repo} + + filter := models.DashboardFilter{FolderIDs: []uuid.UUID{uuid.New()}} + got, xerr := svc.GetDashboardAnalytics(context.Background(), uuid.New(), "7d", filter) + if xerr != nil { + t.Fatalf("GetDashboardAnalytics: %v", xerr) + } + if repo.resolves != 1 { + t.Fatalf("resolved %d times, want once for every section", repo.resolves) + } + if got.Scope == nil || got.Scope.CampaignCount != len(tc.ids) { + t.Fatalf("scope = %+v, want the resolved %d campaigns echoed", got.Scope, len(tc.ids)) + } + for _, section := range []string{"overall", "recent", "top", "trend"} { + if repo.scopes[section] != resolved { + t.Errorf("%s read %+v, want the resolved scope", section, repo.scopes[section]) + } + } + }) + } +} diff --git a/internal/models/analytics.go b/internal/models/analytics.go index d60e71324..82ed5c15b 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -385,6 +385,39 @@ type DashboardAnalytics struct { // the scheduler's clamps; the sidebar meter's denominator. Absent when // it could not be computed. CapacityToday *WorkspaceSendCapacity `json:"capacity_today,omitempty"` + // Scope is the campaign filter the campaign-derived sections were + // computed for; absent when they cover the whole workspace. + Scope *DashboardScope `json:"scope,omitempty"` +} + +// DashboardFilter is the campaign filter a dashboard request asked for. +type DashboardFilter struct { + CampaignIDs []uuid.UUID + FolderIDs []uuid.UUID +} + +// Empty reports a filter that leaves the dashboard workspace-wide. +func (f DashboardFilter) Empty() bool { + return len(f.CampaignIDs) == 0 && len(f.FolderIDs) == 0 +} + +// CampaignScope narrows campaign analytics to a resolved set of campaigns. +// A nil scope is the whole workspace; an empty set matches nothing. +type CampaignScope struct { + CampaignIDs []uuid.UUID +} + +// DashboardScope echoes a resolved filter: the workspace's requested campaigns and folders, and the distinct campaigns they cover. +type DashboardScope struct { + Campaigns []DashboardScopeItem `json:"campaigns"` + Folders []DashboardScopeItem `json:"folders"` + CampaignCount int `json:"campaign_count"` +} + +// DashboardScopeItem names one campaign or folder in a DashboardScope. +type DashboardScopeItem struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` } // DashboardOverallStats contains aggregate statistics for the dashboard @@ -404,7 +437,8 @@ type DashboardOverallStats struct { ReplyRate float64 `json:"reply_rate"` BounceRate float64 `json:"bounce_rate"` ActiveCampaigns int `json:"active_campaigns"` - ActiveAccounts int `json:"active_accounts"` + // ActiveAccounts is workspace-wide; a campaign filter does not narrow it. + ActiveAccounts int `json:"active_accounts"` } // RecentActivityItem represents a single activity event diff --git a/internal/repository/dashboard_scope_live_test.go b/internal/repository/dashboard_scope_live_test.go new file mode 100644 index 000000000..9ac8e4d15 --- /dev/null +++ b/internal/repository/dashboard_scope_live_test.go @@ -0,0 +1,171 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/models" +) + +// The dashboard's campaign filter resolves folders and campaigns to one +// deduplicated set inside the selected workspace, and every campaign section +// reads only that set (issue #869). +func TestLiveDashboardScopeNarrowsCampaignSections(t *testing.T) { + handle, pool := liveContactDB(t) + ctx := context.Background() + userID, orgID, otherOrgID := uuid.New(), uuid.New(), uuid.New() + inFolderA, inFolderB, outside, foreign := uuid.New(), uuid.New(), uuid.New(), uuid.New() + folderID, foreignFolderID := uuid.New(), uuid.New() + + exec := func(query string, args ...any) { + t.Helper() + if _, err := pool.Exec(ctx, query, args...); err != nil { + t.Fatalf("fixture: %v", err) + } + } + exec(`INSERT INTO users (id, first_name, last_name, email) + VALUES ($1, 'Dashboard', 'Scope', $2)`, userID, "dashboard-scope-"+uuid.NewString()+"@example.test") + exec(`INSERT INTO organizations (id, name, owner_user_id) + VALUES ($1, 'Dashboard scope', $3), ($2, 'Dashboard scope other', $3)`, orgID, otherOrgID, userID) + exec(`INSERT INTO folders (id, user_id, organization_id, title, color, position) + VALUES ($1, $3, $4, 'Client A', '#0ea5e9', 0), ($2, $3, $5, 'Other client', '#0ea5e9', 0)`, + folderID, foreignFolderID, userID, orgID, otherOrgID) + + campaigns := []struct { + id uuid.UUID + org uuid.UUID + name string + status string + }{ + {inFolderA, orgID, "Client A - Dentists", "active"}, + {inFolderB, orgID, "Client A - Nurseries", "paused"}, + {outside, orgID, "Client B", "active"}, + {foreign, otherOrgID, "Foreign", "active"}, + } + for _, c := range campaigns { + contactID, stepID := uuid.New(), uuid.New() + exec(`INSERT INTO campaigns (id, user_id, organization_id, name, description, days, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, '', 62, $5, NOW(), NOW())`, c.id, userID, c.org, c.name, c.status) + exec(`INSERT INTO contacts + (id, user_id, organization_id, email, first_name, last_name, company, phone, custom_fields, subscribed, created_at, updated_at) + VALUES ($1, $2, $3, $4, 'Lead', 'Scope', '', '', '{}'::jsonb, true, NOW(), NOW())`, + contactID, userID, c.org, "dashboard-scope-lead-"+uuid.NewString()+"@example.test") + exec(`INSERT INTO campaign_leads (campaign_id, contact_id, position) VALUES ($1, $2, 0)`, c.id, contactID) + exec(`INSERT INTO sequences + (id, campaign_id, organization_id, name, subject, body_plain, body_html, wait_after, position, kind, created_at) + VALUES ($1, $2, $3, 'Step', '', '', '', 0, 1, 'email', NOW())`, stepID, c.id, c.org) + exec(`INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at, replied_at) + VALUES ($1, $2, $3, NOW() - interval '1 hour', NOW())`, c.id, contactID, stepID) + } + // The second campaign also sits in a folder of another workspace, which must not reach it. + exec(`INSERT INTO campaign_folders (campaign_id, folder_id) + VALUES ($1, $3), ($2, $3), ($2, $4)`, inFolderA, inFolderB, folderID, foreignFolderID) + + t.Cleanup(func() { + for _, step := range []struct { + query string + arg any + }{ + {`DELETE FROM campaigns WHERE organization_id = ANY($1)`, []uuid.UUID{orgID, otherOrgID}}, + {`DELETE FROM folders WHERE organization_id = ANY($1)`, []uuid.UUID{orgID, otherOrgID}}, + {`DELETE FROM contacts WHERE organization_id = ANY($1)`, []uuid.UUID{orgID, otherOrgID}}, + {`DELETE FROM organizations WHERE id = ANY($1)`, []uuid.UUID{orgID, otherOrgID}}, + {`DELETE FROM users WHERE id = $1`, userID}, + } { + if _, err := pool.Exec(context.Background(), step.query, step.arg); err != nil { + t.Errorf("cleanup: %v", err) + } + } + }) + + repo := &analyticsRepository{DB: handle} + from, to := time.Now().Add(-24*time.Hour), time.Now().Add(24*time.Hour) + + // The folder plus one of its own campaigns, a foreign campaign and a foreign folder. + view, scope, xerr := repo.ResolveDashboardScope(ctx, orgID, models.DashboardFilter{ + CampaignIDs: []uuid.UUID{inFolderB, foreign}, + FolderIDs: []uuid.UUID{folderID, foreignFolderID}, + }) + if xerr != nil { + t.Fatalf("ResolveDashboardScope: %v", xerr) + } + if view.CampaignCount != 2 || len(scope.CampaignIDs) != 2 { + t.Fatalf("resolved %d campaigns (%v), want the folder's two counted once", view.CampaignCount, scope.CampaignIDs) + } + if len(view.Campaigns) != 1 || view.Campaigns[0].ID != inFolderB || len(view.Folders) != 1 || view.Folders[0].Name != "Client A" { + t.Fatalf("echoed scope = %+v, want only this workspace's campaign and folder", view) + } + + overall, xerr := repo.GetDashboardOverallStats(ctx, orgID, from, to, scope) + if xerr != nil { + t.Fatalf("GetDashboardOverallStats: %v", xerr) + } + if overall.TotalEmailsSent != 2 || overall.TotalReplies != 2 || overall.ActiveCampaigns != 1 { + t.Fatalf("scoped totals = sent %d, replies %d, active campaigns %d; want 2/2/1", + overall.TotalEmailsSent, overall.TotalReplies, overall.ActiveCampaigns) + } + trend, xerr := repo.GetDashboardDailyTrend(ctx, orgID, from, to, scope) + if xerr != nil { + t.Fatalf("GetDashboardDailyTrend: %v", xerr) + } + sent := 0 + for _, d := range trend { + sent += d.Sent + } + if sent != 2 { + t.Fatalf("scoped trend sent = %d, want 2", sent) + } + top, xerr := repo.GetTopCampaigns(ctx, orgID, from, to, 10, "emails_sent", scope) + if xerr != nil { + t.Fatalf("GetTopCampaigns: %v", xerr) + } + recent, xerr := repo.GetRecentActivity(ctx, orgID, 20, scope) + if xerr != nil { + t.Fatalf("GetRecentActivity: %v", xerr) + } + inScope := map[uuid.UUID]bool{inFolderA: true, inFolderB: true} + if len(top) != 2 { + t.Fatalf("scoped top campaigns = %+v, want the two in scope", top) + } + for _, c := range top { + if !inScope[c.CampaignID] { + t.Errorf("top campaigns include %s outside the scope", c.Name) + } + } + if len(recent) == 0 { + t.Fatalf("scoped recent activity is empty, want the scoped replies") + } + for _, a := range recent { + if !inScope[a.CampaignID] { + t.Errorf("recent activity includes %s outside the scope", a.CampaignName) + } + } + + // Nothing of this workspace's: an empty set, never the whole workspace. + view, scope, xerr = repo.ResolveDashboardScope(ctx, orgID, models.DashboardFilter{ + CampaignIDs: []uuid.UUID{foreign}, + FolderIDs: []uuid.UUID{foreignFolderID}, + }) + if xerr != nil { + t.Fatalf("ResolveDashboardScope: %v", xerr) + } + if view.CampaignCount != 0 || len(view.Campaigns) != 0 || len(view.Folders) != 0 { + t.Fatalf("foreign ids resolved to %+v, want nothing", view) + } + overall, xerr = repo.GetDashboardOverallStats(ctx, orgID, from, to, scope) + if xerr != nil { + t.Fatalf("GetDashboardOverallStats: %v", xerr) + } + if overall.TotalEmailsSent != 0 || overall.ActiveCampaigns != 0 { + t.Fatalf("an empty scope counted sent %d, active %d; want nothing", overall.TotalEmailsSent, overall.ActiveCampaigns) + } + whole, xerr := repo.GetDashboardOverallStats(ctx, orgID, from, to, nil) + if xerr != nil { + t.Fatalf("GetDashboardOverallStats: %v", xerr) + } + if whole.TotalEmailsSent != 3 { + t.Fatalf("workspace sent = %d, want this workspace's three", whole.TotalEmailsSent) + } +} diff --git a/internal/repository/engagement_origin_live_test.go b/internal/repository/engagement_origin_live_test.go index 23b7dfae5..21f75b0ad 100644 --- a/internal/repository/engagement_origin_live_test.go +++ b/internal/repository/engagement_origin_live_test.go @@ -93,7 +93,7 @@ func TestLiveEngagementOriginReachesEverySurface(t *testing.T) { } analytics := NewAnalyticsRepository(handle) - recent, xerr := analytics.GetRecentActivity(ctx, f.org, 20) + recent, xerr := analytics.GetRecentActivity(ctx, f.org, 20, nil) if xerr != nil { t.Fatalf("recent activity: %v", xerr) } diff --git a/internal/repository/pg_analytics.go b/internal/repository/pg_analytics.go index 197aff664..be8698f1a 100644 --- a/internal/repository/pg_analytics.go +++ b/internal/repository/pg_analytics.go @@ -46,10 +46,12 @@ type AnalyticsRepository interface { GetContactCounts(ctx context.Context, orgID uuid.UUID) (*models.ContactsUsage, *errx.Error) // Dashboard analytics - GetDashboardOverallStats(ctx context.Context, orgID uuid.UUID, from, to time.Time) (*models.DashboardOverallStats, *errx.Error) - GetRecentActivity(ctx context.Context, orgID uuid.UUID, limit int) ([]models.RecentActivityItem, *errx.Error) - GetTopCampaigns(ctx context.Context, orgID uuid.UUID, from, to time.Time, limit int, sortBy string) ([]models.TopCampaignStats, *errx.Error) - GetDashboardDailyTrend(ctx context.Context, orgID uuid.UUID, from, to time.Time) ([]models.DashboardDailyStats, *errx.Error) + // ResolveDashboardScope resolves a filter to this workspace's campaigns, deduplicated; foreign ids drop out. + ResolveDashboardScope(ctx context.Context, orgID uuid.UUID, filter models.DashboardFilter) (*models.DashboardScope, *models.CampaignScope, *errx.Error) + GetDashboardOverallStats(ctx context.Context, orgID uuid.UUID, from, to time.Time, scope *models.CampaignScope) (*models.DashboardOverallStats, *errx.Error) + GetRecentActivity(ctx context.Context, orgID uuid.UUID, limit int, scope *models.CampaignScope) ([]models.RecentActivityItem, *errx.Error) + GetTopCampaigns(ctx context.Context, orgID uuid.UUID, from, to time.Time, limit int, sortBy string, scope *models.CampaignScope) ([]models.TopCampaignStats, *errx.Error) + GetDashboardDailyTrend(ctx context.Context, orgID uuid.UUID, from, to time.Time, scope *models.CampaignScope) ([]models.DashboardDailyStats, *errx.Error) GetAccountHealthSummary(ctx context.Context, orgID uuid.UUID) (*models.AccountHealthSummary, *errx.Error) // Campaign hourly stats @@ -172,6 +174,27 @@ func calendarDay(t time.Time) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) } +// inCampaignScope narrows column to the scope's campaigns as $arg; a nil +// scope adds nothing, so the caller appends scopeArgs only when it is set. +func inCampaignScope(column string, scope *models.CampaignScope, arg int) string { + if scope == nil { + return "" + } + return fmt.Sprintf(" AND %s = ANY($%d::uuid[])", column, arg) +} + +// scopeArgs is the parameter inCampaignScope refers to, or nothing. +func scopeArgs(scope *models.CampaignScope) []any { + if scope == nil { + return nil + } + ids := scope.CampaignIDs + if ids == nil { + ids = []uuid.UUID{} + } + return []any{ids} +} + func (r *analyticsRepository) GetCampaignSummary(ctx context.Context, orgID, campaignID uuid.UUID, period *models.DateRange) (*models.CampaignSummary, *errx.Error) { cohort, cohortArgs := sentWithin("ccp", period, 3) // Contacts and pending sends are the campaign's state now, so they read @@ -643,7 +666,68 @@ func (r *analyticsRepository) GetContactCounts(ctx context.Context, orgID uuid.U // Dashboard Analytics Methods -func (r *analyticsRepository) GetDashboardOverallStats(ctx context.Context, orgID uuid.UUID, from, to time.Time) (*models.DashboardOverallStats, *errx.Error) { +func (r *analyticsRepository) ResolveDashboardScope(ctx context.Context, orgID uuid.UUID, filter models.DashboardFilter) (*models.DashboardScope, *models.CampaignScope, *errx.Error) { + campaignIDs, folderIDs := filter.CampaignIDs, filter.FolderIDs + if campaignIDs == nil { + campaignIDs = []uuid.UUID{} + } + if folderIDs == nil { + folderIDs = []uuid.UUID{} + } + // A folder reaches only campaigns of its own workspace, and only through a folder of that workspace. + query := ` + SELECT 'campaign', c.id, c.name, 0 FROM campaigns c + WHERE c.organization_id = $1 AND c.id = ANY($2::uuid[]) + UNION ALL + SELECT 'folder', f.id, f.title, f.position FROM folders f + WHERE f.organization_id = $1 AND f.id = ANY($3::uuid[]) + UNION ALL + SELECT 'member', c.id, '', 0 FROM campaigns c + WHERE c.organization_id = $1 + AND (c.id = ANY($2::uuid[]) OR EXISTS ( + SELECT 1 FROM campaign_folders cf + JOIN folders f ON f.id = cf.folder_id AND f.organization_id = $1 + WHERE cf.campaign_id = c.id AND cf.folder_id = ANY($3::uuid[]) + )) + ORDER BY 1, 4, 3 + ` + params := []any{orgID, campaignIDs, folderIDs} + + rows, err := r.DB.Query(ctx, query, params...) + if err != nil { + db.CaptureError(err, query, params, "query") + return nil, nil, errx.InternalError() + } + defer rows.Close() + + scope := &models.DashboardScope{Campaigns: []models.DashboardScopeItem{}, Folders: []models.DashboardScopeItem{}} + members := &models.CampaignScope{CampaignIDs: []uuid.UUID{}} + for rows.Next() { + var kind string + var item models.DashboardScopeItem + var position int + if err := rows.Scan(&kind, &item.ID, &item.Name, &position); err != nil { + db.CaptureError(err, "", nil, "scan") + return nil, nil, errx.InternalError() + } + switch kind { + case "campaign": + scope.Campaigns = append(scope.Campaigns, item) + case "folder": + scope.Folders = append(scope.Folders, item) + default: + members.CampaignIDs = append(members.CampaignIDs, item.ID) + } + } + if err := rows.Err(); err != nil { + db.CaptureError(err, query, params, "rows") + return nil, nil, errx.InternalError() + } + scope.CampaignCount = len(members.CampaignIDs) + return scope, members, nil +} + +func (r *analyticsRepository) GetDashboardOverallStats(ctx context.Context, orgID uuid.UUID, from, to time.Time, scope *models.CampaignScope) (*models.DashboardOverallStats, *errx.Error) { query := ` SELECT COUNT(CASE WHEN ccp.sent_at IS NOT NULL AND ccp.sent_at >= $2 AND ccp.sent_at <= $3 THEN 1 END) as total_sent, @@ -659,15 +743,15 @@ func (r *analyticsRepository) GetDashboardOverallStats(ctx context.Context, orgI ) THEN 1 END) as machine_clicks, COUNT(CASE WHEN ccp.replied_at IS NOT NULL AND ccp.sent_at >= $2 AND ccp.sent_at <= $3 THEN 1 END) as total_replies, COUNT(CASE WHEN ccp.bounced_at IS NOT NULL AND ccp.sent_at >= $2 AND ccp.sent_at <= $3 THEN 1 END) as total_bounces, - (SELECT COUNT(*) FROM campaigns WHERE organization_id = $1 AND status = 'active') as active_campaigns, + (SELECT COUNT(*) FROM campaigns ac WHERE ac.organization_id = $1 AND ac.status = 'active'` + inCampaignScope("ac.id", scope, 4) + `) as active_campaigns, (SELECT COUNT(*) FROM email_accounts WHERE organization_id = $1 AND status = 'active') as active_accounts FROM campaign_contact_progress ccp JOIN campaigns c ON c.id = ccp.campaign_id JOIN sequences s ON s.id = ccp.sequence_id AND s.kind = 'email' - WHERE c.organization_id = $1 + WHERE c.organization_id = $1` + inCampaignScope("c.id", scope, 4) + ` ` - params := []any{orgID, from, to} + params := append([]any{orgID, from, to}, scopeArgs(scope)...) var stats models.DashboardOverallStats err := r.DB.QueryRow(ctx, query, params...).Scan( @@ -697,7 +781,8 @@ func (r *analyticsRepository) GetDashboardOverallStats(ctx context.Context, orgI return &stats, nil } -func (r *analyticsRepository) GetRecentActivity(ctx context.Context, orgID uuid.UUID, limit int) ([]models.RecentActivityItem, *errx.Error) { +func (r *analyticsRepository) GetRecentActivity(ctx context.Context, orgID uuid.UUID, limit int, scope *models.CampaignScope) ([]models.RecentActivityItem, *errx.Error) { + inScope := inCampaignScope("ccp.campaign_id", scope, 3) // Union query to get recent opens, clicks, replies, and bounces. The // origin of an open or click is looked up only for the rows that make // the page, from the person's first logged open or click on the step, @@ -711,7 +796,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, orgID uuid. FROM campaign_contact_progress ccp JOIN campaigns c ON c.id = ccp.campaign_id JOIN contacts co ON co.id = ccp.contact_id - WHERE c.organization_id = $1 AND ccp.opened_at IS NOT NULL AND NOT ccp.opened_machine + WHERE c.organization_id = $1 AND ccp.opened_at IS NOT NULL AND NOT ccp.opened_machine` + inScope + ` UNION ALL @@ -725,7 +810,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, orgID uuid. FROM campaign_contact_progress ccp JOIN campaigns c ON c.id = ccp.campaign_id JOIN contacts co ON co.id = ccp.contact_id - WHERE c.organization_id = $1 AND ccp.clicked_at IS NOT NULL + WHERE c.organization_id = $1 AND ccp.clicked_at IS NOT NULL` + inScope + ` UNION ALL @@ -735,7 +820,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, orgID uuid. FROM campaign_contact_progress ccp JOIN campaigns c ON c.id = ccp.campaign_id JOIN contacts co ON co.id = ccp.contact_id - WHERE c.organization_id = $1 AND ccp.replied_at IS NOT NULL + WHERE c.organization_id = $1 AND ccp.replied_at IS NOT NULL` + inScope + ` UNION ALL @@ -745,7 +830,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, orgID uuid. FROM campaign_contact_progress ccp JOIN campaigns c ON c.id = ccp.campaign_id JOIN contacts co ON co.id = ccp.contact_id - WHERE c.organization_id = $1 AND ccp.bounced_at IS NOT NULL + WHERE c.organization_id = $1 AND ccp.bounced_at IS NOT NULL` + inScope + ` ), page AS ( SELECT * FROM recent_events ORDER BY timestamp DESC @@ -780,7 +865,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, orgID uuid. ORDER BY p.timestamp DESC ` - params := []any{orgID, limit} + params := append([]any{orgID, limit}, scopeArgs(scope)...) rows, err := r.DB.Query(ctx, query, params...) if err != nil { @@ -812,7 +897,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, orgID uuid. return activities, nil } -func (r *analyticsRepository) GetTopCampaigns(ctx context.Context, orgID uuid.UUID, from, to time.Time, limit int, sortBy string) ([]models.TopCampaignStats, *errx.Error) { +func (r *analyticsRepository) GetTopCampaigns(ctx context.Context, orgID uuid.UUID, from, to time.Time, limit int, sortBy string, scope *models.CampaignScope) ([]models.TopCampaignStats, *errx.Error) { // Default sort by emails_sent orderClause := "emails_sent DESC" switch sortBy { @@ -843,14 +928,14 @@ func (r *analyticsRepository) GetTopCampaigns(ctx context.Context, orgID uuid.UU LEFT JOIN campaign_contact_progress ccp ON ccp.campaign_id = c.id AND ccp.sent_at >= $2 AND ccp.sent_at <= $3 AND EXISTS (SELECT 1 FROM sequences s WHERE s.id = ccp.sequence_id AND s.kind = 'email') - WHERE c.organization_id = $1 + WHERE c.organization_id = $1` + inCampaignScope("c.id", scope, 5) + ` GROUP BY c.id, c.name, c.status HAVING COUNT(CASE WHEN ccp.sent_at IS NOT NULL THEN 1 END) > 0 ORDER BY ` + orderClause + ` LIMIT $4 ` - params := []any{orgID, from, to, limit} + params := append([]any{orgID, from, to, limit}, scopeArgs(scope)...) rows, err := r.DB.Query(ctx, query, params...) if err != nil { @@ -872,7 +957,7 @@ func (r *analyticsRepository) GetTopCampaigns(ctx context.Context, orgID uuid.UU return campaigns, nil } -func (r *analyticsRepository) GetDashboardDailyTrend(ctx context.Context, orgID uuid.UUID, from, to time.Time) ([]models.DashboardDailyStats, *errx.Error) { +func (r *analyticsRepository) GetDashboardDailyTrend(ctx context.Context, orgID uuid.UUID, from, to time.Time, scope *models.CampaignScope) ([]models.DashboardDailyStats, *errx.Error) { query := ` SELECT sent_at::date::text as date, @@ -886,12 +971,12 @@ func (r *analyticsRepository) GetDashboardDailyTrend(ctx context.Context, orgID WHERE c.organization_id = $1 AND ccp.sent_at IS NOT NULL AND ccp.sent_at::date >= $2 - AND ccp.sent_at::date <= $3 + AND ccp.sent_at::date <= $3` + inCampaignScope("c.id", scope, 4) + ` GROUP BY sent_at::date ORDER BY sent_at::date ASC ` - params := []any{orgID, from, to} + params := append([]any{orgID, from, to}, scopeArgs(scope)...) rows, err := r.DB.Query(ctx, query, params...) if err != nil { diff --git a/internal/repository/reply_inbox_live_test.go b/internal/repository/reply_inbox_live_test.go index 02f3b3617..b35a4b8c7 100644 --- a/internal/repository/reply_inbox_live_test.go +++ b/internal/repository/reply_inbox_live_test.go @@ -125,7 +125,7 @@ func TestLiveCopiedReplyAtAnUnnamedMailboxFindsNothing(t *testing.T) { func TestLiveRecentActivityNamesTheSendingMailbox(t *testing.T) { handle, _ := liveContactDB(t) f := newReplyInboxFixture(t, func(inbox string) string { return inbox }) - items, xerr := NewAnalyticsRepository(handle).GetRecentActivity(context.Background(), f.org, 10) + items, xerr := NewAnalyticsRepository(handle).GetRecentActivity(context.Background(), f.org, 10, nil) if xerr != nil { t.Fatalf("GetRecentActivity: %v", xerr) } diff --git a/internal/repository/usage_overview_scope_live_test.go b/internal/repository/usage_overview_scope_live_test.go index f32fc4282..b406d6eaa 100644 --- a/internal/repository/usage_overview_scope_live_test.go +++ b/internal/repository/usage_overview_scope_live_test.go @@ -93,21 +93,21 @@ func TestLiveUsageOverviewCountsTheSelectedOrganization(t *testing.T) { } from, to := time.Now().Add(-24*time.Hour), time.Now().Add(24*time.Hour) - overall, xerr := repo.GetDashboardOverallStats(ctx, orgID, from, to) + overall, xerr := repo.GetDashboardOverallStats(ctx, orgID, from, to, nil) if xerr != nil { t.Fatalf("GetDashboardOverallStats: %v", xerr) } if overall.TotalEmailsSent != 1 { t.Fatalf("dashboard total sent = %d, want the one email step", overall.TotalEmailsSent) } - daily, xerr := repo.GetDashboardDailyTrend(ctx, orgID, from, to) + daily, xerr := repo.GetDashboardDailyTrend(ctx, orgID, from, to, nil) if xerr != nil { t.Fatalf("GetDashboardDailyTrend: %v", xerr) } if len(daily) != 1 || daily[0].Sent != 1 { t.Fatalf("dashboard daily trend = %+v, want one email sent", daily) } - top, xerr := repo.GetTopCampaigns(ctx, orgID, from, to, 10, "emails_sent") + top, xerr := repo.GetTopCampaigns(ctx, orgID, from, to, 10, "emails_sent", nil) if xerr != nil { t.Fatalf("GetTopCampaigns: %v", xerr) } diff --git a/ios/Warmbly/Features/Analytics/AnalyticsCampaignFilterSheet.swift b/ios/Warmbly/Features/Analytics/AnalyticsCampaignFilterSheet.swift new file mode 100644 index 000000000..2599c2f46 --- /dev/null +++ b/ios/Warmbly/Features/Analytics/AnalyticsCampaignFilterSheet.swift @@ -0,0 +1,202 @@ +import SwiftUI + +/// Narrows the analytics overview to campaign folders and campaigns, the +/// same filter as the web Analytics page: the server unions every pick and +/// counts each campaign once, and an empty pick is the whole workspace. +/// Folders come from the session; campaigns page in from `GET campaigns`. +struct AnalyticsCampaignFilterSheet: View { + @Environment(AppEnvironment.self) private var env + @Environment(\.dismiss) private var dismiss + + var initial: AnalyticsCampaignFilter + var onApply: (AnalyticsCampaignFilter) -> Void + + @State private var query = "" + @State private var picked = AnalyticsCampaignFilter() + @State private var seeded = false + @State private var campaigns: [Campaign] = [] + @State private var nextCursor: String? + @State private var hasMore = false + @State private var isLoading = false + @State private var errorMessage: String? + @State private var togglePulse = 0 + + private var trimmedQuery: String { query.trimmingCharacters(in: .whitespaces) } + + /// The session's campaign folders, in position order, filtered by the search. + private var folders: [UserGroup] { + let all = (env.session.user?.folders ?? []).sorted { ($0.position ?? 0) < ($1.position ?? 0) } + let q = trimmedQuery.lowercased() + guard !q.isEmpty else { return all } + return all.filter { ($0.name ?? "").lowercased().contains(q) } + } + + var body: some View { + NavigationStack { + list + .navigationTitle("Campaigns") + .navigationBarTitleDisplayMode(.inline) + .searchable( + text: $query, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search campaigns and folders" + ) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Apply") { + onApply(picked) + dismiss() + } + .fontWeight(.semibold) + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .sensoryFeedback(.selection, trigger: togglePulse) + .task { + guard !seeded else { return } + seeded = true + picked = initial + } + .task(id: trimmedQuery) { + // Hold the search until typing pauses. + if !trimmedQuery.isEmpty { try? await Task.sleep(for: .milliseconds(300)) } + guard !Task.isCancelled else { return } + await load(reset: true) + } + } + + private var list: some View { + List { + Button { + togglePulse += 1 + withAnimation(.snappy) { picked = AnalyticsCampaignFilter() } + } label: { + HStack { + Text("All campaigns") + .font(.body.weight(.medium)) + .foregroundStyle(.primary) + Spacer() + if picked.isEmpty { + Image(systemName: "checkmark") + .font(.body.weight(.semibold)) + .foregroundStyle(WTheme.accent) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if !folders.isEmpty { + Section("Folders") { + ForEach(folders) { folder in + row( + title: folder.name ?? "Folder", + dot: Color(uniboxHex: folder.color) ?? WTheme.accent, + isPicked: picked.folders.contains(folder.id) + ) { + toggle(\.folders, folder.id) + } + } + } + } + + Section("Campaigns") { + ForEach(campaigns) { campaign in + row( + title: campaign.name, + dot: statusColor(campaign.status), + isPicked: picked.campaigns.contains(campaign.id) + ) { + toggle(\.campaigns, campaign.id) + } + .onAppear { + if campaign.id == campaigns.last?.id { Task { await load(reset: false) } } + } + } + if isLoading { + ProgressView().frame(maxWidth: .infinity) + } else if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(WTheme.negative) + } else if campaigns.isEmpty { + Text("No campaigns found.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + .listStyle(.plain) + .scrollDismissesKeyboard(.immediately) + } + + private func row(title: String, dot: Color, isPicked: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 12) { + Circle() + .fill(dot) + .frame(width: 9, height: 9) + Text(title) + .font(.body.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer(minLength: 8) + Image(systemName: isPicked ? "checkmark.circle.fill" : "circle") + .font(.system(size: 21)) + .foregroundStyle(isPicked ? WTheme.accent : Color(.systemGray4)) + } + .padding(.vertical, 3) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func toggle(_ key: WritableKeyPath>, _ id: String) { + togglePulse += 1 + withAnimation(.snappy) { + if picked[keyPath: key].contains(id) { + picked[keyPath: key].remove(id) + } else { + picked[keyPath: key].insert(id) + } + } + } + + private func statusColor(_ status: String?) -> Color { + switch status { + case "active": WTheme.positive + case "paused": Tone.amber.color + default: Color(.systemGray3) + } + } + + private func load(reset: Bool) async { + if !reset, !hasMore || isLoading { return } + let search = trimmedQuery + isLoading = true + defer { isLoading = false } + var params: [String: String?] = ["limit": "50"] + if !search.isEmpty { params["q"] = search } + if !reset, let nextCursor { params["cursor"] = nextCursor } + do { + let page: CampaignListPage = try await env.api.get("campaigns", query: params) + guard search == trimmedQuery else { return } + let fresh = page.data ?? [] + if reset { + campaigns = fresh + } else { + campaigns.append(contentsOf: fresh.filter { new in !campaigns.contains { $0.id == new.id } }) + } + nextCursor = page.pagination?.nextCursor + hasMore = page.pagination?.hasMore ?? false + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/ios/Warmbly/Features/Analytics/AnalyticsModels.swift b/ios/Warmbly/Features/Analytics/AnalyticsModels.swift index d9e1afd1e..7cdf69fe2 100644 --- a/ios/Warmbly/Features/Analytics/AnalyticsModels.swift +++ b/ios/Warmbly/Features/Analytics/AnalyticsModels.swift @@ -69,9 +69,12 @@ struct DashboardAnalytics: Codable, Sendable { var topCampaigns: [AnalyticsTopCampaign]? var accountHealth: AnalyticsAccountHealthCounts? var dailyTrend: [AnalyticsTrendPoint]? + /// The campaign filter the campaign sections were computed for; nil + /// when they cover the whole workspace. + var scope: AnalyticsDashboardScope? enum CodingKeys: String, CodingKey { - case period + case period, scope case overallStats = "overall_stats" case recentActivity = "recent_activity" case topCampaigns = "top_campaigns" @@ -80,6 +83,33 @@ struct DashboardAnalytics: Codable, Sendable { } } +/// Campaigns and folders the overview is narrowed to; the server unions +/// them and counts each campaign once. Empty means every campaign. +struct AnalyticsCampaignFilter: Equatable, Sendable { + var campaigns: Set = [] + var folders: Set = [] + + var isEmpty: Bool { campaigns.isEmpty && folders.isEmpty } +} + +/// The requested campaigns and folders that belong to the workspace, and +/// how many distinct campaigns they cover together. +struct AnalyticsDashboardScope: Codable, Sendable { + var campaigns: [AnalyticsScopeItem]? + var folders: [AnalyticsScopeItem]? + var campaignCount: Int? + + enum CodingKeys: String, CodingKey { + case campaigns, folders + case campaignCount = "campaign_count" + } +} + +struct AnalyticsScopeItem: Codable, Sendable { + var id: String + var name: String? +} + struct AnalyticsOverallStats: Codable, Sendable { var totalEmailsSent: Int? var totalOpens: Int? diff --git a/ios/Warmbly/Features/Analytics/AnalyticsRootView.swift b/ios/Warmbly/Features/Analytics/AnalyticsRootView.swift index 72928d515..bda529491 100644 --- a/ios/Warmbly/Features/Analytics/AnalyticsRootView.swift +++ b/ios/Warmbly/Features/Analytics/AnalyticsRootView.swift @@ -95,6 +95,7 @@ struct AnalyticsRootView: View { @State private var metric: AnalyticsTrendMetric = .sent @State private var sidebarOpen = false @State private var sidebarDrag: CGFloat = 0 + @State private var filterOpen = false private static let sidebarWidth: CGFloat = 300 @@ -162,6 +163,14 @@ struct AnalyticsRootView: View { .onChange(of: store.period) { Task { await store.load(env.api) } } + .onChange(of: store.campaignFilter) { + Task { await store.reloadDashboard(env.api) } + } + .sheet(isPresented: $filterOpen) { + AnalyticsCampaignFilterSheet(initial: store.campaignFilter) { picked in + store.campaignFilter = picked + } + } .sensoryFeedback(.selection, trigger: scope) .sensoryFeedback(.selection, trigger: store.period) .sensoryFeedback(.selection, trigger: metric) @@ -196,6 +205,7 @@ struct AnalyticsRootView: View { VStack(alignment: .leading, spacing: 14) { topRow periodPicker + campaignFilterChip heroStats } .padding(.horizontal, 16) @@ -268,6 +278,57 @@ struct AnalyticsRootView: View { .padding(.horizontal, 4) } + private var scopeLabel: String { + store.scopeLabel { id in env.session.user?.folders?.first { $0.id == id }?.name } + } + + /// Opens the campaign filter; the overview's campaign numbers follow it. + private var campaignFilterChip: some View { + let scoped = !store.campaignFilter.isEmpty + return HStack(spacing: 7) { + Button { + filterOpen = true + } label: { + HStack(spacing: 6) { + Image(systemName: "megaphone.fill") + .font(.system(size: 11, weight: .semibold)) + Text(scopeLabel) + .font(.footnote.weight(.semibold)) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .bold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.white.opacity(scoped ? 0.26 : 0.12), in: Capsule()) + } + .buttonStyle(TapScaleStyle()) + .accessibilityLabel("Campaign filter: \(scopeLabel)") + // Deliverability, warmup and accounts are not campaign data, so the filter skips them. + if scoped, scope != .overview { + Text("Headline numbers only") + .font(.caption.weight(.medium)) + .foregroundStyle(.white.opacity(0.75)) + .lineLimit(1) + } + if scoped { + Button { + withAnimation(.snappy) { store.campaignFilter = AnalyticsCampaignFilter() } + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 26, height: 26) + .background(.white.opacity(0.12), in: Circle()) + } + .buttonStyle(TapScaleStyle()) + .accessibilityLabel("Show all campaigns") + } + } + .padding(.horizontal, 4) + } + private var heroStats: some View { let stats = store.dashboard?.overallStats return HStack(spacing: 10) { @@ -478,7 +539,9 @@ struct AnalyticsRootView: View { private var activeRow: some View { let stats = store.dashboard?.overallStats - return Text("\(stats?.activeCampaigns ?? 0) active campaigns · \(stats?.activeAccounts ?? 0) sending accounts") + // Sending accounts are never narrowed by the campaign filter; say so when it is on. + let accounts = store.campaignFilter.isEmpty ? "sending accounts" : "sending accounts in workspace" + return Text("\(stats?.activeCampaigns ?? 0) active campaigns · \(stats?.activeAccounts ?? 0) \(accounts)") .font(.footnote.weight(.medium)) .monospacedDigit() .foregroundStyle(.secondary) diff --git a/ios/Warmbly/Features/Analytics/AnalyticsStore.swift b/ios/Warmbly/Features/Analytics/AnalyticsStore.swift index 947cde073..219bdcff5 100644 --- a/ios/Warmbly/Features/Analytics/AnalyticsStore.swift +++ b/ios/Warmbly/Features/Analytics/AnalyticsStore.swift @@ -6,6 +6,8 @@ import Foundation @Observable final class AnalyticsStore { var period: AnalyticsPeriod = .week + /// Narrows the overview's campaign sections; mailbox health stays workspace-wide. + var campaignFilter = AnalyticsCampaignFilter() private(set) var dashboard: DashboardAnalytics? private(set) var deliverability: DeliverabilitySummary? @@ -40,15 +42,57 @@ final class AnalyticsStore { isLoading = false } + /// Reloads only the overview, for a filter change. + func reloadDashboard(_ api: APIClient) async { + await loadDashboard(api) + } + + /// "All campaigns", a folder or campaign name, or a count. + func scopeLabel(folderName: (String) -> String?) -> String { + let filter = campaignFilter + if filter.isEmpty { return "All campaigns" } + let scope = dashboard?.scope + let count = scope?.campaignCount + let total = count.map { " · \($0) campaign\($0 == 1 ? "" : "s")" } ?? "" + if filter.campaigns.isEmpty, filter.folders.count == 1, let id = filter.folders.first { + let name = scope?.folders?.first { $0.id == id }?.name ?? folderName(id) + return (name ?? "1 folder") + total + } + if filter.folders.isEmpty, filter.campaigns.count == 1, let id = filter.campaigns.first { + return scope?.campaigns?.first { $0.id == id }?.name ?? "1 campaign" + } + if filter.folders.isEmpty { + let n = count ?? filter.campaigns.count + return "\(n) campaign\(n == 1 ? "" : "s")" + } + var parts = ["\(filter.folders.count) folder\(filter.folders.count == 1 ? "" : "s")"] + if !filter.campaigns.isEmpty { + parts.append("\(filter.campaigns.count) campaign\(filter.campaigns.count == 1 ? "" : "s")") + } + return parts.joined(separator: " + ") + total + } + private func loadDashboard(_ api: APIClient) async { + let filter = campaignFilter + var query: [String: String?] = ["period": period.rawValue] + if !filter.campaigns.isEmpty { query["campaign_ids"] = filter.campaigns.sorted().joined(separator: ",") } + if !filter.folders.isEmpty { query["folder_ids"] = filter.folders.sorted().joined(separator: ",") } do { - let result: DashboardAnalytics = try await api.get( - "analytics/dashboard", - query: ["period": period.rawValue] - ) + let result: DashboardAnalytics = try await api.get("analytics/dashboard", query: query) + // A newer filter is already on its way; this answer describes the old one. + guard filter == campaignFilter else { return } dashboard = result dashboardError = nil + // Drop selections the server no longer recognises (deleted, or another workspace's). + if let scope = result.scope { + let campaigns = Set((scope.campaigns ?? []).map(\.id)).intersection(filter.campaigns) + let folders = Set((scope.folders ?? []).map(\.id)).intersection(filter.folders) + if campaigns != filter.campaigns || folders != filter.folders { + campaignFilter = AnalyticsCampaignFilter(campaigns: campaigns, folders: folders) + } + } } catch { + guard filter == campaignFilter else { return } dashboardError = error.localizedDescription } } diff --git a/web/src/app/app/analytics/browseState.test.tsx b/web/src/app/app/analytics/browseState.test.tsx index f014da410..9598415c5 100644 --- a/web/src/app/app/analytics/browseState.test.tsx +++ b/web/src/app/app/analytics/browseState.test.tsx @@ -16,6 +16,7 @@ function Owner({ children }: { children: ReactNode }) { const cases: { name: string; schema: z.ZodType; initial: unknown; value: unknown }[] = [ { name: "analytics.range", schema: schemas.analyticsRange, initial: "7d", value: "90d" }, { name: "analytics.hiddenMetrics", schema: schemas.analyticsHiddenMetrics, initial: [], value: ["sent", "replies"] }, + { name: "analytics.campaigns", schema: schemas.analyticsCampaignFilter, initial: { campaigns: [], folders: [] }, value: { campaigns: ["c1", "c2"], folders: ["f1"] } }, { name: "deliverability.view", schema: schemas.deliverabilityView, initial: { range: "7d", hidden: [] }, value: { range: "30d", hidden: ["providers", "mailboxes"] } }, { name: "deliverability.hiddenMetrics", schema: schemas.deliverabilityHiddenMetrics, initial: ["sent"], value: ["opens", "replies"] }, { name: "emails.search", schema: schemas.browseString, initial: "", value: "alice" }, @@ -83,6 +84,8 @@ describe("accounts and analytics browse schemas", () => { expect(schemas.mailboxTab.safeParse("old").success).toBe(false); expect(schemas.domainTab.safeParse("old").success).toBe(false); expect(schemas.batchSenderStatus.safeParse("needs_signin").success).toBe(false); + expect(schemas.analyticsCampaignFilter.safeParse({ campaigns: [], folders: [], tags: [] }).success).toBe(false); + expect(schemas.analyticsCampaignFilter.safeParse({ campaigns: "c1", folders: [] }).success).toBe(false); expect(schemas.analyticsHiddenMetrics.safeParse(["sent", "sent"]).success).toBe(false); expect(schemas.analyticsHiddenMetrics.safeParse(["sent", "opens", "clicks", "replies"]).success).toBe(false); expect(schemas.deliverabilityHiddenMetrics.safeParse(["sent", "opens", "replies", "bounces", "complaints"]).success).toBe(false); diff --git a/web/src/app/app/analytics/page.tsx b/web/src/app/app/analytics/page.tsx index d74268f2b..9f3291ef3 100644 --- a/web/src/app/app/analytics/page.tsx +++ b/web/src/app/app/analytics/page.tsx @@ -1,8 +1,9 @@ import { NoAccess } from "@/components/layout/NoAccess"; import { usePermission } from "@/hooks/usePermission"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import useBrowseState from "@/hooks/useBrowseState"; -import { analyticsRange, analyticsHiddenMetrics } from "@/lib/browse-accounts-analytics"; +import { useUserProfile } from "@/hooks/context/user"; +import { analyticsRange, analyticsHiddenMetrics, analyticsCampaignFilter } from "@/lib/browse-accounts-analytics"; import { Link } from "@tanstack/react-router"; import { ActivityIcon, @@ -29,13 +30,16 @@ import { MultiTrend, type TrendSeries } from "@/components/ui/charts"; import { TONE_DOT } from "@/components/ui/tones"; import type { DitherTone } from "@/components/ui/dither"; import AnalyticsShareButton from "@/components/app/analytics/AnalyticsShareButton"; +import CampaignScopePicker from "@/components/app/analytics/CampaignScopePicker"; +import { ALL_CAMPAIGNS, isScoped, scopeLabel } from "@/components/app/analytics/campaignScope"; import useDashboard from "@/lib/api/hooks/app/analytics/useDashboard"; import DirectMailSection from "@/components/app/analytics/DirectMailSection"; import OriginBadge from "@/components/app/engagement/OriginBadge"; -import type { RecentActivityItem } from "@/lib/api/models/app/analytics/DashboardOverview"; +import type { DashboardCampaignFilter, RecentActivityItem } from "@/lib/api/models/app/analytics/DashboardOverview"; const AUTO_OPENS_TIP = "Auto-opens: pixel fetches from privacy proxies (e.g. Apple Mail) or within seconds of sending, not a person reading. Logged as delivery proof, not counted as opens"; const AUTO_CLICKS_TIP = "Auto-clicks: links followed by a security gateway scanning the email, not a person; not counted as clicks"; +const UNFILTERED_TIP = "Not campaign data, so the campaign filter does not apply"; type Range = "7d" | "30d" | "90d"; type Metric = "sent" | "opens" | "clicks" | "replies"; @@ -65,9 +69,24 @@ export default function AnalyticsPage() { const [range, setRange] = useBrowseState("analytics.range", "7d", analyticsRange); // Legend toggles: every metric charts together; hidden ones drop out. const [hiddenMetrics, setHiddenMetrics] = useBrowseState("analytics.hiddenMetrics", [], analyticsHiddenMetrics); - const dash = useDashboard(range); + const [filter, setFilter] = useBrowseState("analytics.campaigns", ALL_CAMPAIGNS, analyticsCampaignFilter); + const dash = useDashboard(range, filter); const d = dash.data; const os = d?.overall_stats; + const scoped = isScoped(filter); + const { user } = useUserProfile(); + const label = scopeLabel(filter, d?.scope, (id) => user.folders?.find((f) => f.id === id)?.title, () => undefined); + + // Drop selections the server no longer recognises (deleted, or moved out of the workspace). + const echoed = dash.isPlaceholderData ? undefined : d?.scope; + useEffect(() => { + if (!echoed) return; + const campaigns = filter.campaigns.filter((id) => echoed.campaigns.some((c) => c.id === id)); + const folders = filter.folders.filter((id) => echoed.folders.some((f) => f.id === id)); + if (campaigns.length !== filter.campaigns.length || folders.length !== filter.folders.length) { + setFilter({ campaigns, folders }); + } + }, [echoed, filter, setFilter]); const toggleMetric = (k: Metric) => setHiddenMetrics((cur) => { @@ -97,7 +116,7 @@ export default function AnalyticsPage() { ]; const shareData = { - title: "Workspace performance", + title: scoped ? label : "Workspace performance", subtitle: RANGE_LABEL[range], metrics: [ { label: "Sent", value: num(os?.total_emails_sent), sub: "emails" }, @@ -112,7 +131,11 @@ export default function AnalyticsPage() { return ( - + + {dash.isPlaceholderData && dash.isFetching && ( + + )} + @@ -161,7 +184,7 @@ export default function AnalyticsPage() { labels={trend.labels} series={trend.series} height={280} - emptyLabel="No sends in this window yet" + emptyLabel={scoped ? "No sends from these campaigns in this window" : "No sends in this window yet"} /> )} @@ -190,7 +213,9 @@ export default function AnalyticsPage() { {d?.account_health && ( <> - + + {scoped && } +
@@ -221,7 +246,11 @@ export default function AnalyticsPage() { ))}
) : (d?.top_campaigns?.length ?? 0) === 0 ? ( - + scoped ? ( + + ) : ( + + ) ) : (
{d!.top_campaigns.map((c) => { @@ -247,7 +276,7 @@ export default function AnalyticsPage() {
)} - + @@ -261,7 +290,12 @@ export default function AnalyticsPage() { ))} ) : (d?.recent_activity?.length ?? 0) === 0 ? ( - + ) : (
{d!.recent_activity.map((a, i) => ( @@ -276,6 +310,15 @@ export default function AnalyticsPage() { ); } +// Marks a section the campaign filter does not narrow, so it never reads as filtered. +function WorkspaceWide() { + return ( + + Workspace-wide + + ); +} + function HealthCell({ n, label, tone }: { n: number; label: string; tone: string }) { return (
diff --git a/web/src/components/app/analytics/CampaignScopePicker.tsx b/web/src/components/app/analytics/CampaignScopePicker.tsx new file mode 100644 index 000000000..d2c367acc --- /dev/null +++ b/web/src/components/app/analytics/CampaignScopePicker.tsx @@ -0,0 +1,129 @@ +import React from "react"; +import { MegaphoneIcon } from "lucide-react"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuLabel, + PopoverMenuSeparator, + PopoverMenuTrigger, + SelectButton, +} from "@/components/ui/popover-menu"; +import { CheckSquare } from "@/components/ui/check-square"; +import { useUserProfile } from "@/hooks/context/user"; +import useDebouncedValue from "@/hooks/useDebouncedValue"; +import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns"; +import type { DashboardCampaignFilter, DashboardScope } from "@/lib/api/models/app/analytics/DashboardOverview"; +import { ALL_CAMPAIGNS, isScoped, scopeLabel } from "./campaignScope"; + +// The Analytics page's campaign filter: every campaign, or any mix of +// folders and campaigns, which the server unions and counts once each. +export default function CampaignScopePicker({ + value, + onChange, + scope, +}: { + value: DashboardCampaignFilter; + onChange: (next: DashboardCampaignFilter) => void; + scope?: DashboardScope; +}) { + const { user } = useUserProfile(); + const folders = user.folders ?? []; + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const search = useDebouncedValue(query.trim()); + // Only listed while the menu is open; the trigger reads names from the server's echo. + const list = useCampaigns({ query: search, folder: "", enabled: open }); + + const needle = query.trim().toLowerCase(); + const shownFolders = needle ? folders.filter((f) => f.title.toLowerCase().includes(needle)) : folders; + const label = scopeLabel( + value, + scope, + (id) => folders.find((f) => f.id === id)?.title, + (id) => list.campaigns.find((c) => c.id === id)?.name, + ); + + const toggle = (key: keyof DashboardCampaignFilter, id: string) => { + const current = value[key]; + onChange({ ...value, [key]: current.includes(id) ? current.filter((x) => x !== id) : [...current, id] }); + }; + + return ( + { + setOpen(o); + if (!o) setQuery(""); + }} + > + + } + label={label} + title={label} + aria-label={`Campaign filter: ${label}`} + className={isScoped(value) ? "border-sky-300 bg-sky-50 text-sky-800 hover:border-sky-400" : undefined} + /> + + +
+ setQuery(e.target.value)} + placeholder="Search campaigns and folders…" + aria-label="Search campaigns and folders" + className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none" + /> +
+ onChange(ALL_CAMPAIGNS)}> + All campaigns + + + {shownFolders.length > 0 && ( + <> + + Folders + {shownFolders.map((f) => ( + } + trailing={} + onSelect={() => toggle("folders", f.id)} + > + {f.title} + + ))} + + )} + + + Campaigns + {list.campaigns.map((c) => ( + } + trailing={ + + } + onSelect={() => toggle("campaigns", c.id)} + > + {c.name} + + ))} + {list.campaigns.length === 0 && ( +
+ {list.isPending ? "Loading…" : list.isError ? "Couldn't load campaigns." : "No campaigns found."} +
+ )} +
+
+ ); +} diff --git a/web/src/components/app/analytics/DirectMailSection.tsx b/web/src/components/app/analytics/DirectMailSection.tsx index bb2f00dbd..4b84aa403 100644 --- a/web/src/components/app/analytics/DirectMailSection.tsx +++ b/web/src/components/app/analytics/DirectMailSection.tsx @@ -51,7 +51,8 @@ function SkeletonRows({ rows = 3 }: { rows?: number }) { ); } -export default function DirectMailSection({ period }: { period: string }) { +// unfiltered marks the section while the page is narrowed to campaigns: direct mail belongs to none. +export default function DirectMailSection({ period, unfiltered = false }: { period: string; unfiltered?: boolean }) { const q = useDirectMail(period); const d: DirectMailAnalytics | undefined = q.data; const vol = d?.volume; @@ -78,6 +79,11 @@ export default function DirectMailSection({ period }: { period: string }) { return ( <> + {unfiltered && ( + + Not filtered by campaign + + )} ({ f1: "Client A", f2: "Client B" })[id]; +const campaignName = (id: string) => ({ c1: "Dentists" })[id]; + +describe("campaign scope label", () => { + it("reads all campaigns when nothing is selected", () => { + expect(isScoped(ALL_CAMPAIGNS)).toBe(false); + expect(scopeLabel(ALL_CAMPAIGNS, undefined, folderName, campaignName)).toBe("All campaigns"); + }); + + it("names a folder with the campaigns the server resolved", () => { + const filter = { campaigns: [], folders: ["f1"] }; + expect(scopeLabel(filter, undefined, folderName, campaignName)).toBe("Client A"); + const scope = { campaigns: [], folders: [{ id: "f1", name: "Client A" }], campaign_count: 6 }; + expect(scopeLabel(filter, scope, folderName, campaignName)).toBe("Client A · 6 campaigns"); + }); + + it("names one campaign, and counts several", () => { + expect(scopeLabel({ campaigns: ["c1"], folders: [] }, undefined, folderName, campaignName)).toBe("Dentists"); + expect(scopeLabel({ campaigns: ["c1", "c2", "c3"], folders: [] }, undefined, folderName, campaignName)).toBe("3 campaigns"); + }); + + it("counts a mix once, by the server's deduplicated total", () => { + const filter = { campaigns: ["c1"], folders: ["f1", "f2"] }; + const scope = { campaigns: [{ id: "c1", name: "Dentists" }], folders: [], campaign_count: 1 }; + expect(scopeLabel(filter, scope, folderName, campaignName)).toBe("2 folders + 1 campaign · 1 campaign"); + }); +}); diff --git a/web/src/components/app/analytics/campaignScope.ts b/web/src/components/app/analytics/campaignScope.ts new file mode 100644 index 000000000..b0ce4e4ad --- /dev/null +++ b/web/src/components/app/analytics/campaignScope.ts @@ -0,0 +1,37 @@ +import type { DashboardCampaignFilter, DashboardScope } from "@/lib/api/models/app/analytics/DashboardOverview"; + +export const ALL_CAMPAIGNS: DashboardCampaignFilter = { campaigns: [], folders: [] }; + +export function isScoped(filter: DashboardCampaignFilter): boolean { + return filter.campaigns.length > 0 || filter.folders.length > 0; +} + +function plural(n: number, word: string): string { + return `${n.toLocaleString()} ${word}${n === 1 ? "" : "s"}`; +} + +// scopeLabel names a filter: "All campaigns", "Client A · 6 campaigns", +// one campaign's name, or a count. Names come from the server's echo first. +export function scopeLabel( + filter: DashboardCampaignFilter, + scope: DashboardScope | undefined, + folderName: (id: string) => string | undefined, + campaignName: (id: string) => string | undefined, +): string { + if (!isScoped(filter)) return "All campaigns"; + const total = scope ? ` · ${plural(scope.campaign_count, "campaign")}` : ""; + const { campaigns, folders } = filter; + if (folders.length === 1 && campaigns.length === 0) { + const id = folders[0]; + const name = scope?.folders.find((f) => f.id === id)?.name ?? folderName(id); + return `${name ?? "1 folder"}${total}`; + } + if (campaigns.length === 1 && folders.length === 0) { + const id = campaigns[0]; + return scope?.campaigns.find((c) => c.id === id)?.name ?? campaignName(id) ?? "1 campaign"; + } + if (folders.length === 0) return plural(scope?.campaign_count ?? campaigns.length, "campaign"); + const parts = [plural(folders.length, "folder")]; + if (campaigns.length) parts.push(plural(campaigns.length, "campaign")); + return `${parts.join(" + ")}${total}`; +} diff --git a/web/src/lib/api/client/app/analytics/getDashboard.ts b/web/src/lib/api/client/app/analytics/getDashboard.ts index fd12738c8..e6e000006 100644 --- a/web/src/lib/api/client/app/analytics/getDashboard.ts +++ b/web/src/lib/api/client/app/analytics/getDashboard.ts @@ -1,12 +1,16 @@ import type DashboardOverview from "@/lib/api/models/app/analytics/DashboardOverview"; +import type { DashboardCampaignFilter } from "@/lib/api/models/app/analytics/DashboardOverview"; import Request from "../../Request"; // Workspace dashboard analytics. Returned as a bare object (no {data} envelope), // so no unwrap is needed here — but the period must be forwarded. -export default async function getDashboard(period: string = "7d"): Promise { +export default async function getDashboard(period: string = "7d", filter?: DashboardCampaignFilter): Promise { + const params = new URLSearchParams({ period }); + if (filter?.campaigns.length) params.set("campaign_ids", filter.campaigns.join(",")); + if (filter?.folders.length) params.set("folder_ids", filter.folders.join(",")); return await Request({ method: "GET", - url: `/analytics/dashboard?period=${encodeURIComponent(period)}`, + url: `/analytics/dashboard?${params.toString()}`, authorization: true, }) } diff --git a/web/src/lib/api/hooks/app/analytics/useDashboard.ts b/web/src/lib/api/hooks/app/analytics/useDashboard.ts index 574c832df..648898852 100644 --- a/web/src/lib/api/hooks/app/analytics/useDashboard.ts +++ b/web/src/lib/api/hooks/app/analytics/useDashboard.ts @@ -1,12 +1,19 @@ -import { queryOptions, useQuery } from "@tanstack/react-query"; +import { keepPreviousData, queryOptions, useQuery } from "@tanstack/react-query"; import getDashboard from "@/lib/api/client/app/analytics/getDashboard"; +import type { DashboardCampaignFilter } from "@/lib/api/models/app/analytics/DashboardOverview"; -export const dashboardQuery = (period: string = "7d") => - queryOptions({ - queryKey: ["analytics", "dashboard", period], - queryFn: () => getDashboard(period), +// The unfiltered key stays ["analytics", "dashboard", period], shared with the sidebar meter. +export const dashboardQuery = (period: string = "7d", filter?: DashboardCampaignFilter) => { + const scoped = !!filter && (filter.campaigns.length > 0 || filter.folders.length > 0); + return queryOptions({ + queryKey: scoped + ? ["analytics", "dashboard", period, { campaigns: [...filter.campaigns].sort(), folders: [...filter.folders].sort() }] + : ["analytics", "dashboard", period], + queryFn: () => getDashboard(period, scoped ? filter : undefined), }); +}; -export default function useDashboard(period: string = "7d") { - return useQuery(dashboardQuery(period)) +export default function useDashboard(period: string = "7d", filter?: DashboardCampaignFilter) { + // Changing the filter keeps the last numbers on screen until the new ones land. + return useQuery({ ...dashboardQuery(period, filter), placeholderData: keepPreviousData }) } diff --git a/web/src/lib/api/models/app/analytics/DashboardOverview.ts b/web/src/lib/api/models/app/analytics/DashboardOverview.ts index 9c0a0f42e..fa4dee8aa 100644 --- a/web/src/lib/api/models/app/analytics/DashboardOverview.ts +++ b/web/src/lib/api/models/app/analytics/DashboardOverview.ts @@ -1,7 +1,7 @@ import type { WorkspaceSendCapacity } from "@/lib/api/models/app/campaigns/SendPlan" import type { EngagementOrigin } from "@/lib/api/models/app/contacts/ContactTimelineEvent" -// GET /analytics/dashboard?period=7d|30d|90d — a single (un-enveloped) object +// GET /analytics/dashboard?period=7d|30d|90d[&campaign_ids][&folder_ids] — a single (un-enveloped) object // mirroring the backend models.DashboardAnalytics. The previous flat shape // (total_campaigns/total_contacts…) did not match the wire body. @@ -74,4 +74,26 @@ export default interface DashboardOverview { // What the workspace's mailboxes can send today under the scheduler's // clamps; the sidebar meter's denominator. Absent when not computed. capacity_today?: WorkspaceSendCapacity + // The campaign filter the campaign sections were computed for; absent + // when they cover the whole workspace. + scope?: DashboardScope +} + +// What the page asks for: campaign and folder ids, unioned by the server. +export interface DashboardCampaignFilter { + campaigns: string[] + folders: string[] +} + +export interface DashboardScopeItem { + id: string + name: string +} + +// The requested campaigns and folders that belong to the workspace, and how +// many distinct campaigns they cover together. +export interface DashboardScope { + campaigns: DashboardScopeItem[] + folders: DashboardScopeItem[] + campaign_count: number } diff --git a/web/src/lib/browse-accounts-analytics.ts b/web/src/lib/browse-accounts-analytics.ts index 0459548c5..16cc9673f 100644 --- a/web/src/lib/browse-accounts-analytics.ts +++ b/web/src/lib/browse-accounts-analytics.ts @@ -11,6 +11,10 @@ export const mailboxSort = z.strictObject({ by: z.enum(["mailbox", "status", "sent", "warmup", "inbox", "health"]), reverse: z.boolean(), }).nullable(); +export const analyticsCampaignFilter = z.strictObject({ + campaigns: z.array(z.string()).max(100), + folders: z.array(z.string()).max(100), +}); export const analyticsHiddenMetrics = z.array(z.enum(["sent", "opens", "clicks", "replies"])) .max(3).refine((values) => new Set(values).size === values.length); export const deliverabilityHiddenMetrics = z.array(z.enum(["bounces", "complaints", "opens", "replies", "sent"]))