From 497466587f1f74ac3bcc09dbd03827a8d1dca9af Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 9 Jan 2026 16:55:38 +1100 Subject: [PATCH 1/5] notification page created --- frontend/src/lib/wailsjs/go/models.ts | 10 +++---- frontend/src/lib/watchtower/index.ts | 4 ++- .../lib/watchtower/notifications.svelte.ts | 27 +++++++++++++++++++ frontend/src/routes/(orgs)/+layout.svelte | 8 +++++- .../routes/(orgs)/notifications/+page.svelte | 23 ++++++++++++++++ .../src/routes/(orgs)/notifications/+page.ts | 7 +++++ internal/notifications/types.go | 10 +++---- 7 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 frontend/src/lib/watchtower/notifications.svelte.ts create mode 100644 frontend/src/routes/(orgs)/notifications/+page.svelte create mode 100644 frontend/src/routes/(orgs)/notifications/+page.ts diff --git a/frontend/src/lib/wailsjs/go/models.ts b/frontend/src/lib/wailsjs/go/models.ts index 5e42668..23b6a8a 100755 --- a/frontend/src/lib/wailsjs/go/models.ts +++ b/frontend/src/lib/wailsjs/go/models.ts @@ -1,11 +1,11 @@ export namespace notifications { export class Notification { - id?: number; - organisation_id?: number; - status?: string; - content?: string; - type?: string; + id: number; + organisation_id: number; + status: string; + content: string; + type: string; // Go type: time created_at: any; // Go type: time diff --git a/frontend/src/lib/watchtower/index.ts b/frontend/src/lib/watchtower/index.ts index b70ab6d..faddece 100644 --- a/frontend/src/lib/watchtower/index.ts +++ b/frontend/src/lib/watchtower/index.ts @@ -1,7 +1,9 @@ import { OrgService } from "$lib/watchtower/orgs.svelte"; import { ProductsService } from "$lib/watchtower/products.svelte"; +import { NotificationsService } from "$lib/watchtower/notifications.svelte"; const orgSvc = new OrgService(); const productSvc = new ProductsService(); +const notificationSvc = new NotificationsService(); -export { orgSvc, productSvc }; +export { orgSvc, productSvc, notificationSvc }; diff --git a/frontend/src/lib/watchtower/notifications.svelte.ts b/frontend/src/lib/watchtower/notifications.svelte.ts new file mode 100644 index 0000000..0305153 --- /dev/null +++ b/frontend/src/lib/watchtower/notifications.svelte.ts @@ -0,0 +1,27 @@ +import { GetUnreadNotifications, MarkNotificationAsRead } from "$lib/wailsjs/go/watchtower/Service"; + +export class NotificationsService { + /** + * Retrieves the unread notifications for the user. + */ + async getUnread() { + return (await GetUnreadNotifications()) ?? []; + } + + /** + * Marks a notification as read based on the provided notification ID. + */ + async markAsRead(id: number) { + return await MarkNotificationAsRead(id); + } + + /** + * Marks all unread notifications as read. + */ + async markAllAsRead() { + const notifications = await this.getUnread(); + for (const notification of notifications) { + await this.markAsRead(notification.id); + } + } +} diff --git a/frontend/src/routes/(orgs)/+layout.svelte b/frontend/src/routes/(orgs)/+layout.svelte index 7731a9b..9017817 100644 --- a/frontend/src/routes/(orgs)/+layout.svelte +++ b/frontend/src/routes/(orgs)/+layout.svelte @@ -6,7 +6,8 @@ Castle, LayoutDashboard, PanelLeftClose, - PanelLeftOpen + PanelLeftOpen, + MessageSquare } from "@lucide/svelte"; import { cn } from "$lib/utils"; import { NavItem, NavHeader } from "$components/nav/index.js"; @@ -59,6 +60,11 @@ {/snippet} + + {#snippet icon()} + + {/snippet} + {#snippet icon()} diff --git a/frontend/src/routes/(orgs)/notifications/+page.svelte b/frontend/src/routes/(orgs)/notifications/+page.svelte new file mode 100644 index 0000000..5da03d9 --- /dev/null +++ b/frontend/src/routes/(orgs)/notifications/+page.svelte @@ -0,0 +1,23 @@ + + +
+ + + {#if notifications.length === 0} + + {:else} + {#each notifications as notification (notification.id)} +
+

{notification.content}

+

{notification.created_at}

+
+ {/each} + {/if} +
diff --git a/frontend/src/routes/(orgs)/notifications/+page.ts b/frontend/src/routes/(orgs)/notifications/+page.ts new file mode 100644 index 0000000..03671e9 --- /dev/null +++ b/frontend/src/routes/(orgs)/notifications/+page.ts @@ -0,0 +1,7 @@ +import type { PageLoad } from "./$types"; +import { notificationSvc } from "$lib/watchtower"; + +export const load: PageLoad = async () => { + const notifications = await notificationSvc.getUnread(); + return { notifications }; +}; diff --git a/internal/notifications/types.go b/internal/notifications/types.go index d8339e5..4413558 100644 --- a/internal/notifications/types.go +++ b/internal/notifications/types.go @@ -19,11 +19,11 @@ const ( ) type Notification struct { - ID int64 `json:"id,omitempty"` - OrganisationID int64 `json:"organisation_id,omitempty"` - Status NotificationStatus `json:"status,omitempty"` - Content string `json:"content,omitempty"` - Type string `json:"type,omitempty"` + ID int64 `json:"id"` + OrganisationID int64 `json:"organisation_id"` + Status NotificationStatus `json:"status"` + Content string `json:"content"` + Type string `json:"type"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } From 6778c19c3ec1c442b7b3573984b4be8c912e4742 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 9 Jan 2026 21:28:43 +1100 Subject: [PATCH 2/5] fixing notification to pull unread from all orgs --- internal/database/notifications.sql.gen.go | 10 +++++----- internal/database/queries/notifications.sql | 6 +++--- internal/notifications/interfaces.go | 4 +--- internal/notifications/service.go | 5 +---- internal/watchtower/notifications_test.go | 8 ++++---- 5 files changed, 14 insertions(+), 19 deletions(-) diff --git a/internal/database/notifications.sql.gen.go b/internal/database/notifications.sql.gen.go index 7b0540e..54ac759 100644 --- a/internal/database/notifications.sql.gen.go +++ b/internal/database/notifications.sql.gen.go @@ -87,15 +87,15 @@ func (q *Queries) GetNotificationByExternalID(ctx context.Context, externalID st return i, err } -const getUnreadNotificationsByOrgID = `-- name: GetUnreadNotificationsByOrgID :many +const getUnreadNotifications = `-- name: GetUnreadNotifications :many SELECT id, organisation_id, external_id, type, content, status, created_at, updated_at FROM organisation_notifications -WHERE organisation_id = ? - AND status = 'unread' +WHERE status = 'unread' +ORDER BY created_at DESC ` -func (q *Queries) GetUnreadNotificationsByOrgID(ctx context.Context, organisationID sql.NullInt64) ([]OrganisationNotification, error) { - rows, err := q.db.QueryContext(ctx, getUnreadNotificationsByOrgID, organisationID) +func (q *Queries) GetUnreadNotifications(ctx context.Context) ([]OrganisationNotification, error) { + rows, err := q.db.QueryContext(ctx, getUnreadNotifications) if err != nil { return nil, err } diff --git a/internal/database/queries/notifications.sql b/internal/database/queries/notifications.sql index 0695934..030cdc4 100644 --- a/internal/database/queries/notifications.sql +++ b/internal/database/queries/notifications.sql @@ -34,11 +34,11 @@ SET status = ?, WHERE id = ? RETURNING *; --- name: GetUnreadNotificationsByOrgID :many +-- name: GetUnreadNotifications :many SELECT * FROM organisation_notifications -WHERE organisation_id = ? - AND status = 'unread'; +WHERE status = 'unread' +ORDER BY created_at DESC; -- name: DeleteOrgNotificationByDate :exec DELETE diff --git a/internal/notifications/interfaces.go b/internal/notifications/interfaces.go index 0451957..919912e 100644 --- a/internal/notifications/interfaces.go +++ b/internal/notifications/interfaces.go @@ -2,8 +2,6 @@ package notifications import ( "context" - "database/sql" - "watchtower/internal/database" ) @@ -12,7 +10,7 @@ type Store interface { UpdateOrgNotificationByID(ctx context.Context, arg database.UpdateOrgNotificationByIDParams) (database.OrganisationNotification, error) UpdateOrgNotificationStatusByID(ctx context.Context, arg database.UpdateOrgNotificationStatusByIDParams) (database.OrganisationNotification, error) GetNotificationByExternalID(ctx context.Context, externalID string) (database.OrganisationNotification, error) - GetUnreadNotificationsByOrgID(ctx context.Context, organisationID sql.NullInt64) ([]database.OrganisationNotification, error) + GetUnreadNotifications(ctx context.Context) ([]database.OrganisationNotification, error) DeleteOrgNotificationByDate(ctx context.Context, createdAt int64) error } diff --git a/internal/notifications/service.go b/internal/notifications/service.go index 396ff11..8614731 100644 --- a/internal/notifications/service.go +++ b/internal/notifications/service.go @@ -50,10 +50,7 @@ func (s *Service) GetUnreadNotifications(ctx context.Context) ([]Notification, e logger := logging.FromContext(ctx).With("service", "notifications") logger.Debug("Fetching unread notifications") - models, err := s.store.GetUnreadNotificationsByOrgID(ctx, sql.NullInt64{ - Int64: 0, - Valid: true, - }) + models, err := s.store.GetUnreadNotifications(ctx) if err != nil { logger.Error("Error fetching unread notifications", "error", err) return []Notification{}, err diff --git a/internal/watchtower/notifications_test.go b/internal/watchtower/notifications_test.go index d271ec4..fbf6b22 100644 --- a/internal/watchtower/notifications_test.go +++ b/internal/watchtower/notifications_test.go @@ -51,15 +51,15 @@ func TestService_Notifications(t *testing.T) { }) odize.AssertNoError(t, err) - unread, err := s.GetUnreadNotifications() + unread, err := s.notificationSvc.GetNotificationByExternalID(ctx, "test-external-id-2") odize.AssertNoError(t, err) - err = s.MarkNotificationAsRead(unread[0].ID) + err = s.MarkNotificationAsRead(unread.ID) odize.AssertNoError(t, err) - verifyUnread, err := s.GetUnreadNotifications() + verifyUnread, err := s.notificationSvc.GetNotificationByExternalID(ctx, "test-external-id-2") odize.AssertNoError(t, err) - odize.AssertEqual(t, 0, len(verifyUnread)) + odize.AssertEqual(t, notifications.StatusRead, verifyUnread.Status) }). Test("DeleteOldNotifications should delete notifications", func(t *testing.T) { orgID := int64(1003) From a9b87cf1b3e019ebb7bfc9eea9382c80a7075a49 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 9 Jan 2026 21:41:40 +1100 Subject: [PATCH 3/5] adding base page --- .gitignore | 3 ++- frontend/src/routes/(orgs)/notifications/+page.svelte | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 96f08cc..93859de 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ coverage* .DS_Store .task -.idea \ No newline at end of file +.idea +.ai \ No newline at end of file diff --git a/frontend/src/routes/(orgs)/notifications/+page.svelte b/frontend/src/routes/(orgs)/notifications/+page.svelte index 5da03d9..30ebede 100644 --- a/frontend/src/routes/(orgs)/notifications/+page.svelte +++ b/frontend/src/routes/(orgs)/notifications/+page.svelte @@ -15,6 +15,7 @@ {:else} {#each notifications as notification (notification.id)}
+

{notification.type}

{notification.content}

{notification.created_at}

From 3009debb3bd7bce6bba4138d85b95c2b53332dfe Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 9 Jan 2026 22:35:10 +1100 Subject: [PATCH 4/5] fixing bug, and updating notification service + page --- frontend/src/lib/hooks/formats.test.ts | 40 ++++++++- frontend/src/lib/hooks/formats.ts | 18 ++++ .../routes/(orgs)/notifications/+page.svelte | 88 ++++++++++++++++--- internal/database/notifications.sql.gen.go | 1 + internal/database/queries/notifications.sql | 3 +- internal/notifications/service_test.go | 11 ++- internal/watchtower/notifications_test.go | 12 ++- 7 files changed, 154 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/hooks/formats.test.ts b/frontend/src/lib/hooks/formats.test.ts index 106e8d6..6897e88 100644 --- a/frontend/src/lib/hooks/formats.test.ts +++ b/frontend/src/lib/hooks/formats.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; -import { formatDate, truncate } from "$lib/hooks/formats"; +import { formatDate, truncate, toSentenceCase } from "$lib/hooks/formats"; describe("Hooks: formats", () => { beforeEach(() => { @@ -123,3 +123,41 @@ describe("Hooks: formats", () => { }); }); }); + +describe("toSentenceCase", () => { + it("should convert camelCase to Sentence case", () => { + expect(toSentenceCase("camelCase")).toBe("Camel case"); + }); + + it("should convert PascalCase to Sentence case", () => { + expect(toSentenceCase("PascalCase")).toBe("Pascal case"); + }); + + it("should convert snake_case to Sentence case", () => { + expect(toSentenceCase("snake_case")).toBe("Snake case"); + }); + + it("should convert SCREAMING_SNAKE_CASE to Sentence case", () => { + expect(toSentenceCase("HELLO_WORLD")).toBe("Hello world"); + }); + + it("should handle mixed formats and acronyms", () => { + expect(toSentenceCase("HTTPClient")).toBe("Http client"); + expect(toSentenceCase("getUser_data")).toBe("Get user data"); + }); + + it("should handle multiple underscores or hyphens", () => { + expect(toSentenceCase("multiple__underscores")).toBe("Multiple underscores"); + expect(toSentenceCase("kebab-case-string")).toBe("Kebab case string"); + }); + + it("should handle empty strings or null values", () => { + expect(toSentenceCase("")).toBe(""); + // @ts-expect-error - testing runtime behavior for non-string + expect(toSentenceCase(null)).toBe(null); + }); + + it("should trim surrounding whitespace", () => { + expect(toSentenceCase(" padded_string ")).toBe("Padded string"); + }); +}); diff --git a/frontend/src/lib/hooks/formats.ts b/frontend/src/lib/hooks/formats.ts index 6dff6fb..9825742 100644 --- a/frontend/src/lib/hooks/formats.ts +++ b/frontend/src/lib/hooks/formats.ts @@ -20,3 +20,21 @@ export function truncate(str: string) { const sub = str.substring(0, maxLength); return `${sub}...`; } + +/** + * Converts a string from camelCase, snake_case, or PascalCase to `Sentence case`. + */ +export function toSentenceCase(str: string): string { + if (!str) { + return str; + } + + const result = str + .replace(/[_-]+/g, " ") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/([A-Z])([A-Z][a-z])/g, "$1 $2") + .trim() + .toLowerCase(); + + return result.charAt(0).toUpperCase() + result.slice(1); +} diff --git a/frontend/src/routes/(orgs)/notifications/+page.svelte b/frontend/src/routes/(orgs)/notifications/+page.svelte index 30ebede..7cbb4dc 100644 --- a/frontend/src/routes/(orgs)/notifications/+page.svelte +++ b/frontend/src/routes/(orgs)/notifications/+page.svelte @@ -1,24 +1,92 @@ -
- +
+
+ + {#if notifications.length > 0} + + {/if} +
{#if notifications.length === 0} - - {:else} - {#each notifications as notification (notification.id)} -
-

{notification.type}

-

{notification.content}

-

{notification.created_at}

+ +
+
- {/each} +
+ {:else} +
+ {#each notifications as notification (notification.id)} + +
+
+
+ +
+
+
+
+ + {toSentenceCase(notification.type)} + + + {formatDate(notification.created_at)} + +
+

+ {notification.content} +

+
+
+ +
+
+
+ {/each} +
{/if}
diff --git a/internal/database/notifications.sql.gen.go b/internal/database/notifications.sql.gen.go index 54ac759..60764be 100644 --- a/internal/database/notifications.sql.gen.go +++ b/internal/database/notifications.sql.gen.go @@ -58,6 +58,7 @@ const deleteOrgNotificationByDate = `-- name: DeleteOrgNotificationByDate :exec DELETE FROM organisation_notifications WHERE created_at < ? +AND status = 'read' ` func (q *Queries) DeleteOrgNotificationByDate(ctx context.Context, createdAt int64) error { diff --git a/internal/database/queries/notifications.sql b/internal/database/queries/notifications.sql index 030cdc4..107bc87 100644 --- a/internal/database/queries/notifications.sql +++ b/internal/database/queries/notifications.sql @@ -43,4 +43,5 @@ ORDER BY created_at DESC; -- name: DeleteOrgNotificationByDate :exec DELETE FROM organisation_notifications -WHERE created_at < ?; +WHERE created_at < ? +AND status = 'read'; diff --git a/internal/notifications/service_test.go b/internal/notifications/service_test.go index 224b305..86b7217 100644 --- a/internal/notifications/service_test.go +++ b/internal/notifications/service_test.go @@ -77,14 +77,19 @@ func TestService(t *testing.T) { }) odize.AssertNoError(t, err) + unread, err := s.GetNotificationByExternalID(ctx, "ext4") + odize.AssertNoError(t, err) + + err = s.MarkNotificationAsRead(ctx, unread.ID) + odize.AssertNoError(t, err) + cutoff := time.Now().Add(1 * time.Minute) err = s.DeleteNotificationsByDate(ctx, cutoff) odize.AssertNoError(t, err) - unread, err := s.GetUnreadNotifications(ctx) - odize.AssertNoError(t, err) - odize.AssertEqual(t, 0, len(unread)) + _, err = s.GetNotificationByExternalID(ctx, "ext4") + odize.AssertError(t, err) }). Run() diff --git a/internal/watchtower/notifications_test.go b/internal/watchtower/notifications_test.go index fbf6b22..7d1f456 100644 --- a/internal/watchtower/notifications_test.go +++ b/internal/watchtower/notifications_test.go @@ -71,6 +71,12 @@ func TestService_Notifications(t *testing.T) { }) odize.AssertNoError(t, err) + unread, err := s.notificationSvc.GetNotificationByExternalID(ctx, "test-external-id-3") + odize.AssertNoError(t, err) + + err = s.MarkNotificationAsRead(unread.ID) + odize.AssertNoError(t, err) + // DeleteOldNotifications uses time.Now() and the query uses created_at < ?. // Since we just created it in the same second, it might not be deleted. // Let's wait 1 second to ensure created_at < time.Now(). @@ -79,10 +85,8 @@ func TestService_Notifications(t *testing.T) { err = s.DeleteOldNotifications() odize.AssertNoError(t, err) - unread, err := s.GetUnreadNotifications() - odize.AssertNoError(t, err) - - odize.AssertEqual(t, 0, len(unread)) + _, err = s.notificationSvc.GetNotificationByExternalID(ctx, "test-external-id-3") + odize.AssertError(t, err) }). Test("CreateUnreadPRNotification should create notifications for recent PRs", func(t *testing.T) { // Setup: Org, Product, Repo, PR From 35b85fe33dbce0664a2b5662bc1e7e5c10405949 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 9 Jan 2026 22:58:41 +1100 Subject: [PATCH 5/5] adding filter capability --- .../routes/(orgs)/notifications/+page.svelte | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/frontend/src/routes/(orgs)/notifications/+page.svelte b/frontend/src/routes/(orgs)/notifications/+page.svelte index 7cbb4dc..a4a1263 100644 --- a/frontend/src/routes/(orgs)/notifications/+page.svelte +++ b/frontend/src/routes/(orgs)/notifications/+page.svelte @@ -4,15 +4,25 @@ import * as Card from "$lib/components/ui/card"; import { Badge } from "$lib/components/ui/badge"; import { Button } from "$lib/components/ui/button"; - import { Check, Bell, Inbox } from "@lucide/svelte"; + import { Check, Bell, Inbox, Search } from "@lucide/svelte"; import { formatDate, toSentenceCase } from "$lib/hooks/formats"; - import { notificationSvc } from "$lib/watchtower"; - import { invalidateAll } from "$app/navigation"; + import { notificationSvc, orgSvc } from "$lib/watchtower"; + import { goto, invalidateAll } from "$app/navigation"; + import { resolve } from "$app/paths"; + import { SimpleFilter } from "$lib/hooks/filters.svelte"; + import { BaseInput } from "$components/base_input"; const { data } = $props(); let notifications = $derived(data.notifications); + let searchState = $state(""); + const searchFilter = $derived( + new SimpleFilter(notifications, (item) => { + return item.content.toLowerCase().includes(searchState.toLowerCase()); + }) + ); + async function markAllAsRead() { await notificationSvc.markAllAsRead(); await invalidateAll(); @@ -22,9 +32,14 @@ await notificationSvc.markAsRead(id); await invalidateAll(); } + + async function routeToOrgDashboard(orgId: number) { + await orgSvc.setDefault(orgId); + await goto(resolve("/dashboard")); + } -
+
{#if notifications.length > 0} @@ -45,9 +60,22 @@
{:else} +
+
+ + +
+
- {#each notifications as notification (notification.id)} - + {#each searchFilter.data as notification (notification.id)} + { + e.preventDefault(); + + routeToOrgDashboard(notification.organisation_id); + }} + class="overflow-hidden p-0 hover:cursor-pointer" + >