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/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/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..a4a1263 --- /dev/null +++ b/frontend/src/routes/(orgs)/notifications/+page.svelte @@ -0,0 +1,120 @@ + + +
+
+ + {#if notifications.length > 0} + + {/if} +
+ + {#if notifications.length === 0} + +
+ +
+
+ {:else} +
+
+ + +
+
+
+ {#each searchFilter.data as notification (notification.id)} + { + e.preventDefault(); + + routeToOrgDashboard(notification.organisation_id); + }} + class="overflow-hidden p-0 hover:cursor-pointer" + > +
+
+
+ +
+
+
+
+ + {toSentenceCase(notification.type)} + + + {formatDate(notification.created_at)} + +
+

+ {notification.content} +

+
+
+ +
+
+
+ {/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/database/notifications.sql.gen.go b/internal/database/notifications.sql.gen.go index 7b0540e..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 { @@ -87,15 +88,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..107bc87 100644 --- a/internal/database/queries/notifications.sql +++ b/internal/database/queries/notifications.sql @@ -34,13 +34,14 @@ 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 FROM organisation_notifications -WHERE created_at < ?; +WHERE created_at < ? +AND status = 'read'; 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/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/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"` } diff --git a/internal/watchtower/notifications_test.go b/internal/watchtower/notifications_test.go index d271ec4..7d1f456 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) @@ -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