Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/lib/wailsjs/go/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export namespace notifications {
export class Notification {
id: number;
organisation_id: number;
external_id: string;
status: string;
content: string;
type: string;
Expand All @@ -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"];
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/lib/wailsjs/go/watchtower/Service.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ export function CreateOrganisation(arg1:string,arg2:string,arg3:string,arg4:stri

export function CreateProduct(arg1:string,arg2:string,arg3:Array<string>,arg4:number):Promise<products.ProductDTO>;

export function CreateUnreadPRNotification():Promise<void>;
export function CreateUnreadNotification():Promise<void>;

export function CreateUnreadSecurityNotification():Promise<void>;
export function CreateUnreadPRNotification():Promise<number>;

export function CreateUnreadSecurityNotification():Promise<number>;

export function DeleteAllOrgs():Promise<void>;

Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/wailsjs/go/watchtower/Service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']();
}
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/lib/watchtower/notifications.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()", () => {
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/lib/watchtower/notifications.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/watchtower/types.ts
Original file line number Diff line number Diff line change
@@ -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";
23 changes: 20 additions & 3 deletions frontend/src/routes/(orgs)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -31,6 +36,14 @@
expand = !expand;
settingsSvc.setSidebarExpanded(expand);
}

onMount(() => {
EventsOn(EVENT_UNREAD_NOTIFICATIONS, () => notificationSvc.getUnread(true));
});

onDestroy(() => {
EventsOff(EVENT_UNREAD_NOTIFICATIONS);
});
</script>

<div class="flex h-screen">
Expand Down Expand Up @@ -62,7 +75,11 @@
</NavItem>
<NavItem to="/notifications" {expand} label="Notifications">
{#snippet icon()}
<MessageSquare size={24} />
{#if hasUnreadNotifications}
<MessageSquareDot size={24} />
{:else}
<MessageSquare size={24} />
{/if}
{/snippet}
</NavItem>
</div>
Expand Down
21 changes: 21 additions & 0 deletions internal/notifications/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
102 changes: 102 additions & 0 deletions internal/notifications/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions internal/notifications/transforms.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions internal/notifications/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
4 changes: 2 additions & 2 deletions internal/watchtower/notifications_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
40 changes: 27 additions & 13 deletions internal/watchtower/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down
Loading