From 2f5d1163d296bd94b09f928dd7ba38abd3c629f8 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Thu, 12 Feb 2026 19:46:16 +0100 Subject: [PATCH] add latest events to dashboard, remove completed, fix missing auto refresh box --- backend/app/administration.go | 2 + backend/controller/campaign.go | 33 ++ backend/database/campaignEvent.go | 1 + backend/model/campaignEvent.go | 1 + backend/repository/campaign.go | 102 ++++- backend/service/campaign.go | 38 ++ frontend/src/lib/api/api.js | 15 + .../src/lib/components/table/Table.svelte | 5 +- frontend/src/routes/dashboard/+page.svelte | 401 +++++++++++------- 9 files changed, 437 insertions(+), 161 deletions(-) diff --git a/backend/app/administration.go b/backend/app/administration.go index c491fdb..8c02172 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_ALL_EVENTS = "/api/v1/campaign/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" @@ -409,6 +410,7 @@ func setupRoutes( GET(ROUTE_V1_CAMPAIGN_UPCOMING, middleware.SessionHandler, controllers.Campaign.GetAllUpcoming). 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_ALL_EVENTS, middleware.SessionHandler, controllers.Campaign.GetAllEvents). 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). diff --git a/backend/controller/campaign.go b/backend/controller/campaign.go index e1f32c1..1003dc7 100644 --- a/backend/controller/campaign.go +++ b/backend/controller/campaign.go @@ -468,6 +468,39 @@ func (c *Campaign) GetAllFinished(g *gin.Context) { c.Response.OK(g, campaigns) } +// GetAllEvents gets all events across campaigns +func (c *Campaign) GetAllEvents(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + // parse request + companyID := companyIDFromRequestQuery(g) + includeTestCampaigns := g.Query("includeTest") == "true" + queryArgs, ok := c.handleQueryArgs(g) + if !ok { + return + } + // force ordering by created_at desc for performance (no sorting allowed) + queryArgs.OrderBy = repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "created_at") + queryArgs.Desc = true + // disable search for performance + queryArgs.Search = "" + // get events + events, err := c.CampaignService.GetAllEvents( + g.Request.Context(), + session, + companyID, + queryArgs, + includeTestCampaigns, + ) + // handle responses + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, events) +} + // GetEventsByCampaignID gets events by campaign id func (c *Campaign) GetEventsByCampaignID(g *gin.Context) { session, _, ok := c.handleSession(g) diff --git a/backend/database/campaignEvent.go b/backend/database/campaignEvent.go index 8ac4baa..99d3f1a 100644 --- a/backend/database/campaignEvent.go +++ b/backend/database/campaignEvent.go @@ -38,6 +38,7 @@ type CampaignEvent struct { RecipientID *uuid.UUID `gorm:"index;type:uuid;"` Recipient *Recipient + Campaign *Campaign CompanyID *uuid.UUID `gorm:"type:uuid;"` } diff --git a/backend/model/campaignEvent.go b/backend/model/campaignEvent.go index 96faf1f..31387f7 100644 --- a/backend/model/campaignEvent.go +++ b/backend/model/campaignEvent.go @@ -11,6 +11,7 @@ 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"` diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go index 531ddae..3cad2b8 100644 --- a/backend/repository/campaign.go +++ b/backend/repository/campaign.go @@ -66,11 +66,19 @@ type CampaignOption struct { // CampaignEventOption is options for preloading type CampaignEventOption struct { *vo.QueryArgs - // WithCampaign bool + WithCampaign bool WithUser bool EventTypeIDs []string } +// CampaignEventAllOption is options for preloading events across all campaigns +type CampaignEventAllOption struct { + *vo.QueryArgs + WithCampaign bool + WithUser bool + IncludeTestCampaigns bool +} + // Campaign is a Campaign repository type Campaign struct { DB *gorm.DB @@ -114,11 +122,29 @@ func (r *Campaign) load(db *gorm.DB, options *CampaignOption) *gorm.DB { // preloadEventRecipient preloads the event user func (r *Campaign) preloadEventRecipient(db *gorm.DB, options *CampaignEventOption) *gorm.DB { + if options.WithUser { + db = db.Preload("Recipient", func(db *gorm.DB) *gorm.DB { + return db.Select("id", "first_name", "last_name", "email") + }) + } + if options.WithCampaign { + db = db.Preload("Campaign", func(db *gorm.DB) *gorm.DB { + return db.Select("id", "name", "company_id").Preload("Company") + }) + } + return db +} + +// preloadAllEventRecipient preloads the event user for all events queries +func (r *Campaign) preloadAllEventRecipient(db *gorm.DB, options *CampaignEventAllOption) *gorm.DB { if options.WithUser { db = db.Preload("Recipient", func(db *gorm.DB) *gorm.DB { return db }) } + if options.WithCampaign { + db = db.Preload("Campaign.Company") + } return db } @@ -583,6 +609,71 @@ func (r *Campaign) GetAllFinished( return result, nil } +// GetAllEvents gets all campaign events across campaigns +func (r *Campaign) GetAllEvents( + ctx context.Context, + companyID *uuid.UUID, + options *CampaignEventAllOption, +) (*model.Result[model.CampaignEvent], error) { + result := model.NewEmptyResult[model.CampaignEvent]() + db := r.preloadAllEventRecipient(r.DB, options) + db = r.joinEvent(db) + db, err := useQuery(db, database.CAMPAIGN_TABLE, options.QueryArgs, allowedCampaginEventViewColumns...) + if err != nil { + return result, errs.Wrap(err) + } + var dbCampaignEvents []database.CampaignEvent + db = db. + Joins(LeftJoinOn( + database.CAMPAIGN_EVENT_TABLE, + "recipient_id", + database.RECIPIENT_TABLE, + "id", + )). + Joins(LeftJoinOn( + database.CAMPAIGN_EVENT_TABLE, + "campaign_id", + database.CAMPAIGN_TABLE, + "id", + )) + + // filter by company if in company context + if companyID != nil { + db = db.Where(TableColumn(database.CAMPAIGN_TABLE, "company_id")+" = ?", companyID) + } + + // filter test campaigns if not included + if !options.IncludeTestCampaigns { + db = db.Where(TableColumn(database.CAMPAIGN_TABLE, "is_test")+" = ?", false) + } + + res := db.Find(&dbCampaignEvents) + + if res.Error != nil { + return result, res.Error + } + + hasNextPage, err := useHasNextPage( + db, + database.CAMPAIGN_TABLE, + options.QueryArgs, + allowedCampaginEventViewColumns..., + ) + if err != nil { + return result, errs.Wrap(err) + } + result.HasNextPage = hasNextPage + + for _, dbCampaignEvent := range dbCampaignEvents { + c, err := ToCampaignEvent(&dbCampaignEvent) + if err != nil { + return nil, errs.Wrap(err) + } + result.Rows = append(result.Rows, c) + } + return result, nil +} + // GetEventsByCampaignID gets all campaign events by campaign id func (r *Campaign) GetEventsByCampaignID( ctx context.Context, @@ -1720,6 +1811,14 @@ func ToCampaignEvent(row *database.CampaignEvent) (*model.CampaignEvent, error) } recipient = r } + var campaign *model.Campaign + if row.Campaign != nil { + c, err := ToCampaign(row.Campaign) + if err != nil { + return nil, errs.Wrap(err) + } + campaign = c + } ip := vo.NewOptionalString64Must(row.IPAddress) userAgent := vo.NewOptionalString255Must(row.UserAgent) data := vo.NewOptionalString1MBMust(row.Data) @@ -1729,6 +1828,7 @@ func ToCampaignEvent(row *database.CampaignEvent) (*model.CampaignEvent, error) ID: row.ID, CreatedAt: row.CreatedAt, CampaignID: row.CampaignID, + Campaign: campaign, IP: ip, UserAgent: userAgent, Data: data, diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 387eae6..8673fc2 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -912,6 +912,44 @@ func (c *Campaign) GetRecipientsByCampaignID( return result, nil } +// GetAllEvents gets all events across campaigns +func (c *Campaign) GetAllEvents( + ctx context.Context, + session *model.Session, + companyID *uuid.UUID, + queryArgs *vo.QueryArgs, + includeTestCampaigns bool, +) (*model.Result[model.CampaignEvent], error) { + result := model.NewEmptyResult[model.CampaignEvent]() + ae := NewAuditEvent("Campaign.GetAllEvents", session) + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + c.LogAuthError(err) + return result, errs.Wrap(err) + } + if !isAuthorized { + c.AuditLogNotAuthorized(ae) + return result, errs.ErrAuthorizationFailed + } + result, err = c.CampaignRepository.GetAllEvents( + ctx, + companyID, + &repository.CampaignEventAllOption{ + QueryArgs: queryArgs, + WithUser: true, + WithCampaign: true, + IncludeTestCampaigns: includeTestCampaigns, + }, + ) + if err != nil { + c.Logger.Errorw("failed to get all events", "error", err) + return result, errs.Wrap(err) + } + // no audit on read + return result, nil +} + // GetEventsByCampaignID gets all events for a campaign func (c *Campaign) GetEventsByCampaignID( ctx context.Context, diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index c8dba64..99ff2d4 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -807,6 +807,21 @@ export class API { ); }, + /** + * Get all events across campaigns. + * + * @param {TableURLParams} options + * @param {string|null} companyID + * @returns {Promise} + */ + getAllEvents: async (options, companyID = null) => { + return await getJSON( + this.getPath( + `/campaign/events?${appendQuery(options)}${this.appendCompanyQuery(companyID)}` + ) + ); + }, + /** * Get campaigns stats * if no company ID is provided it retrieves the global stats including all companies diff --git a/frontend/src/lib/components/table/Table.svelte b/frontend/src/lib/components/table/Table.svelte index b8140d6..0eec088 100644 --- a/frontend/src/lib/components/table/Table.svelte +++ b/frontend/src/lib/components/table/Table.svelte @@ -26,6 +26,7 @@ export let isGhost = false; // if there is more data to paginate export let hasNextPage = true; + export let noSearch = false; let tableWrapper = null; let columnsLength = columns.length; @@ -52,7 +53,9 @@
{#if pagination} - + {#if !noSearch} + + {/if} {/if}
{ + const value = Number(autoRefreshOptions.byKey(optKey)); + autoRefreshStore.setEnabled(value > 0); + autoRefreshStore.setInterval(value); + setPageAutoRefresh('dashboard', $autoRefreshStore); + }; + // hooks onMount(() => { const context = appStateService.getContext(); @@ -96,12 +119,12 @@ refresh(); activeTableURLParams.onChange(() => refreshActiveCampaigns(true)); scheduledTableURLParams.onChange(() => refreshScheduledCampaigns(true)); - completedTableURLParams.onChange(() => refreshFinishedCampaigns(true)); + eventsTableURLParams.onChange(() => refreshEvents(true)); return () => { activeTableURLParams.unsubscribe(); scheduledTableURLParams.unsubscribe(); - completedTableURLParams.unsubscribe(); + eventsTableURLParams.unsubscribe(); }; }); @@ -121,10 +144,11 @@ active = res.data.active; scheduled = res.data.upcoming; finished = res.data.finished; + await setEventTypes(); await refreshCalendarCampaings(); await refreshActiveCampaigns(showLoading); await refreshScheduledCampaigns(showLoading); - await refreshFinishedCampaigns(showLoading); + await refreshEvents(showLoading); await refreshCampaignStats(showLoading); } catch (e) { addToast('Failed to load data', 'Error'); @@ -216,31 +240,47 @@ } }; - const refreshFinishedCampaigns = async (showLoading = true) => { - if (showLoading) { - isFinishedCampaignsLoading = true; - } + const setEventTypes = async () => { try { + const res = await api.campaign.getAllEventTypes(); + if (!res.success) { + addToast('Failed to load event types', 'Error'); + console.error('failed to load event types', res.error); + return; + } + res.data.map((t) => (eventTypesIDToNameMap[t.id] = t.name)); + availableEventTypes = res.data.map((t) => ({ value: t.id, label: t.name })); + availableEventTypes.unshift({ value: '', label: 'All Events' }); + } catch (e) { + addToast('Failed to load event types', 'Error'); + console.error('failed to load event types', e); + } + }; + + const refreshEvents = async (showIsLoading = true) => { + try { + if (showIsLoading) { + isEventsLoading = true; + } const options = { - page: completedTableURLParams.currentPage, - perPage: completedTableURLParams.perPage, - sortBy: completedTableURLParams.sortBy, - sortOrder: completedTableURLParams.sortOrder, - search: completedTableURLParams.search, + page: eventsTableURLParams.page, + perPage: eventsTableURLParams.perPage, + sortBy: eventsTableURLParams.sortBy, + sortOrder: eventsTableURLParams.sortOrder, + search: eventsTableURLParams.search, includeTest: includeTestCampaigns }; - const res = await api.campaign.getAllFinished(options, contextCompanyID); - if (!res.success) { - throw res.error; + const res = await api.campaign.getAllEvents(options, contextCompanyID); + if (res.success) { + events = res.data?.rows ?? []; + eventsHasNextPage = res.data?.hasNextPage ?? false; } - completedCampaigns = res.data.rows; - completedCampaignsHasNextPage = res.data.hasNextPage; } catch (e) { - addToast('Failed to load finshed campaigns', 'Error'); - console.error('Failed to load finshed campaigns', e); + addToast('Failed to load events', 'Error'); + console.error('failed to load events', e); } finally { - if (showLoading) { - isFinishedCampaignsLoading = false; + if (showIsLoading) { + isEventsLoading = false; } } }; @@ -288,98 +328,131 @@
-
+
Dashboard -
- - Include test campaigns - - - { - // refresh all data - let res = await api.campaign.getStats(contextCompanyID, { - includeTest: includeTestCampaigns - }); - if (!res.success) { - throw res.error; - } - await refreshRepeatOffenders(); - - active = res.data.active; - scheduled = res.data.upcoming; - finished = res.data.finished; - - // refresh table data directly like campaign page does - const activeOptions = { - page: activeTableURLParams.currentPage, - perPage: activeTableURLParams.perPage, - sortBy: activeTableURLParams.sortBy, - sortOrder: activeTableURLParams.sortOrder, - search: activeTableURLParams.search, - includeTest: includeTestCampaigns - }; - const activeRes = await api.campaign.getAllActive(activeOptions, contextCompanyID); - if (activeRes.success) { - activeCampaigns = []; - await tick(); - activeCampaigns = activeRes.data.rows; - } - - const scheduledOptions = { - page: scheduledTableURLParams.currentPage, - perPage: scheduledTableURLParams.perPage, - sortBy: scheduledTableURLParams.sortBy, - sortOrder: scheduledTableURLParams.sortOrder, - search: scheduledTableURLParams.search, - includeTest: includeTestCampaigns - }; - const scheduledRes = await api.campaign.getAllUpcoming( - scheduledOptions, - contextCompanyID - ); - if (scheduledRes.success) { - scheduledCampaigns = []; - await tick(); - scheduledCampaigns = scheduledRes.data.rows; - } - - const completedOptions = { - page: completedTableURLParams.currentPage, - perPage: completedTableURLParams.perPage, - sortBy: completedTableURLParams.sortBy, - sortOrder: completedTableURLParams.sortOrder, - search: completedTableURLParams.search, - includeTest: includeTestCampaigns - }; - const completedRes = await api.campaign.getAllFinished( - completedOptions, - contextCompanyID - ); - if (completedRes.success) { - completedCampaigns = []; - await tick(); - completedCampaigns = completedRes.data.rows; - } - - const statsRes = await api.campaign.getAllCampaignStats(contextCompanyID); - if (statsRes.success) { - campaignStats = []; - await tick(); - campaignStats = statsRes.data.rows || []; - } - - await refreshCalendarCampaings(); - }} - /> +
+ +
+ + Auto-Refresh + + +
+ { + // refresh all data + let res = await api.campaign.getStats(contextCompanyID, { + includeTest: includeTestCampaigns + }); + if (!res.success) { + throw res.error; + } + await refreshRepeatOffenders(); + + active = res.data.active; + scheduled = res.data.upcoming; + finished = res.data.finished; + + // refresh table data directly like campaign page does + const activeOptions = { + page: activeTableURLParams.currentPage, + perPage: activeTableURLParams.perPage, + sortBy: activeTableURLParams.sortBy, + sortOrder: activeTableURLParams.sortOrder, + search: activeTableURLParams.search, + includeTest: includeTestCampaigns + }; + const activeRes = await api.campaign.getAllActive(activeOptions, contextCompanyID); + if (activeRes.success) { + activeCampaigns = activeRes.data.rows; + activeCampaignsHasNextPage = activeRes.data.hasNextPage; + } + + // refresh scheduled campaigns + const scheduledOptions = { + page: scheduledTableURLParams.currentPage, + perPage: scheduledTableURLParams.perPage, + sortBy: scheduledTableURLParams.sortBy, + sortOrder: scheduledTableURLParams.sortOrder, + search: scheduledTableURLParams.search, + includeTest: includeTestCampaigns + }; + const scheduledRes = await api.campaign.getAllUpcoming(scheduledOptions, contextCompanyID); + if (scheduledRes.success) { + scheduledCampaigns = scheduledRes.data.rows; + scheduledCampaignsHasNextPage = scheduledRes.data.hasNextPage; + } + + // refresh events + const eventsOptions = { + page: eventsTableURLParams.currentPage, + perPage: eventsTableURLParams.perPage, + sortBy: eventsTableURLParams.sortBy, + sortOrder: eventsTableURLParams.sortOrder, + search: eventsTableURLParams.search, + includeTest: includeTestCampaigns + }; + const eventsRes = await api.campaign.getAllEvents(eventsOptions, contextCompanyID); + if (eventsRes.success) { + events = eventsRes.data?.rows ?? []; + eventsHasNextPage = eventsRes.data?.hasNextPage ?? false; + } + + const statsRes = await api.campaign.getAllCampaignStats(contextCompanyID); + if (statsRes.success) { + campaignStats = []; + await tick(); + campaignStats = statsRes.data.rows || []; + } + + await refreshCalendarCampaings(); + }} + /> {#if contextCompanyName} {contextCompanyName} {/if} @@ -491,6 +564,56 @@
+ Recent events +
+ + {#each events as event (event.id)} + + + + + + + {#if event.campaign?.name} + + {event.campaign.name} + + {/if} + + + {#if event.recipient?.email} + + {event.recipient.email} + + {/if} + + {#if !contextCompanyID} + + {#if event.campaign?.company?.name} + {event.campaign.company.name} + {/if} + + {/if} + + {/each} +
+
+ {contextCompanyName ? 'Calendar' : 'Shared Calendar'}
- Completed campaigns -
- - {#each completedCampaigns as campaign} - - - - {#if campaign.isTest} - - {/if} - - {campaign.name} - - - - - - - - - - onClickViewCampaign(campaign.id)} /> - - - - {/each} -
-