diff --git a/frontend/src/lib/wailsjs/go/models.ts b/frontend/src/lib/wailsjs/go/models.ts index 23b6a8a..7ba645f 100755 --- a/frontend/src/lib/wailsjs/go/models.ts +++ b/frontend/src/lib/wailsjs/go/models.ts @@ -3,6 +3,7 @@ export namespace notifications { export class Notification { id: number; organisation_id: number; + external_id: string; status: string; content: string; type: string; @@ -19,6 +20,7 @@ export namespace notifications { if ('string' === typeof source) source = JSON.parse(source); this.id = source["id"]; this.organisation_id = source["organisation_id"]; + this.external_id = source["external_id"]; this.status = source["status"]; this.content = source["content"]; this.type = source["type"]; diff --git a/frontend/src/lib/wailsjs/go/watchtower/Service.d.ts b/frontend/src/lib/wailsjs/go/watchtower/Service.d.ts index 80a2c1e..8421724 100755 --- a/frontend/src/lib/wailsjs/go/watchtower/Service.d.ts +++ b/frontend/src/lib/wailsjs/go/watchtower/Service.d.ts @@ -9,9 +9,11 @@ export function CreateOrganisation(arg1:string,arg2:string,arg3:string,arg4:stri export function CreateProduct(arg1:string,arg2:string,arg3:Array,arg4:number):Promise; -export function CreateUnreadPRNotification():Promise; +export function CreateUnreadNotification():Promise; -export function CreateUnreadSecurityNotification():Promise; +export function CreateUnreadPRNotification():Promise; + +export function CreateUnreadSecurityNotification():Promise; export function DeleteAllOrgs():Promise; diff --git a/frontend/src/lib/wailsjs/go/watchtower/Service.js b/frontend/src/lib/wailsjs/go/watchtower/Service.js index f57af68..f2ab0d3 100755 --- a/frontend/src/lib/wailsjs/go/watchtower/Service.js +++ b/frontend/src/lib/wailsjs/go/watchtower/Service.js @@ -10,6 +10,10 @@ export function CreateProduct(arg1, arg2, arg3, arg4) { return window['go']['watchtower']['Service']['CreateProduct'](arg1, arg2, arg3, arg4); } +export function CreateUnreadNotification() { + return window['go']['watchtower']['Service']['CreateUnreadNotification'](); +} + export function CreateUnreadPRNotification() { return window['go']['watchtower']['Service']['CreateUnreadPRNotification'](); } diff --git a/frontend/src/lib/watchtower/notifications.svelte.test.ts b/frontend/src/lib/watchtower/notifications.svelte.test.ts index 3b143f4..6c8a73f 100644 --- a/frontend/src/lib/watchtower/notifications.svelte.test.ts +++ b/frontend/src/lib/watchtower/notifications.svelte.test.ts @@ -54,6 +54,17 @@ describe("NotificationsService", () => { await notificationSvc.getUnread(); expect(spyGetUnread).toHaveBeenCalledTimes(2); }); + it("should get unread if force flag is passed", async () => { + const notificationSvc = new NotificationsService(); + await notificationSvc.getUnread(); + expect(spyGetUnread).toHaveBeenCalledTimes(1); + + await notificationSvc.getUnread(); + expect(spyGetUnread).toHaveBeenCalledTimes(1); + + await notificationSvc.getUnread(true); + expect(spyGetUnread).toHaveBeenCalledTimes(2); + }); }); describe("markAsRead()", () => { diff --git a/frontend/src/lib/watchtower/notifications.svelte.ts b/frontend/src/lib/watchtower/notifications.svelte.ts index 0149f3b..ecb9fba 100644 --- a/frontend/src/lib/watchtower/notifications.svelte.ts +++ b/frontend/src/lib/watchtower/notifications.svelte.ts @@ -6,14 +6,17 @@ export class NotificationsService { readonly #unread: notifications.Notification[]; #lastSync?: number; + readonly hasUnread: boolean; + constructor() { this.#unread = $state([]); + this.hasUnread = $derived(this.#unread.length > 0); } /** * Retrieves the unread notifications for the user. */ - async getUnread() { - if (this.isStale()) { + async getUnread(force: boolean = false) { + if (this.isStale() || force) { await this.forceGetUnread(); } diff --git a/frontend/src/lib/watchtower/types.ts b/frontend/src/lib/watchtower/types.ts index a27e581..bc1c22f 100644 --- a/frontend/src/lib/watchtower/types.ts +++ b/frontend/src/lib/watchtower/types.ts @@ -1,3 +1,5 @@ export const STALE_TIMEOUT_MINUTES = 2; export const TIME_TWO_MINUTES = 1000 * 60 * 2; + +export const EVENT_UNREAD_NOTIFICATIONS = "UNREAD_NOTIFICATIONS"; diff --git a/frontend/src/routes/(orgs)/+layout.svelte b/frontend/src/routes/(orgs)/+layout.svelte index 9017817..ed3aca1 100644 --- a/frontend/src/routes/(orgs)/+layout.svelte +++ b/frontend/src/routes/(orgs)/+layout.svelte @@ -7,20 +7,25 @@ LayoutDashboard, PanelLeftClose, PanelLeftOpen, - MessageSquare + MessageSquare, + MessageSquareDot } from "@lucide/svelte"; import { cn } from "$lib/utils"; import { NavItem, NavHeader } from "$components/nav/index.js"; - import { orgSvc } from "$lib/watchtower"; + import { notificationSvc, orgSvc } from "$lib/watchtower"; import { Button } from "$components/ui/button"; import { Separator } from "$components/ui/separator"; import { settingsSvc } from "$lib/settings"; import { BaseTooltip } from "$components/base_tooltip/index.js"; + import { onDestroy, onMount } from "svelte"; + import { EventsOff, EventsOn } from "$lib/wailsjs/runtime"; + import { EVENT_UNREAD_NOTIFICATIONS } from "$lib/watchtower/types"; let { children }: LayoutProps = $props(); const organisation = $derived(orgSvc.defaultOrg); const allOrgs = $derived(orgSvc.organisations); + let hasUnreadNotifications = $derived(notificationSvc.hasUnread); let expand = $state(settingsSvc.sidebarExpanded); let expandedStyle = $derived(expand ? "w-42" : "w-14"); @@ -31,6 +36,14 @@ expand = !expand; settingsSvc.setSidebarExpanded(expand); } + + onMount(() => { + EventsOn(EVENT_UNREAD_NOTIFICATIONS, () => notificationSvc.getUnread(true)); + }); + + onDestroy(() => { + EventsOff(EVENT_UNREAD_NOTIFICATIONS); + });
@@ -62,7 +75,11 @@ {#snippet icon()} - + {#if hasUnreadNotifications} + + {:else} + + {/if} {/snippet}
diff --git a/internal/notifications/service.go b/internal/notifications/service.go index 8614731..12ca5f5 100644 --- a/internal/notifications/service.go +++ b/internal/notifications/service.go @@ -45,6 +45,27 @@ func (s *Service) CreateNotification(ctx context.Context, params CreateNotificat return err } +// BulkCreateNotifications creates multiple notifications in a single operation and returns the count or an error if any fail. +func (s *Service) BulkCreateNotifications(ctx context.Context, notifications []CreateNotificationParams) (int, error) { + logger := logging.FromContext(ctx).With("service", "notifications") + logger.Debug("Creating notifications in bulk", "count", len(notifications)) + + for _, item := range notifications { + err := s.CreateNotification(ctx, CreateNotificationParams{ + ExternalID: item.ExternalID, + OrgID: item.OrgID, + NotificationType: item.NotificationType, + Content: item.Content, + }) + if err != nil { + logger.Error("Error creating notification", "error", err) + return 0, err + } + } + + return len(notifications), nil +} + // GetUnreadNotifications fetches all unread notifications for the specified organisation ID. Returns a list of notifications or an error. func (s *Service) GetUnreadNotifications(ctx context.Context) ([]Notification, error) { logger := logging.FromContext(ctx).With("service", "notifications") diff --git a/internal/notifications/service_test.go b/internal/notifications/service_test.go index 86b7217..eb2adcb 100644 --- a/internal/notifications/service_test.go +++ b/internal/notifications/service_test.go @@ -91,6 +91,108 @@ func TestService(t *testing.T) { _, err = s.GetNotificationByExternalID(ctx, "ext4") odize.AssertError(t, err) }). + Test("BulkCreateNotifications should create multiple notifications", func(t *testing.T) { + notifications := []CreateNotificationParams{ + { + OrgID: 1, + NotificationType: "bulk-type-1", + Content: "bulk-content-1", + ExternalID: "bulk-ext-1", + }, + { + OrgID: 1, + NotificationType: "bulk-type-2", + Content: "bulk-content-2", + ExternalID: "bulk-ext-2", + }, + } + + count, err := s.BulkCreateNotifications(ctx, notifications) + odize.AssertNoError(t, err) + odize.AssertEqual(t, 2, count) + + n1, err := s.GetNotificationByExternalID(ctx, "bulk-ext-1") + odize.AssertNoError(t, err) + odize.AssertEqual(t, "bulk-content-1", n1.Content) + + n2, err := s.GetNotificationByExternalID(ctx, "bulk-ext-2") + odize.AssertNoError(t, err) + odize.AssertEqual(t, "bulk-content-2", n2.Content) + }). + Test("BulkCreateNotifications should handle empty list", func(t *testing.T) { + count, err := s.BulkCreateNotifications(ctx, []CreateNotificationParams{}) + odize.AssertNoError(t, err) + odize.AssertEqual(t, 0, count) + }). + Test("BulkCreateNotifications should handle duplicate external IDs by ignoring them", func(t *testing.T) { + notifications := []CreateNotificationParams{ + { + OrgID: 1, + NotificationType: "type", + Content: "content-original", + ExternalID: "dup-ext", + }, + { + OrgID: 1, + NotificationType: "type", + Content: "content-duplicate", + ExternalID: "dup-ext", + }, + } + + count, err := s.BulkCreateNotifications(ctx, notifications) + odize.AssertNoError(t, err) + odize.AssertEqual(t, 2, count) + + n, err := s.GetNotificationByExternalID(ctx, "dup-ext") + odize.AssertNoError(t, err) + odize.AssertEqual(t, "content-original", n.Content) + }). + Test("GetUnreadNotifications should return only unread notifications", func(t *testing.T) { + orgID := int64(5) + + // Create an unread notification + err := s.CreateNotification(ctx, CreateNotificationParams{ + OrgID: orgID, + NotificationType: "unread-type", + Content: "unread-content", + ExternalID: "unread-ext", + }) + odize.AssertNoError(t, err) + + // Create another notification and mark it as read + err = s.CreateNotification(ctx, CreateNotificationParams{ + OrgID: orgID, + NotificationType: "read-type", + Content: "read-content", + ExternalID: "read-ext", + }) + odize.AssertNoError(t, err) + + notif, err := s.GetNotificationByExternalID(ctx, "read-ext") + odize.AssertNoError(t, err) + + err = s.MarkNotificationAsRead(ctx, notif.ID) + odize.AssertNoError(t, err) + + // Fetch unread notifications + unread, err := s.GetUnreadNotifications(ctx) + odize.AssertNoError(t, err) + + foundUnread := false + foundRead := false + for _, n := range unread { + if n.ExternalID == "unread-ext" { + foundUnread = true + } + if n.ExternalID == "read-ext" { + foundRead = true + } + } + + odize.AssertTrue(t, foundUnread) + odize.AssertFalse(t, foundRead) + }). Run() odize.AssertNoError(t, err) diff --git a/internal/notifications/transforms.go b/internal/notifications/transforms.go index 924a658..c91a243 100644 --- a/internal/notifications/transforms.go +++ b/internal/notifications/transforms.go @@ -13,6 +13,7 @@ func fromNotificationModel(model database.OrganisationNotification) Notification return Notification{ ID: model.ID, OrganisationID: model.OrganisationID.Int64, + ExternalID: model.ExternalID, Status: NotificationStatus(model.Status), Content: model.Content, Type: model.Type, diff --git a/internal/notifications/types.go b/internal/notifications/types.go index 4413558..1cd8e44 100644 --- a/internal/notifications/types.go +++ b/internal/notifications/types.go @@ -21,6 +21,7 @@ const ( type Notification struct { ID int64 `json:"id"` OrganisationID int64 `json:"organisation_id"` + ExternalID string `json:"external_id"` Status NotificationStatus `json:"status"` Content string `json:"content"` Type string `json:"type"` diff --git a/internal/watchtower/notifications_test.go b/internal/watchtower/notifications_test.go index 19869b7..a3d27c5 100644 --- a/internal/watchtower/notifications_test.go +++ b/internal/watchtower/notifications_test.go @@ -109,7 +109,7 @@ func TestService_Notifications(t *testing.T) { odize.AssertNoError(t, err) // Action - err = s.CreateUnreadPRNotification() + _, err = s.CreateUnreadPRNotification() odize.AssertNoError(t, err) // Verify @@ -148,7 +148,7 @@ func TestService_Notifications(t *testing.T) { odize.AssertNoError(t, err) // Action - err = s.CreateUnreadSecurityNotification() + _, err = s.CreateUnreadSecurityNotification() odize.AssertNoError(t, err) // Verify diff --git a/internal/watchtower/sync.go b/internal/watchtower/sync.go index bd3ae4c..16ae628 100644 --- a/internal/watchtower/sync.go +++ b/internal/watchtower/sync.go @@ -33,14 +33,32 @@ func (s *Service) Startup(ctx context.Context) { s.ctx = ctx } +func (s *Service) CreateUnreadNotification() (int, error) { + logger := logging.FromContext(s.ctx) + + prCount, err := s.CreateUnreadPRNotification() + if err != nil { + logger.Error("Error creating unread pull request notification", "error", err) + return 0, err + } + + secCount, err := s.CreateUnreadSecurityNotification() + if err != nil { + logger.Error("Error creating unread security notification", "error", err) + return 0, err + } + + return prCount + secCount, nil +} + // CreateUnreadPRNotification generates unread notifications for recent pull requests by fetching their IDs and creating notifications. -func (s *Service) CreateUnreadPRNotification() error { +func (s *Service) CreateUnreadPRNotification() (int, error) { logger := logging.FromContext(s.ctx) prs, err := s.productSvc.GetRecentPullRequests(s.ctx) if err != nil { logger.Error("Error fetching recent pull requests", "error", err) - return err + return 0, err } return s.createNotification("OPEN_PULL_REQUEST", "New pull request", prs) @@ -49,34 +67,30 @@ func (s *Service) CreateUnreadPRNotification() error { // CreateUnreadSecurityNotification generates unread security notifications for recent security alerts. // It retrieves recent security-related IDs and creates notifications for each using the notification service. // Returns an error if fetching security IDs or creating notifications fails. -func (s *Service) CreateUnreadSecurityNotification() error { +func (s *Service) CreateUnreadSecurityNotification() (int, error) { logger := logging.FromContext(s.ctx) secList, err := s.productSvc.GetRecentSecurity(s.ctx) if err != nil { logger.Error("Error fetching recent security", "error", err) - return err + return 0, err } return s.createNotification("OPEN_SECURITY_ALERT", "New security alert", secList) } -func (s *Service) createNotification(notificationType string, content string, recentlyChanged []products.RecentlyChangedEntity) error { - logger := logging.FromContext(s.ctx) - logger.Debug("creating unread notifications", "count", len(recentlyChanged)) +func (s *Service) createNotification(notificationType string, content string, recentlyChanged []products.RecentlyChangedEntity) (int, error) { - for _, entity := range recentlyChanged { - if err := s.notificationSvc.CreateNotification(s.ctx, notifications.CreateNotificationParams{ + notificationsList := make([]notifications.CreateNotificationParams, len(recentlyChanged)) + for i, entity := range recentlyChanged { + notificationsList[i] = notifications.CreateNotificationParams{ OrgID: entity.OrganisationID, ExternalID: entity.ExternalID, NotificationType: notificationType, Content: fmt.Sprintf("%s: %s", entity.RepositoryName, content), - }); err != nil { - logger.Error("Error creating notification", "error", err) - return err } } - return nil + return s.notificationSvc.BulkCreateNotifications(s.ctx, notificationsList) } // SyncOrgs synchronizes stale organisations by retrieving them and invoking the sync process for each. diff --git a/internal/watchtower/worker.go b/internal/watchtower/worker.go index 66c0b75..9039043 100644 --- a/internal/watchtower/worker.go +++ b/internal/watchtower/worker.go @@ -9,9 +9,11 @@ import ( "github.com/go-co-op/gocron/v2" "github.com/google/uuid" + "github.com/wailsapp/wails/v2/pkg/runtime" ) type Workers struct { + ctx context.Context watchTower *Service cron gocron.Scheduler logger *slog.Logger @@ -25,6 +27,7 @@ func NewWorkers(wt *Service) (*Workers, error) { } return &Workers{ + ctx: context.Background(), watchTower: wt, cron: s, logger: logger, @@ -52,6 +55,8 @@ func (w *Workers) AddJobs() error { } func (w *Workers) Start(ctx context.Context) { + w.ctx = ctx + w.logger.Debug("Starting workers") w.cron.Start() @@ -88,11 +93,12 @@ func (w *Workers) jobDeleteOldNotifications() { func (w *Workers) afterOrgSync(jobID uuid.UUID, jobName string) { w.logger.Debug("Running notification worker") - if err := w.watchTower.CreateUnreadPRNotification(); err != nil { + totalUnread, err := w.watchTower.CreateUnreadNotification() + if err != nil { w.logger.Error("Error creating unread PR notification", "error", err) } - if err := w.watchTower.CreateUnreadSecurityNotification(); err != nil { - w.logger.Error("Error creating unread security notification", "error", err) + if totalUnread > 0 { + runtime.EventsEmit(w.ctx, "UNREAD_NOTIFICATIONS") } }