diff --git a/backend/model/campaignEvent.go b/backend/model/campaignEvent.go index 368135b..a290729 100644 --- a/backend/model/campaignEvent.go +++ b/backend/model/campaignEvent.go @@ -16,11 +16,10 @@ type CampaignEvent struct { UserAgent *vo.OptionalString255 `json:"userAgent"` Data *vo.OptionalString1MB `json:"data"` Metadata *vo.OptionalString1MB `json:"metadata"` - // AnonymizedID is the pseudonym that links an anonymous campaign's events to one - // another and to the recipient row. It is an internal join key and must never be - // serialized: exposing it would let a reader group a pseudonym's events into a - // per-person journey and re-identify it by matching the send event to a - // recipient. Stats and grouping read it server-side, off the database. + // AnonymizedID is the pseudonym that links an anonymous campaign's events to each + // other and to the recipient row. It must never be sent to a client: a reader with + // it could group one person's events together and work out who they are by matching + // the send event to a recipient. Stats and grouping read it on the server. AnonymizedID *uuid.UUID `json:"-"` // if null the recipient has been anonymized RecipientID *uuid.UUID `json:"recipientID"` diff --git a/backend/model/campaignRecipient.go b/backend/model/campaignRecipient.go index 2442071..702510e 100644 --- a/backend/model/campaignRecipient.go +++ b/backend/model/campaignRecipient.go @@ -22,10 +22,10 @@ type CampaignRecipient struct { LastAttemptAt nullable.Nullable[time.Time] `json:"lastAttemptAt"` SentAt nullable.Nullable[time.Time] `json:"sentAt"` SelfManaged nullable.Nullable[bool] `json:"selfManaged"` - // AnonymizedID is the stable pseudonym. It is an internal join key that ties the - // pseudonym to identity while the recipient row still carries a name, so it must - // never be serialized: a reader with it could group a pseudonym's events into a - // per-person journey and re-identify them. Anonymization reads it server-side. + // AnonymizedID is the pseudonym stamped on a recipient's events. It ties those + // events back to identity while the recipient row still has a name, so it must + // never be sent to a client: a reader with it could group one person's events + // together and work out who they are. Only the server reads it. AnonymizedID nullable.Nullable[uuid.UUID] `json:"-"` // Sent is a coarse, timing-free send indicator used in place of the exact // SendAt/SentAt for anonymous campaigns, which are withheld so per-recipient @@ -36,9 +36,9 @@ type CampaignRecipient struct { // null recipientID means that the data has been anonymized RecipientID nullable.Nullable[uuid.UUID] `json:"recipientID"` Recipient *Recipient `json:"recipient"` - // Position and Department are snapshotted from the recipient at - // materialization for anonymous campaigns so grouped statistics survive after - // the recipient relation is severed. + // Position and Department are copied from the recipient when an anonymous + // campaign's recipient list is built, so the grouped statistics still work after + // the link back to the recipient is removed. Position nullable.Nullable[string] `json:"position"` Department nullable.Nullable[string] `json:"department"` NotableEventID nullable.Nullable[uuid.UUID] `json:"notableEventID"` diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go index a947729..867dfbb 100644 --- a/backend/repository/campaign.go +++ b/backend/repository/campaign.go @@ -2670,50 +2670,51 @@ func (r *Campaign) GetGroupedResultStats( var rows []groupRow // the person key is the pseudonym when present, else the recipient id, so counts // work for normal, anonymous and anonymized campaigns; the group value is the - // snapshot, falling back to the recipient's attribute. groupColumn is allowlisted - // above, so its interpolation is safe. + // snapshot, falling back to the recipient's attribute. Events are aggregated to + // one row per person first, then joined once to the recipients so the join key is + // a plain column the database can index; joining the raw event rows on the + // COALESCE expression scans every recipient against every event and is quadratic. + // groupColumn is allowlisted above, so its interpolation is safe. query := fmt.Sprintf(` SELECT - COALESCE(NULLIF(cr.%[1]s, ''), r.%[1]s) AS grp, - COUNT(DISTINCT COALESCE(cr.anonymized_id, cr.recipient_id)) AS total, - COUNT(DISTINCT CASE WHEN clicked.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS clicked, - COUNT(DISTINCT CASE WHEN submitted.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS submitted, - COUNT(DISTINCT CASE WHEN reported.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS reported, - COUNT(DISTINCT CASE WHEN started.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS training_started, - COUNT(DISTINCT CASE WHEN completed.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS training_completed - FROM campaign_recipients cr - LEFT JOIN recipients r ON r.id = cr.recipient_id + p.grp AS grp, + COUNT(DISTINCT p.person) AS total, + COUNT(DISTINCT CASE WHEN ev.clicked = 1 THEN p.person END) AS clicked, + COUNT(DISTINCT CASE WHEN ev.submitted = 1 THEN p.person END) AS submitted, + COUNT(DISTINCT CASE WHEN ev.reported = 1 THEN p.person END) AS reported, + COUNT(DISTINCT CASE WHEN ev.training_started = 1 THEN p.person END) AS training_started, + COUNT(DISTINCT CASE WHEN ev.training_completed = 1 THEN p.person END) AS training_completed + FROM ( + SELECT + COALESCE(cr.anonymized_id, cr.recipient_id) AS person, + COALESCE(NULLIF(cr.%[1]s, ''), r.%[1]s) AS grp + FROM campaign_recipients cr + LEFT JOIN recipients r ON r.id = cr.recipient_id + WHERE cr.campaign_id = ? AND COALESCE(cr.anonymized_id, cr.recipient_id) IS NOT NULL + ) AS p LEFT JOIN ( - SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events - WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id IN (?, ?, ?) - ) AS clicked ON clicked.pk = COALESCE(cr.anonymized_id, cr.recipient_id) - LEFT JOIN ( - SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events - WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id = ? - ) AS submitted ON submitted.pk = COALESCE(cr.anonymized_id, cr.recipient_id) - LEFT JOIN ( - SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events - WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id = ? - ) AS reported ON reported.pk = COALESCE(cr.anonymized_id, cr.recipient_id) - LEFT JOIN ( - SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events - WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id = ? - ) AS started ON started.pk = COALESCE(cr.anonymized_id, cr.recipient_id) - LEFT JOIN ( - SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events - WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id = ? - ) AS completed ON completed.pk = COALESCE(cr.anonymized_id, cr.recipient_id) - WHERE cr.campaign_id = ? AND COALESCE(cr.anonymized_id, cr.recipient_id) IS NOT NULL - GROUP BY COALESCE(NULLIF(cr.%[1]s, ''), r.%[1]s) + SELECT + COALESCE(anonymized_id, recipient_id) AS person, + MAX(CASE WHEN event_id IN (?, ?, ?) THEN 1 ELSE 0 END) AS clicked, + MAX(CASE WHEN event_id = ? THEN 1 ELSE 0 END) AS submitted, + MAX(CASE WHEN event_id = ? THEN 1 ELSE 0 END) AS reported, + MAX(CASE WHEN event_id = ? THEN 1 ELSE 0 END) AS training_started, + MAX(CASE WHEN event_id = ? THEN 1 ELSE 0 END) AS training_completed + FROM campaign_events + WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL + GROUP BY COALESCE(anonymized_id, recipient_id) + ) AS ev ON ev.person = p.person + GROUP BY p.grp ORDER BY total DESC `, groupColumn) res := r.DB.WithContext(ctx).Raw(query, - campaignID, beforeID, pageID, afterID, - campaignID, submitID, - campaignID, reportID, - campaignID, startedID, - campaignID, completedID, + campaignID, + beforeID, pageID, afterID, + submitID, + reportID, + startedID, + completedID, campaignID, ).Scan(&rows) if res.Error != nil {