mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-09-18 07:02:19 +02:00
added anonymous campaigns and position/department stats across campaigns and reports.
Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
@@ -170,6 +170,8 @@ const (
|
||||
ROUTE_V1_CAMPAIGN_NAME = "/api/v1/campaign/name/:name"
|
||||
ROUTE_V1_CAMPAIGN_RECIPIENTS = "/api/v1/campaign/:id/recipients"
|
||||
ROUTE_V1_CAMPAIGN_RESULT_STATS = "/api/v1/campaign/:id/statistics"
|
||||
ROUTE_V1_CAMPAIGN_GROUPED_STATS = "/api/v1/campaign/:id/grouped-statistics"
|
||||
ROUTE_V1_CAMPAIGN_HAS_GROUP_DATA = "/api/v1/campaign/:id/has-group-data"
|
||||
ROUTE_V1_CAMPAIGN_EVENTS = "/api/v1/campaign/:id/events"
|
||||
ROUTE_V1_CAMPAIGN_ALL_EVENTS = "/api/v1/campaign/events"
|
||||
ROUTE_V1_CAMPAIGN_EVENT_ID = "/api/v1/campaign/event/:id"
|
||||
@@ -501,6 +503,8 @@ func setupRoutes(
|
||||
DELETE(ROUTE_V1_CAMPAIGN_EVENT_ID, middleware.SessionHandler, controllers.Campaign.DeleteEventByID).
|
||||
GET(ROUTE_V1_CAMPAIGN_STATS, middleware.SessionHandler, controllers.Campaign.GetStats).
|
||||
GET(ROUTE_V1_CAMPAIGN_RESULT_STATS, middleware.SessionHandler, controllers.Campaign.GetResultStats).
|
||||
GET(ROUTE_V1_CAMPAIGN_GROUPED_STATS, middleware.SessionHandler, controllers.Campaign.GetGroupedResultStats).
|
||||
GET(ROUTE_V1_CAMPAIGN_HAS_GROUP_DATA, middleware.SessionHandler, controllers.Campaign.GetHasGroupData).
|
||||
GET(ROUTE_V1_CAMPAIGN_STATS_ID, middleware.SessionHandler, controllers.Campaign.GetCampaignStats).
|
||||
GET(ROUTE_V1_CAMPAIGN_STATS_ALL, middleware.SessionHandler, controllers.Campaign.GetAllCampaignStats).
|
||||
POST(ROUTE_V1_CAMPAIGN_STATS_CREATE, middleware.SessionHandler, controllers.Campaign.CreateCampaignStats).
|
||||
|
||||
+69
-2
@@ -692,6 +692,54 @@ func trainingMilestoneEventName(pageType string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// applyEventAnonymization strips identity and stamps the recipient's pseudonym on an
|
||||
// event for an anonymous campaign, so it stays countable but carries no identity. It
|
||||
// is a no-op for a normal campaign. Call before persisting an event.
|
||||
func (s *Server) applyEventAnonymization(
|
||||
ctx context.Context,
|
||||
campaign *model.Campaign,
|
||||
campaignID uuid.UUID,
|
||||
recipientID uuid.UUID,
|
||||
event *model.CampaignEvent,
|
||||
) {
|
||||
if campaign == nil {
|
||||
return
|
||||
}
|
||||
// resolve anonymity; if the flag is unset fall back to the db, and strip identity
|
||||
// if it still cannot be determined (fail closed)
|
||||
isAnon, err := campaign.IsAnonymous.Get()
|
||||
if err != nil {
|
||||
isAnon, err = s.repositories.Campaign.IsAnonymousByID(ctx, &campaignID)
|
||||
if err != nil {
|
||||
s.logger.Errorw("failed to resolve campaign anonymity, stripping identity", "error", err)
|
||||
event.Anonymize(nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !isAnon {
|
||||
return
|
||||
}
|
||||
// strip identity and attach the pseudonym if it loads; on failure the event is
|
||||
// stored uncounted rather than with identity (fail closed)
|
||||
cr, err := s.repositories.CampaignRecipient.GetByCampaignAndRecipientID(
|
||||
ctx,
|
||||
&campaignID,
|
||||
&recipientID,
|
||||
&repository.CampaignRecipientOption{},
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Errorw("failed to load pseudonym for anonymous event, storing without identity", "error", err)
|
||||
event.Anonymize(nil)
|
||||
return
|
||||
}
|
||||
aid, err := cr.AnonymizedID.Get()
|
||||
if err != nil {
|
||||
event.Anonymize(nil)
|
||||
return
|
||||
}
|
||||
event.Anonymize(&aid)
|
||||
}
|
||||
|
||||
// emitTrainingMilestoneIfNeeded records a training_started or training_completed
|
||||
// event for a training campaign, at most once per recipient. The raw page visit
|
||||
// event is still recorded separately, so this adds a deduplicated milestone on
|
||||
@@ -720,8 +768,20 @@ func (s *Server) emitTrainingMilestoneIfNeeded(
|
||||
if milestoneEventID == nil {
|
||||
return nil
|
||||
}
|
||||
// a milestone is recorded once per recipient per campaign
|
||||
has, err := s.repositories.Campaign.HasEvent(c, &campaignID, &recipientID, milestoneEventID)
|
||||
// a milestone is recorded once per recipient per campaign. an anonymous
|
||||
// campaign's events carry the pseudonym rather than a recipient id, so dedup on
|
||||
// that; otherwise a milestone would be recorded on every page visit.
|
||||
var has bool
|
||||
var err error
|
||||
if isAnon, _ := campaign.IsAnonymous.Get(); isAnon {
|
||||
if cr, crErr := s.repositories.CampaignRecipient.GetByCampaignAndRecipientID(c, &campaignID, &recipientID, &repository.CampaignRecipientOption{}); crErr == nil {
|
||||
if aid, aerr := cr.AnonymizedID.Get(); aerr == nil {
|
||||
has, err = s.repositories.Campaign.HasEventByAnonymizedID(c, &campaignID, &aid, milestoneEventID)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
has, err = s.repositories.Campaign.HasEvent(c, &campaignID, &recipientID, milestoneEventID)
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Errorw("failed to check for existing training milestone event", "error", err)
|
||||
// continue and attempt to create it
|
||||
@@ -742,6 +802,7 @@ func (s *Server) emitTrainingMilestoneIfNeeded(
|
||||
Data: vo.NewEmptyOptionalString1MB(),
|
||||
Metadata: model.ExtractCampaignEventMetadata(c, campaign),
|
||||
}
|
||||
s.applyEventAnonymization(c, campaign, campaignID, recipientID, event)
|
||||
if err := s.repositories.Campaign.SaveEvent(c, event); err != nil {
|
||||
return fmt.Errorf("failed to save training milestone event: %s", err)
|
||||
}
|
||||
@@ -1308,6 +1369,7 @@ func (s *Server) checkAndServePhishingPage(
|
||||
Data: submittedData,
|
||||
Metadata: metadata,
|
||||
}
|
||||
s.applyEventAnonymization(c, campaign, campaignID, recipientID, event)
|
||||
err = s.repositories.Campaign.SaveEvent(c, event)
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("failed to save campaign event: %s", err)
|
||||
@@ -1456,6 +1518,7 @@ func (s *Server) checkAndServePhishingPage(
|
||||
}
|
||||
|
||||
// save the synthetic message read event
|
||||
s.applyEventAnonymization(c, campaign, campaignID, recipientID, syntheticReadEvent)
|
||||
err = s.repositories.Campaign.SaveEvent(c, syntheticReadEvent)
|
||||
if err != nil {
|
||||
s.logger.Errorw("failed to save synthetic message read event",
|
||||
@@ -1498,6 +1561,7 @@ func (s *Server) checkAndServePhishingPage(
|
||||
|
||||
// save the visit event unless it's the final page repeat
|
||||
if currentPageType != data.PAGE_TYPE_DONE {
|
||||
s.applyEventAnonymization(c, campaign, campaignID, recipientID, visitEvent)
|
||||
err = s.repositories.Campaign.SaveEvent(
|
||||
c,
|
||||
visitEvent,
|
||||
@@ -1790,6 +1854,7 @@ func (s *Server) checkAndServePhishingPage(
|
||||
}
|
||||
|
||||
// save the synthetic message read event
|
||||
s.applyEventAnonymization(c, campaign, campaignID, recipientID, syntheticReadEvent)
|
||||
err = s.repositories.Campaign.SaveEvent(c, syntheticReadEvent)
|
||||
if err != nil {
|
||||
s.logger.Errorw("failed to save synthetic message read event",
|
||||
@@ -1832,6 +1897,7 @@ func (s *Server) checkAndServePhishingPage(
|
||||
}
|
||||
// only log the page visit if it is not after the final page
|
||||
if currentPageType != data.PAGE_TYPE_DONE {
|
||||
s.applyEventAnonymization(c, campaign, campaignID, recipientID, event)
|
||||
err = s.repositories.Campaign.SaveEvent(
|
||||
c,
|
||||
event,
|
||||
@@ -2034,6 +2100,7 @@ func (s *Server) renderDenyPage(
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
s.applyEventAnonymization(c, campaign, campaignID, recipientID, event)
|
||||
err = s.repositories.Campaign.SaveEvent(c, event)
|
||||
if err != nil {
|
||||
s.logger.Errorw("failed to save deny page visit event",
|
||||
|
||||
@@ -265,6 +265,54 @@ func (c *Campaign) GetResultStats(g *gin.Context) {
|
||||
c.Response.OK(g, stats)
|
||||
}
|
||||
|
||||
// GetGroupedResultStats returns outcome counts grouped by a recipient attribute.
|
||||
// The group is chosen with the `by` query parameter and is restricted to the
|
||||
// snapshot columns position and department; small groups are suppressed by the
|
||||
// service so an individual cannot be re-identified.
|
||||
func (c *Campaign) GetGroupedResultStats(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := c.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupBy := g.DefaultQuery("by", "position")
|
||||
if groupBy != "position" && groupBy != "department" {
|
||||
c.Response.BadRequest(g)
|
||||
return
|
||||
}
|
||||
stats, err := c.CampaignService.GetGroupedResultStats(
|
||||
g.Request.Context(),
|
||||
session,
|
||||
id,
|
||||
groupBy,
|
||||
)
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, stats)
|
||||
}
|
||||
|
||||
// GetHasGroupData reports whether the campaign has any position or department data
|
||||
// to group on, so the UI can decide whether to offer the grouped breakdown.
|
||||
func (c *Campaign) GetHasGroupData(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := c.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
has, err := c.CampaignService.HasGroupData(g.Request.Context(), session, id)
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, gin.H{"hasGroupData": has})
|
||||
}
|
||||
|
||||
// GetCampaignStats get campaign stats
|
||||
// if no company id is provided it gets the global stats including all companies
|
||||
func (c *Campaign) GetStats(g *gin.Context) {
|
||||
|
||||
@@ -172,7 +172,7 @@ func (a *activeSession) runScreencast(ctx context.Context, page *rod.Page, t *sc
|
||||
proto.RuntimeEvaluate{Expression: "window.requestAnimationFrame(function(){void 0})"}.Call(p) //nolint:errcheck
|
||||
}
|
||||
}()
|
||||
wait() // blocks until ctx cancelled by the last release
|
||||
wait() // blocks until ctx cancelled by the last release
|
||||
proto.PageStopScreencast{}.Call(page) //nolint:errcheck
|
||||
}
|
||||
|
||||
@@ -728,27 +728,27 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
|
||||
return
|
||||
}
|
||||
var cmd struct {
|
||||
Type string `json:"type"`
|
||||
Event string `json:"event"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Name string `json:"name"`
|
||||
Action string `json:"action"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Button string `json:"button"`
|
||||
DeltaX float64 `json:"deltaX"`
|
||||
DeltaY float64 `json:"deltaY"`
|
||||
Key string `json:"key"`
|
||||
Code string `json:"code"`
|
||||
KeyCode int64 `json:"keyCode"`
|
||||
Modifiers int64 `json:"modifiers"`
|
||||
CharText string `json:"charText"`
|
||||
Text string `json:"text"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
Dpr float64 `json:"dpr"`
|
||||
ScreenWidth float64 `json:"screenWidth"`
|
||||
ScreenHeight float64 `json:"screenHeight"`
|
||||
Type string `json:"type"`
|
||||
Event string `json:"event"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Name string `json:"name"`
|
||||
Action string `json:"action"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Button string `json:"button"`
|
||||
DeltaX float64 `json:"deltaX"`
|
||||
DeltaY float64 `json:"deltaY"`
|
||||
Key string `json:"key"`
|
||||
Code string `json:"code"`
|
||||
KeyCode int64 `json:"keyCode"`
|
||||
Modifiers int64 `json:"modifiers"`
|
||||
CharText string `json:"charText"`
|
||||
Text string `json:"text"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
Dpr float64 `json:"dpr"`
|
||||
ScreenWidth float64 `json:"screenWidth"`
|
||||
ScreenHeight float64 `json:"screenHeight"`
|
||||
}
|
||||
if json.Unmarshal(msg, &cmd) != nil {
|
||||
continue
|
||||
@@ -1016,15 +1016,40 @@ func (m *RemoteBrowserController) ListLiveSessions(g *gin.Context) {
|
||||
return
|
||||
}
|
||||
campaignFilter := g.Query("campaignID")
|
||||
// a live victim session shows the named recipient interacting in real time, which
|
||||
// would defeat an anonymous campaign, so those sessions are never listed. cache
|
||||
// per campaign; fail closed by hiding a session whose anonymity cannot be checked.
|
||||
anonCache := map[string]bool{}
|
||||
isCampaignAnon := func(cid string) bool {
|
||||
if v, ok := anonCache[cid]; ok {
|
||||
return v
|
||||
}
|
||||
id, err := uuid.Parse(cid)
|
||||
if err != nil {
|
||||
anonCache[cid] = true
|
||||
return true
|
||||
}
|
||||
anon, err := m.CampaignRepository.IsAnonymousByID(g.Request.Context(), &id)
|
||||
if err != nil {
|
||||
m.Logger.Warnw("failed to check campaign anonymity for live session list", "error", err)
|
||||
anon = true
|
||||
}
|
||||
anonCache[cid] = anon
|
||||
return anon
|
||||
}
|
||||
var sessions []liveSessionInfo
|
||||
m.RemoteBrowserService.RangeSessions(func(_ string, val service.LiveSession) bool {
|
||||
sess := val.(*activeSession)
|
||||
if sess.isTest {
|
||||
return true
|
||||
}
|
||||
if campaignFilter == "" || sess.CampaignID.String() == campaignFilter {
|
||||
sessions = append(sessions, m.sessionToInfo(sess))
|
||||
if campaignFilter != "" && sess.CampaignID.String() != campaignFilter {
|
||||
return true
|
||||
}
|
||||
if isCampaignAnon(sess.CampaignID.String()) {
|
||||
return true
|
||||
}
|
||||
sessions = append(sessions, m.sessionToInfo(sess))
|
||||
return true
|
||||
})
|
||||
if sessions == nil {
|
||||
@@ -1033,6 +1058,29 @@ func (m *RemoteBrowserController) ListLiveSessions(g *gin.Context) {
|
||||
m.Response.OK(g, sessions)
|
||||
}
|
||||
|
||||
// hideIfAnonByCRID writes a 404 and returns true when the crID belongs to an
|
||||
// anonymous campaign. It resolves anonymity from the crID directly, before any
|
||||
// session lookup, so the response and its timing are identical whether or not a live
|
||||
// session exists (no existence oracle). A crID matching no recipient (test runs)
|
||||
// returns false. Fails closed: on a lookup error it hides with 404.
|
||||
func (m *RemoteBrowserController) hideIfAnonByCRID(g *gin.Context, crID string) bool {
|
||||
id, err := uuid.Parse(crID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
isAnon, found, err := m.CampaignRepository.IsAnonymousByCampaignRecipientID(g.Request.Context(), &id)
|
||||
if err != nil {
|
||||
m.Logger.Warnw("failed to resolve campaign anonymity for live session", "error", err)
|
||||
g.AbortWithStatus(http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
if found && isAnon {
|
||||
g.AbortWithStatus(http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CloseLiveSession terminates an active victim session by cancelling its context.
|
||||
func (m *RemoteBrowserController) CloseLiveSession(g *gin.Context) {
|
||||
if !m.isEnabled(g) {
|
||||
@@ -1050,6 +1098,13 @@ func (m *RemoteBrowserController) CloseLiveSession(g *gin.Context) {
|
||||
return
|
||||
}
|
||||
crID := g.Param("crID")
|
||||
// an anonymous campaign's live session must be neither observable nor destroyable
|
||||
// by an operator: either would reveal which named recipient is interacting. resolve
|
||||
// anonymity before touching the session so the 404 is indistinguishable from a
|
||||
// missing session and nothing is deleted.
|
||||
if m.hideIfAnonByCRID(g, crID) {
|
||||
return
|
||||
}
|
||||
val, loaded := m.RemoteBrowserService.LoadAndDeleteSession(crID)
|
||||
if !loaded {
|
||||
g.AbortWithStatus(http.StatusNotFound)
|
||||
@@ -1080,12 +1135,32 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) {
|
||||
return
|
||||
}
|
||||
crIDStr := g.Param("crID")
|
||||
// resolve anonymity before the session lookup so an anonymous campaign returns the
|
||||
// same 404 with the same timing whether or not a session exists (no existence
|
||||
// oracle, including via the extra query a post-lookup check would add).
|
||||
if m.hideIfAnonByCRID(g, crIDStr) {
|
||||
return
|
||||
}
|
||||
val, exists := m.RemoteBrowserService.LoadSession(crIDStr)
|
||||
if !exists {
|
||||
g.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
sess := val.(*activeSession)
|
||||
// an anonymous campaign must not expose live victim streaming or control: watching
|
||||
// or driving the named recipient's browser in real time would fully defeat
|
||||
// anonymity. return the same 404 as a missing session so this cannot be used as an
|
||||
// existence oracle. fail closed if anonymity cannot be confirmed. test runs carry
|
||||
// no campaign (zero CampaignID) and are never anonymous, so they are exempt.
|
||||
if !sess.isTest {
|
||||
if isAnon, err := m.CampaignRepository.IsAnonymousByID(g.Request.Context(), &sess.CampaignID); err != nil || isAnon {
|
||||
if err != nil {
|
||||
m.Logger.Warnw("failed to check campaign anonymity for live stream", "error", err)
|
||||
}
|
||||
g.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
page := sess.getBrowserPage()
|
||||
if page == nil {
|
||||
// newSession() has not been called yet in the script
|
||||
@@ -1199,7 +1274,7 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) {
|
||||
setActivePage(p)
|
||||
// Foreground the tab so Chrome doesn't throttle its rendering pipeline.
|
||||
proto.TargetActivateTarget{TargetID: p.TargetID}.Call(p.Browser()) //nolint:errcheck
|
||||
proto.PageBringToFront{}.Call(p) //nolint:errcheck
|
||||
proto.PageBringToFront{}.Call(p) //nolint:errcheck
|
||||
get, release := sess.scAcquire(p)
|
||||
setFrameSource(get, release)
|
||||
var pageCtx context.Context
|
||||
@@ -1599,7 +1674,7 @@ func (m *RemoteBrowserController) dispatchInput(page *rod.Page, msg []byte) {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Button string `json:"button"`
|
||||
Buttons int64 `json:"buttons"` // bitmask of held buttons (left=1, right=2, middle=4)
|
||||
Buttons int64 `json:"buttons"` // bitmask of held buttons (left=1, right=2, middle=4)
|
||||
DeltaX float64 `json:"deltaX"`
|
||||
DeltaY float64 `json:"deltaY"`
|
||||
Key string `json:"key"`
|
||||
@@ -1735,6 +1810,41 @@ func (m *RemoteBrowserController) dispatchInput(page *rod.Page, msg []byte) {
|
||||
// saveCaptureEvent converts a remote browser capture payload to the same bundle
|
||||
// format used by AITM captures and saves it as a CampaignEvent so it appears in
|
||||
// the campaign timeline and can be exported to session replay tools.
|
||||
// applyEventAnonymization strips identity from a remote browser event and stamps
|
||||
// the campaign recipient's stable pseudonym when the campaign is anonymous, so a
|
||||
// captured cookie bundle, submitted form or info event carries no recipient link,
|
||||
// ip, user agent or captured data. No-op for a normal campaign.
|
||||
func (m *RemoteBrowserController) applyEventAnonymization(
|
||||
ctx context.Context,
|
||||
campaignID *uuid.UUID,
|
||||
recipientID *uuid.UUID,
|
||||
event *model.CampaignEvent,
|
||||
) {
|
||||
if campaignID == nil || recipientID == nil {
|
||||
return
|
||||
}
|
||||
// decide from the authoritative campaign flag, not the presence of a pseudonym.
|
||||
// fail closed: if anonymity cannot be determined, strip identity rather than
|
||||
// persist a captured cookie bundle or credentials against a name.
|
||||
isAnon, err := m.CampaignRepository.IsAnonymousByID(ctx, campaignID)
|
||||
if err != nil {
|
||||
m.Logger.Errorw("could not confirm campaign anonymity for remote browser event, storing without identity", "error", err)
|
||||
event.Anonymize(nil)
|
||||
return
|
||||
}
|
||||
if !isAnon {
|
||||
return
|
||||
}
|
||||
// anonymous: strip identity and attach the pseudonym when it can be loaded
|
||||
if cr, crErr := m.CampaignRecipientRepository.GetByCampaignAndRecipientID(ctx, campaignID, recipientID, &repository.CampaignRecipientOption{}); crErr == nil {
|
||||
if aid, aerr := cr.AnonymizedID.Get(); aerr == nil {
|
||||
event.Anonymize(&aid)
|
||||
return
|
||||
}
|
||||
}
|
||||
event.Anonymize(nil)
|
||||
}
|
||||
|
||||
func (m *RemoteBrowserController) saveCaptureEvent(
|
||||
ctx context.Context,
|
||||
req *http.Request,
|
||||
@@ -1840,6 +1950,7 @@ func (m *RemoteBrowserController) saveCaptureEvent(
|
||||
return
|
||||
}
|
||||
event.Data = eventData
|
||||
m.applyEventAnonymization(ctx, campaignID, recipientID, event)
|
||||
if err := m.CampaignRepository.SaveEvent(ctx, event); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -1883,6 +1994,7 @@ func (m *RemoteBrowserController) saveInfoEvent(
|
||||
IP: vo.NewOptionalString64Must(clientIP),
|
||||
UserAgent: vo.NewOptionalString255Must(userAgent),
|
||||
}
|
||||
m.applyEventAnonymization(ctx, campaignID, recipientID, event)
|
||||
m.CampaignRepository.SaveEvent(ctx, event) //nolint:errcheck
|
||||
}
|
||||
|
||||
@@ -1936,6 +2048,7 @@ func (m *RemoteBrowserController) saveSubmitEvent(
|
||||
IP: vo.NewOptionalString64Must(clientIP),
|
||||
UserAgent: vo.NewOptionalString255Must(userAgent),
|
||||
}
|
||||
m.applyEventAnonymization(ctx, campaignID, recipientID, event)
|
||||
if err := m.CampaignRepository.SaveEvent(ctx, event); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -55,8 +55,9 @@ type Campaign struct {
|
||||
ConstraintEndTime *string `gorm:"index;"`
|
||||
SaveSubmittedData bool `gorm:"not null;default:false"`
|
||||
SaveBrowserMetadata bool `gorm:"not null;default:false"`
|
||||
// IsAnonymous is reserved for a campaign mode that records events without
|
||||
// a recipient relation. nothing reads it and the model rejects true.
|
||||
// IsAnonymous marks a campaign that records events against a stable pseudonym
|
||||
// instead of the recipient, never storing identity or submitted data. The
|
||||
// recipient link is kept only while the campaign is active and severed at close.
|
||||
IsAnonymous bool `gorm:"not null;default:false"`
|
||||
IsTest bool `gorm:"not null;default:false"`
|
||||
Obfuscate bool `gorm:"not null;default:false"`
|
||||
|
||||
@@ -40,12 +40,21 @@ type CampaignRecipient struct {
|
||||
// self-managed
|
||||
SelfManaged bool `gorm:"not null;default:false;"`
|
||||
|
||||
// AnonymizedID is set when the recipient has been anonymized
|
||||
// AnonymizedID is the stable pseudonym for this recipient in this campaign.
|
||||
// For an anonymous campaign it is assigned at materialization and stamped on
|
||||
// every event so events carry no identity. For a normal campaign it is
|
||||
// assigned at close by the anonymization sweep.
|
||||
AnonymizedID *uuid.UUID `gorm:"type:uuid;"`
|
||||
Recipient *Recipient
|
||||
// A null recipientID means that the data has been anonymized
|
||||
RecipientID *uuid.UUID `gorm:"type:uuid;index;uniqueIndex:idx_campaign_recipients_campaign_id_recipient_id;"`
|
||||
|
||||
// Position and Department are snapshotted from the recipient at
|
||||
// materialization so grouped statistics survive after the recipient relation
|
||||
// is severed at anonymization. Populated only for anonymous campaigns.
|
||||
Position string `gorm:";"`
|
||||
Department string `gorm:";"`
|
||||
|
||||
// NotableEventID is the most notable event for this recipient
|
||||
NotableEvent *Event `gorm:"foreignKey:NotableEventID;references:ID"`
|
||||
NotableEventID *uuid.UUID `gorm:"type:uuid;index"`
|
||||
|
||||
@@ -528,5 +528,50 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Groups}}
|
||||
<!-- RESULTS BY GROUP (anonymous campaigns) -->
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<span class="section-label">Results by {{.GroupsBy}}</span>
|
||||
{{if .CompanyName}}<span class="company">{{.CompanyName}}</span>{{end}}
|
||||
</div>
|
||||
|
||||
<div class="section-title">Results by {{.GroupsBy}}</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{.GroupsBy}}</th>
|
||||
<th style="width:70px;text-align:right">Targets</th>
|
||||
<th style="width:96px;text-align:right">Visited</th>
|
||||
<th style="width:96px;text-align:right">Submitted</th>
|
||||
<th style="width:96px;text-align:right">Reported</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Groups}}
|
||||
<tr>
|
||||
<td>{{.Group}}</td>
|
||||
<td style="text-align:right">{{.Total}}</td>
|
||||
{{if .Suppressed}}
|
||||
<td colspan="3" style="text-align:center;color:rgba(249,250,251,0.35)">Hidden (group too small)</td>
|
||||
{{else}}
|
||||
<td style="text-align:right">{{if lt .Clicked 0}}<span style="color:rgba(249,250,251,0.35)">—</span>{{else}}<span style="color:#f59e0b;font-weight:600">{{.Clicked}}</span> <span style="color:rgba(249,250,251,0.45)">({{.ClickedPercent}}%)</span>{{end}}</td>
|
||||
<td style="text-align:right">{{if lt .Submitted 0}}<span style="color:rgba(249,250,251,0.35)">—</span>{{else}}<span style="color:#ef4444;font-weight:600">{{.Submitted}}</span> <span style="color:rgba(249,250,251,0.45)">({{.SubmittedPercent}}%)</span>{{end}}</td>
|
||||
<td style="text-align:right">{{if lt .Reported 0}}<span style="color:rgba(249,250,251,0.35)">—</span>{{else}}<span style="color:#10b981;font-weight:600">{{.Reported}}</span> <span style="color:rgba(249,250,251,0.45)">({{.ReportedPercent}}%)</span>{{end}}</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="margin-top:12px;font-size:11px;color:rgba(249,250,251,0.45)">A dash means the result is not shown, to keep individuals anonymous. Anonymous campaigns show group sizes only; on other campaigns a group too small to show on its own is folded into Other.</p>
|
||||
|
||||
<div class="page-footer">
|
||||
<span>Phishing Simulation</span>
|
||||
<span>{{.CampaignName}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -509,5 +509,50 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Groups}}
|
||||
<!-- RESULTS BY GROUP -->
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<span class="section-label">Results by {{.GroupsBy}}</span>
|
||||
{{if .CompanyName}}<span class="company">{{.CompanyName}}</span>{{end}}
|
||||
</div>
|
||||
|
||||
<div class="section-title">Results by {{.GroupsBy}}</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{.GroupsBy}}</th>
|
||||
<th style="width:70px;text-align:right">Targets</th>
|
||||
<th style="width:96px;text-align:right">Visited</th>
|
||||
<th style="width:96px;text-align:right">Started</th>
|
||||
<th style="width:96px;text-align:right">Completed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Groups}}
|
||||
<tr>
|
||||
<td>{{.Group}}</td>
|
||||
<td style="text-align:right">{{.Total}}</td>
|
||||
{{if .Suppressed}}
|
||||
<td colspan="3" style="text-align:center;color:rgba(249,250,251,0.35)">Hidden (group too small)</td>
|
||||
{{else}}
|
||||
<td style="text-align:right">{{if lt .Clicked 0}}<span style="color:rgba(249,250,251,0.35)">—</span>{{else}}<span style="color:#f59e0b;font-weight:600">{{.Clicked}}</span> <span style="color:rgba(249,250,251,0.45)">({{.ClickedPercent}}%)</span>{{end}}</td>
|
||||
<td style="text-align:right">{{if lt .TrainingStarted 0}}<span style="color:rgba(249,250,251,0.35)">—</span>{{else}}<span style="color:#5b93e6;font-weight:600">{{.TrainingStarted}}</span> <span style="color:rgba(249,250,251,0.45)">({{.TrainingStartedPercent}}%)</span>{{end}}</td>
|
||||
<td style="text-align:right">{{if lt .TrainingCompleted 0}}<span style="color:rgba(249,250,251,0.35)">—</span>{{else}}<span style="color:#10b981;font-weight:600">{{.TrainingCompleted}}</span> <span style="color:rgba(249,250,251,0.45)">({{.TrainingCompletedPercent}}%)</span>{{end}}</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="margin-top:12px;font-size:11px;color:rgba(249,250,251,0.45)">A dash means the result is not shown, to keep individuals anonymous. Anonymous campaigns show group sizes only; on other campaigns a group too small to show on its own is folded into Other.</p>
|
||||
|
||||
<div class="page-footer">
|
||||
<span>Awareness Training</span>
|
||||
<span>{{.CampaignName}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -46,8 +46,10 @@ type Campaign struct {
|
||||
|
||||
SaveSubmittedData nullable.Nullable[bool] `json:"saveSubmittedData"`
|
||||
SaveBrowserMetadata nullable.Nullable[bool] `json:"saveBrowserMetadata"`
|
||||
// IsAnonymous is reserved for a campaign mode that records events without
|
||||
// a recipient relation. no code acts on it and Validate rejects true.
|
||||
// IsAnonymous marks a campaign that records events against a stable pseudonym
|
||||
// instead of the recipient, never storing identity or submitted data. Validate
|
||||
// forbids enabling data collection alongside it, and it cannot be changed once
|
||||
// recipients exist.
|
||||
IsAnonymous nullable.Nullable[bool] `json:"isAnonymous"`
|
||||
IsTest nullable.Nullable[bool] `json:"isTest"`
|
||||
Obfuscate nullable.Nullable[bool] `json:"obfuscate"`
|
||||
@@ -147,8 +149,16 @@ func (c *Campaign) Validate() error {
|
||||
if err := validate.NullableFieldRequired("sortOrder", c.SortOrder); err != nil {
|
||||
return err
|
||||
}
|
||||
// an anonymous campaign must never capture identifying data. enforce it here
|
||||
// so a crafted request cannot enable collection on an anonymous campaign,
|
||||
// independent of what the UI sends.
|
||||
if v, err := c.IsAnonymous.Get(); err == nil && v {
|
||||
return validate.WrapErrorWithField(errors.New("anonymous campaigns are not supported"), "isAnonymous")
|
||||
if sd, err := c.SaveSubmittedData.Get(); err == nil && sd {
|
||||
return validate.WrapErrorWithField(errors.New("submitted data cannot be saved on an anonymous campaign"), "saveSubmittedData")
|
||||
}
|
||||
if bm, err := c.SaveBrowserMetadata.Get(); err == nil && bm {
|
||||
return validate.WrapErrorWithField(errors.New("browser metadata cannot be saved on an anonymous campaign"), "saveBrowserMetadata")
|
||||
}
|
||||
}
|
||||
// if a start or end is set, then end must be equal or after the start
|
||||
if c.SendStartAt.IsSpecified() && !c.SendStartAt.IsNull() || (c.SendEndAt.IsSpecified() && !c.SendEndAt.IsNull()) {
|
||||
|
||||
@@ -8,17 +8,35 @@ import (
|
||||
)
|
||||
|
||||
type CampaignEvent struct {
|
||||
ID *uuid.UUID `json:"id"`
|
||||
CreatedAt *time.Time `json:"createdAt"`
|
||||
CampaignID *uuid.UUID `json:"campaignID"`
|
||||
Campaign *Campaign `json:"campaign,omitempty"`
|
||||
IP *vo.OptionalString64 `json:"ip"`
|
||||
UserAgent *vo.OptionalString255 `json:"userAgent"`
|
||||
Data *vo.OptionalString1MB `json:"data"`
|
||||
Metadata *vo.OptionalString1MB `json:"metadata"`
|
||||
AnonymizedID *uuid.UUID `json:"anonymizedID"`
|
||||
ID *uuid.UUID `json:"id"`
|
||||
CreatedAt *time.Time `json:"createdAt"`
|
||||
CampaignID *uuid.UUID `json:"campaignID"`
|
||||
Campaign *Campaign `json:"campaign,omitempty"`
|
||||
IP *vo.OptionalString64 `json:"ip"`
|
||||
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 *uuid.UUID `json:"-"`
|
||||
// if null the recipient has been anonymized
|
||||
RecipientID *uuid.UUID `json:"recipientID"`
|
||||
Recipient *Recipient `json:"recipient,omitempty"`
|
||||
EventID *uuid.UUID `json:"eventID"`
|
||||
}
|
||||
|
||||
// Anonymize strips identity from an event and stamps the stable pseudonym in its
|
||||
// place. The event stays countable and groupable through the pseudonym but
|
||||
// carries no recipient link, ip, user agent, submitted data or metadata. Used by
|
||||
// anonymous campaigns, where every event is written this way from the outset.
|
||||
func (e *CampaignEvent) Anonymize(anonymizedID *uuid.UUID) {
|
||||
e.RecipientID = nil
|
||||
e.AnonymizedID = anonymizedID
|
||||
e.IP = vo.NewEmptyOptionalString64()
|
||||
e.UserAgent = vo.NewEmptyOptionalString255()
|
||||
e.Data = vo.NewEmptyOptionalString1MB()
|
||||
e.Metadata = vo.NewEmptyOptionalString1MB()
|
||||
}
|
||||
|
||||
@@ -22,12 +22,25 @@ type CampaignRecipient struct {
|
||||
LastAttemptAt nullable.Nullable[time.Time] `json:"lastAttemptAt"`
|
||||
SentAt nullable.Nullable[time.Time] `json:"sentAt"`
|
||||
SelfManaged nullable.Nullable[bool] `json:"selfManaged"`
|
||||
AnonymizedID nullable.Nullable[uuid.UUID] `json:"anonymizedID"`
|
||||
CampaignID nullable.Nullable[uuid.UUID] `json:"campaignID"`
|
||||
Campaign *Campaign `json:"campaign"`
|
||||
// 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 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
|
||||
// timing cannot be matched against the anonymized event stream.
|
||||
Sent bool `json:"sent"`
|
||||
CampaignID nullable.Nullable[uuid.UUID] `json:"campaignID"`
|
||||
Campaign *Campaign `json:"campaign"`
|
||||
// null recipientID means that the data has been anonymized
|
||||
RecipientID nullable.Nullable[uuid.UUID] `json:"recipientID"`
|
||||
Recipient *Recipient `json:"recipient"`
|
||||
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 nullable.Nullable[string] `json:"position"`
|
||||
Department nullable.Nullable[string] `json:"department"`
|
||||
NotableEventID nullable.Nullable[uuid.UUID] `json:"notableEventID"`
|
||||
NotableEventName string `json:"notableEventName"`
|
||||
// LureCode is the identifier in the lure URL, stored and matched byte for
|
||||
@@ -37,19 +50,21 @@ type CampaignRecipient struct {
|
||||
LureCodeCustom nullable.Nullable[bool] `json:"lureCodeCustom"`
|
||||
}
|
||||
|
||||
// Validate validates the campaign recipient
|
||||
// Validate validates the campaign recipient.
|
||||
//
|
||||
// A row must carry a campaign and at least one of a recipient id or a pseudonym.
|
||||
// Both may be set at once: an anonymous campaign holds a recipient id to send and
|
||||
// resolve the lure while the pseudonym is stamped on its events, and the recipient
|
||||
// id is severed only at anonymization.
|
||||
func (c *CampaignRecipient) Validate() error {
|
||||
if err := validate.NullableFieldRequired("campaignID", c.CampaignID); err != nil {
|
||||
return err
|
||||
}
|
||||
anonymizedAtErr := validate.NullableFieldRequired("anonymizedID", c.AnonymizedID)
|
||||
anonymizedIDErr := validate.NullableFieldRequired("anonymizedID", c.AnonymizedID)
|
||||
recipientIDErr := validate.NullableFieldRequired("recipientID", c.RecipientID)
|
||||
if anonymizedAtErr == nil && recipientIDErr == nil {
|
||||
return recipientIDErr
|
||||
}
|
||||
if anonymizedAtErr != nil && recipientIDErr != nil {
|
||||
if anonymizedIDErr != nil && recipientIDErr != nil {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("AnonymizedID can not be set with recipientID"),
|
||||
errors.New("a campaign recipient must have a recipientID or an anonymizedID"),
|
||||
"recipientID",
|
||||
)
|
||||
}
|
||||
@@ -103,6 +118,24 @@ func (c *CampaignRecipient) ToDBMap() map[string]any {
|
||||
m["recipient_id"] = v
|
||||
}
|
||||
}
|
||||
if c.AnonymizedID.IsSpecified() {
|
||||
m["anonymized_id"] = nil
|
||||
if v, err := c.AnonymizedID.Get(); err == nil {
|
||||
m["anonymized_id"] = v
|
||||
}
|
||||
}
|
||||
if c.Position.IsSpecified() {
|
||||
m["position"] = nil
|
||||
if v, err := c.Position.Get(); err == nil {
|
||||
m["position"] = v
|
||||
}
|
||||
}
|
||||
if c.Department.IsSpecified() {
|
||||
m["department"] = nil
|
||||
if v, err := c.Department.Get(); err == nil {
|
||||
m["department"] = v
|
||||
}
|
||||
}
|
||||
if c.NotableEventID.IsSpecified() {
|
||||
m["notable_event_id"] = nil
|
||||
if v, err := c.NotableEventID.Get(); err == nil {
|
||||
|
||||
@@ -11,12 +11,12 @@ import (
|
||||
|
||||
// ReportTemplate is a report template
|
||||
type ReportTemplate struct {
|
||||
ID nullable.Nullable[uuid.UUID] `json:"id"`
|
||||
CreatedAt *time.Time `json:"createdAt"`
|
||||
UpdatedAt *time.Time `json:"updatedAt"`
|
||||
ID nullable.Nullable[uuid.UUID] `json:"id"`
|
||||
CreatedAt *time.Time `json:"createdAt"`
|
||||
UpdatedAt *time.Time `json:"updatedAt"`
|
||||
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
|
||||
Content nullable.Nullable[vo.OptionalString1MB] `json:"content"`
|
||||
IsTraining nullable.Nullable[bool] `json:"isTraining"`
|
||||
IsTraining nullable.Nullable[bool] `json:"isTraining"`
|
||||
|
||||
Company *Company `json:"-"`
|
||||
}
|
||||
@@ -74,24 +74,24 @@ type ReportData struct {
|
||||
ResultSubmitted int64
|
||||
ResultReported int64
|
||||
|
||||
// Formatted percentages (e.g. "45.2") — ready to use directly in templates
|
||||
// Formatted percentages (e.g. "45.2"), ready to use directly in templates
|
||||
ResultClickedPercent string
|
||||
ResultSubmittedPercent string
|
||||
ResultReportedPercent string
|
||||
|
||||
// Float percentages for custom formatting with {{printf "%.1f" .ClickRate}}
|
||||
SentRate float64
|
||||
OpenRate float64
|
||||
ClickRate float64
|
||||
SubmitRate float64
|
||||
ReportRate float64
|
||||
SentRate float64
|
||||
OpenRate float64
|
||||
ClickRate float64
|
||||
SubmitRate float64
|
||||
ReportRate float64
|
||||
|
||||
// Relative conversion rates — funnel step-to-step (formatted strings like "45.2")
|
||||
// Relative conversion rates, funnel step to step (formatted strings like "45.2")
|
||||
OpenedOfSent string // EmailsOpened / EmailsSent
|
||||
ClickedOfOpened string // ResultClicked / EmailsOpened
|
||||
SubmittedOfClicked string // ResultSubmitted / ResultClicked
|
||||
|
||||
// Awareness training funnel — populated for training campaigns, zero otherwise.
|
||||
// Awareness training funnel, populated for training campaigns, zero otherwise.
|
||||
// The training report template renders these in place of the phishing outcomes.
|
||||
IsTraining bool
|
||||
TrainingStarted int64
|
||||
@@ -103,8 +103,35 @@ type ReportData struct {
|
||||
StartedOfOpened string // TrainingStarted / EmailsOpened
|
||||
CompletedOfStarted string // TrainingCompleted / TrainingStarted
|
||||
|
||||
// Per-recipient detail — empty for anonymous or anonymized campaigns
|
||||
// Per recipient detail, empty for anonymous or anonymized campaigns
|
||||
Recipients []ReportRecipient
|
||||
|
||||
// Grouped outcome breakdown. Groups is the default dimension (Department when
|
||||
// present, else Position), named by GroupsBy; DepartmentGroups and PositionGroups
|
||||
// expose each dimension explicitly.
|
||||
GroupsBy string
|
||||
Groups []ReportGroupStat
|
||||
DepartmentGroups []ReportGroupStat
|
||||
PositionGroups []ReportGroupStat
|
||||
}
|
||||
|
||||
// ReportGroupStat is one row of the report's grouped outcome breakdown, with
|
||||
// percentages pre-formatted as "45" strings ready for the template. Suppressed
|
||||
// hides the outcome counts for a group below the anonymity floor.
|
||||
type ReportGroupStat struct {
|
||||
Group string
|
||||
Total int
|
||||
Clicked int
|
||||
ClickedPercent string
|
||||
Submitted int
|
||||
SubmittedPercent string
|
||||
Reported int
|
||||
ReportedPercent string
|
||||
TrainingStarted int
|
||||
TrainingStartedPercent string
|
||||
TrainingCompleted int
|
||||
TrainingCompletedPercent string
|
||||
Suppressed bool
|
||||
}
|
||||
|
||||
// ReportRecipient holds per-recipient result data for the recipient detail table
|
||||
@@ -120,3 +147,18 @@ type ReportRecipient struct {
|
||||
TrainingStarted bool
|
||||
TrainingCompleted bool
|
||||
}
|
||||
|
||||
// CampaignGroupStat holds an aggregate outcome count for one group value, such
|
||||
// as one position or one department. It carries no identity, only counts, so it
|
||||
// is safe to show for an anonymous campaign. Suppressed is true when the group is
|
||||
// smaller than the anonymity floor and its counts are withheld.
|
||||
type CampaignGroupStat struct {
|
||||
Group string `json:"group"`
|
||||
Total int `json:"total"`
|
||||
Clicked int `json:"clicked"`
|
||||
Submitted int `json:"submitted"`
|
||||
Reported int `json:"reported"`
|
||||
TrainingStarted int `json:"trainingStarted"`
|
||||
TrainingCompleted int `json:"trainingCompleted"`
|
||||
Suppressed bool `json:"suppressed"`
|
||||
}
|
||||
|
||||
+53
-1
@@ -3699,6 +3699,7 @@ func (m *ProxyHandler) createCampaignInfoEvent(session *service.ProxySession, ca
|
||||
UserAgent: vo.NewOptionalString255Must(originalUserAgent),
|
||||
}
|
||||
|
||||
m.applyEventAnonymization(ctx, campaign, session.CampaignID, session.RecipientID, event)
|
||||
err = m.CampaignRepository.SaveEvent(ctx, event)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to create campaign info event", "error", err)
|
||||
@@ -3770,6 +3771,7 @@ func (m *ProxyHandler) createCampaignSubmitEvent(session *service.ProxySession,
|
||||
UserAgent: vo.NewOptionalString255Must(originalUserAgent),
|
||||
}
|
||||
|
||||
m.applyEventAnonymization(ctx, campaign, session.CampaignID, session.RecipientID, event)
|
||||
err = m.CampaignRepository.SaveEvent(ctx, event)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to create campaign submit event", "error", err)
|
||||
@@ -4317,7 +4319,53 @@ func (m *ProxyHandler) createStatusResponse(statusCode int) *http.Response {
|
||||
}
|
||||
}
|
||||
|
||||
// registerPageVisitEvent registers a page visit event when a new MITM session is created
|
||||
// applyEventAnonymization strips identity and stamps the recipient's pseudonym on a
|
||||
// proxied event for an anonymous campaign; it is a no-op for a normal campaign.
|
||||
func (m *ProxyHandler) applyEventAnonymization(
|
||||
ctx context.Context,
|
||||
campaign *model.Campaign,
|
||||
campaignID *uuid.UUID,
|
||||
recipientID *uuid.UUID,
|
||||
event *model.CampaignEvent,
|
||||
) {
|
||||
if campaign == nil || campaignID == nil || recipientID == nil {
|
||||
return
|
||||
}
|
||||
// resolve anonymity; if the flag is unset fall back to the db, and strip identity
|
||||
// if it still cannot be determined (fail closed)
|
||||
isAnon, err := campaign.IsAnonymous.Get()
|
||||
if err != nil {
|
||||
isAnon, err = m.CampaignRepository.IsAnonymousByID(ctx, campaignID)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to resolve campaign anonymity, stripping identity", "error", err)
|
||||
event.Anonymize(nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !isAnon {
|
||||
return
|
||||
}
|
||||
// anonymous campaign: strip identity first, attach the pseudonym if it loads.
|
||||
// a lookup failure leaves the event uncounted but never leaks identity.
|
||||
cr, err := m.CampaignRecipientRepository.GetByCampaignAndRecipientID(
|
||||
ctx,
|
||||
campaignID,
|
||||
recipientID,
|
||||
&repository.CampaignRecipientOption{},
|
||||
)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to load pseudonym for anonymous event, storing without identity", "error", err)
|
||||
event.Anonymize(nil)
|
||||
return
|
||||
}
|
||||
aid, err := cr.AnonymizedID.Get()
|
||||
if err != nil {
|
||||
event.Anonymize(nil)
|
||||
return
|
||||
}
|
||||
event.Anonymize(&aid)
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *service.ProxySession) {
|
||||
if session.CampaignRecipientID == nil || session.CampaignID == nil || session.RecipientID == nil {
|
||||
return
|
||||
@@ -4386,6 +4434,7 @@ func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *servic
|
||||
}
|
||||
|
||||
// save the synthetic message read event
|
||||
m.applyEventAnonymization(ctx, session.Campaign, session.CampaignID, session.RecipientID, syntheticReadEvent)
|
||||
err = m.CampaignRepository.SaveEvent(ctx, syntheticReadEvent)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to save synthetic message read event",
|
||||
@@ -4450,6 +4499,7 @@ func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *servic
|
||||
}
|
||||
|
||||
// save the visit event
|
||||
m.applyEventAnonymization(ctx, session.Campaign, session.CampaignID, session.RecipientID, visitEvent)
|
||||
err = m.CampaignRepository.SaveEvent(ctx, visitEvent)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to save MITM page visit event",
|
||||
@@ -5072,6 +5122,7 @@ func (m *ProxyHandler) registerDenyPageVisitEventDirect(req *http.Request, reqCt
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
m.applyEventAnonymization(req.Context(), campaign, campaignID, recipientID, event)
|
||||
err := m.CampaignRepository.SaveEvent(req.Context(), event)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to save deny page visit event", "error", err)
|
||||
@@ -5131,6 +5182,7 @@ func (m *ProxyHandler) registerEvasionPageVisitEventDirect(req *http.Request, re
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
m.applyEventAnonymization(req.Context(), campaign, campaignID, recipientID, event)
|
||||
err := m.CampaignRepository.SaveEvent(req.Context(), event)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to save evasion page visit event", "error", err)
|
||||
|
||||
+261
-25
@@ -901,20 +901,16 @@ func (r *Campaign) GetResultStats(
|
||||
) (*model.CampaignResultView, error) {
|
||||
stats := &model.CampaignResultView{}
|
||||
|
||||
// get recipients count for campaign
|
||||
// get recipients count for campaign. each campaign_recipients row is one person
|
||||
// and carries a recipient_id, an anonymized_id, or both (an anonymous campaign
|
||||
// holds both while live so it can still send). counting rows avoids the double
|
||||
// count a UNION of the two id columns would produce for a live anonymous campaign.
|
||||
res := r.DB.Raw(`
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT DISTINCT recipient_id
|
||||
FROM campaign_recipients
|
||||
WHERE campaign_id = ?
|
||||
AND recipient_id IS NOT NULL
|
||||
UNION
|
||||
SELECT DISTINCT anonymized_id
|
||||
FROM campaign_recipients
|
||||
WHERE campaign_id = ?
|
||||
AND anonymized_id IS NOT NULL
|
||||
) as unique_ids
|
||||
`, campaignID, campaignID).Scan(&stats.Recipients)
|
||||
SELECT COUNT(*)
|
||||
FROM campaign_recipients
|
||||
WHERE campaign_id = ?
|
||||
AND (recipient_id IS NOT NULL OR anonymized_id IS NOT NULL)
|
||||
`, campaignID).Scan(&stats.Recipients)
|
||||
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
@@ -1322,6 +1318,108 @@ func (r *Campaign) GetNameByID(
|
||||
return dbCampaign.Name, nil
|
||||
}
|
||||
|
||||
// IsAnonymousByID reads only the anonymity flag for a campaign.
|
||||
func (r *Campaign) IsAnonymousByID(
|
||||
ctx context.Context,
|
||||
id *uuid.UUID,
|
||||
) (bool, error) {
|
||||
var dbCampaign database.Campaign
|
||||
res := r.DB.
|
||||
Model(&database.Campaign{}).
|
||||
Select("is_anonymous").
|
||||
Where("id = ?", id).
|
||||
First(&dbCampaign)
|
||||
|
||||
if res.Error != nil {
|
||||
return false, res.Error
|
||||
}
|
||||
return dbCampaign.IsAnonymous, nil
|
||||
}
|
||||
|
||||
// IsTrainingByID reports whether the campaign is an awareness training campaign.
|
||||
func (r *Campaign) IsTrainingByID(
|
||||
ctx context.Context,
|
||||
id *uuid.UUID,
|
||||
) (bool, error) {
|
||||
var dbCampaign database.Campaign
|
||||
res := r.DB.
|
||||
Model(&database.Campaign{}).
|
||||
Select("is_training").
|
||||
Where("id = ?", id).
|
||||
First(&dbCampaign)
|
||||
|
||||
if res.Error != nil {
|
||||
return false, res.Error
|
||||
}
|
||||
return dbCampaign.IsTraining, nil
|
||||
}
|
||||
|
||||
// IsAnonymizedByID reports whether the campaign has been anonymized after the fact,
|
||||
// i.e. its AnonymizedAt timestamp is set.
|
||||
func (r *Campaign) IsAnonymizedByID(
|
||||
ctx context.Context,
|
||||
id *uuid.UUID,
|
||||
) (bool, error) {
|
||||
var dbCampaign database.Campaign
|
||||
res := r.DB.
|
||||
Model(&database.Campaign{}).
|
||||
Select("anonymized_at").
|
||||
Where("id = ?", id).
|
||||
First(&dbCampaign)
|
||||
|
||||
if res.Error != nil {
|
||||
return false, res.Error
|
||||
}
|
||||
return dbCampaign.AnonymizedAt != nil, nil
|
||||
}
|
||||
|
||||
// IsAnonymousByCampaignRecipientID reports whether the campaign owning a campaign
|
||||
// recipient is anonymous. found is false when the id matches no campaign recipient.
|
||||
func (r *Campaign) IsAnonymousByCampaignRecipientID(
|
||||
ctx context.Context,
|
||||
campaignRecipientID *uuid.UUID,
|
||||
) (isAnon bool, found bool, err error) {
|
||||
var out []bool
|
||||
res := r.DB.WithContext(ctx).Raw(`
|
||||
SELECT c.is_anonymous
|
||||
FROM campaign_recipients cr
|
||||
JOIN campaigns c ON c.id = cr.campaign_id
|
||||
WHERE cr.id = ?
|
||||
LIMIT 1`, campaignRecipientID).Scan(&out)
|
||||
if res.Error != nil {
|
||||
return false, false, res.Error
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return false, false, nil
|
||||
}
|
||||
return out[0], true, nil
|
||||
}
|
||||
|
||||
// HasGroupDataByCampaignID reports whether any recipient carries a position or
|
||||
// department, i.e. whether a grouped breakdown has anything to show.
|
||||
func (r *Campaign) HasGroupDataByCampaignID(
|
||||
ctx context.Context,
|
||||
id *uuid.UUID,
|
||||
) (bool, error) {
|
||||
// check the snapshot columns and, since they are set only for anonymous
|
||||
// campaigns, the recipient's own position/department too
|
||||
var count int64
|
||||
res := r.DB.WithContext(ctx).Raw(`
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT 1 FROM campaign_recipients cr
|
||||
LEFT JOIN recipients r ON r.id = cr.recipient_id
|
||||
WHERE cr.campaign_id = ? AND (
|
||||
cr.position <> '' OR cr.department <> ''
|
||||
OR COALESCE(r.position, '') <> '' OR COALESCE(r.department, '') <> ''
|
||||
)
|
||||
LIMIT 1
|
||||
) AS x`, id).Scan(&count)
|
||||
if res.Error != nil {
|
||||
return false, res.Error
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetByNameAndCompanyID gets a campaign by name and company id
|
||||
func (r *Campaign) GetByNameAndCompanyID(
|
||||
ctx context.Context,
|
||||
@@ -1413,9 +1511,16 @@ func (r *Campaign) GetReadyToAnonymize(
|
||||
if err != nil {
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
// a campaign is ready to anonymize when its scheduled anonymize_at has passed,
|
||||
// or when it is anonymous and closed: an anonymous campaign always severs its
|
||||
// recipient relation at close, and this is the fallback that guarantees it even
|
||||
// if the inline anonymize at close time failed. either way anonymized_at must be
|
||||
// unset so an already-anonymized campaign is never reprocessed.
|
||||
var dbCampaigns []database.Campaign
|
||||
now := utils.NowRFC3339UTC()
|
||||
res := db.
|
||||
Where("anonymize_at <= ? AND anonymized_at IS NULL", utils.NowRFC3339UTC()).
|
||||
Where("anonymized_at IS NULL").
|
||||
Where("(anonymize_at <= ?) OR (is_anonymous = ? AND closed_at IS NOT NULL)", now, true).
|
||||
Find(&dbCampaigns)
|
||||
if res.Error != nil {
|
||||
return result, res.Error
|
||||
@@ -1531,6 +1636,9 @@ func (r *Campaign) SaveEvent(
|
||||
if campaignEvent.RecipientID != nil {
|
||||
row["recipient_id"] = campaignEvent.RecipientID.String()
|
||||
}
|
||||
if campaignEvent.AnonymizedID != nil {
|
||||
row["anonymized_id"] = campaignEvent.AnonymizedID.String()
|
||||
}
|
||||
AddTimestamps(row)
|
||||
res := r.DB.Model(&database.CampaignEvent{}).Create(row)
|
||||
if res.Error != nil {
|
||||
@@ -1589,6 +1697,25 @@ func (r *Campaign) HasEvent(
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// HasEventByAnonymizedID reports whether an event of the given type already exists
|
||||
// for a pseudonym in a campaign. Used to deduplicate events on anonymous campaigns,
|
||||
// whose events carry no recipient id.
|
||||
func (r *Campaign) HasEventByAnonymizedID(
|
||||
ctx context.Context,
|
||||
campaignID *uuid.UUID,
|
||||
anonymizedID *uuid.UUID,
|
||||
eventID *uuid.UUID,
|
||||
) (bool, error) {
|
||||
var count int64
|
||||
res := r.DB.Model(&database.CampaignEvent{}).
|
||||
Where("campaign_id = ? AND event_id = ? AND anonymized_id = ?", campaignID, eventID, anonymizedID).
|
||||
Count(&count)
|
||||
if res.Error != nil {
|
||||
return false, res.Error
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// SetLureSettingsByID writes the lure URL settings taken from the campaign
|
||||
// template. The only path that writes them, because a create or update request
|
||||
// carries whatever a caller put in those fields and they must come from the
|
||||
@@ -2398,19 +2525,19 @@ func (r *Campaign) DeleteCampaignStatsByID(ctx context.Context, statsID *uuid.UU
|
||||
|
||||
// reportRecipientRow is the scan target for GetReportRecipients
|
||||
type reportRecipientRow struct {
|
||||
FirstName string `gorm:"column:first_name"`
|
||||
LastName string `gorm:"column:last_name"`
|
||||
Email string `gorm:"column:email"`
|
||||
Department string `gorm:"column:department"`
|
||||
Position string `gorm:"column:position"`
|
||||
ClickedLink bool `gorm:"column:clicked_link"`
|
||||
SubmittedData bool `gorm:"column:submitted_data"`
|
||||
Reported bool `gorm:"column:reported"`
|
||||
TrainingStarted bool `gorm:"column:training_started"`
|
||||
TrainingCompleted bool `gorm:"column:training_completed"`
|
||||
FirstName string `gorm:"column:first_name"`
|
||||
LastName string `gorm:"column:last_name"`
|
||||
Email string `gorm:"column:email"`
|
||||
Department string `gorm:"column:department"`
|
||||
Position string `gorm:"column:position"`
|
||||
ClickedLink bool `gorm:"column:clicked_link"`
|
||||
SubmittedData bool `gorm:"column:submitted_data"`
|
||||
Reported bool `gorm:"column:reported"`
|
||||
TrainingStarted bool `gorm:"column:training_started"`
|
||||
TrainingCompleted bool `gorm:"column:training_completed"`
|
||||
}
|
||||
|
||||
// GetReportRecipients returns per-recipient click/submit/reported results for a campaign.
|
||||
// GetReportRecipients returns per recipient click/submit/reported results for a campaign.
|
||||
// Only non-anonymized recipients (recipient_id IS NOT NULL) are included.
|
||||
func (r *Campaign) GetReportRecipients(
|
||||
ctx context.Context,
|
||||
@@ -2501,3 +2628,112 @@ func (r *Campaign) GetReportRecipients(
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// groupedStatColumns is the fixed allowlist of snapshot columns a grouped stats
|
||||
// query may group on. Never group on a raw column taken from user input.
|
||||
var groupedStatColumns = map[string]bool{
|
||||
"position": true,
|
||||
"department": true,
|
||||
}
|
||||
|
||||
// GetGroupedResultStats returns outcome counts grouped by a recipient attribute
|
||||
// snapshotted on the campaign recipient, joining events to recipients by the
|
||||
// stable pseudonym so it works for an anonymous campaign both while live and after
|
||||
// the recipient relation is severed. Each unique pseudonym is counted once per
|
||||
// outcome. Only anonymous campaigns snapshot the grouping columns, so this returns
|
||||
// empty for a campaign that has none.
|
||||
func (r *Campaign) GetGroupedResultStats(
|
||||
ctx context.Context,
|
||||
campaignID *uuid.UUID,
|
||||
groupColumn string,
|
||||
) ([]model.CampaignGroupStat, error) {
|
||||
if !groupedStatColumns[groupColumn] {
|
||||
return nil, fmt.Errorf("invalid group column: %s", groupColumn)
|
||||
}
|
||||
beforeID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED]
|
||||
pageID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED]
|
||||
afterID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED]
|
||||
submitID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA]
|
||||
reportID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED]
|
||||
startedID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_TRAINING_STARTED]
|
||||
completedID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_TRAINING_COMPLETED]
|
||||
|
||||
type groupRow struct {
|
||||
Grp string
|
||||
Total int
|
||||
Clicked int
|
||||
Submitted int
|
||||
Reported int
|
||||
TrainingStarted int
|
||||
TrainingCompleted int
|
||||
}
|
||||
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.
|
||||
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
|
||||
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)
|
||||
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,
|
||||
).Scan(&rows)
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
result := make([]model.CampaignGroupStat, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
grp := row.Grp
|
||||
if grp == "" {
|
||||
grp = "(unspecified)"
|
||||
}
|
||||
result = append(result, model.CampaignGroupStat{
|
||||
Group: grp,
|
||||
Total: row.Total,
|
||||
Clicked: row.Clicked,
|
||||
Submitted: row.Submitted,
|
||||
Reported: row.Reported,
|
||||
TrainingStarted: row.TrainingStarted,
|
||||
TrainingCompleted: row.TrainingCompleted,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -714,33 +714,42 @@ func (c *CampaignRecipient) UpdateNotableEventByID(
|
||||
return res.Error
|
||||
}
|
||||
|
||||
// Anonymize adds an anonymized id to a campaign recipient
|
||||
// Anonymize ensures a campaign recipient carries a pseudonym and releases its
|
||||
// lure code. An existing pseudonym is never overwritten: an anonymous campaign
|
||||
// assigns a stable one at materialization and its events already carry it, so a
|
||||
// later anonymize (at close, or when the recipient is deleted) must keep it or the
|
||||
// events would be orphaned from the recipient row.
|
||||
func (r *CampaignRecipient) Anonymize(
|
||||
ctx context.Context,
|
||||
campaignID *uuid.UUID,
|
||||
recipientID *uuid.UUID,
|
||||
anonymizedID *uuid.UUID,
|
||||
) error {
|
||||
// releasing here stops the recipient's lure link resolving and hands the code
|
||||
// back for reuse
|
||||
row := map[string]interface{}{
|
||||
"anonymized_id": anonymizedID.String(),
|
||||
"lure_code": nil,
|
||||
}
|
||||
AddUpdatedAt(row)
|
||||
db := r.DB.Model(&database.CampaignRecipient{})
|
||||
|
||||
// if campaignID is nil, anonymize across all campaigns (e.g., when deleting recipient)
|
||||
// otherwise, only anonymize for the specific campaign
|
||||
if campaignID != nil {
|
||||
db = db.Where("campaign_id = ? AND recipient_id = ?", campaignID, recipientID)
|
||||
} else {
|
||||
db = db.Where("recipient_id = ?", recipientID)
|
||||
// where clause: a specific campaign, or all campaigns for the recipient (used
|
||||
// when deleting the recipient).
|
||||
scope := func(db *gorm.DB) *gorm.DB {
|
||||
if campaignID != nil {
|
||||
return db.Where("campaign_id = ? AND recipient_id = ?", campaignID, recipientID)
|
||||
}
|
||||
return db.Where("recipient_id = ?", recipientID)
|
||||
}
|
||||
|
||||
res := db.Updates(row)
|
||||
// release the lure code so the link stops resolving and the code can be reused.
|
||||
lureRow := map[string]interface{}{"lure_code": nil}
|
||||
AddUpdatedAt(lureRow)
|
||||
if res := scope(r.DB.Model(&database.CampaignRecipient{})).Updates(lureRow); res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
|
||||
if res.Error != nil {
|
||||
// assign the pseudonym only where none exists yet. an anonymous campaign already
|
||||
// has a stable one whose events reference it, so overwriting would orphan them.
|
||||
// a plain assignment (not COALESCE) keeps the uuid column type unambiguous on
|
||||
// both sqlite and postgres.
|
||||
idRow := map[string]interface{}{"anonymized_id": anonymizedID.String()}
|
||||
AddUpdatedAt(idRow)
|
||||
if res := scope(r.DB.Model(&database.CampaignRecipient{})).
|
||||
Where("anonymized_id IS NULL").
|
||||
Updates(idRow); res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
return nil
|
||||
@@ -895,6 +904,8 @@ func ToCampaignRecipient(row *database.CampaignRecipient) (*model.CampaignRecipi
|
||||
if row.LureCode != nil {
|
||||
lureCode = nullable.NewNullableWithValue(*row.LureCode)
|
||||
}
|
||||
position := nullable.NewNullableWithValue(row.Position)
|
||||
department := nullable.NewNullableWithValue(row.Department)
|
||||
return &model.CampaignRecipient{
|
||||
ID: id,
|
||||
CancelledAt: cancelledAt,
|
||||
@@ -907,6 +918,8 @@ func ToCampaignRecipient(row *database.CampaignRecipient) (*model.CampaignRecipi
|
||||
AnonymizedID: anonymizedID,
|
||||
RecipientID: recipientID,
|
||||
Recipient: recipient,
|
||||
Position: position,
|
||||
Department: department,
|
||||
NotableEventID: notableEventID,
|
||||
NotableEventName: notableEventName,
|
||||
LureCode: lureCode,
|
||||
|
||||
+535
-90
@@ -414,6 +414,61 @@ func isLureCodeConflict(err error) bool {
|
||||
// insertScheduledRecipient inserts one scheduled recipient, redrawing its code if
|
||||
// it was claimed between allocation and insert. The loop has no transaction to
|
||||
// roll back, so a losing race would otherwise abort a half written schedule.
|
||||
// campaignAnonymous reports whether a campaign is anonymous, failing closed: if the
|
||||
// flag cannot be read it returns true so identity is protected rather than exposed.
|
||||
func campaignAnonymous(campaign *model.Campaign) bool {
|
||||
if campaign == nil {
|
||||
return true
|
||||
}
|
||||
isAnon, err := campaign.IsAnonymous.Get()
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return isAnon
|
||||
}
|
||||
|
||||
// applyAnonymousSnapshot assigns the stable random pseudonym and snapshots the
|
||||
// grouping attributes onto a campaign recipient for an anonymous campaign; it is a
|
||||
// no-op for a normal campaign.
|
||||
func (c *Campaign) applyAnonymousSnapshot(
|
||||
campaign *model.Campaign,
|
||||
campaignRecipient *model.CampaignRecipient,
|
||||
recipient *model.Recipient,
|
||||
) {
|
||||
if !campaignAnonymous(campaign) {
|
||||
return
|
||||
}
|
||||
campaignRecipient.AnonymizedID = nullable.NewNullableWithValue(uuid.New())
|
||||
position := ""
|
||||
if v, err := recipient.Position.Get(); err == nil {
|
||||
position = v.String()
|
||||
}
|
||||
department := ""
|
||||
if v, err := recipient.Department.Get(); err == nil {
|
||||
department = v.String()
|
||||
}
|
||||
campaignRecipient.Position = nullable.NewNullableWithValue(position)
|
||||
campaignRecipient.Department = nullable.NewNullableWithValue(department)
|
||||
}
|
||||
|
||||
// anonymizeEventForRecipient strips identity from an event and stamps the recipient's
|
||||
// pseudonym. It fails closed: with a pseudonym it always strips and stamps it;
|
||||
// without one it strips (uncounted) only for an anonymous campaign, and is a no-op
|
||||
// for a normal campaign.
|
||||
func anonymizeEventForRecipient(isAnonymous bool, campaignRecipient *model.CampaignRecipient, event *model.CampaignEvent) {
|
||||
// a pseudonym is present only on anonymous campaigns: strip identity and stamp it
|
||||
if campaignRecipient != nil {
|
||||
if aid, err := campaignRecipient.AnonymizedID.Get(); err == nil {
|
||||
event.Anonymize(&aid)
|
||||
return
|
||||
}
|
||||
}
|
||||
// no pseudonym: strip (uncounted) only when the campaign is anonymous
|
||||
if isAnonymous {
|
||||
event.Anonymize(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Campaign) insertScheduledRecipient(
|
||||
ctx context.Context,
|
||||
allocator *lureCodeAllocator,
|
||||
@@ -703,6 +758,7 @@ func (c *Campaign) schedule(
|
||||
CampaignID: nullable.NewNullableWithValue(campaignID),
|
||||
SelfManaged: nullable.NewNullableWithValue(true),
|
||||
}
|
||||
c.applyAnonymousSnapshot(campaign, campaignRecipients[i], recipient)
|
||||
if err := allocator.applyTo(campaignRecipients[i]); err != nil {
|
||||
c.Logger.Errorw("failed to allocate lure code", "error", err)
|
||||
return err
|
||||
@@ -781,6 +837,7 @@ func (c *Campaign) schedule(
|
||||
SendAt: nullable.NewNullableWithValue(jitteredStartAt),
|
||||
NotableEventID: nullable.NewNullableWithValue(*scheduledEvent),
|
||||
}
|
||||
c.applyAnonymousSnapshot(campaign, campaignRecipient, recipients[0])
|
||||
if err := allocator.applyTo(campaignRecipient); err != nil {
|
||||
c.Logger.Errorw("failed to allocate lure code", "error", err)
|
||||
return err
|
||||
@@ -862,6 +919,7 @@ func (c *Campaign) schedule(
|
||||
SendAt: nullable.NewNullableWithValue(jitteredTime),
|
||||
NotableEventID: nullable.NewNullableWithValue(*scheduledEvent),
|
||||
}
|
||||
c.applyAnonymousSnapshot(campaign, campaignRecipient, recipient)
|
||||
if err := allocator.applyTo(campaignRecipient); err != nil {
|
||||
c.Logger.Errorw("failed to allocate lure code", "error", err)
|
||||
return err
|
||||
@@ -912,6 +970,7 @@ func (c *Campaign) schedule(
|
||||
SendAt: nullable.NewNullableWithValue(jitteredSentAt),
|
||||
NotableEventID: nullable.NewNullableWithValue(*scheduledEvent),
|
||||
}
|
||||
c.applyAnonymousSnapshot(campaign, campaignRecipients[i], recipient)
|
||||
if err := allocator.applyTo(campaignRecipients[i]); err != nil {
|
||||
c.Logger.Errorw("failed to allocate lure code", "error", err)
|
||||
return err
|
||||
@@ -1268,6 +1327,158 @@ func (c *Campaign) GetResultStats(
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// anonymityGroupFloor is the smallest group whose outcome counts are shown; groups
|
||||
// below it are merged so a lone recipient's outcome cannot be read.
|
||||
const anonymityGroupFloor = 3
|
||||
|
||||
// OTHER_GROUP_LABEL is the bucket name for merged small groups.
|
||||
const OTHER_GROUP_LABEL = "Other (small groups)"
|
||||
|
||||
// GetGroupedResultStats returns outcome counts grouped by a recipient attribute,
|
||||
// suppressing any group smaller than the anonymity floor. Only anonymous campaigns
|
||||
// snapshot the grouping attributes, so a normal campaign returns an empty result.
|
||||
func (c *Campaign) GetGroupedResultStats(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
campaignID *uuid.UUID,
|
||||
groupColumn string,
|
||||
) ([]model.CampaignGroupStat, error) {
|
||||
ae := NewAuditEvent("Campaign.GetGroupedResultStats", session)
|
||||
ae.Details["campaignId"] = campaignID.String()
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
c.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
c.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
// grouping attributes are snapshotted only for anonymous campaigns. a closed
|
||||
// normal campaign has pseudonyms but no snapshot, which would surface as a single
|
||||
// unlabeled group, so restrict this to anonymous campaigns.
|
||||
// shown for anonymous campaigns (in place of hidden per recipient outcomes) and
|
||||
// for training campaigns (the started/completed breakdown).
|
||||
isAnon, err := c.CampaignRepository.IsAnonymousByID(ctx, campaignID)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to check campaign anonymity for grouped stats", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
isTraining, err := c.CampaignRepository.IsTrainingByID(ctx, campaignID)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to check campaign training flag for grouped stats", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAnon && !isTraining {
|
||||
return []model.CampaignGroupStat{}, nil
|
||||
}
|
||||
// hide per group outcomes for a campaign anonymized after the fact too, so this
|
||||
// matches the report path and stays safe if snapshotting ever changes
|
||||
isAnonymized, err := c.CampaignRepository.IsAnonymizedByID(ctx, campaignID)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to check campaign anonymized flag for grouped stats", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
stats, err := c.CampaignRepository.GetGroupedResultStats(ctx, campaignID, groupColumn)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to get grouped result stats", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
// no audit on read
|
||||
return suppressSmallGroups(stats, isAnon || isAnonymized), nil
|
||||
}
|
||||
|
||||
// HasGroupData reports whether the campaign's recipients carry any position or
|
||||
// department, so the UI can decide whether to offer the grouped breakdown.
|
||||
func (c *Campaign) HasGroupData(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
campaignID *uuid.UUID,
|
||||
) (bool, error) {
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
c.LogAuthError(err)
|
||||
return false, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
return false, errs.ErrAuthorizationFailed
|
||||
}
|
||||
return c.CampaignRepository.HasGroupDataByCampaignID(ctx, campaignID)
|
||||
}
|
||||
|
||||
// hasNamedGroups reports whether any group has a real attribute value rather than
|
||||
// the "(unspecified)" or Other placeholder.
|
||||
func hasNamedGroups(stats []model.CampaignGroupStat) bool {
|
||||
for _, g := range stats {
|
||||
if g.Group != "(unspecified)" && g.Group != OTHER_GROUP_LABEL {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// suppressSmallGroups merges groups below the floor into an "Other" bucket, then
|
||||
// absorbs the smallest shown groups until that bucket also reaches the floor, so a
|
||||
// small group cannot be recovered by subtracting the shown groups from the total.
|
||||
// When hideOutcomes is set (anonymized campaigns) every per group outcome is withheld
|
||||
// and only the group sizes are shown: publishing per group outcomes alongside the
|
||||
// campaign wide totals lets a shown or dashed column be back solved to an
|
||||
// individual's result, so anonymized campaigns expose group sizes only. Normal and
|
||||
// training campaigns keep exact counts.
|
||||
func suppressSmallGroups(stats []model.CampaignGroupStat, hideOutcomes bool) []model.CampaignGroupStat {
|
||||
shown := []model.CampaignGroupStat{}
|
||||
var other model.CampaignGroupStat
|
||||
other.Group = OTHER_GROUP_LABEL
|
||||
addToOther := func(g model.CampaignGroupStat) {
|
||||
other.Total += g.Total
|
||||
other.Clicked += g.Clicked
|
||||
other.Submitted += g.Submitted
|
||||
other.Reported += g.Reported
|
||||
other.TrainingStarted += g.TrainingStarted
|
||||
other.TrainingCompleted += g.TrainingCompleted
|
||||
}
|
||||
for _, g := range stats {
|
||||
if g.Total >= anonymityGroupFloor {
|
||||
shown = append(shown, g)
|
||||
} else {
|
||||
addToOther(g)
|
||||
}
|
||||
}
|
||||
// smallest shown first, so absorbing to reach the floor sacrifices the least detail
|
||||
sort.Slice(shown, func(i, j int) bool { return shown[i].Total < shown[j].Total })
|
||||
for other.Total > 0 && other.Total < anonymityGroupFloor && len(shown) > 0 {
|
||||
addToOther(shown[0])
|
||||
shown = shown[1:]
|
||||
}
|
||||
if other.Total > 0 {
|
||||
// only possible when the whole campaign has fewer than `floor` people, where
|
||||
// the campaign-wide aggregate is unavoidable anyway; withhold the breakdown.
|
||||
if other.Total < anonymityGroupFloor {
|
||||
other.Clicked = 0
|
||||
other.Submitted = 0
|
||||
other.Reported = 0
|
||||
other.TrainingStarted = 0
|
||||
other.TrainingCompleted = 0
|
||||
other.Suppressed = true
|
||||
}
|
||||
shown = append(shown, other)
|
||||
}
|
||||
if hideOutcomes {
|
||||
// anonymized campaigns show group sizes only; -1 renders as a dash
|
||||
for i := range shown {
|
||||
if !shown[i].Suppressed {
|
||||
shown[i].Clicked = -1
|
||||
shown[i].Submitted = -1
|
||||
shown[i].Reported = -1
|
||||
shown[i].TrainingStarted = -1
|
||||
shown[i].TrainingCompleted = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
return shown
|
||||
}
|
||||
|
||||
// GetRecipientsByCampaignID gets all recipients for a campaign
|
||||
func (c *Campaign) GetRecipientsByCampaignID(
|
||||
ctx context.Context,
|
||||
@@ -1288,8 +1499,22 @@ func (c *Campaign) GetRecipientsByCampaignID(
|
||||
c.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
// get all recipients
|
||||
if options.OrderBy == "" {
|
||||
// an anonymous campaign keeps identity but withholds each outcome and timing
|
||||
// (below). force an identity neutral order first so those withheld values cannot
|
||||
// be read back from the row order. fail closed: on error, treat as anonymous.
|
||||
isAnon, err := c.CampaignRepository.IsAnonymousByID(ctx, campaignID)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to check campaign anonymity before returning recipients", "error", err)
|
||||
isAnon = true
|
||||
}
|
||||
if isAnon {
|
||||
options.OrderBy = "recipients.first_name"
|
||||
// search matches every allowed column, including the outcome and timing
|
||||
// columns withheld from the response, so it could filter the named list by
|
||||
// who clicked or when. clear it so only the sort is available, on a neutral
|
||||
// column.
|
||||
options.Search = ""
|
||||
} else if options.OrderBy == "" {
|
||||
options.OrderBy = "campaign_recipients.sent_at"
|
||||
}
|
||||
result, err = c.CampaignRecipientRepository.GetByCampaignID(
|
||||
@@ -1301,6 +1526,18 @@ func (c *Campaign) GetRecipientsByCampaignID(
|
||||
c.Logger.Errorw("failed to get recipients by campaign id", "error", err)
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
if isAnon {
|
||||
for _, row := range result.Rows {
|
||||
row.NotableEventID.SetNull()
|
||||
row.NotableEventName = ""
|
||||
// replace exact timing with a coarse sent indicator
|
||||
row.Sent = row.SentAt.IsSpecified() && !row.SentAt.IsNull()
|
||||
row.SendAt.SetNull()
|
||||
row.SentAt.SetNull()
|
||||
row.LastAttemptAt.SetNull()
|
||||
row.CancelledAt.SetNull()
|
||||
}
|
||||
}
|
||||
// no audit on read
|
||||
return result, nil
|
||||
}
|
||||
@@ -1582,6 +1819,14 @@ func (c *Campaign) SaveTrackingPixelLoaded(
|
||||
c.Logger.Errorw("failed to get campaign recipient by id", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// an anonymized campaign recipient has no recipient id, which happens for a
|
||||
// closed anonymous campaign whose link was severed. these public endpoints can
|
||||
// be hit long after close from a token in a delivered email, so return quietly
|
||||
// rather than dereferencing a null id.
|
||||
if campaignRecipient.RecipientID.IsNull() {
|
||||
c.Logger.Debugw("skipping tracking pixel event: recipient is anonymized", "campaignRecipientID", campaignRecipientID.String())
|
||||
return nil
|
||||
}
|
||||
recipientID := campaignRecipient.RecipientID.MustGet()
|
||||
campaignID := campaignRecipient.CampaignID.MustGet()
|
||||
|
||||
@@ -1625,6 +1870,7 @@ func (c *Campaign) SaveTrackingPixelLoaded(
|
||||
Data: vo.NewEmptyOptionalString1MB(),
|
||||
Metadata: metadata,
|
||||
}
|
||||
anonymizeEventForRecipient(campaignAnonymous(campaign), campaignRecipient, campaignEvent)
|
||||
err = c.CampaignRepository.SaveEvent(ctx, campaignEvent)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to save tracking pixel loaded event", "error", err)
|
||||
@@ -1677,6 +1923,14 @@ func (c *Campaign) SaveRecipientReported(
|
||||
c.Logger.Errorw("failed to get campaign recipient by id", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// an anonymized campaign recipient has no recipient id, which happens for a
|
||||
// closed anonymous campaign whose link was severed. this public endpoint can be
|
||||
// hit long after close from a token in a delivered email, so return quietly
|
||||
// rather than dereferencing a null id.
|
||||
if campaignRecipient.RecipientID.IsNull() {
|
||||
c.Logger.Debugw("skipping report event: recipient is anonymized", "campaignRecipientID", campaignRecipientID.String())
|
||||
return nil
|
||||
}
|
||||
recipientID := campaignRecipient.RecipientID.MustGet()
|
||||
campaignID := campaignRecipient.CampaignID.MustGet()
|
||||
|
||||
@@ -1704,8 +1958,15 @@ func (c *Campaign) SaveRecipientReported(
|
||||
reportedEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED]
|
||||
|
||||
// de-duplicate: a recipient that already reported is not recorded again so a
|
||||
// button that fires more than once does not spam the timeline
|
||||
alreadyReported, err := c.CampaignRepository.HasEvent(ctx.Request.Context(), &campaignID, &recipientID, reportedEventID)
|
||||
// button that fires more than once does not spam the timeline. an anonymous
|
||||
// campaign's events carry the pseudonym rather than a recipient id, so dedup on
|
||||
// that instead.
|
||||
var alreadyReported bool
|
||||
if aid, aErr := campaignRecipient.AnonymizedID.Get(); aErr == nil {
|
||||
alreadyReported, err = c.CampaignRepository.HasEventByAnonymizedID(ctx.Request.Context(), &campaignID, &aid, reportedEventID)
|
||||
} else {
|
||||
alreadyReported, err = c.CampaignRepository.HasEvent(ctx.Request.Context(), &campaignID, &recipientID, reportedEventID)
|
||||
}
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to check existing reported event", "error", err)
|
||||
return errs.Wrap(err)
|
||||
@@ -1733,6 +1994,7 @@ func (c *Campaign) SaveRecipientReported(
|
||||
Data: vo.NewEmptyOptionalString1MB(),
|
||||
Metadata: metadata,
|
||||
}
|
||||
anonymizeEventForRecipient(campaignAnonymous(campaign), campaignRecipient, campaignEvent)
|
||||
err = c.CampaignRepository.SaveEvent(ctx, campaignEvent)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to save reported event", "error", err)
|
||||
@@ -1863,6 +2125,24 @@ func (c *Campaign) UpdateByID(
|
||||
current.SaveBrowserMetadata.Set(v)
|
||||
}
|
||||
if v, err := incoming.IsAnonymous.Get(); err == nil {
|
||||
// anonymity cannot change once recipients are materialized: pseudonyms are
|
||||
// assigned at materialization, and a self managed campaign is not otherwise
|
||||
// edit locked, so a late flip to anonymous would leave already recorded events
|
||||
// carrying identity. block the change once any recipient exists.
|
||||
currentAnon := campaignAnonymous(current)
|
||||
if v != currentAnon {
|
||||
hasRecipients, hErr := c.CampaignRecipientRepository.HasRecipientsByCampaignID(ctx, id)
|
||||
if hErr != nil {
|
||||
c.Logger.Errorw("failed to check recipients before changing anonymity", "error", hErr)
|
||||
return errs.Wrap(hErr)
|
||||
}
|
||||
if hasRecipients {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("anonymous mode cannot be changed after recipients are added"),
|
||||
"isAnonymous",
|
||||
)
|
||||
}
|
||||
}
|
||||
current.IsAnonymous.Set(v)
|
||||
}
|
||||
if v, err := incoming.IsTest.Get(); err == nil {
|
||||
@@ -2590,8 +2870,8 @@ func (c *Campaign) sendCampaignMessages(
|
||||
)
|
||||
return errs.Wrap(errors.Join(err, closeErr))
|
||||
}
|
||||
// validate the template is parseable before entering the per-recipient loop.
|
||||
// the actual per-recipient execution uses TemplateFuncsWithDeviceCode so that
|
||||
// validate the template is parseable before entering the per recipient loop.
|
||||
// the actual per recipient execution uses TemplateFuncsWithDeviceCode so that
|
||||
// {{MicrosoftDeviceCode}} / {{MicrosoftDeviceCodeURL}} resolve correctly.
|
||||
t := template.New("email")
|
||||
t = t.Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID))
|
||||
@@ -2886,7 +3166,7 @@ func (c *Campaign) sendCampaignMessages(
|
||||
// the only builder that knows about proxy first pages and path mode codes
|
||||
(*t)["URL"] = customCampaignURL
|
||||
|
||||
// build per-recipient template funcs so that {{MicrosoftDeviceCode}} resolves
|
||||
// build per recipient template funcs so that {{MicrosoftDeviceCode}} resolves
|
||||
// to a real device code for this campaign recipient.
|
||||
// use the actual recipient id (not the campaign recipient row id) so the
|
||||
// lookup key matches what the landing page path uses.
|
||||
@@ -2914,7 +3194,7 @@ func (c *Campaign) sendCampaignMessages(
|
||||
// to the correct recipient id
|
||||
recipientMailTmpl, err := template.New("email").Funcs(recipientDeviceFuncs).Parse(content.String())
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to parse per-recipient mail template", "error", err)
|
||||
c.Logger.Errorw("failed to parse per recipient mail template", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
var bodyBuffer bytes.Buffer
|
||||
@@ -3207,6 +3487,18 @@ func (c *Campaign) saveSendingResult(
|
||||
Data: data,
|
||||
Metadata: vo.NewEmptyOptionalString1MB(),
|
||||
}
|
||||
// a send failure reason can contain the recipient address, so an anonymous
|
||||
// campaign must not keep it. Anonymize blanks the data along with identity. a real
|
||||
// anonymous recipient always carries a pseudonym here (assigned at materialization),
|
||||
// and the helper strips on that regardless of the flag; the flag only covers the
|
||||
// should-not-happen no-pseudonym case, so on a lookup error default to not
|
||||
// anonymous to avoid stripping a normal campaign's send event.
|
||||
sendIsAnon, anonErr := c.CampaignRepository.IsAnonymousByID(ctx, &campaignID)
|
||||
if anonErr != nil {
|
||||
c.Logger.Errorw("failed to check campaign anonymity for send result event", "error", anonErr)
|
||||
sendIsAnon = false
|
||||
}
|
||||
anonymizeEventForRecipient(sendIsAnon, campaignRecipient, campaignEvent)
|
||||
err = c.CampaignRepository.SaveEvent(ctx, campaignEvent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save event: %s", err)
|
||||
@@ -3694,6 +3986,19 @@ func (c *Campaign) closeCampaign(
|
||||
}
|
||||
}
|
||||
|
||||
// an anonymous campaign severs its recipient relation at close so no identity is
|
||||
// retained after it ends. best effort here; the anonymize sweep (GetReadyToAnonymize)
|
||||
// is the fallback that retries if this fails. skip if already anonymized, e.g. when
|
||||
// AnonymizeByID drove this close.
|
||||
if campaignAnonymous(campaign) {
|
||||
alreadyAnonymized := campaign.AnonymizedAt.IsSpecified() && !campaign.AnonymizedAt.IsNull()
|
||||
if !alreadyAnonymized {
|
||||
if err := c.anonymizeCampaignRecipients(ctx, id); err != nil {
|
||||
c.Logger.Errorw("failed to anonymize recipients at close, sweep will retry", "error", err, "campaignID", id.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// automatic report delivery when the company has opted in (skip test campaigns).
|
||||
// rendering the PDF and sending mail can take seconds, so it runs in the
|
||||
// background with its own context to avoid blocking the close path and the
|
||||
@@ -4259,6 +4564,7 @@ func (c *Campaign) SetSentAtByCampaignRecipientID(
|
||||
Data: details,
|
||||
Metadata: vo.NewEmptyOptionalString1MB(),
|
||||
}
|
||||
anonymizeEventForRecipient(campaignAnonymous(campaign), campaignRecipient, campaignEvent)
|
||||
|
||||
err = c.CampaignRepository.SaveEvent(ctx, campaignEvent)
|
||||
if err != nil {
|
||||
@@ -4330,9 +4636,26 @@ func (c *Campaign) HandleWebhooks(
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// an anonymous campaign must never send identity or captured data off box, so
|
||||
// the effective webhook level is capped and email and data are withheld
|
||||
// regardless of each webhook's configured level.
|
||||
isAnon, err := c.CampaignRepository.IsAnonymousByID(ctx, campaignID)
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// an anonymous campaign must not push per recipient events to a webhook: even
|
||||
// without identity, one delivery per recipient action at its exact instant
|
||||
// reinstates the timing the recipient view deliberately coarsens, which in a
|
||||
// small campaign can single out who acted. campaign-level events (recipientID
|
||||
// nil, e.g. campaign closed) still fire.
|
||||
if isAnon && recipientID != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// get email once for all webhooks
|
||||
var email *vo.Email
|
||||
if recipientID != nil {
|
||||
if recipientID != nil && !isAnon {
|
||||
email, err = c.RecipientRepository.GetEmailByID(ctx, recipientID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errs.Wrap(err)
|
||||
@@ -4361,6 +4684,11 @@ func (c *Campaign) HandleWebhooks(
|
||||
webhookID := webhookConfig.WebhookID.MustGet()
|
||||
webhookEvents := webhookConfig.GetWebhookEventsOrDefault()
|
||||
dataLevel := webhookConfig.GetWebhookIncludeDataOrDefault()
|
||||
// cap the level for an anonymous campaign: basic still names the campaign
|
||||
// but carries no email and no captured data; full would leak both.
|
||||
if isAnon && dataLevel == model.WebhookDataLevelFull {
|
||||
dataLevel = model.WebhookDataLevelBasic
|
||||
}
|
||||
|
||||
// check if this event should trigger this webhook
|
||||
if !model.IsWebhookEventEnabled(webhookEvents, eventName) {
|
||||
@@ -4423,6 +4751,86 @@ func (c *Campaign) HandleWebhook(
|
||||
return c.HandleWebhooks(ctx, campaignID, recipientID, eventName, capturedData)
|
||||
}
|
||||
|
||||
// anonymizeCampaignRecipients severs the recipient relation for a campaign and
|
||||
// finalizes anonymization. Each recipient keeps its existing pseudonym (an
|
||||
// anonymous campaign assigned one at materialization) or is given a fresh one and
|
||||
// has its events rewritten (a normal campaign anonymized at close). Lure codes are
|
||||
// released, recipient group links dropped, recipient ids removed, and anonymized_at
|
||||
// stamped. The campaign must already be closed. Safe to run more than once: a
|
||||
// second run finds recipient ids already null and skips them.
|
||||
func (c *Campaign) anonymizeCampaignRecipients(ctx context.Context, id *uuid.UUID) error {
|
||||
campaignRecipientsResult, err := c.CampaignRecipientRepository.GetByCampaignID(
|
||||
ctx,
|
||||
id,
|
||||
&repository.CampaignRecipientOption{},
|
||||
)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to get campaign recipients by campaign id", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
for _, cr := range campaignRecipientsResult.Rows {
|
||||
if cr.RecipientID.IsNull() {
|
||||
c.Logger.Debug("skipping anonymization of campaign recipient without recipient")
|
||||
continue
|
||||
}
|
||||
recipientID := cr.RecipientID.MustGet()
|
||||
campaignID, err := cr.CampaignID.Get()
|
||||
if err != nil {
|
||||
c.Logger.Debug("Recipient removed or anonymized, skipping in anonymization")
|
||||
continue
|
||||
}
|
||||
// an anonymous campaign already assigned a stable pseudonym at
|
||||
// materialization and its events already carry it. keep that id, only
|
||||
// release the lure code and sever the recipient link below. rewriting
|
||||
// events would fail to match, since they hold no recipient id, and
|
||||
// regenerating the id would orphan the events already written.
|
||||
if existing, err := cr.AnonymizedID.Get(); err == nil {
|
||||
if err := c.CampaignRecipientRepository.Anonymize(ctx, &campaignID, &recipientID, &existing); err != nil {
|
||||
c.Logger.Errorw("failed to finalize anonymous campaign recipient", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// add anonymized ID to each campaign recipient
|
||||
anonymizedID := uuid.New()
|
||||
cr.AnonymizedID = nullable.NewNullableWithValue(anonymizedID)
|
||||
err = c.CampaignRecipientRepository.Anonymize(ctx, &campaignID, &recipientID, &anonymizedID)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to add anonymized ID to campaign recipient", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// anonymize events and assign each anonymized ID so the events can still be tracked
|
||||
err = c.CampaignRepository.AnonymizeCampaignEvent(
|
||||
ctx,
|
||||
&campaignID,
|
||||
&recipientID,
|
||||
&anonymizedID,
|
||||
)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to anonymize campaign event", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
}
|
||||
// delete the relation between the campaign and the recipient groups
|
||||
err = c.CampaignRepository.RemoveCampaignRecipientGroups(ctx, id)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to delete campaign recipient groups by campaign id", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// remove the recipient ID from the campaign recipient so only the anomymized ID is left
|
||||
err = c.CampaignRecipientRepository.RemoveRecipientIDByCampaignID(ctx, id)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to remove recipient ID from campaign recipients", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// finally add a timestamp to the campaign to indicate when it was anonymized
|
||||
if err := c.CampaignRepository.AddAnonymizedAt(ctx, id); err != nil {
|
||||
c.Logger.Errorw("failed to add anonymized at to campaign", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AnonymizeByID anonymizes a campaign including the events
|
||||
func (c *Campaign) AnonymizeByID(
|
||||
ctx context.Context,
|
||||
@@ -4464,63 +4872,9 @@ func (c *Campaign) AnonymizeByID(
|
||||
c.Logger.Errorw("failed to close campaign by id before anonymization", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// assign a anonymized ID to each campaign recipient and make a map between
|
||||
// their ID and the anonymized ID, this is a itermidiate step to anonymize the events
|
||||
// where campaign receipients have both a anonymized ID and the recipient ID
|
||||
campaignRecipientsResult, err := c.CampaignRecipientRepository.GetByCampaignID(
|
||||
ctx,
|
||||
id,
|
||||
&repository.CampaignRecipientOption{},
|
||||
)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to get campaign recipients by campaign id", "error", err)
|
||||
if err := c.anonymizeCampaignRecipients(ctx, id); err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
for _, cr := range campaignRecipientsResult.Rows {
|
||||
if cr.RecipientID.IsNull() {
|
||||
c.Logger.Debug("skipping anonymization of campaign recipient without recipient")
|
||||
continue
|
||||
}
|
||||
// add anonymized ID to each campaign recipient
|
||||
anonymizedID := uuid.New()
|
||||
cr.AnonymizedID = nullable.NewNullableWithValue(anonymizedID)
|
||||
recipientID := cr.RecipientID.MustGet()
|
||||
campaignID, err := cr.CampaignID.Get()
|
||||
if err != nil {
|
||||
c.Logger.Debug("Recipient removed or anonymized, skipping in anonymization")
|
||||
continue
|
||||
}
|
||||
err = c.CampaignRecipientRepository.Anonymize(ctx, &campaignID, &recipientID, &anonymizedID)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to add anonymized ID to campaign recipient", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// anonymize events and assign each anonymized ID so the events can still be tracked
|
||||
err = c.CampaignRepository.AnonymizeCampaignEvent(
|
||||
ctx,
|
||||
&campaignID,
|
||||
&recipientID,
|
||||
&anonymizedID,
|
||||
)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to anonymize campaign event", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
}
|
||||
// delete the relation between the campaign and the recipient groups
|
||||
err = c.CampaignRepository.RemoveCampaignRecipientGroups(ctx, id)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to delete campaign recipient groups by campaign id", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// remove the recipient ID from the campaign recipient so only the anomymized ID is left
|
||||
err = c.CampaignRecipientRepository.RemoveRecipientIDByCampaignID(ctx, id)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to remove recipient ID from campaign recipients", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// finally add a timestamp to the campaign to indicate when it was anonymized
|
||||
err = c.CampaignRepository.AddAnonymizedAt(ctx, id)
|
||||
c.AuditLogAuthorized(ae)
|
||||
|
||||
return nil
|
||||
@@ -4746,7 +5100,7 @@ func (c *Campaign) sendSingleCampaignMessage(
|
||||
return errors.New("failed to get email content")
|
||||
}
|
||||
|
||||
// validate parseability with company funcs; execution uses per-recipient device code funcs
|
||||
// validate parseability with company funcs; execution uses per recipient device code funcs
|
||||
t := template.New("email")
|
||||
t = t.Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID))
|
||||
_, err = t.Parse(content.String())
|
||||
@@ -4908,7 +5262,7 @@ func (c *Campaign) sendSingleEmailSMTP(
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// build per-recipient template funcs so that {{MicrosoftDeviceCode}} resolves correctly.
|
||||
// build per recipient template funcs so that {{MicrosoftDeviceCode}} resolves correctly.
|
||||
// use the actual recipient id (not the campaign recipient row id) so the
|
||||
// lookup key matches what the landing page path uses.
|
||||
recipientID := campaignRecipient.ID.MustGet()
|
||||
@@ -4967,10 +5321,10 @@ func (c *Campaign) sendSingleEmailSMTP(
|
||||
}
|
||||
m.Subject(subjectBuffer.String())
|
||||
|
||||
// parse and execute body with per-recipient device code funcs
|
||||
// parse and execute body with per recipient device code funcs
|
||||
recipientMailTmpl, err := template.New("email").Funcs(recipientDeviceFuncs).Parse(emailContent)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to parse per-recipient mail template", "error", err)
|
||||
c.Logger.Errorw("failed to parse per recipient mail template", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
var bodyBuffer bytes.Buffer
|
||||
@@ -5720,25 +6074,34 @@ func (c *Campaign) ProcessReportedCSV(
|
||||
continue
|
||||
}
|
||||
|
||||
// check if already reported (to avoid duplicates)
|
||||
existingEvent, err := c.CampaignRepository.GetEventsByCampaignID(
|
||||
ctx,
|
||||
campaignID,
|
||||
&repository.CampaignEventOption{
|
||||
QueryArgs: &vo.QueryArgs{
|
||||
Limit: 1,
|
||||
},
|
||||
EventTypeIDs: []string{reportedEventID.String()},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
// check if already reported (to avoid duplicates). an anonymous campaign's
|
||||
// events carry the pseudonym rather than a recipient id, so dedup on that.
|
||||
alreadyReported := false
|
||||
if err == nil && existingEvent != nil {
|
||||
for _, event := range existingEvent.Rows {
|
||||
if event.RecipientID != nil && *event.RecipientID == recipientID {
|
||||
alreadyReported = true
|
||||
break
|
||||
if aid, aErr := campaignRecipient.AnonymizedID.Get(); aErr == nil {
|
||||
alreadyReported, err = c.CampaignRepository.HasEventByAnonymizedID(ctx, campaignID, &aid, reportedEventID)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to check existing reported event", "error", err)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
existingEvent, gErr := c.CampaignRepository.GetEventsByCampaignID(
|
||||
ctx,
|
||||
campaignID,
|
||||
&repository.CampaignEventOption{
|
||||
QueryArgs: &vo.QueryArgs{
|
||||
Limit: 1,
|
||||
},
|
||||
EventTypeIDs: []string{reportedEventID.String()},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if gErr == nil && existingEvent != nil {
|
||||
for _, event := range existingEvent.Rows {
|
||||
if event.RecipientID != nil && *event.RecipientID == recipientID {
|
||||
alreadyReported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5762,6 +6125,7 @@ func (c *Campaign) ProcessReportedCSV(
|
||||
Data: vo.NewEmptyOptionalString1MB(),
|
||||
Metadata: vo.NewEmptyOptionalString1MB(),
|
||||
}
|
||||
anonymizeEventForRecipient(campaignAnonymous(campaign), campaignRecipient, campaignEvent)
|
||||
|
||||
// save the event with custom timestamp
|
||||
err = c.saveReportedEvent(campaignEvent, parsedDate)
|
||||
@@ -5809,6 +6173,9 @@ func (c *Campaign) saveReportedEvent(
|
||||
if campaignEvent.RecipientID != nil {
|
||||
row["recipient_id"] = campaignEvent.RecipientID.String()
|
||||
}
|
||||
if campaignEvent.AnonymizedID != nil {
|
||||
row["anonymized_id"] = campaignEvent.AnonymizedID.String()
|
||||
}
|
||||
|
||||
res := c.CampaignRepository.DB.Model(&database.CampaignEvent{}).Create(row)
|
||||
if res.Error != nil {
|
||||
@@ -5891,10 +6258,13 @@ func (c *Campaign) buildReportHTMLWithData(
|
||||
}
|
||||
}
|
||||
|
||||
// the per recipient table is only available while the recipient relation exists
|
||||
// the per recipient table exposes identity, so it is omitted for an anonymous
|
||||
// campaign (even while live, before the anonymization sweep sets AnonymizedAt)
|
||||
// and for any campaign that has already been anonymized.
|
||||
var recipients []model.ReportRecipient
|
||||
isAnon := campaignAnonymous(campaign)
|
||||
isAnonymized := campaign.AnonymizedAt.IsSpecified() && !campaign.AnonymizedAt.IsNull()
|
||||
if !isAnonymized {
|
||||
if !isAnon && !isAnonymized {
|
||||
recipients, err = c.CampaignRepository.GetReportRecipients(ctx, campaignID)
|
||||
if err != nil {
|
||||
c.Logger.Warnw("failed to get report recipients, continuing without detail table", "error", err)
|
||||
@@ -5902,7 +6272,32 @@ func (c *Campaign) buildReportHTMLWithData(
|
||||
}
|
||||
}
|
||||
|
||||
rd := buildReportData(name, companyName, campaign, stats, recipients)
|
||||
// breakdown for every campaign. default to department when present, else
|
||||
// position; both dimensions are exposed so a template can render either.
|
||||
var groups, departmentGroups, positionGroups []model.CampaignGroupStat
|
||||
groupsBy := "Department"
|
||||
if raw, gErr := c.CampaignRepository.GetGroupedResultStats(ctx, campaignID, "department"); gErr != nil {
|
||||
c.Logger.Warnw("failed to get department grouped stats for report", "error", gErr)
|
||||
} else {
|
||||
departmentGroups = suppressSmallGroups(raw, isAnon || isAnonymized)
|
||||
}
|
||||
if raw, gErr := c.CampaignRepository.GetGroupedResultStats(ctx, campaignID, "position"); gErr != nil {
|
||||
c.Logger.Warnw("failed to get position grouped stats for report", "error", gErr)
|
||||
} else {
|
||||
positionGroups = suppressSmallGroups(raw, isAnon || isAnonymized)
|
||||
}
|
||||
if hasNamedGroups(departmentGroups) {
|
||||
groups, groupsBy = departmentGroups, "Department"
|
||||
} else if hasNamedGroups(positionGroups) {
|
||||
groups, groupsBy = positionGroups, "Position"
|
||||
} else {
|
||||
// the recipients carry no department or position, so there is nothing to
|
||||
// group by. leave the default breakdown empty so the report omits the group
|
||||
// page rather than showing a single unlabeled bucket.
|
||||
groups = nil
|
||||
}
|
||||
|
||||
rd := buildReportData(name, companyName, campaign, stats, recipients, groups, groupsBy, departmentGroups, positionGroups)
|
||||
|
||||
var buf bytes.Buffer
|
||||
tmpl, err := template.New("report").Funcs(TemplateFuncs()).Parse(templateContent)
|
||||
@@ -6274,6 +6669,10 @@ func buildReportData(
|
||||
campaign *model.Campaign,
|
||||
stats *model.CampaignResultView,
|
||||
recipients []model.ReportRecipient,
|
||||
groups []model.CampaignGroupStat,
|
||||
groupsBy string,
|
||||
departmentGroups []model.CampaignGroupStat,
|
||||
positionGroups []model.CampaignGroupStat,
|
||||
) *model.ReportData {
|
||||
isTrainingReport := false
|
||||
if v, err := campaign.IsTraining.Get(); err == nil {
|
||||
@@ -6346,10 +6745,44 @@ func buildReportData(
|
||||
StartedOfOpened: relPct(stats.TrainingStarted, stats.TrackingPixelLoaded),
|
||||
CompletedOfStarted: relPct(stats.TrainingCompleted, stats.TrainingStarted),
|
||||
|
||||
Recipients: recipients,
|
||||
Recipients: recipients,
|
||||
GroupsBy: groupsBy,
|
||||
Groups: toReportGroupStats(groups),
|
||||
DepartmentGroups: toReportGroupStats(departmentGroups),
|
||||
PositionGroups: toReportGroupStats(positionGroups),
|
||||
}
|
||||
}
|
||||
|
||||
// toReportGroupStats converts raw grouped counts to report rows with the
|
||||
// percentage of each group that clicked, submitted and reported pre-formatted.
|
||||
func toReportGroupStats(groups []model.CampaignGroupStat) []model.ReportGroupStat {
|
||||
groupPct := func(count, total int) string {
|
||||
if count < 0 || total == 0 {
|
||||
return "" // withheld (homogeneous) or n/a
|
||||
}
|
||||
return fmt.Sprintf("%.0f", float64(count)/float64(total)*100)
|
||||
}
|
||||
out := make([]model.ReportGroupStat, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
out = append(out, model.ReportGroupStat{
|
||||
Group: g.Group,
|
||||
Total: g.Total,
|
||||
Clicked: g.Clicked,
|
||||
ClickedPercent: groupPct(g.Clicked, g.Total),
|
||||
Submitted: g.Submitted,
|
||||
SubmittedPercent: groupPct(g.Submitted, g.Total),
|
||||
Reported: g.Reported,
|
||||
ReportedPercent: groupPct(g.Reported, g.Total),
|
||||
TrainingStarted: g.TrainingStarted,
|
||||
TrainingStartedPercent: groupPct(g.TrainingStarted, g.Total),
|
||||
TrainingCompleted: g.TrainingCompleted,
|
||||
TrainingCompletedPercent: groupPct(g.TrainingCompleted, g.Total),
|
||||
Suppressed: g.Suppressed,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// htmlEscapeReportData returns a copy of the report data with the externally
|
||||
// supplied strings HTML escaped, for the HTML contexts (the rendered report and
|
||||
// the html email body). text/template does not escape values, so this stops
|
||||
@@ -6372,5 +6805,17 @@ func htmlEscapeReportData(rd *model.ReportData) *model.ReportData {
|
||||
r.Position = template.HTMLEscapeString(r.Position)
|
||||
cp.Recipients[i] = r
|
||||
}
|
||||
cp.GroupsBy = template.HTMLEscapeString(rd.GroupsBy)
|
||||
escapeGroups := func(in []model.ReportGroupStat) []model.ReportGroupStat {
|
||||
out := make([]model.ReportGroupStat, len(in))
|
||||
for i, g := range in {
|
||||
g.Group = template.HTMLEscapeString(g.Group)
|
||||
out[i] = g
|
||||
}
|
||||
return out
|
||||
}
|
||||
cp.Groups = escapeGroups(rd.Groups)
|
||||
cp.DepartmentGroups = escapeGroups(rd.DepartmentGroups)
|
||||
cp.PositionGroups = escapeGroups(rd.PositionGroups)
|
||||
return &cp
|
||||
}
|
||||
|
||||
@@ -409,6 +409,39 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode(
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// applyEventAnonymization strips identity and stamps the pseudonym on a device
|
||||
// code event when the campaign is anonymous. It fails closed: if the recipient
|
||||
// cannot be loaded and the campaign is anonymous, or anonymity cannot be
|
||||
// determined, the event is stored without identity rather than with it.
|
||||
func (s *MicrosoftDeviceCode) applyEventAnonymization(
|
||||
ctx context.Context,
|
||||
campaignID *uuid.UUID,
|
||||
recipientID *uuid.UUID,
|
||||
event *model.CampaignEvent,
|
||||
) {
|
||||
if campaignID == nil || recipientID == nil {
|
||||
return
|
||||
}
|
||||
// decide from the authoritative campaign flag, not the presence of a pseudonym.
|
||||
// fail closed: if anonymity cannot be determined, strip identity.
|
||||
isAnon, err := s.CampaignRepository.IsAnonymousByID(ctx, campaignID)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("could not confirm campaign anonymity for device code event, storing without identity", "error", err)
|
||||
event.Anonymize(nil)
|
||||
return
|
||||
}
|
||||
if !isAnon {
|
||||
return
|
||||
}
|
||||
if cr, crErr := s.CampaignRecipientRepository.GetByCampaignAndRecipientID(ctx, campaignID, recipientID, &repository.CampaignRecipientOption{}); crErr == nil {
|
||||
if aid, aerr := cr.AnonymizedID.Get(); aerr == nil {
|
||||
event.Anonymize(&aid)
|
||||
return
|
||||
}
|
||||
}
|
||||
event.Anonymize(nil)
|
||||
}
|
||||
|
||||
// saveDeviceCodeCreatedEvent saves a campaign event recording that a device code was created (or
|
||||
// failed to be created). failReason should be empty on success.
|
||||
func (s *MicrosoftDeviceCode) saveDeviceCodeCreatedEvent(
|
||||
@@ -450,6 +483,7 @@ func (s *MicrosoftDeviceCode) saveDeviceCodeCreatedEvent(
|
||||
Data: eventData,
|
||||
Metadata: vo.NewEmptyOptionalString1MB(),
|
||||
}
|
||||
s.applyEventAnonymization(ctx, campaignID, recipientID, campaignEvent)
|
||||
if saveErr := s.CampaignRepository.SaveEvent(ctx, campaignEvent); saveErr != nil {
|
||||
s.Logger.Errorw("failed to save device code created event", "error", saveErr)
|
||||
}
|
||||
@@ -523,18 +557,6 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M
|
||||
return nil
|
||||
}
|
||||
|
||||
// we have tokens — mark the entry as captured
|
||||
if err := s.MicrosoftDeviceCodeRepository.MarkCaptured(
|
||||
ctx,
|
||||
&entryID,
|
||||
tokenResp.AccessToken,
|
||||
tokenResp.RefreshToken,
|
||||
tokenResp.IDToken,
|
||||
); err != nil {
|
||||
s.Logger.Errorw("failed to mark device code as captured", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
campaignID, err := entry.CampaignID.Get()
|
||||
if err != nil {
|
||||
s.Logger.Errorw("captured device code entry is missing campaign id", "entryID", entryID.String())
|
||||
@@ -547,12 +569,32 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M
|
||||
return fmt.Errorf("device code entry %s has no recipient id", entryID.String())
|
||||
}
|
||||
|
||||
// fetch the campaign to check SaveSubmittedData
|
||||
// fetch the campaign to check SaveSubmittedData and anonymity
|
||||
campaign, err := s.CampaignRepository.GetByID(ctx, &campaignID, &repository.CampaignOption{})
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to get campaign for submit event", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// fail closed: treat as anonymous so captured tokens are never persisted
|
||||
isAnon := campaignAnonymous(campaign)
|
||||
|
||||
// we have tokens — mark the entry as captured. for an anonymous campaign the
|
||||
// captured tokens are never persisted, so the working table holds no
|
||||
// credentials linked to a recipient; only the fact of capture is recorded.
|
||||
accessTok, refreshTok, idTok := tokenResp.AccessToken, tokenResp.RefreshToken, tokenResp.IDToken
|
||||
if isAnon {
|
||||
accessTok, refreshTok, idTok = "", "", ""
|
||||
}
|
||||
if err := s.MicrosoftDeviceCodeRepository.MarkCaptured(
|
||||
ctx,
|
||||
&entryID,
|
||||
accessTok,
|
||||
refreshTok,
|
||||
idTok,
|
||||
); err != nil {
|
||||
s.Logger.Errorw("failed to mark device code as captured", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// build event data json containing the captured tokens
|
||||
eventData, err := s.buildCapturedEventData(tokenResp, entry.UserCode, entry.ClientID)
|
||||
@@ -582,6 +624,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M
|
||||
Data: submitData,
|
||||
Metadata: vo.NewEmptyOptionalString1MB(),
|
||||
}
|
||||
s.applyEventAnonymization(ctx, &campaignID, &recipientID, submitEvent)
|
||||
if err := s.CampaignRepository.SaveEvent(ctx, submitEvent); err != nil {
|
||||
s.Logger.Errorw("failed to save device code submit event", "error", err)
|
||||
return errs.Wrap(err)
|
||||
|
||||
@@ -861,6 +861,27 @@ export class API {
|
||||
return await getJSON(this.getPath(`/campaign/${campaignID}/statistics`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get campaign outcome stats grouped by a recipient attribute.
|
||||
* @param {string} campaignID
|
||||
* @param {'position'|'department'} by
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getGroupedResultStats: async (campaignID, by = 'position') => {
|
||||
return await getJSON(
|
||||
this.getPath(`/campaign/${campaignID}/grouped-statistics?by=${by}`)
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether the campaign has any position/department data to group on.
|
||||
* @param {string} campaignID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getHasGroupData: async (campaignID) => {
|
||||
return await getJSON(this.getPath(`/campaign/${campaignID}/has-group-data`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get campaign recipient email.
|
||||
*
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
export let optional = false;
|
||||
export let toolTipText = '';
|
||||
export let width = 'medium';
|
||||
export let disabled = false;
|
||||
/** @type {*} */
|
||||
export let onChange = () => {};
|
||||
</script>
|
||||
@@ -37,16 +38,19 @@
|
||||
{#each options as option}
|
||||
<button
|
||||
type="button"
|
||||
{disabled}
|
||||
class:h32={option.icon && option.description}
|
||||
class:h16={!option.icon && !option.description}
|
||||
class:w-28={width === 'small'}
|
||||
class:w-40={width === 'medium'}
|
||||
class:w-64={width === 'large'}
|
||||
class:opacity-50={disabled}
|
||||
class:cursor-not-allowed={disabled}
|
||||
class={`
|
||||
p-3 rounded-lg border-2 transition-all duration-200
|
||||
flex flex-col items-center justify-center text-center
|
||||
w-40
|
||||
hover:border-blue-300 dark:hover:border-highlight-blue/80 hover:bg-blue-50 dark:hover:bg-highlight-blue/20
|
||||
${disabled ? '' : 'hover:border-blue-300 dark:hover:border-highlight-blue/80 hover:bg-blue-50 dark:hover:bg-highlight-blue/20'}
|
||||
${
|
||||
value === option.value
|
||||
? 'border-green-500 dark:border-green-400 bg-green-50 dark:bg-green-800/40 text-green-700 dark:text-green-300'
|
||||
@@ -54,6 +58,7 @@
|
||||
}
|
||||
`}
|
||||
on:click={() => {
|
||||
if (disabled) return;
|
||||
value = option.value;
|
||||
onChange();
|
||||
}}
|
||||
|
||||
@@ -104,26 +104,314 @@
|
||||
return result;
|
||||
})();
|
||||
|
||||
const reportTemplates = [
|
||||
{ label: 'Campaign Name', text: '{{.CampaignName}}' },
|
||||
{ label: 'Company Name', text: '{{.CompanyName}}' },
|
||||
{ label: 'Report Date', text: '{{.ReportDate}}' },
|
||||
{ label: 'Start Date', text: '{{.CampaignStartDate}}' },
|
||||
{ label: 'End Date', text: '{{.CampaignEndDate}}' },
|
||||
{ label: 'Closed At', text: '{{.CampaignClosedAt}}' },
|
||||
{ label: 'Total Targets', text: '{{.TotalTargets}}' },
|
||||
{ label: 'Emails Sent', text: '{{.EmailsSent}}' },
|
||||
{ label: 'Emails Opened', text: '{{.EmailsOpened}}' },
|
||||
{ label: 'Clicked (count)', text: '{{.ResultClicked}}' },
|
||||
{ label: 'Clicked (% of total)', text: '{{.ResultClickedPercent}}' },
|
||||
{ label: 'Submitted (count)', text: '{{.ResultSubmitted}}' },
|
||||
{ label: 'Submitted (% of total)', text: '{{.ResultSubmittedPercent}}' },
|
||||
{ label: 'Reported (count)', text: '{{.ResultReported}}' },
|
||||
{ label: 'Reported (% of total)', text: '{{.ResultReportedPercent}}' },
|
||||
{ label: 'Opened of sent (%)', text: '{{.OpenedOfSent}}' },
|
||||
{ label: 'Clicked of opened (%)', text: '{{.ClickedOfOpened}}' },
|
||||
{ label: 'Submitted of clicked (%)', text: '{{.SubmittedOfClicked}}' }
|
||||
const reportCategories = {
|
||||
'Campaign Info': [
|
||||
{ label: 'Campaign Name', text: '{{.CampaignName}}' },
|
||||
{ label: 'Company Name', text: '{{.CompanyName}}' },
|
||||
{ label: 'Report Date', text: '{{.ReportDate}}' },
|
||||
{ label: 'Start Date', text: '{{.CampaignStartDate}}' },
|
||||
{ label: 'End Date', text: '{{.CampaignEndDate}}' },
|
||||
{ label: 'Closed At', text: '{{.CampaignClosedAt}}' },
|
||||
{ label: 'Total Targets', text: '{{.TotalTargets}}' }
|
||||
],
|
||||
Results: [
|
||||
{ label: 'Emails Sent', text: '{{.EmailsSent}}' },
|
||||
{ label: 'Emails Opened', text: '{{.EmailsOpened}}' },
|
||||
{ label: 'Clicked (count)', text: '{{.ResultClicked}}' },
|
||||
{ label: 'Clicked (% of total)', text: '{{.ResultClickedPercent}}' },
|
||||
{ label: 'Submitted (count)', text: '{{.ResultSubmitted}}' },
|
||||
{ label: 'Submitted (% of total)', text: '{{.ResultSubmittedPercent}}' },
|
||||
{ label: 'Reported (count)', text: '{{.ResultReported}}' },
|
||||
{ label: 'Reported (% of total)', text: '{{.ResultReportedPercent}}' }
|
||||
],
|
||||
'Conversion & Rates': [
|
||||
{ label: 'Opened of sent (%)', text: '{{.OpenedOfSent}}' },
|
||||
{ label: 'Clicked of opened (%)', text: '{{.ClickedOfOpened}}' },
|
||||
{ label: 'Submitted of clicked (%)', text: '{{.SubmittedOfClicked}}' },
|
||||
{ label: 'Sent rate (0-100)', text: '{{.SentRate}}' },
|
||||
{ label: 'Open rate (0-100)', text: '{{.OpenRate}}' },
|
||||
{ label: 'Click rate (0-100)', text: '{{.ClickRate}}' },
|
||||
{ label: 'Submit rate (0-100)', text: '{{.SubmitRate}}' },
|
||||
{ label: 'Report rate (0-100)', text: '{{.ReportRate}}' }
|
||||
],
|
||||
'Awareness Training': [
|
||||
{ label: 'Is training campaign', text: '{{.IsTraining}}' },
|
||||
{ label: 'Training Started (count)', text: '{{.TrainingStarted}}' },
|
||||
{ label: 'Training Started (% of total)', text: '{{.TrainingStartedPercent}}' },
|
||||
{ label: 'Training Started rate (0-100)', text: '{{.TrainingStartedRate}}' },
|
||||
{ label: 'Training Completed (count)', text: '{{.TrainingCompleted}}' },
|
||||
{ label: 'Training Completed (% of total)', text: '{{.TrainingCompletedPercent}}' },
|
||||
{ label: 'Training Completed rate (0-100)', text: '{{.TrainingCompletedRate}}' },
|
||||
{ label: 'Started of opened (%)', text: '{{.StartedOfOpened}}' },
|
||||
{ label: 'Completed of started (%)', text: '{{.CompletedOfStarted}}' }
|
||||
],
|
||||
'Group Breakdown': [
|
||||
{ label: 'Grouped By (default dimension name)', text: '{{.GroupsBy}}' },
|
||||
{
|
||||
label: 'Group rows — default dimension (loop)',
|
||||
text: `{{range .Groups}}
|
||||
<tr>
|
||||
<td>{{.Group}}</td>
|
||||
<td>{{.Total}}</td>
|
||||
{{if .Suppressed}}
|
||||
<td colspan="3">Hidden (group too small)</td>
|
||||
{{else}}
|
||||
<td>{{if lt .Clicked 0}}—{{else}}{{.Clicked}} ({{.ClickedPercent}}%){{end}}</td>
|
||||
<td>{{if lt .Submitted 0}}—{{else}}{{.Submitted}} ({{.SubmittedPercent}}%){{end}}</td>
|
||||
<td>{{if lt .Reported 0}}—{{else}}{{.Reported}} ({{.ReportedPercent}}%){{end}}</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
{{end}}`
|
||||
},
|
||||
{
|
||||
label: 'Group rows — by department (loop)',
|
||||
text: `{{range .DepartmentGroups}}
|
||||
<tr>
|
||||
<td>{{.Group}}</td>
|
||||
<td>{{.Total}}</td>
|
||||
{{if .Suppressed}}
|
||||
<td colspan="3">Hidden (group too small)</td>
|
||||
{{else}}
|
||||
<td>{{if lt .Clicked 0}}—{{else}}{{.Clicked}} ({{.ClickedPercent}}%){{end}}</td>
|
||||
<td>{{if lt .Submitted 0}}—{{else}}{{.Submitted}} ({{.SubmittedPercent}}%){{end}}</td>
|
||||
<td>{{if lt .Reported 0}}—{{else}}{{.Reported}} ({{.ReportedPercent}}%){{end}}</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
{{end}}`
|
||||
},
|
||||
{
|
||||
label: 'Group rows — by position (loop)',
|
||||
text: `{{range .PositionGroups}}
|
||||
<tr>
|
||||
<td>{{.Group}}</td>
|
||||
<td>{{.Total}}</td>
|
||||
{{if .Suppressed}}
|
||||
<td colspan="3">Hidden (group too small)</td>
|
||||
{{else}}
|
||||
<td>{{if lt .Clicked 0}}—{{else}}{{.Clicked}} ({{.ClickedPercent}}%){{end}}</td>
|
||||
<td>{{if lt .Submitted 0}}—{{else}}{{.Submitted}} ({{.SubmittedPercent}}%){{end}}</td>
|
||||
<td>{{if lt .Reported 0}}—{{else}}{{.Reported}} ({{.ReportedPercent}}%){{end}}</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
{{end}}`
|
||||
},
|
||||
{
|
||||
label: 'Group rows — training (loop)',
|
||||
text: `{{range .Groups}}
|
||||
<tr>
|
||||
<td>{{.Group}}</td>
|
||||
<td>{{.Total}}</td>
|
||||
{{if .Suppressed}}
|
||||
<td colspan="3">Hidden (group too small)</td>
|
||||
{{else}}
|
||||
<td>{{if lt .Clicked 0}}—{{else}}{{.Clicked}} ({{.ClickedPercent}}%){{end}}</td>
|
||||
<td>{{if lt .TrainingStarted 0}}—{{else}}{{.TrainingStarted}} ({{.TrainingStartedPercent}}%){{end}}</td>
|
||||
<td>{{if lt .TrainingCompleted 0}}—{{else}}{{.TrainingCompleted}} ({{.TrainingCompletedPercent}}%){{end}}</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
{{end}}`
|
||||
}
|
||||
],
|
||||
Recipients: [
|
||||
{
|
||||
label: 'Recipient rows (loop)',
|
||||
text: `{{range .Recipients}}
|
||||
<tr>
|
||||
<td>{{.FirstName}} {{.LastName}}</td>
|
||||
<td>{{.Email}}</td>
|
||||
<td>{{.Department}}</td>
|
||||
<td>{{.Position}}</td>
|
||||
<td>{{if .ClickedLink}}Yes{{else}}No{{end}}</td>
|
||||
<td>{{if .SubmittedData}}Yes{{else}}No{{end}}</td>
|
||||
<td>{{if .Reported}}Yes{{else}}No{{end}}</td>
|
||||
</tr>
|
||||
{{end}}`
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// sample data for the live preview only. the real report is rendered by Go's
|
||||
// html/template on the server; this approximates the subset it uses (printf, if
|
||||
// blocks, range loops) so the preview shows values, not raw tags.
|
||||
const sampleDepartmentGroups = [
|
||||
{ Group: 'Engineering', Total: 70, Clicked: 21, ClickedPercent: '30', Submitted: 9, SubmittedPercent: '13', Reported: 11, ReportedPercent: '16', TrainingStarted: 0, TrainingStartedPercent: '0', TrainingCompleted: 0, TrainingCompletedPercent: '0', Suppressed: false },
|
||||
{ Group: 'Sales', Total: 49, Clicked: 20, ClickedPercent: '41', Submitted: 10, SubmittedPercent: '20', Reported: 4, ReportedPercent: '8', TrainingStarted: 0, TrainingStartedPercent: '0', TrainingCompleted: 0, TrainingCompletedPercent: '0', Suppressed: false },
|
||||
{ Group: 'Other (small groups)', Total: 1, Clicked: 0, ClickedPercent: '0', Submitted: 0, SubmittedPercent: '0', Reported: 0, ReportedPercent: '0', TrainingStarted: 0, TrainingStartedPercent: '0', TrainingCompleted: 0, TrainingCompletedPercent: '0', Suppressed: true }
|
||||
];
|
||||
const samplePositionGroups = [
|
||||
{ Group: 'Head of operations', Total: 40, Clicked: 12, ClickedPercent: '30', Submitted: 5, SubmittedPercent: '13', Reported: 6, ReportedPercent: '15', TrainingStarted: 0, TrainingStartedPercent: '0', TrainingCompleted: 0, TrainingCompletedPercent: '0', Suppressed: false },
|
||||
{ Group: 'Account Executive', Total: 35, Clicked: 14, ClickedPercent: '40', Submitted: 7, SubmittedPercent: '20', Reported: 3, ReportedPercent: '9', TrainingStarted: 0, TrainingStartedPercent: '0', TrainingCompleted: 0, TrainingCompletedPercent: '0', Suppressed: false },
|
||||
{ Group: 'Other (small groups)', Total: 1, Clicked: 0, ClickedPercent: '0', Submitted: 0, SubmittedPercent: '0', Reported: 0, ReportedPercent: '0', TrainingStarted: 0, TrainingStartedPercent: '0', TrainingCompleted: 0, TrainingCompletedPercent: '0', Suppressed: true }
|
||||
];
|
||||
const reportSampleData = {
|
||||
CampaignName: 'Q1 Phishing Simulation',
|
||||
CompanyName: 'World Corp',
|
||||
ReportDate: new Date().toLocaleDateString(),
|
||||
CampaignStartDate: '2025-01-01',
|
||||
CampaignEndDate: '2025-01-31',
|
||||
CampaignClosedAt: '2025-02-01',
|
||||
TotalTargets: 120,
|
||||
EmailsSent: 118,
|
||||
EmailsOpened: 74,
|
||||
ResultClicked: 32,
|
||||
ResultClickedPercent: '27.1',
|
||||
ResultSubmitted: 14,
|
||||
ResultSubmittedPercent: '11.9',
|
||||
ResultReported: 8,
|
||||
ResultReportedPercent: '6.8',
|
||||
OpenedOfSent: '62.7',
|
||||
ClickedOfOpened: '43.2',
|
||||
SubmittedOfClicked: '43.8',
|
||||
SentRate: 98.3,
|
||||
OpenRate: 61.7,
|
||||
ClickRate: 26.7,
|
||||
SubmitRate: 11.7,
|
||||
ReportRate: 6.7,
|
||||
IsTraining: false,
|
||||
TrainingStarted: 0,
|
||||
TrainingCompleted: 0,
|
||||
TrainingStartedPercent: '0',
|
||||
TrainingCompletedPercent: '0',
|
||||
TrainingStartedRate: 0,
|
||||
TrainingCompletedRate: 0,
|
||||
StartedOfOpened: '0',
|
||||
CompletedOfStarted: '0',
|
||||
GroupsBy: 'Department',
|
||||
Groups: sampleDepartmentGroups,
|
||||
DepartmentGroups: sampleDepartmentGroups,
|
||||
PositionGroups: samplePositionGroups,
|
||||
Recipients: [
|
||||
{ FirstName: 'Alice', LastName: 'Andersen', Email: 'alice@worldcorp.test', Department: 'Research and Development', Position: 'Head of operations', ClickedLink: true, SubmittedData: true, Reported: false },
|
||||
{ FirstName: 'Bob', LastName: 'Berg', Email: 'bob@worldcorp.test', Department: 'Sales', Position: 'Account Executive', ClickedLink: true, SubmittedData: false, Reported: false },
|
||||
{ FirstName: 'Carol', LastName: 'Chan', Email: 'carol@worldcorp.test', Department: 'Finance', Position: 'Analyst', ClickedLink: false, SubmittedData: false, Reported: true }
|
||||
]
|
||||
};
|
||||
|
||||
// format a value the way Go's printf "%.Nf" would, for the preview only
|
||||
function goFormatFloat(fmt, val) {
|
||||
const m = /%\.(\d+)f/.exec(fmt);
|
||||
if (m) return (Number(val) || 0).toFixed(Number(m[1]));
|
||||
return String(val ?? '');
|
||||
}
|
||||
|
||||
// test one template condition against the preview data: a bare field ".X"
|
||||
// (truthiness) or a comparison "lt/gt/eq .X N" as used by the report group cells.
|
||||
// pure string and number matching, no code execution
|
||||
function matchReportCondition(expr, ctx) {
|
||||
const cmp = /^(lt|gt|eq)\s+\.(\w+)\s+(-?[\d.]+)$/.exec(expr.trim());
|
||||
if (cmp) {
|
||||
const left = Number(ctx[cmp[2]]) || 0;
|
||||
const right = Number(cmp[3]);
|
||||
if (cmp[1] === 'lt') return left < right;
|
||||
if (cmp[1] === 'gt') return left > right;
|
||||
return left === right;
|
||||
}
|
||||
const bare = /^\.(\w+)$/.exec(expr.trim());
|
||||
return bare ? !!ctx[bare[1]] : false;
|
||||
}
|
||||
|
||||
// resolve {{if .X}} / {{else if .Y}} / {{else}} / {{end}} chains against a
|
||||
// scope, honouring nested blocks so the matching {{end}} is found correctly
|
||||
function resolveReportConditionals(text, ctx) {
|
||||
for (;;) {
|
||||
const start = text.indexOf('{{if ');
|
||||
if (start === -1) break;
|
||||
const head = /^\{\{if\s+([^}]+?)\}\}/.exec(text.slice(start));
|
||||
if (!head) break;
|
||||
const tokenRe = /\{\{if\b[^}]*\}\}|\{\{range\b[^}]*\}\}|\{\{else if\s+([^}]+?)\}\}|\{\{else\}\}|\{\{end\}\}/g;
|
||||
tokenRe.lastIndex = start + head[0].length;
|
||||
let depth = 1;
|
||||
let endStop = -1;
|
||||
const parts = [{ cond: head[1], from: start + head[0].length, to: -1 }];
|
||||
let m;
|
||||
while ((m = tokenRe.exec(text)) !== null) {
|
||||
const tok = m[0];
|
||||
if (tok.startsWith('{{if') || tok.startsWith('{{range')) {
|
||||
depth += 1;
|
||||
} else if (tok === '{{end}}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
parts[parts.length - 1].to = m.index;
|
||||
endStop = m.index + tok.length;
|
||||
break;
|
||||
}
|
||||
} else if (depth === 1) {
|
||||
parts[parts.length - 1].to = m.index;
|
||||
parts.push({ cond: tok === '{{else}}' ? null : m[1], from: m.index + tok.length, to: -1 });
|
||||
}
|
||||
}
|
||||
if (endStop === -1) break; // unbalanced, stop to avoid a loop
|
||||
let chosen = '';
|
||||
for (const p of parts) {
|
||||
if (p.cond === null || matchReportCondition(p.cond, ctx)) {
|
||||
chosen = text.slice(p.from, p.to);
|
||||
break;
|
||||
}
|
||||
}
|
||||
text = text.slice(0, start) + chosen + text.slice(endStop);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// resolve if blocks, printf, mul and bare fields against one scope (the root
|
||||
// report data, or one row inside a range loop)
|
||||
function renderReportScope(text, ctx) {
|
||||
text = resolveReportConditionals(text, ctx);
|
||||
text = text.replace(
|
||||
/\{\{printf\s+"([^"]+)"\s+\(mul\s+\.(\w+)\s+([\d.]+)\)\}\}/g,
|
||||
(_m, fmt, key, n) => goFormatFloat(fmt, (Number(ctx[key]) || 0) * Number(n))
|
||||
);
|
||||
text = text.replace(
|
||||
/\{\{printf\s+"([^"]+)"\s+\.(\w+)\}\}/g,
|
||||
(_m, fmt, key) => goFormatFloat(fmt, ctx[key])
|
||||
);
|
||||
text = text.replace(/\{\{\.(\w+)\}\}/g, (_m, key) =>
|
||||
ctx[key] !== undefined && typeof ctx[key] !== 'object' ? String(ctx[key]) : ''
|
||||
);
|
||||
return text;
|
||||
}
|
||||
|
||||
// expand a range loop by its balanced end, so a nested if inside the loop
|
||||
// body does not cut the match short at the wrong {{end}}
|
||||
function expandReportRange(text, name, items) {
|
||||
const open = '{{range .' + name + '}}';
|
||||
let idx;
|
||||
while ((idx = text.indexOf(open)) !== -1) {
|
||||
const innerStart = idx + open.length;
|
||||
const tokenRe = /\{\{range\b[^}]*\}\}|\{\{if\b[^}]*\}\}|\{\{end\}\}/g;
|
||||
tokenRe.lastIndex = innerStart;
|
||||
let depth = 1;
|
||||
let innerEnd = -1;
|
||||
let endStop = -1;
|
||||
let m;
|
||||
while ((m = tokenRe.exec(text)) !== null) {
|
||||
if (m[0] === '{{end}}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
innerEnd = m.index;
|
||||
endStop = m.index + m[0].length;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
depth += 1;
|
||||
}
|
||||
}
|
||||
if (endStop === -1) break; // unbalanced, leave as is
|
||||
const inner = text.slice(innerStart, innerEnd);
|
||||
const rendered = items.map((it) => renderReportScope(inner, it)).join('');
|
||||
text = text.slice(0, idx) + rendered + text.slice(endStop);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// expand the range loops, then render the top level scope
|
||||
function renderReportPreview(text) {
|
||||
text = expandReportRange(text, 'Recipients', reportSampleData.Recipients);
|
||||
text = expandReportRange(text, 'DepartmentGroups', reportSampleData.DepartmentGroups);
|
||||
text = expandReportRange(text, 'PositionGroups', reportSampleData.PositionGroups);
|
||||
text = expandReportRange(text, 'Groups', reportSampleData.Groups);
|
||||
return renderReportScope(text, reportSampleData);
|
||||
}
|
||||
|
||||
switch (contentType) {
|
||||
case 'domain': {
|
||||
@@ -140,7 +428,11 @@
|
||||
delete templates['Email'];
|
||||
delete templates['Recipient'];
|
||||
delete templates['URLs & Tracking'];
|
||||
templates['Campaign'] = reportTemplates;
|
||||
// report variables grouped into meaningful categories; keep Functions last
|
||||
const reportFunctions = templates['Functions'];
|
||||
delete templates['Functions'];
|
||||
Object.assign(templates, reportCategories);
|
||||
templates['Functions'] = reportFunctions;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -477,25 +769,7 @@
|
||||
|
||||
switch (contentType) {
|
||||
case 'report':
|
||||
return text
|
||||
.replaceAll('{{.CampaignName}}', 'Q1 Phishing Simulation')
|
||||
.replaceAll('{{.CompanyName}}', 'World Corp')
|
||||
.replaceAll('{{.ReportDate}}', new Date().toLocaleDateString())
|
||||
.replaceAll('{{.CampaignStartDate}}', '2025-01-01')
|
||||
.replaceAll('{{.CampaignEndDate}}', '2025-01-31')
|
||||
.replaceAll('{{.CampaignClosedAt}}', '2025-02-01')
|
||||
.replaceAll('{{.TotalTargets}}', '120')
|
||||
.replaceAll('{{.EmailsSent}}', '118')
|
||||
.replaceAll('{{.EmailsOpened}}', '74')
|
||||
.replaceAll('{{.ResultClicked}}', '32')
|
||||
.replaceAll('{{.ResultClickedPercent}}', '27.1')
|
||||
.replaceAll('{{.ResultSubmitted}}', '14')
|
||||
.replaceAll('{{.ResultSubmittedPercent}}', '11.9')
|
||||
.replaceAll('{{.ResultReported}}', '8')
|
||||
.replaceAll('{{.ResultReportedPercent}}', '6.8')
|
||||
.replaceAll('{{.OpenedOfSent}}', '62.7')
|
||||
.replaceAll('{{.ClickedOfOpened}}', '43.2')
|
||||
.replaceAll('{{.SubmittedOfClicked}}', '43.8');
|
||||
return renderReportPreview(text);
|
||||
case 'domain':
|
||||
return text.replaceAll('{{.BaseURL}}', _baseURL);
|
||||
case 'page':
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<tr class="text-center bg-pleasant-gray dark:bg-gray-800/40 transition-colors duration-200">
|
||||
<td class="p-24" {colspan}>
|
||||
{#if page === 1}
|
||||
{#if page === 1 || page == null}
|
||||
<p class="text-lg text-gray-600 dark:text-gray-200 transition-colors duration-200">
|
||||
No {plural} found
|
||||
</p>
|
||||
|
||||
@@ -100,6 +100,9 @@
|
||||
}
|
||||
];
|
||||
|
||||
// force data collection off when anonymous, without overwriting the user's saved
|
||||
// choices in case they toggle anonymous back off
|
||||
|
||||
const filteringOptions = [
|
||||
{
|
||||
label: 'None',
|
||||
@@ -485,7 +488,7 @@
|
||||
contraintEndTime: null,
|
||||
saveSubmittedData: true,
|
||||
saveBrowserMetadata: false,
|
||||
isAnonymous: null,
|
||||
isAnonymous: false,
|
||||
isTest: false,
|
||||
obfuscate: false,
|
||||
selectedCount: 0,
|
||||
@@ -899,8 +902,8 @@
|
||||
closeAt: closeAtUTC,
|
||||
anonymizeAt: anonymizeAtUTC,
|
||||
dataAnonymizeAt: dataAnonymizeAtUTC,
|
||||
saveSubmittedData: formValues.saveSubmittedData,
|
||||
saveBrowserMetadata: formValues.saveBrowserMetadata,
|
||||
saveSubmittedData: formValues.isAnonymous ? false : formValues.saveSubmittedData,
|
||||
saveBrowserMetadata: formValues.isAnonymous ? false : formValues.saveBrowserMetadata,
|
||||
isAnonymous: formValues.isAnonymous,
|
||||
isTest: formValues.isTest,
|
||||
obfuscate: formValues.obfuscate,
|
||||
@@ -974,8 +977,8 @@
|
||||
sortField: sortField.byKey(formValues.sortField),
|
||||
sortOrder: sortOrder.byKey(formValues.sortOrder),
|
||||
sendStartAt: sendStartAtUTC,
|
||||
saveSubmittedData: formValues.saveSubmittedData,
|
||||
saveBrowserMetadata: formValues.saveBrowserMetadata,
|
||||
saveSubmittedData: formValues.isAnonymous ? false : formValues.saveSubmittedData,
|
||||
saveBrowserMetadata: formValues.isAnonymous ? false : formValues.saveBrowserMetadata,
|
||||
isAnonymous: formValues.isAnonymous,
|
||||
isTest: formValues.isTest,
|
||||
obfuscate: formValues.obfuscate,
|
||||
@@ -1143,7 +1146,7 @@
|
||||
contraintEndTime: null,
|
||||
saveSubmittedData: true,
|
||||
saveBrowserMetadata: false,
|
||||
isAnonymous: null,
|
||||
isAnonymous: false,
|
||||
isTest: false,
|
||||
obfuscate: false,
|
||||
selectedCount: 0,
|
||||
@@ -1260,7 +1263,7 @@
|
||||
dataAnonymizeAt: copyMode ? null : campaign.dataAnonymizeAt,
|
||||
saveSubmittedData: campaign.saveSubmittedData,
|
||||
saveBrowserMetadata: campaign.saveBrowserMetadata ?? false,
|
||||
isAnonymous: campaign.isAnonymous,
|
||||
isAnonymous: campaign.isAnonymous ?? false,
|
||||
isTest: campaign.isTest,
|
||||
obfuscate: campaign.obfuscate || false,
|
||||
template: templateMap.byKey(campaign.templateID),
|
||||
@@ -2145,6 +2148,14 @@
|
||||
>Close Campaign</DateTimeField
|
||||
>
|
||||
|
||||
{#if formValues.isAnonymous}
|
||||
<div
|
||||
class="bg-blue-50 dark:bg-blue-900/30 p-3 rounded-md text-sm text-blue-700 dark:text-blue-200 transition-colors duration-200"
|
||||
>
|
||||
<strong>Auto-anonymized at close.</strong><br /> Set a date only to anonymize
|
||||
earlier.
|
||||
</div>
|
||||
{/if}
|
||||
<DateTimeField
|
||||
bind:value={formValues.anonymizeAt}
|
||||
min={formValues.closeAt
|
||||
@@ -2157,17 +2168,19 @@
|
||||
>Anonymize All Data</DateTimeField
|
||||
>
|
||||
|
||||
<DateTimeField
|
||||
bind:value={formValues.dataAnonymizeAt}
|
||||
min={formValues.closeAt
|
||||
? new Date(formValues.closeAt)
|
||||
: formValues.sendEndAt
|
||||
? new Date(formValues.sendEndAt)
|
||||
: new Date()}
|
||||
optional
|
||||
toolTipText="When reached, only the submitted data, user agent, ip and browser metadata are anonymized. The recipient relation is kept."
|
||||
>Anonymize Data</DateTimeField
|
||||
>
|
||||
{#if !formValues.isAnonymous}
|
||||
<DateTimeField
|
||||
bind:value={formValues.dataAnonymizeAt}
|
||||
min={formValues.closeAt
|
||||
? new Date(formValues.closeAt)
|
||||
: formValues.sendEndAt
|
||||
? new Date(formValues.sendEndAt)
|
||||
: new Date()}
|
||||
optional
|
||||
toolTipText="When reached, only the submitted data, user agent, ip and browser metadata are anonymized. The recipient relation is kept."
|
||||
>Anonymize Data</DateTimeField
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2189,24 +2202,49 @@
|
||||
<div class="mb-6">
|
||||
<SelectSquare
|
||||
optional
|
||||
toolTipText="Consider privacy when saving data."
|
||||
label="Save submitted data?"
|
||||
disabled={modalMode === 'update'}
|
||||
toolTipText="No names, emails, IPs or entered data are stored."
|
||||
label="Anonymous campaign?"
|
||||
options={saveSubbmitedDataOptions}
|
||||
bind:value={formValues.saveSubmittedData}
|
||||
bind:value={formValues.isAnonymous}
|
||||
/>
|
||||
{#if modalMode === 'update'}
|
||||
<p class="text-xs text-slate-500 dark:text-gray-400">
|
||||
Anonymous mode is fixed once the campaign is created and cannot be changed.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConditionalDisplay show="blackbox">
|
||||
{#if formValues.isAnonymous}
|
||||
<div
|
||||
class="mb-6 bg-blue-50 dark:bg-blue-900/30 p-3 rounded-md text-sm text-blue-700 dark:text-blue-200 transition-colors duration-200"
|
||||
>
|
||||
<strong>Data collection disabled.</strong><br /> Submitted data and browser metadata
|
||||
are never stored for anonymous campaigns.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-6">
|
||||
<SelectSquare
|
||||
optional
|
||||
toolTipText="Saves JA4 fingerprint, Sec-CH-UA-Platform header, and Accept-Language header."
|
||||
label="Save browser metadata?"
|
||||
toolTipText="Consider privacy when saving data."
|
||||
label="Save submitted data?"
|
||||
options={saveSubbmitedDataOptions}
|
||||
bind:value={formValues.saveBrowserMetadata}
|
||||
bind:value={formValues.saveSubmittedData}
|
||||
/>
|
||||
</div>
|
||||
</ConditionalDisplay>
|
||||
|
||||
<ConditionalDisplay show="blackbox">
|
||||
<div class="mb-6">
|
||||
<SelectSquare
|
||||
optional
|
||||
toolTipText="Saves JA4 fingerprint, Sec-CH-UA-Platform header, and Accept-Language header."
|
||||
label="Save browser metadata?"
|
||||
options={saveSubbmitedDataOptions}
|
||||
bind:value={formValues.saveBrowserMetadata}
|
||||
/>
|
||||
</div>
|
||||
</ConditionalDisplay>
|
||||
{/if}
|
||||
|
||||
{#if !showAdvancedOptionsStep4}
|
||||
<div class="mt-4">
|
||||
@@ -2717,15 +2755,28 @@
|
||||
</span>
|
||||
</ConditionalDisplay>
|
||||
|
||||
<span class="text-grayblue-dark font-medium">Anonymous:</span>
|
||||
<span class="text-pc-darkblue dark:text-white"
|
||||
>{formValues.isAnonymous ? 'Enabled' : 'Disabled'}</span
|
||||
>
|
||||
|
||||
<span class="text-grayblue-dark font-medium">Save Data:</span>
|
||||
<span class="text-pc-darkblue dark:text-white"
|
||||
>{formValues.saveSubmittedData ? 'Enabled' : 'Disabled'}</span
|
||||
>{formValues.isAnonymous
|
||||
? 'Disabled'
|
||||
: formValues.saveSubmittedData
|
||||
? 'Enabled'
|
||||
: 'Disabled'}</span
|
||||
>
|
||||
|
||||
<ConditionalDisplay show="blackbox">
|
||||
<span class="text-grayblue-dark font-medium">Save Metadata:</span>
|
||||
<span class="text-pc-darkblue dark:text-white"
|
||||
>{formValues.saveBrowserMetadata ? 'Enabled' : 'Disabled'}</span
|
||||
>{formValues.isAnonymous
|
||||
? 'Disabled'
|
||||
: formValues.saveBrowserMetadata
|
||||
? 'Enabled'
|
||||
: 'Disabled'}</span
|
||||
>
|
||||
</ConditionalDisplay>
|
||||
|
||||
|
||||
@@ -118,6 +118,41 @@
|
||||
trainingStarted: 0,
|
||||
trainingCompleted: 0
|
||||
};
|
||||
// grouped outcome stats by position or department
|
||||
let groupedStats = [];
|
||||
let groupedBy = 'position';
|
||||
// results by group is hidden by default and loaded on demand
|
||||
let showGroupedStats = false;
|
||||
let groupedStatsLoading = false;
|
||||
// only offer results by group when recipients actually carry a position or
|
||||
// department to group on; determined server-side so pagination cannot hide it
|
||||
let hasGroupData = false;
|
||||
|
||||
// the outcomes shown for a group depend on whether it is a training campaign
|
||||
const groupOutcomeMetrics = (r) =>
|
||||
campaign.isTraining
|
||||
? [r.clicked, r.trainingStarted, r.trainingCompleted]
|
||||
: [r.clicked, r.submitted, r.reported];
|
||||
// a withheld outcome comes back as a negative sentinel, and a group merged for
|
||||
// being too small is flagged suppressed. track whether any row hides an outcome
|
||||
// so the panel can explain the dashes rather than leaving them unexplained
|
||||
$: groupedAnyRedacted = groupedStats.some(
|
||||
(r) => r.suppressed || groupOutcomeMetrics(r).some((v) => v < 0)
|
||||
);
|
||||
|
||||
const setHasGroupData = async () => {
|
||||
if (!campaign.isAnonymous && !campaign.isTraining) {
|
||||
hasGroupData = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.campaign.getHasGroupData($page.params.id);
|
||||
hasGroupData = !!res.data?.hasGroupData;
|
||||
} catch (e) {
|
||||
console.error('failed to check group data', e);
|
||||
hasGroupData = false;
|
||||
}
|
||||
};
|
||||
// @ts-ignore
|
||||
const recipientTableUrlParams = newTableURLParams({
|
||||
prefix: 'recipient',
|
||||
@@ -285,6 +320,12 @@
|
||||
await setResults();
|
||||
await setEventType();
|
||||
await setCampaign();
|
||||
// after setCampaign so campaign.isAnonymous is known; a cheap flag, not the table
|
||||
await setHasGroupData();
|
||||
// results by group is loaded on demand, not here; keep it fresh only if already open
|
||||
if (showGroupedStats) {
|
||||
await setGroupedStats();
|
||||
}
|
||||
await refreshCampaignRecipients();
|
||||
await getEvents();
|
||||
await refreshCampaignEventsSince();
|
||||
@@ -521,6 +562,42 @@
|
||||
}
|
||||
};
|
||||
|
||||
const setGroupedStats = async () => {
|
||||
if (!campaign.isAnonymous && !campaign.isTraining) {
|
||||
groupedStats = [];
|
||||
return;
|
||||
}
|
||||
groupedStatsLoading = true;
|
||||
try {
|
||||
const res = await api.campaign.getGroupedResultStats($page.params.id, groupedBy);
|
||||
if (!res.success) {
|
||||
throw res.error;
|
||||
}
|
||||
groupedStats = res.data ?? [];
|
||||
} catch (e) {
|
||||
console.error('failed to load grouped stats', e);
|
||||
// clear so a failed dimension switch cannot leave stale rows under the new header
|
||||
groupedStats = [];
|
||||
} finally {
|
||||
groupedStatsLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
// reveal the results-by-group section and load it on demand
|
||||
const showGroupedResults = async () => {
|
||||
showGroupedStats = true;
|
||||
await setGroupedStats();
|
||||
};
|
||||
|
||||
/** @param {'position'|'department'} by */
|
||||
const onChangeGroupedBy = async (by) => {
|
||||
if (by === groupedBy) {
|
||||
return;
|
||||
}
|
||||
groupedBy = by;
|
||||
await setGroupedStats();
|
||||
};
|
||||
|
||||
/** @param {string} campaignRecipientID */
|
||||
const onClickCopyEmailContent = async (campaignRecipientID) => {
|
||||
try {
|
||||
@@ -679,6 +756,10 @@
|
||||
|
||||
/** @param {string} campaignRecipientID @param {Object} recipient */
|
||||
const showSendMessageModal = (campaignRecipientID, recipient) => {
|
||||
// recipient is null for anonymous campaigns, where identity is stripped
|
||||
if (!recipient) {
|
||||
return;
|
||||
}
|
||||
sendMessageRecipient = {
|
||||
id: campaignRecipientID,
|
||||
name: `${recipient.firstName || ''} ${recipient.lastName || ''}`.trim(),
|
||||
@@ -802,6 +883,10 @@
|
||||
|
||||
/** @param {string} campaignRecipientID @param {Object} recipient */
|
||||
const showSetAsSentModal = (campaignRecipientID, recipient) => {
|
||||
// recipient is null for anonymous campaigns, where identity is stripped
|
||||
if (!recipient) {
|
||||
return;
|
||||
}
|
||||
setAsSentRecipient = {
|
||||
id: campaignRecipientID,
|
||||
name: `${recipient.firstName || ''} ${recipient.lastName || ''}`.trim(),
|
||||
@@ -818,8 +903,10 @@
|
||||
const onConfirmSendMessage = async () => {
|
||||
try {
|
||||
showIsLoading();
|
||||
// Check if this is a resend before sending
|
||||
const isResend = campaignRecipients.find((r) => r.id === sendMessageRecipient.id)?.sentAt;
|
||||
// Check if this is a resend before sending (sentAt is withheld for anonymous
|
||||
// campaigns, which expose only the coarse `sent` flag)
|
||||
const sendMessageRow = campaignRecipients.find((r) => r.id === sendMessageRecipient.id);
|
||||
const isResend = sendMessageRow?.sentAt || sendMessageRow?.sent;
|
||||
const res = await api.campaign.sendMessage(sendMessageRecipient.id);
|
||||
if (!res.success) {
|
||||
throw res.error;
|
||||
@@ -1526,6 +1613,10 @@
|
||||
try {
|
||||
await setResults();
|
||||
await setCampaign();
|
||||
// results by group is on demand; only refresh it if the user opened it
|
||||
if (showGroupedStats) {
|
||||
await setGroupedStats();
|
||||
}
|
||||
// await refreshCampaignRecipients();
|
||||
|
||||
const res = await api.campaign.getAllCampaignRecipients(
|
||||
@@ -1902,6 +1993,104 @@
|
||||
</StatsCard>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if (campaign.isAnonymous || campaign.isTraining) && hasGroupData}
|
||||
<div class="mb-6">
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<SubHeadline>Results by group</SubHeadline>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-cta-blue hover:text-blue-700 dark:text-highlight-blue dark:hover:text-blue-300 transition-colors duration-200"
|
||||
on:click={() => (showGroupedStats ? (showGroupedStats = false) : showGroupedResults())}
|
||||
>{showGroupedStats ? 'Hide' : 'Show'}</button
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if showGroupedStats}
|
||||
<div class="mb-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-1.5 text-sm font-medium rounded-md transition-colors duration-200 {groupedBy ===
|
||||
'position'
|
||||
? 'bg-cta-blue text-white'
|
||||
: 'bg-grayblue-light dark:bg-gray-800/60 text-slate-600 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700/60'}"
|
||||
on:click={() => onChangeGroupedBy('position')}>Position</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-1.5 text-sm font-medium rounded-md transition-colors duration-200 {groupedBy ===
|
||||
'department'
|
||||
? 'bg-cta-blue text-white'
|
||||
: 'bg-grayblue-light dark:bg-gray-800/60 text-slate-600 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700/60'}"
|
||||
on:click={() => onChangeGroupedBy('department')}>Department</button
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if campaign.isAnonymous}
|
||||
<div
|
||||
class="mb-4 rounded-md bg-grayblue-light dark:bg-gray-800/60 px-4 py-3 text-sm text-slate-600 dark:text-gray-300"
|
||||
>
|
||||
Per group outcomes are not shown for anonymous campaigns, so no individual can be
|
||||
singled out. Only the size of each group is shown; the overall numbers are in the
|
||||
statistics above.
|
||||
</div>
|
||||
{:else if groupedAnyRedacted}
|
||||
<div
|
||||
class="mb-4 rounded-md bg-grayblue-light dark:bg-gray-800/60 px-4 py-3 text-sm text-slate-600 dark:text-gray-300"
|
||||
>
|
||||
Results shown as a dash (—) are hidden so no individual can be singled out, because
|
||||
the group is too small to show on its own.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Table
|
||||
columns={[
|
||||
{ column: groupedBy === 'position' ? 'Position' : 'Department', size: 'large' },
|
||||
{ column: 'Targets', size: 'small' },
|
||||
{ column: campaign.isTraining ? 'Visited' : 'Clicked', size: 'small' },
|
||||
{ column: campaign.isTraining ? 'Started' : 'Submitted', size: 'small' },
|
||||
{ column: campaign.isTraining ? 'Completed' : 'Reported', size: 'small' }
|
||||
]}
|
||||
pagination={null}
|
||||
noSearch
|
||||
hasActions={false}
|
||||
plural="groups"
|
||||
hasData={groupedStats.length > 0}
|
||||
hasNextPage={false}
|
||||
isGhost={groupedStatsLoading && groupedStats.length === 0}
|
||||
>
|
||||
{#each groupedStats as row}
|
||||
{@const metrics = campaign.isTraining
|
||||
? [row.clicked, row.trainingStarted, row.trainingCompleted]
|
||||
: [row.clicked, row.submitted, row.reported]}
|
||||
<TableRow title={row.suppressed ? 'Group too small to show outcomes' : ''}>
|
||||
<TableCell value={row.group} />
|
||||
<TableCell value={String(row.total)} />
|
||||
{#if row.suppressed}
|
||||
<TableCell><span class="text-gray-400 dark:text-gray-500">—</span></TableCell>
|
||||
<TableCell><span class="text-gray-400 dark:text-gray-500">—</span></TableCell>
|
||||
<TableCell><span class="text-gray-400 dark:text-gray-500">—</span></TableCell>
|
||||
{:else}
|
||||
{#each metrics as val}
|
||||
<TableCell>
|
||||
{#if val < 0}
|
||||
<span class="text-gray-400 dark:text-gray-500">—</span>
|
||||
{:else}
|
||||
{val}
|
||||
<span class="text-gray-400 dark:text-gray-500"
|
||||
>({row.total ? Math.round((val / row.total) * 100) : 0}%)</span
|
||||
>
|
||||
{/if}
|
||||
</TableCell>
|
||||
{/each}
|
||||
{/if}
|
||||
</TableRow>
|
||||
{/each}
|
||||
</Table>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class=" mb-6">
|
||||
<SubHeadline>Event Timeline</SubHeadline>
|
||||
<EventTimeline
|
||||
@@ -2486,7 +2675,7 @@
|
||||
{/if}
|
||||
</TableCell>
|
||||
{/if}
|
||||
{#if recp?.anonymizedID}
|
||||
{#if !recp?.recipient}
|
||||
<TableCell value={'anonymized'} />
|
||||
<TableCell value={'anonymized'} />
|
||||
<TableCell value={'anonymized'} />
|
||||
@@ -2496,7 +2685,7 @@
|
||||
on:click={() => openEventsModal(recp.recipientID)}
|
||||
class="block w-full py-1 text-left"
|
||||
>
|
||||
{recp.recipient.firstName}
|
||||
{recp.recipient?.firstName ?? ''}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -2504,7 +2693,7 @@
|
||||
on:click={() => openEventsModal(recp.recipientID)}
|
||||
class="block w-full py-1 text-left"
|
||||
>
|
||||
{recp.recipient.lastName}
|
||||
{recp.recipient?.lastName ?? ''}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -2528,11 +2717,35 @@
|
||||
</TableCell>
|
||||
{/if}
|
||||
<TableCell>
|
||||
<EventName eventName={recp?.notableEventName} />
|
||||
{#if campaign.isAnonymous}
|
||||
<span
|
||||
class="text-sm italic text-gray-400 dark:text-gray-500"
|
||||
title="Per-recipient outcomes are hidden for anonymous campaigns. See Results by group."
|
||||
>Hidden</span
|
||||
>
|
||||
{:else}
|
||||
<EventName eventName={recp?.notableEventName} />
|
||||
{/if}
|
||||
</TableCell>
|
||||
<TableCell value={recp?.sendAt} isDate />
|
||||
<TableCell value={recp?.sentAt} isDate />
|
||||
<TableCell value={recp?.cancelledAt} isDate />
|
||||
{#if campaign.isAnonymous}
|
||||
<!-- exact per recipient timing is withheld so it cannot be matched against
|
||||
the anonymized event stream; a coarse sent indicator keeps self-managed usable -->
|
||||
<TableCell>
|
||||
<span class="text-sm italic text-gray-400 dark:text-gray-500">Hidden</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
class="text-sm {recp?.sent
|
||||
? 'text-slate-600 dark:text-gray-200'
|
||||
: 'text-gray-400 dark:text-gray-500'}">{recp?.sent ? 'Sent' : '—'}</span
|
||||
>
|
||||
</TableCell>
|
||||
<TableCell value={recp?.cancelledAt} isDate />
|
||||
{:else}
|
||||
<TableCell value={recp?.sendAt} isDate />
|
||||
<TableCell value={recp?.sentAt} isDate />
|
||||
<TableCell value={recp?.cancelledAt} isDate />
|
||||
{/if}
|
||||
{#if showLureCodeColumn}
|
||||
<TableCell value={recp?.lureCode || ''} />
|
||||
{/if}
|
||||
@@ -2547,7 +2760,7 @@
|
||||
/>
|
||||
|
||||
<TableDropDownButton
|
||||
name={recp.sentAt ? `Send message again` : `Send message`}
|
||||
name={recp.sentAt || recp.sent ? `Send message again` : `Send message`}
|
||||
title={isContextMismatch()
|
||||
? campaign.companyID
|
||||
? 'Switch to company view to perform this action'
|
||||
@@ -2560,10 +2773,13 @@
|
||||
? 'Recipient cancelled'
|
||||
: recp.sentAt
|
||||
? `Send message again (last sent: ${new Date(recp.sentAt).toLocaleDateString()})`
|
||||
: `Send message to recipient`}
|
||||
: recp.sent
|
||||
? `Send message again`
|
||||
: `Send message to recipient`}
|
||||
on:click={() => showSendMessageModal(recp.id, recp.recipient)}
|
||||
disabled={!!campaign.closedAt ||
|
||||
recp.cancelledAt ||
|
||||
!recp.recipient ||
|
||||
!!recp.recipient?.scimSoftDeletedAt ||
|
||||
isContextMismatch()}
|
||||
/>
|
||||
@@ -2602,14 +2818,17 @@
|
||||
? 'Campaign is anonymized'
|
||||
: !recp.recipient
|
||||
? 'Recipient not available'
|
||||
: recp.sentAt
|
||||
: recp.sentAt || recp.sent
|
||||
? 'Changing this will break the link already sent to this recipient'
|
||||
: 'Choose the identifier used in this recipient lure URL'}
|
||||
on:click={() => showLureCodeModal(recp)}
|
||||
/>
|
||||
<TableUpdateButton
|
||||
name="Copy email content"
|
||||
disabled={!!campaign.closedAt || !!campaign.anonymizedAt || isContextMismatch()}
|
||||
disabled={!!campaign.closedAt ||
|
||||
!!campaign.anonymizedAt ||
|
||||
!recp.recipient ||
|
||||
isContextMismatch()}
|
||||
title={isContextMismatch()
|
||||
? campaign.companyID
|
||||
? 'Switch to company view to perform this action'
|
||||
@@ -2634,7 +2853,10 @@
|
||||
? 'Campaign is closed'
|
||||
: ''}
|
||||
on:click={() => onClickSetEmailSent(recp.id, recp.recipient)}
|
||||
disabled={!!campaign.closedAt || recp.cancelledAt || isContextMismatch()}
|
||||
disabled={!!campaign.closedAt ||
|
||||
recp.cancelledAt ||
|
||||
!recp.recipient ||
|
||||
isContextMismatch()}
|
||||
/>
|
||||
{/if}
|
||||
<TableViewButton
|
||||
|
||||
Reference in New Issue
Block a user