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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ coverage*
.DS_Store

.task
.idea
.idea
.ai
40 changes: 39 additions & 1 deletion frontend/src/lib/hooks/formats.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -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");
});
});
18 changes: 18 additions & 0 deletions frontend/src/lib/hooks/formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
10 changes: 5 additions & 5 deletions frontend/src/lib/wailsjs/go/models.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/lib/watchtower/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
27 changes: 27 additions & 0 deletions frontend/src/lib/watchtower/notifications.svelte.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
8 changes: 7 additions & 1 deletion frontend/src/routes/(orgs)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -59,6 +60,11 @@
<Castle size={24} />
{/snippet}
</NavItem>
<NavItem to="/notifications" {expand} label="Notifications">
{#snippet icon()}
<MessageSquare size={24} />
{/snippet}
</NavItem>
</div>
<NavItem {expand} to="/settings" label="Settings">
{#snippet icon()}
Expand Down
120 changes: 120 additions & 0 deletions frontend/src/routes/(orgs)/notifications/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<script lang="ts">
import { PageTitle } from "$components/page_title";
import { EmptySlate } from "$components/empty_slate";
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, Search } from "@lucide/svelte";
import { formatDate, toSentenceCase } from "$lib/hooks/formats";
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();
}

async function markAsRead(id: number) {
await notificationSvc.markAsRead(id);
await invalidateAll();
}

async function routeToOrgDashboard(orgId: number) {
await orgSvc.setDefault(orgId);
await goto(resolve("/dashboard"));
}
</script>

<div class="page-container">
<div class="flex items-center justify-between">
<PageTitle title="Notifications" subtitle="Unread notifications across all orgs" />
{#if notifications.length > 0}
<Button variant="outline" size="sm" onclick={markAllAsRead}>
<Check class="mr-2 h-4 w-4" />
Mark all read
</Button>
{/if}
</div>

{#if notifications.length === 0}
<EmptySlate
title="No new notifications"
description="You're all caught up! Check back later for updates."
>
<div class="mt-4">
<Inbox class="h-12 w-12 text-muted-foreground/20" />
</div>
</EmptySlate>
{:else}
<div class="mb-2 flex w-full justify-end">
<div class="flex items-center gap-2">
<Search class="" />
<BaseInput bind:value={searchState} placeholder="Filter notifications" />
</div>
</div>
<div class="flex flex-col gap-2">
{#each searchFilter.data as notification (notification.id)}
<Card.Root
onclick={(e) => {
e.preventDefault();

routeToOrgDashboard(notification.organisation_id);
}}
class="overflow-hidden p-0 hover:cursor-pointer"
>
<div class="flex items-center gap-4 p-4">
<div class="shrink-0">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10"
>
<Bell class="h-5 w-5 text-primary" />
</div>
</div>
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center gap-2">
<Badge variant="secondary" class="text-xs">
{toSentenceCase(notification.type)}
</Badge>
<span class="text-xs text-muted-foreground">
{formatDate(notification.created_at)}
</span>
</div>
<p class="truncate text-sm font-medium">
{notification.content}
</p>
</div>
<div class="shrink-0">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={(e) => {
e.stopImmediatePropagation();

markAsRead(notification.id);
}}
title="Mark as read"
>
<Check class="h-4 w-4" />
</Button>
</div>
</div>
</Card.Root>
{/each}
</div>
{/if}
</div>
7 changes: 7 additions & 0 deletions frontend/src/routes/(orgs)/notifications/+page.ts
Original file line number Diff line number Diff line change
@@ -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 };
};
11 changes: 6 additions & 5 deletions internal/database/notifications.sql.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions internal/database/queries/notifications.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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';
4 changes: 1 addition & 3 deletions internal/notifications/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package notifications

import (
"context"
"database/sql"

"watchtower/internal/database"
)

Expand All @@ -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
}

Expand Down
5 changes: 1 addition & 4 deletions internal/notifications/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions internal/notifications/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading