added delete campaign event when campaign is open

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-02-03 21:31:02 +01:00
parent 0f194044c4
commit 33845aeec2
10 changed files with 352 additions and 19 deletions
+2
View File
@@ -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).
+13
View File
@@ -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
}
+25
View File
@@ -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
+71
View File
@@ -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,
+24
View File
@@ -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,
+114
View File
@@ -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(
+10
View File
@@ -882,6 +882,16 @@ export class API {
return await deleteJSON(this.getPath(`/campaign/${id}`));
},
/**
* Delete a campaign event.
*
* @param {string} eventID
* @returns {Promise<ApiResponse>}
*/
deleteEvent: async (eventID) => {
return await deleteJSON(this.getPath(`/campaign/event/${eventID}`));
},
/**
* Get campaign statistics by campaign ID.
*
@@ -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 = [];
@@ -1,13 +1,9 @@
<script>
import { toEvent } from '$lib/utils/events';
import { onMount } from 'svelte';
export let eventName;
let event = { name: '', priority: null, color: null };
onMount(() => {
event = toEvent(eventName);
});
// reactive statement to update event when eventName prop changes
$: event = toEvent(eventName);
</script>
<div
+79 -3
View File
@@ -40,6 +40,8 @@
import FormGrid from '$lib/components/FormGrid.svelte';
import FormColumns from '$lib/components/FormColumns.svelte';
import FormColumn from '$lib/components/FormColumn.svelte';
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
import EventName from '$lib/components/table/EventName.svelte';
import { goto } from '$app/navigation';
import { globalButtonDisabledAttributes } from '$lib/utils/form';
@@ -147,6 +149,12 @@
let reportedDateColumn = '';
let pendingEmailPreviewRecipient = null;
let storedCookieData = '';
let isDeleteEventAlertVisible = false;
let deleteEventValues = {
id: null,
name: null,
eventName: null
};
// cleanup when modal is cancelled
$: if (!isTrackingPixelWarningVisible && pendingEmailPreviewRecipient) {
@@ -657,6 +665,42 @@
}
};
const openDeleteEventAlert = (event) => {
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 @@
<TableCell>
<CellCopy text={event.metadata || ''} />
</TableCell>
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableDeleteButton
on:click={() => 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'}
/>
</TableDropDownEllipsis>
</TableCellAction>
</TableRow>
{/each}
</Table>
@@ -1836,7 +1894,6 @@
pagination={recipientEventsTableParams}
plural="events"
hasData={!!recipientEvents.length}
hasActions={false}
>
{#each recipientEvents as event}
<TableRow>
@@ -1872,6 +1929,20 @@
<TableCell>
<CellCopy text={event.metadata || ''} />
</TableCell>
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableDeleteButton
on:click={() => 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'}
/>
</TableDropDownEllipsis>
</TableCellAction>
</TableRow>
{/each}
</Table>
@@ -2377,4 +2448,9 @@
<FormFooter closeModal={closeReportedCSVModal} isSubmitting={isReportedCSVSubmitting} />
</FormGrid>
</Modal>
<DeleteAlert
name={`${deleteEventValues.eventName} event for ${deleteEventValues.name}`}
onClick={() => onClickDeleteEvent(deleteEventValues.id)}
bind:isVisible={isDeleteEventAlertVisible}
/>
</main>