diff --git a/backend/app/administration.go b/backend/app/administration.go index cd6eb1e..c491fdb 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -143,6 +143,7 @@ const ( ROUTE_V1_CAMPAIGN_RECIPIENTS = "/api/v1/campaign/:id/recipients" ROUTE_V1_CAMPAIGN_RESULT_STATS = "/api/v1/campaign/:id/statistics" ROUTE_V1_CAMPAIGN_EVENTS = "/api/v1/campaign/:id/events" + ROUTE_V1_CAMPAIGN_EVENT_ID = "/api/v1/campaign/event/:id" ROUTE_V1_CAMPAIGN_EVENT_NAMES = "/api/v1/campaign/event-types" ROUTE_V1_CAMPAIGN_STATS = "/api/v1/campaign/statistics" ROUTE_V1_CAMPAIGN_STATS_ID = "/api/v1/campaign/:id/stats" @@ -409,6 +410,7 @@ func setupRoutes( GET(ROUTE_V1_CAMPAIGN_FINISHED, middleware.SessionHandler, controllers.Campaign.GetAllFinished). GET(ROUTE_V1_CAMPAIGN_EVENT_NAMES, middleware.SessionHandler, controllers.Campaign.GetAllEventTypes). GET(ROUTE_V1_CAMPAIGN_EVENTS, middleware.SessionHandler, controllers.Campaign.GetEventsByCampaignID). + 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_STATS_ID, middleware.SessionHandler, controllers.Campaign.GetCampaignStats). diff --git a/backend/cache/local.go b/backend/cache/local.go index 24c4775..8f68ca2 100644 --- a/backend/cache/local.go +++ b/backend/cache/local.go @@ -81,3 +81,16 @@ func IsMoreNotableCampaignRecipientEventID(currentID, newID *uuid.UUID) bool { return IsMoreNotableCampaignRecipientEvent(newEventName, currentEventName) } + +// GetEventPriority returns the priority for an event type id, or -1 if not found +func GetEventPriority(eventTypeID *uuid.UUID) int { + if eventTypeID == nil { + return -1 + } + eventName := EventNameByID[eventTypeID.String()] + priority, exists := CampaignEventPriority[eventName] + if !exists { + return -1 + } + return priority +} diff --git a/backend/controller/campaign.go b/backend/controller/campaign.go index ad3fd62..fcba6cd 100644 --- a/backend/controller/campaign.go +++ b/backend/controller/campaign.go @@ -901,6 +901,31 @@ func (c *Campaign) DeleteByID(g *gin.Context) { c.Response.OK(g, gin.H{}) } +// DeleteEventByID deletes a single campaign event by its id +func (c *Campaign) DeleteEventByID(g *gin.Context) { + // handle session + session, _, ok := c.handleSession(g) + if !ok { + return + } + // parse request + id, ok := c.handleParseIDParam(g) + if !ok { + return + } + // delete event + err := c.CampaignService.DeleteEventByID(g.Request.Context(), session, id) + // handle responses + if errors.Is(err, errs.ErrCampaignAlreadyClosed) { + c.Response.ValidationFailed(g, "", err) + return + } + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{}) +} + // AnonymizeByID anonymizes a campaign by its id func (c *Campaign) AnonymizeByID(g *gin.Context) { // handle session diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go index efca882..29f2ed8 100644 --- a/backend/repository/campaign.go +++ b/backend/repository/campaign.go @@ -1296,6 +1296,77 @@ func (r *Campaign) DeleteEventsByCampaignID( return nil } +// GetEventByID gets a campaign event by its id +func (r *Campaign) GetEventByID( + ctx context.Context, + eventID *uuid.UUID, +) (*database.CampaignEvent, error) { + var event database.CampaignEvent + res := r.DB.WithContext(ctx). + Where("id = ?", eventID). + First(&event) + if res.Error != nil { + return nil, res.Error + } + return &event, nil +} + +// DeleteEventByID deletes a campaign event by its id +func (r *Campaign) DeleteEventByID( + ctx context.Context, + eventID *uuid.UUID, +) error { + res := r.DB.WithContext(ctx). + Where("id = ?", eventID). + Delete(&database.CampaignEvent{}) + return res.Error +} + +// GetMostNotableEventForRecipient finds the most notable event for a recipient in a campaign +// excludes the specified event id (the one being deleted) +func (r *Campaign) GetMostNotableEventForRecipient( + ctx context.Context, + campaignID *uuid.UUID, + recipientID *uuid.UUID, + excludeEventID *uuid.UUID, +) (*uuid.UUID, error) { + var events []database.CampaignEvent + query := r.DB.WithContext(ctx). + Where("campaign_id = ?", campaignID). + Where("id != ?", excludeEventID) + + // handle both regular and anonymized recipients + if recipientID != nil { + query = query.Where("recipient_id = ?", recipientID) + } + + res := query.Find(&events) + if res.Error != nil { + return nil, res.Error + } + + if len(events) == 0 { + return nil, nil + } + + // find the most notable event using priority + var mostNotableEventID *uuid.UUID + highestPriority := -1 + + for _, event := range events { + if event.EventID == nil { + continue + } + priority := cache.GetEventPriority(event.EventID) + if priority > highestPriority { + highestPriority = priority + mostNotableEventID = event.EventID + } + } + + return mostNotableEventID, nil +} + // AddAnonymizedAt adds an anonymized at time to a campaign func (r *Campaign) AddAnonymizedAt( ctx context.Context, diff --git a/backend/repository/campaignRecipient.go b/backend/repository/campaignRecipient.go index a9b4f4c..2773fb9 100644 --- a/backend/repository/campaignRecipient.go +++ b/backend/repository/campaignRecipient.go @@ -418,6 +418,30 @@ func (c *CampaignRecipient) UpdateByID( return nil } +// UpdateNotableEventByID updates only the notable event id for a campaign recipient +func (c *CampaignRecipient) UpdateNotableEventByID( + ctx context.Context, + campaignRecipientID *uuid.UUID, + notableEventTypeID *uuid.UUID, +) error { + if campaignRecipientID == nil { + return nil + } + + row := map[string]any{ + "notable_event_id": notableEventTypeID, + } + AddUpdatedAt(row) + + res := c.DB. + WithContext(ctx). + Model(&database.CampaignRecipient{}). + Where("id = ?", campaignRecipientID). + Updates(row) + + return res.Error +} + // Anonymize adds an anonymized id to a campaign recipient func (r *CampaignRecipient) Anonymize( ctx context.Context, diff --git a/backend/service/campaign.go b/backend/service/campaign.go index de8eec7..eb2bbe2 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -1621,6 +1621,120 @@ func (c *Campaign) DeleteByID( return nil } +// DeleteEventByID deletes a single campaign event by its ID +// only allows deletion on open campaigns (not closed) +// recalculates the notable event if the deleted event was the notable one +func (c *Campaign) DeleteEventByID( + ctx context.Context, + session *model.Session, + eventID *uuid.UUID, +) error { + ae := NewAuditEvent("Campaign.DeleteEventByID", session) + ae.Details["eventID"] = eventID.String() + + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + c.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + c.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + + // get the event to be deleted + event, err := c.CampaignRepository.GetEventByID(ctx, eventID) + if err != nil { + c.Logger.Errorw("failed to get campaign event by id", "error", err, "eventID", eventID.String()) + return errs.Wrap(err) + } + + ae.Details["campaignID"] = event.CampaignID.String() + if event.RecipientID != nil { + ae.Details["recipientID"] = event.RecipientID.String() + } + + // get the campaign to check if it's closed + campaign, err := c.CampaignRepository.GetByID(ctx, event.CampaignID, &repository.CampaignOption{}) + if err != nil { + c.Logger.Errorw("failed to get campaign", "error", err, "campaignID", event.CampaignID.String()) + return errs.Wrap(err) + } + + // check if campaign is closed + if campaign.ClosedAt.IsSpecified() && !campaign.ClosedAt.IsNull() { + c.Logger.Debugw("cannot delete event from closed campaign", + "campaignID", event.CampaignID.String(), + "eventID", eventID.String(), + ) + return errs.ErrCampaignAlreadyClosed + } + + // get the campaign recipient to recalculate notable event after deletion + var campaignRecipient *model.CampaignRecipient + + if event.RecipientID != nil { + campaignRecipient, err = c.CampaignRecipientRepository.GetByCampaignAndRecipientID( + ctx, + event.CampaignID, + event.RecipientID, + &repository.CampaignRecipientOption{}, + ) + if err != nil { + c.Logger.Errorw("failed to get campaign recipient", + "error", err, + "campaignID", event.CampaignID.String(), + "recipientID", event.RecipientID.String(), + ) + return errs.Wrap(err) + } + } + + // delete the event + err = c.CampaignRepository.DeleteEventByID(ctx, eventID) + if err != nil { + c.Logger.Errorw("failed to delete campaign event", "error", err, "eventID", eventID.String()) + return errs.Wrap(err) + } + + // recalculate notable event after deleting an event for this recipient + if campaignRecipient != nil { + campaignRecipientID, err := campaignRecipient.ID.Get() + if err != nil { + c.Logger.Errorw("campaign recipient has no ID, skipping notable event update", + "error", err, + "campaignID", event.CampaignID.String(), + ) + } else { + mostNotableEventTypeID, err := c.CampaignRepository.GetMostNotableEventForRecipient( + ctx, + event.CampaignID, + event.RecipientID, + eventID, + ) + if err != nil { + c.Logger.Errorw("failed to get most notable event for recipient", + "error", err, + "campaignID", event.CampaignID.String(), + "recipientID", event.RecipientID.String(), + ) + } else { + err = c.CampaignRecipientRepository.UpdateNotableEventByID(ctx, &campaignRecipientID, mostNotableEventTypeID) + if err != nil { + c.Logger.Errorw("failed to update campaign recipient notable event", + "error", err, + "campaignRecipientID", campaignRecipientID.String(), + ) + } + } + } + } + + c.AuditLogAuthorized(ae) + return nil +} + // SendNextBatch sends the next batch of emails // atm this is only audit logged on auth failures func (c *Campaign) SendNextBatch( diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index d7ccbeb..8525cf8 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -882,6 +882,16 @@ export class API { return await deleteJSON(this.getPath(`/campaign/${id}`)); }, + /** + * Delete a campaign event. + * + * @param {string} eventID + * @returns {Promise} + */ + deleteEvent: async (eventID) => { + return await deleteJSON(this.getPath(`/campaign/event/${eventID}`)); + }, + /** * Get campaign statistics by campaign ID. * diff --git a/frontend/src/lib/components/EventTimeline.svelte b/frontend/src/lib/components/EventTimeline.svelte index 93d6d36..1137318 100644 --- a/frontend/src/lib/components/EventTimeline.svelte +++ b/frontend/src/lib/components/EventTimeline.svelte @@ -130,7 +130,7 @@ let processedEvents = []; let filteredEventsCache = null; let lastFilterHash = ''; - let lastEventsRef = null; + let lastEventsHash = ''; // animation and timing handles let rafId = null; @@ -534,16 +534,15 @@ xScale = d3.scaleTime().domain([domainStart, domainEnd]).range([0, width]); - renderCircles(xScale); - updateAxesImmediate(xScale); + // use transformed scale if available, otherwise base scale + const activeScale = currentTransform ? currentTransform.rescaleX(xScale) : xScale; + + renderCircles(activeScale); + updateAxesImmediate(activeScale); updateCenterDate(xScale); updateCurrentTimeIndicatorImmediate(); - if (currentTransform) { - const newX = currentTransform.rescaleX(xScale); - updateAxesImmediate(newX); - updateCirclePositionsFast(newX); - } else { + if (!currentTransform) { const optimalScale = calculateOptimalScale(domainStart, domainEnd); const initialTranslate = (width - width * optimalScale) / 2; currentTransform = d3.zoomIdentity.translate(initialTranslate, 0).scale(optimalScale); @@ -810,8 +809,11 @@ }); // reactive: process events when they change - $: if (events !== lastEventsRef) { - lastEventsRef = events; + // use content-based hash to detect changes (not reference equality) + // include all event ids to detect deletions from any position + $: eventsHash = events?.map((e) => e.id).join(',') || ''; + $: if (eventsHash !== lastEventsHash) { + lastEventsHash = eventsHash; processedEvents = processEvents(events); filteredEventsCache = null; circleNodes = []; diff --git a/frontend/src/lib/components/table/EventName.svelte b/frontend/src/lib/components/table/EventName.svelte index 4c0c7c7..cb63704 100644 --- a/frontend/src/lib/components/table/EventName.svelte +++ b/frontend/src/lib/components/table/EventName.svelte @@ -1,13 +1,9 @@
{ + isDeleteEventAlertVisible = true; + deleteEventValues.id = event.id; + deleteEventValues.name = event.recipient?.email || 'anonymized'; + const systemEventName = campaign.eventTypesIDToNameMap[event.eventID] || ''; + deleteEventValues.eventName = toEvent(systemEventName).name || 'Unknown Event'; + }; + + const onClickDeleteEvent = async (eventID) => { + try { + const res = await api.campaign.deleteEvent(eventID); + if (res.success) { + addToast('Event deleted', 'Success'); + // refresh events, stats, and recipients table (for notable event update) + await getEvents(); + await setResults(); + await refreshRecipientsTimes(); + await refreshCampaignRecipients(); + // reset timeline and refetch all events (timeline only fetches new events incrementally) + timelineEvents = []; + lastPoll3399Nano = ''; + await tick(); + await refreshCampaignEventsSince(); + // refresh recipient events modal if open + if (isEventsModalVisible && recipientEventsRecipient.id) { + await setRecipientEvents(recipientEventsRecipient.id); + } + } + return res; + } catch (e) { + addToast('Failed to delete event', 'Error'); + console.error('failed to delete campaign event:', e); + throw e; + } + }; + const onConfirmCloseCampaign = async (a) => { let res; try { @@ -774,7 +818,8 @@ if (!res.success) { throw res.error; } - campaignRecipients = res.data?.rows ?? []; + // force new array reference to trigger svelte reactivity + campaignRecipients = [...(res.data?.rows ?? [])]; campaignRecipientsHasNextPage = res.data?.hasNextPage ?? false; } catch (e) { addToast('Failed to load recipients', 'Error'); @@ -1636,7 +1681,6 @@ plural="events" hasData={!!campaign.events.length} hasNextPage={campaignEventsHasNextPage} - hasActions={false} isGhost={isEventTableLoading} > {#each campaign.events as event (event.id)} @@ -1694,6 +1738,20 @@ + + + + openDeleteEventAlert(event)} + disabled={!!campaign.closedAt || !!campaign.anonymizedAt} + title={campaign.closedAt + ? 'Cannot delete events from closed campaign' + : campaign.anonymizedAt + ? 'Cannot delete events from anonymized campaign' + : 'Delete event'} + /> + + {/each} @@ -1836,7 +1894,6 @@ pagination={recipientEventsTableParams} plural="events" hasData={!!recipientEvents.length} - hasActions={false} > {#each recipientEvents as event} @@ -1872,6 +1929,20 @@ + + + + openDeleteEventAlert(event)} + disabled={!!campaign.closedAt || !!campaign.anonymizedAt} + title={campaign.closedAt + ? 'Cannot delete events from closed campaign' + : campaign.anonymizedAt + ? 'Cannot delete events from anonymized campaign' + : 'Delete event'} + /> + + {/each} @@ -2377,4 +2448,9 @@ + onClickDeleteEvent(deleteEventValues.id)} + bind:isVisible={isDeleteEventAlertVisible} + />