feat: scope contact activity timelines to the selected organization so teammates can load contacts and campaigns created by other members

This commit is contained in:
Matthew Meszaros
2026-09-16 08:20:31 -07:00
parent 4597156b6f
commit 6762b4e491
8 changed files with 78 additions and 38 deletions
+1 -1
View File
@@ -709,7 +709,7 @@ Returns a `data` array plus a `pagination` envelope.
`GET /contacts/:id/timeline`
Returns the merged activity feed for a contact: sends, opens, clicks (one per link, naming the link), replies, bounces, deliverability and suppression events, notes, meeting bookings, and lifecycle events (the contact's creation with its first-touch source, and every time it joined or left a campaign or a category). Requires a selected organization (org-scoped events would otherwise be hidden), so a request with no organization returns `400`.
Returns the selected organization's merged activity feed for a contact: sends, opens, clicks (one per link, naming the link), replies, bounces, deliverability and suppression events, notes, meeting bookings, and lifecycle events (the contact's creation with its first-touch source, and every time it joined or left a campaign or a category). Every member with permission to view contacts receives the same timeline, regardless of who created the contact or its campaigns. A request with no selected organization returns `400`, and a contact outside the selected organization returns `404`.
Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`
+1 -1
View File
@@ -133,7 +133,7 @@ A new contact is also an event. `contact.created` goes to your [webhooks](/guide
## Activity timeline
The **Activity** tab of a contact is one feed, newest first, of everything Warmbly knows about them: every campaign email sent, opened, clicked, replied to or bounced (with the campaign, step, subject and sending mailbox), replies with their classified intent, deliverability and suppression events, notes, meetings, and the contact's lifecycle: when it was created and how, and each time it joined or left a campaign or a category.
The **Activity** tab of a contact is one workspace-wide feed, newest first, of everything Warmbly knows about them. Every member with permission to view contacts sees the same timeline, regardless of who created the contact or its campaigns. It includes every campaign email sent, opened, clicked, replied to or bounced (with the campaign, step, subject and sending mailbox), replies with their classified intent, deliverability and suppression events, notes, meetings, and the contact's lifecycle: when it was created and how, and each time it joined or left a campaign or a category.
Opens appear once per event, not once per email: a second open from another device is its own row. Opens and clicks show the mail client or browser they came from and the city and country when known (see [what is recorded per open and click](/guides/analytics/#what-is-recorded-per-open-and-click)); expanded, the operating system, device and full location. A click names the link: the row reads **Clicked Pricing** and, expanded, shows the link's text, its full URL, the UTM source, medium, campaign and content it carried, and the browser. Every link in an email is tracked on its own, so two links clicked are two rows. Opens and clicks that came from a machine rather than the person (a mail privacy proxy, a security gateway that follows every link at delivery) carry an **auto** badge, and the expanded row says which rule caught them; see [Link tracking and UTM parameters](/guides/campaigns/#link-tracking-and-utm-parameters).
+1 -6
View File
@@ -143,11 +143,6 @@ func (h *Handler) ListContactCampaignStates(c *gin.Context) {
// and require a selected organization; we 400 if there isn't one to
// avoid silently returning a misleadingly thin feed.
func (h *Handler) ListContactTimeline(c *gin.Context) {
userID, err := middleware.GetUserUUID(c)
if err != nil {
errx.Handle(c, errx.ErrAuth)
return
}
contactID, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.Handle(c, errx.ErrUuid)
@@ -194,7 +189,7 @@ func (h *Handler) ListContactTimeline(c *gin.Context) {
cursor = &models.ContactTimelineKey{At: t}
}
res, xerr := h.ContactService.ListTimeline(c.Request.Context(), userID, orgID, contactID, limit, cursor)
res, xerr := h.ContactService.ListTimeline(c.Request.Context(), *orgID, contactID, limit, cursor)
if xerr != nil {
errx.Handle(c, xerr)
return
+1 -1
View File
@@ -250,7 +250,7 @@ func (d Deps) getContactTimeline(ctx context.Context, inv Invocation, args json.
limit = 50
}
orgID := inv.OrgID
res, xerr := d.Contacts.ListTimeline(ctx, inv.UserID, &orgID, cid, limit, nil)
res, xerr := d.Contacts.ListTimeline(ctx, orgID, cid, limit, nil)
if xerr != nil {
return "", fromErrx(xerr)
}
+2 -2
View File
@@ -219,6 +219,6 @@ func (s *contactService) ListSentEmails(ctx context.Context, userID, contactID u
return s.contactRepository.ListSentEmails(ctx, userID, contactID, limit, beforeSentAt, beforeTaskID)
}
func (s *contactService) ListTimeline(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, contactID uuid.UUID, limit int, cursor *models.ContactTimelineKey) (*models.ContactTimelineResult, *errx.Error) {
return s.contactRepository.ListTimeline(ctx, userID, orgID, contactID, limit, cursor)
func (s *contactService) ListTimeline(ctx context.Context, orgID, contactID uuid.UUID, limit int, cursor *models.ContactTimelineKey) (*models.ContactTimelineResult, *errx.Error) {
return s.contactRepository.ListTimeline(ctx, orgID, contactID, limit, cursor)
}
+1 -1
View File
@@ -75,7 +75,7 @@ type ContactService interface {
// ListTimeline returns a merged, reverse-chronological feed of all
// engagement + CRM events for the contact.
ListTimeline(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, contactID uuid.UUID, limit int, cursor *models.ContactTimelineKey) (*models.ContactTimelineResult, *errx.Error)
ListTimeline(ctx context.Context, orgID, contactID uuid.UUID, limit int, cursor *models.ContactTimelineKey) (*models.ContactTimelineResult, *errx.Error)
// SetCampaignWaker wires the campaign service so attaching a lead to a
// running campaign wakes that campaign's parked send chain. Optional: with
@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/utils/paging"
)
@@ -62,7 +63,7 @@ func TestLiveContactTimelineLifecycleEvents(t *testing.T) {
timeline := func() []models.ContactTimelineEvent {
t.Helper()
res, xerr := repo.ListTimeline(ctx, f.owner, &f.org, id, 50, nil)
res, xerr := repo.ListTimeline(ctx, f.org, id, 50, nil)
if xerr != nil {
t.Fatalf("timeline: %v", xerr)
}
@@ -157,6 +158,48 @@ func TestLiveContactTimelineLifecycleEvents(t *testing.T) {
}
}
// Issue #550: organization members see the timeline regardless of who created the contact.
func TestLiveContactTimelineIsOrganizationWide(t *testing.T) {
handle, pool := liveContactDB(t)
f := newSharedOrgFixture(t, pool)
ctx := context.Background()
repo := NewContactRepostory(handle)
step := uuid.New()
if _, err := pool.Exec(ctx, `
INSERT INTO contact_activities (contact_id, organization_id, user_id, activity_type, metadata)
VALUES ($1, $2, $3, 'contact_created', '{"source":"manual"}')
`, f.contact, f.org, f.owner); err != nil {
t.Fatalf("seed activity: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO sequences (id, campaign_id, organization_id, name, subject, body_plain, body_html)
VALUES ($1, $2, $3, 'Intro', 'Quick question', '', '')
`, step, f.campaign, f.org); err != nil {
t.Fatalf("seed sequence: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at)
VALUES ($1, $2, $3, NOW())
`, f.campaign, f.contact, step); err != nil {
t.Fatalf("seed campaign activity: %v", err)
}
res, xerr := repo.ListTimeline(ctx, f.org, f.contact, 50, nil)
if xerr != nil {
t.Fatalf("teammate timeline: %v", xerr)
}
if n := countTimeline(res.Data, models.TimelineContactCreated, nil); n != 1 {
t.Fatalf("teammate timeline has %d contact_created events, want 1", n)
}
if n := countTimeline(res.Data, models.TimelineEmailSent, nil); n != 1 {
t.Fatalf("teammate timeline has %d email_sent events, want 1", n)
}
if _, xerr := repo.ListTimeline(ctx, uuid.New(), f.contact, 50, nil); xerr != errx.ErrNotFound {
t.Fatalf("other organization timeline error = %v, want not found", xerr)
}
}
// A contact created from a campaign's Leads tab is attributed to that campaign
// by name, resolved server-side.
func TestLiveContactSourceCampaignResolvesName(t *testing.T) {
@@ -239,7 +282,7 @@ func TestLiveContactTimelinePagesOnTiesWithoutGapsOrRepeats(t *testing.T) {
var all []models.ContactTimelineEvent
var cursor *models.ContactTimelineKey
for page := 0; ; page++ {
res, xerr := repo.ListTimeline(ctx, f.owner, &f.org, f.contact, 3, cursor)
res, xerr := repo.ListTimeline(ctx, f.org, f.contact, 3, cursor)
if xerr != nil {
t.Fatalf("page %d: %v", page, xerr)
}
@@ -292,7 +335,7 @@ func TestLiveContactTimelinePagesOnTiesWithoutGapsOrRepeats(t *testing.T) {
// The legacy bare timestamp still means "strictly older than": rank zero
// sits below every source, so nothing at that instant qualifies.
res, xerr := repo.ListTimeline(ctx, f.owner, &f.org, f.contact, 50, &models.ContactTimelineKey{At: at})
res, xerr := repo.ListTimeline(ctx, f.org, f.contact, 50, &models.ContactTimelineKey{At: at})
if xerr != nil {
t.Fatalf("before: %v", xerr)
}
+25 -23
View File
@@ -93,11 +93,12 @@ type ContactRepository interface {
// 200. Powers the dashboard variable picker's real-field suggestions.
DistinctCustomFieldKeys(ctx context.Context, orgID uuid.UUID) ([]string, error)
// 360 view read paths. orgID is optional — when nil, the suppression
// + deliverability + reply joins are skipped (they're org-scoped).
// GetDetail also serves user-only reads, where orgID is nil and
// organization-scoped joins are skipped.
GetDetail(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, contactID uuid.UUID) (*models.ContactDetail, *errx.Error)
ListSentEmails(ctx context.Context, userID, contactID uuid.UUID, limit int, beforeSentAt *time.Time, beforeTaskID *uuid.UUID) (*models.ContactSentEmailsResult, *errx.Error)
ListTimeline(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, contactID uuid.UUID, limit int, cursor *models.ContactTimelineKey) (*models.ContactTimelineResult, *errx.Error)
// ListTimeline is always scoped to the selected organization.
ListTimeline(ctx context.Context, orgID, contactID uuid.UUID, limit int, cursor *models.ContactTimelineKey) (*models.ContactTimelineResult, *errx.Error)
// ListCampaignStates returns the contact's campaigns with their flow,
// this contact's progress on every step, and the derived lead status.
ListCampaignStates(ctx context.Context, orgID, contactID uuid.UUID) ([]models.ContactCampaignState, *errx.Error)
@@ -3431,7 +3432,7 @@ func timelineKeyset(atCol string, source models.ContactTimelineSource, idCol str
// the cursor on that tuple, so two events at the same instant, from the
// same table or different ones, land on one side of a page boundary or the
// other and are never skipped or repeated. A nil cursor is the first page.
func (r *contactRepository) ListTimeline(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, contactID uuid.UUID, limit int, cursor *models.ContactTimelineKey) (*models.ContactTimelineResult, *errx.Error) {
func (r *contactRepository) ListTimeline(ctx context.Context, orgID, contactID uuid.UUID, limit int, cursor *models.ContactTimelineKey) (*models.ContactTimelineResult, *errx.Error) {
if limit <= 0 || limit > 200 {
limit = 50
}
@@ -3442,8 +3443,8 @@ func (r *contactRepository) ListTimeline(ctx context.Context, userID uuid.UUID,
// off email rather than contact_id.
var contactEmail string
if err := r.DB.QueryRow(ctx,
`SELECT email FROM contacts WHERE id = $1 AND user_id = $2`,
contactID, userID,
`SELECT email FROM contacts WHERE id = $1 AND organization_id = $2`,
contactID, orgID,
).Scan(&contactEmail); err != nil {
if err == pgx.ErrNoRows {
return nil, errx.ErrNotFound
@@ -3504,7 +3505,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, userID uuid.UUID,
LIMIT 1
) ea ON TRUE
WHERE ccp.contact_id = $1
AND cam.user_id = $2
AND cam.organization_id = $2
AND ev.at IS NOT NULL
AND (ev.at, ev.source, ccp.sequence_id) < ($3::timestamptz, $4::int, $5::uuid)
AND NOT (ev.source = %[2]d AND EXISTS (
@@ -3526,9 +3527,9 @@ func (r *contactRepository) ListTimeline(ctx context.Context, userID uuid.UUID,
models.TimelineSourceProgressReplied,
models.TimelineSourceProgressBounced,
)
prows, err := r.DB.Query(ctx, progressQuery, contactID, userID, after.At, afterSource, after.ID, fetch)
prows, err := r.DB.Query(ctx, progressQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, progressQuery, []any{contactID, userID, after.At, afterSource, after.ID, fetch}, "ListTimeline progress")
db.CaptureError(err, progressQuery, []any{contactID, orgID, after.At, afterSource, after.ID, fetch}, "ListTimeline progress")
return nil, errx.InternalError()
}
for prows.Next() {
@@ -3604,14 +3605,14 @@ func (r *contactRepository) ListTimeline(ctx context.Context, userID uuid.UUID,
WHERE t.id = lc.task_id
) ea ON TRUE
WHERE lc.contact_id = $1
AND cam.user_id = $2
AND cam.organization_id = $2
AND ` + timelineKeyset("lc.clicked_at", models.TimelineSourceLinkClick, "lc.id", 3) + `
ORDER BY lc.clicked_at DESC, lc.id DESC
LIMIT $6
`
crows, err := r.DB.Query(ctx, clickQuery, contactID, userID, after.At, afterSource, after.ID, fetch)
crows, err := r.DB.Query(ctx, clickQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, clickQuery, []any{contactID, userID, after.At, afterSource, after.ID, fetch}, "ListTimeline link clicks")
db.CaptureError(err, clickQuery, []any{contactID, orgID, after.At, afterSource, after.ID, fetch}, "ListTimeline link clicks")
return nil, errx.InternalError()
}
for crows.Next() {
@@ -3688,14 +3689,14 @@ func (r *contactRepository) ListTimeline(ctx context.Context, userID uuid.UUID,
WHERE t.id = o.task_id
) ea ON TRUE
WHERE o.contact_id = $1
AND cam.user_id = $2
AND cam.organization_id = $2
AND ` + timelineKeyset("o.opened_at", models.TimelineSourceOpen, "o.id", 3) + `
ORDER BY o.opened_at DESC, o.id DESC
LIMIT $6
`
orows, err := r.DB.Query(ctx, openQuery, contactID, userID, after.At, afterSource, after.ID, fetch)
orows, err := r.DB.Query(ctx, openQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, openQuery, []any{contactID, userID, after.At, afterSource, after.ID, fetch}, "ListTimeline opens")
db.CaptureError(err, openQuery, []any{contactID, orgID, after.At, afterSource, after.ID, fetch}, "ListTimeline opens")
return nil, errx.InternalError()
}
for orows.Next() {
@@ -3751,7 +3752,8 @@ func (r *contactRepository) ListTimeline(ctx context.Context, userID uuid.UUID,
return nil, errx.InternalError()
}
if orgID != nil {
// Scope the organization-only sources together.
{
// 2. Reply intents (inbound replies with classification).
replyQuery := `
SELECT ri.id, ri.created_at, ri.intent, ri.campaign_id, cam.name, ri.task_id
@@ -3763,7 +3765,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, userID uuid.UUID,
ORDER BY ri.created_at DESC, ri.id DESC
LIMIT $6
`
rrows, err := r.DB.Query(ctx, replyQuery, *orgID, contactEmail, after.At, afterSource, after.ID, fetch)
rrows, err := r.DB.Query(ctx, replyQuery, orgID, contactEmail, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, replyQuery, nil, "ListTimeline replies")
return nil, errx.InternalError()
@@ -3800,7 +3802,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, userID uuid.UUID,
ORDER BY de.created_at DESC, de.id DESC
LIMIT $7
`
drows, err := r.DB.Query(ctx, delivQuery, *orgID, contactID, contactEmail, after.At, afterSource, after.ID, fetch)
drows, err := r.DB.Query(ctx, delivQuery, orgID, contactID, contactEmail, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, delivQuery, nil, "ListTimeline deliv")
return nil, errx.InternalError()
@@ -3841,7 +3843,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, userID uuid.UUID,
ORDER BY created_at DESC, id DESC
LIMIT $6
`
srows, err := r.DB.Query(ctx, suppQuery, *orgID, contactEmail, after.At, afterSource, after.ID, fetch)
srows, err := r.DB.Query(ctx, suppQuery, orgID, contactEmail, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, suppQuery, nil, "ListTimeline suppression")
return nil, errx.InternalError()
@@ -3882,7 +3884,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, userID uuid.UUID,
ORDER BY created_at DESC, id DESC
LIMIT $6
`
nrows, err := r.DB.Query(ctx, notesQuery, contactID, *orgID, after.At, afterSource, after.ID, fetch)
nrows, err := r.DB.Query(ctx, notesQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, notesQuery, nil, "ListTimeline notes")
return nil, errx.InternalError()
@@ -3920,7 +3922,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, userID uuid.UUID,
ORDER BY created_at DESC, id DESC
LIMIT $6
`
mrows, err := r.DB.Query(ctx, meetingQuery, contactID, *orgID, after.At, afterSource, after.ID, fetch)
mrows, err := r.DB.Query(ctx, meetingQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, meetingQuery, nil, "ListTimeline meetings")
return nil, errx.InternalError()
@@ -3981,7 +3983,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, userID uuid.UUID,
ORDER BY created_at DESC, id DESC
LIMIT $6
`
lrows, err := r.DB.Query(ctx, lifeQuery, contactID, *orgID, after.At, afterSource, after.ID, fetch)
lrows, err := r.DB.Query(ctx, lifeQuery, contactID, orgID, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, lifeQuery, nil, "ListTimeline lifecycle")
return nil, errx.InternalError()
@@ -4050,7 +4052,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, userID uuid.UUID,
ORDER BY h.occurred_at DESC, h.id DESC
LIMIT $6
`
hrows, err := r.DB.Query(ctx, hitQuery, *orgID, contactID, after.At, afterSource, after.ID, fetch)
hrows, err := r.DB.Query(ctx, hitQuery, orgID, contactID, after.At, afterSource, after.ID, fetch)
if err != nil {
db.CaptureError(err, hitQuery, nil, "ListTimeline page hits")
return nil, errx.InternalError()