Skip to content

Commit 1c1b92a

Browse files
committed
feat(new-deepnotes): notifications list route, tests, and plan progress
1 parent acce973 commit 1c1b92a

8 files changed

Lines changed: 490 additions & 6 deletions

File tree

new-deepnotes/PLAN_PROGRESS.md

Lines changed: 22 additions & 6 deletions
Large diffs are not rendered by default.

new-deepnotes/apps/web/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Vue 3 SPA for the greenfield stack. The bundle talks to the API only through [`s
1717
- `src/features/auth/` — session bootstrap (`/api/sessions/refresh` + `GET /api/users/me` when the `loggedIn` cookie is set), demo login, email/password + 2FA step, shared helpers.
1818
- `src/features/home/` — first shell screen after auth.
1919
- `src/features/groups/``GET /api/users/me/groups` plus per-group `main-page`, `members`, and `pages` (first window) for a read-only [Groups](src/features/groups/GroupsView.vue) screen (`/groups`, signed-in only).
20+
- `src/features/notifications/``GET /api/users/me/notifications` and `POST …/notifications/read` for [Notifications](src/features/notifications/NotificationsView.vue) (`/notifications`, signed-in only; list shows `type` + time, bodies stay encrypted in this MVP).
2021
- `src/router.ts``vue-router` history routes.
2122

2223
## Local dev

new-deepnotes/apps/web/src/App.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ async function onLogout() {
5656
>
5757
<RouterLink to="/groups">Groups</RouterLink>
5858
</Button>
59+
<Button
60+
v-if="isAuthenticated"
61+
as-child
62+
size="sm"
63+
variant="ghost"
64+
>
65+
<RouterLink to="/notifications">Notifications</RouterLink>
66+
</Button>
5967
<template v-if="!isAuthenticated">
6068
<Button as-child variant="ghost" size="sm">
6169
<RouterLink to="/register">Register</RouterLink>
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<script setup lang="ts">
2+
import { watch } from "vue";
3+
import { useRouter } from "vue-router";
4+
5+
import { Button } from "@/components/ui/button";
6+
import {
7+
Card,
8+
CardContent,
9+
CardDescription,
10+
CardHeader,
11+
CardTitle,
12+
} from "@/components/ui/card";
13+
14+
import { useSession } from "../auth/useSession";
15+
import { useNotifications } from "./useNotifications";
16+
17+
const router = useRouter();
18+
const { isAuthenticated, bootstrapped } = useSession();
19+
const {
20+
rows,
21+
loadFirst,
22+
loadMore,
23+
markRead,
24+
loading,
25+
error,
26+
hasMore,
27+
markingRead,
28+
} = useNotifications();
29+
30+
watch(
31+
[bootstrapped, isAuthenticated],
32+
() => {
33+
if (!bootstrapped.value) {
34+
return;
35+
}
36+
if (!isAuthenticated.value) {
37+
void router.replace({ name: "login", query: { redirect: "/notifications" } });
38+
return;
39+
}
40+
void loadFirst();
41+
},
42+
{ immediate: true },
43+
);
44+
45+
function formatWhen(iso: string): string {
46+
const d = new Date(iso);
47+
if (Number.isNaN(d.getTime())) {
48+
return iso;
49+
}
50+
return new Intl.DateTimeFormat(undefined, {
51+
dateStyle: "medium",
52+
timeStyle: "short",
53+
}).format(d);
54+
}
55+
</script>
56+
57+
<template>
58+
<div class="space-y-4">
59+
<div class="flex flex-wrap items-center justify-between gap-2">
60+
<h1 class="text-lg font-semibold tracking-tight">Notifications</h1>
61+
<div class="flex flex-wrap gap-2">
62+
<Button
63+
v-if="isAuthenticated && rows.length > 0"
64+
:disabled="markingRead || loading"
65+
size="sm"
66+
variant="secondary"
67+
@click="markRead()"
68+
>
69+
Mark all as read
70+
</Button>
71+
<Button
72+
v-if="isAuthenticated"
73+
:disabled="loading"
74+
size="sm"
75+
variant="outline"
76+
@click="loadFirst()"
77+
>
78+
Refresh
79+
</Button>
80+
</div>
81+
</div>
82+
83+
<p class="text-muted-foreground text-sm">
84+
This list shows type and time. Message bodies stay encrypted in the API response.
85+
</p>
86+
87+
<p
88+
v-if="!bootstrapped || (loading && rows.length === 0)"
89+
class="text-muted-foreground text-sm"
90+
>
91+
Loading…
92+
</p>
93+
<p v-else-if="error" class="text-destructive text-sm">
94+
{{ error }}
95+
</p>
96+
<ul
97+
v-else-if="rows.length > 0"
98+
class="space-y-3"
99+
>
100+
<li v-for="n in rows" :key="n.id">
101+
<Card :class="n.unread ? 'border-primary/40 bg-muted/30' : ''">
102+
<CardHeader class="pb-2">
103+
<div class="flex items-start justify-between gap-2">
104+
<CardTitle class="text-base">
105+
{{ n.type }}
106+
<span
107+
v-if="n.unread"
108+
class="bg-primary text-primary-foreground ml-2 inline-block rounded px-1.5 py-0.5 text-xs font-medium"
109+
>New</span>
110+
</CardTitle>
111+
<CardDescription class="shrink-0 text-xs">
112+
{{ formatWhen(n.dateTime) }}
113+
</CardDescription>
114+
</div>
115+
</CardHeader>
116+
<CardContent class="text-muted-foreground text-xs">
117+
Message body is not shown (encrypted).
118+
</CardContent>
119+
</Card>
120+
</li>
121+
</ul>
122+
<p
123+
v-else
124+
class="text-muted-foreground text-sm"
125+
>
126+
No notifications.
127+
</p>
128+
129+
<div v-if="hasMore" class="flex justify-center">
130+
<Button
131+
:disabled="loading"
132+
size="sm"
133+
variant="outline"
134+
@click="loadMore()"
135+
>
136+
{{ loading ? "Loading…" : "Load older" }}
137+
</Button>
138+
</div>
139+
</div>
140+
</template>
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
import type { DeepnotesApiClient } from "../../api/client";
4+
import {
5+
fetchNotificationsPage,
6+
isNotificationUnread,
7+
markAllNotificationsRead,
8+
} from "./notifications-list";
9+
10+
describe("isNotificationUnread", () => {
11+
it("treats all as unread when the user has no read cursor", () => {
12+
expect(isNotificationUnread(42, null)).toBe(true);
13+
});
14+
15+
it("compares id to the stored read cursor", () => {
16+
expect(isNotificationUnread(5, 4)).toBe(true);
17+
expect(isNotificationUnread(4, 4)).toBe(false);
18+
expect(isNotificationUnread(3, 4)).toBe(false);
19+
});
20+
});
21+
22+
describe("fetchNotificationsPage", () => {
23+
it("maps items and first-page read cursor", async () => {
24+
const client = {
25+
GET: vi.fn().mockResolvedValue({
26+
response: { status: 200 },
27+
data: {
28+
items: [
29+
{
30+
id: 10,
31+
type: "group-invite",
32+
encryptedSymmetricKey: "YQ==",
33+
encryptedContent: "Yg==",
34+
dateTime: "2026-01-01T12:00:00.000Z",
35+
},
36+
],
37+
hasMore: false,
38+
lastNotificationRead: 4,
39+
},
40+
}),
41+
};
42+
const out = await fetchNotificationsPage({
43+
client: client as unknown as DeepnotesApiClient,
44+
});
45+
expect(out.error).toBeNull();
46+
expect(out.hasMore).toBe(false);
47+
expect(out.lastNotificationRead).toBe(4);
48+
expect(out.rows[0]).toMatchObject({
49+
id: 10,
50+
type: "group-invite",
51+
unread: true,
52+
});
53+
});
54+
55+
it("uses readCursorForUnread when loading older pages", async () => {
56+
const client = {
57+
GET: vi.fn().mockResolvedValue({
58+
response: { status: 200 },
59+
data: {
60+
items: [
61+
{
62+
id: 2,
63+
type: "old",
64+
encryptedSymmetricKey: "YQ==",
65+
encryptedContent: "Yg==",
66+
dateTime: "2025-01-01T12:00:00.000Z",
67+
},
68+
],
69+
hasMore: false,
70+
},
71+
}),
72+
};
73+
const out = await fetchNotificationsPage({
74+
client: client as unknown as DeepnotesApiClient,
75+
lastNotificationId: 10,
76+
readCursorForUnread: 5,
77+
});
78+
expect(out.rows[0]?.unread).toBe(false);
79+
});
80+
81+
it("returns an error on failed GET", async () => {
82+
const client = {
83+
GET: vi.fn().mockResolvedValue({
84+
response: { status: 401 },
85+
error: { message: "No session" },
86+
data: undefined,
87+
}),
88+
};
89+
const out = await fetchNotificationsPage({
90+
client: client as unknown as DeepnotesApiClient,
91+
});
92+
expect(out.rows).toEqual([]);
93+
expect(out.error).toBe("No session");
94+
});
95+
});
96+
97+
describe("markAllNotificationsRead", () => {
98+
it("returns ok on 204", async () => {
99+
const client = {
100+
POST: vi.fn().mockResolvedValue({
101+
response: { status: 204 },
102+
}),
103+
};
104+
const out = await markAllNotificationsRead({
105+
client: client as unknown as DeepnotesApiClient,
106+
});
107+
expect(out).toEqual({ ok: true, error: null });
108+
});
109+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { DeepnotesApiClient } from "../../api/client";
2+
3+
export type NotificationRow = {
4+
id: number;
5+
type: string;
6+
dateTime: string;
7+
/** True when this row is newer than the server’s read cursor (or cursor unset). */
8+
unread: boolean;
9+
};
10+
11+
/**
12+
* `GET /api/users/me/notifications` with optional older-than pagination.
13+
* The API includes `lastNotificationRead` on the first page only; for `load more`,
14+
* pass the same cursor you stored from the first response so unread badges stay
15+
* correct.
16+
* Payload ciphertext is not decrypted here; the UI shows type + time only.
17+
*/
18+
export async function fetchNotificationsPage(input: {
19+
client: DeepnotesApiClient;
20+
lastNotificationId?: number;
21+
/** Required when `lastNotificationId` is set — the read cursor from the first page. */
22+
readCursorForUnread?: number | null;
23+
}): Promise<{
24+
rows: NotificationRow[];
25+
hasMore: boolean;
26+
lastNotificationRead: number | null | undefined;
27+
error: string | null;
28+
}> {
29+
const { client, lastNotificationId, readCursorForUnread } = input;
30+
const res = await client.GET("/api/users/me/notifications", {
31+
params: {
32+
query:
33+
lastNotificationId != null ? { lastNotificationId } : {},
34+
},
35+
});
36+
37+
if (res.response.status !== 200 || !res.data) {
38+
if (res.error && typeof res.error === "object" && "message" in res.error) {
39+
return {
40+
rows: [],
41+
hasMore: false,
42+
lastNotificationRead: undefined,
43+
error: String((res.error as { message?: string }).message),
44+
};
45+
}
46+
return {
47+
rows: [],
48+
hasMore: false,
49+
lastNotificationRead: undefined,
50+
error: "Could not load notifications.",
51+
};
52+
}
53+
54+
const { items, hasMore, lastNotificationRead } = res.data;
55+
const readCursor =
56+
lastNotificationId == null
57+
? (lastNotificationRead ?? null)
58+
: (readCursorForUnread ?? null);
59+
60+
const rows: NotificationRow[] = items.map((it) => ({
61+
id: it.id,
62+
type: it.type,
63+
dateTime: it.dateTime,
64+
unread: isNotificationUnread(it.id, readCursor),
65+
}));
66+
67+
return {
68+
rows,
69+
hasMore: Boolean(hasMore),
70+
lastNotificationRead:
71+
lastNotificationId == null ? (lastNotificationRead ?? null) : undefined,
72+
error: null,
73+
};
74+
}
75+
76+
/** Server stores the newest notification id the user has acknowledged. */
77+
export function isNotificationUnread(
78+
notificationId: number,
79+
lastNotificationRead: number | null,
80+
): boolean {
81+
if (lastNotificationRead == null) {
82+
return true;
83+
}
84+
return notificationId > lastNotificationRead;
85+
}
86+
87+
export async function markAllNotificationsRead(input: {
88+
client: DeepnotesApiClient;
89+
}): Promise<{ ok: boolean; error: string | null }> {
90+
const res = await input.client.POST("/api/users/me/notifications/read", {});
91+
if (res.response.status === 204) {
92+
return { ok: true, error: null };
93+
}
94+
if (res.error && typeof res.error === "object" && "message" in res.error) {
95+
return {
96+
ok: false,
97+
error: String((res.error as { message?: string }).message),
98+
};
99+
}
100+
return { ok: false, error: "Could not update read state." };
101+
}

0 commit comments

Comments
 (0)