mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-09-17 22:52:19 +02:00
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user