create-astram-app 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * NotificationsMenu — the header bell (G-027 step 5): the user's PERSONAL notices (changes to
3
- * their own access). Badge = unread personal count (useNotificationCounts). The list loads when
4
- * the menu opens; opening marks nothing read. Clicking a notice marks it read and follows its
5
- * link only when app-relative; "Mark all read" clears the category. Text only, never HTML.
3
+ * their own access, and app notices). Badge = unread personal count (useNotificationCounts). The
4
+ * list (unread only, newest 20; G-035) loads when the menu opens; opening marks nothing read.
5
+ * Each row carries its severity dot. Clicking a notice marks it read and follows its link only
6
+ * when app-relative; "Mark all read" clears the category; "View all notifications" opens the
7
+ * Notifications page (`/notifications?tab=personal`), with "N more" when over 20 are unread.
8
+ * Text only, never HTML.
6
9
  *
7
10
  * Also exports the pieces AnnouncementsMenu reuses: `HeaderMenuTrigger` (the header icon button
8
11
  * with tooltip and count badge) and `NoticeSection` (one category's notices).
@@ -10,7 +13,7 @@
10
13
  import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
11
14
  import { useNavigate } from 'react-router-dom';
12
15
  import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
13
- import { Bell, CheckCheck, Loader2, RotateCw } from 'lucide-react';
16
+ import { Bell, CheckCheck, ListChecks, Loader2, RotateCw } from 'lucide-react';
14
17
  import { toast } from 'sonner';
15
18
  import { useAuth } from '@/auth/AuthContext';
16
19
  import { cn } from '@/lib/utils';
@@ -22,7 +25,16 @@ import {
22
25
  DropdownMenuLabel,
23
26
  DropdownMenuSeparator,
24
27
  } from '@/components/ui/dropdown-menu';
25
- import { badgeLabel, badgeText, relativeTime, safeAppLink } from './notificationsView';
28
+ import {
29
+ DROPDOWN_LIMIT,
30
+ badgeLabel,
31
+ badgeText,
32
+ moreText,
33
+ relativeTime,
34
+ safeAppLink,
35
+ severityInfo,
36
+ viewAllHref,
37
+ } from './notificationsView';
26
38
  import { useNotificationCounts } from './useNotificationCounts';
27
39
 
28
40
  /**
@@ -110,19 +122,19 @@ export function MenuError({ text, onRetry }) {
110
122
  );
111
123
  }
112
124
 
113
- const LIST_LIMIT = 20;
114
-
115
125
  /**
116
- * One category's latest notices. Mounted only while the menu is open (Radix unmounts closed
117
- * content), so it loads on open. `heading` (optional) labels the section.
126
+ * One category's newest UNREAD notices (G-035: at most 20, plus "N more" and a "View all
127
+ * notifications" link to the Notifications page tab). Mounted only while the menu is open
128
+ * (Radix unmounts closed content), so it loads on open. `heading` (optional) labels the section;
129
+ * `emptyText` defaults to "You're all caught up".
118
130
  */
119
- export function NoticeSection({ category, heading, emptyText }) {
131
+ export function NoticeSection({ category, heading, emptyText = 'You’re all caught up' }) {
120
132
  const { tokens, notifications } = useAuth();
121
133
  const counts = useNotificationCounts();
122
134
  const { refresh } = counts;
123
135
  const navigate = useNavigate();
124
136
  const token = tokens?.accessToken ?? null;
125
- const [view, setView] = useState({ status: 'loading', rows: [], error: '', now: 0 });
137
+ const [view, setView] = useState({ status: 'loading', rows: [], total: 0, error: '', now: 0 });
126
138
  const [busyAll, setBusyAll] = useState(false);
127
139
  const alive = useRef(true);
128
140
  // Bumped per list load and when "Mark all read" starts, so only the latest load applies: a
@@ -139,9 +151,14 @@ export function NoticeSection({ category, heading, emptyText }) {
139
151
  const load = useCallback(async () => {
140
152
  if (!token) return;
141
153
  const seq = ++loadSeq.current;
142
- const r = await notifications.listNotifications(token, { category, limit: LIST_LIMIT });
154
+ const r = await notifications.listNotifications(token, {
155
+ category,
156
+ unread: true,
157
+ limit: DROPDOWN_LIMIT,
158
+ });
143
159
  if (!alive.current || seq !== loadSeq.current) return;
144
- if (r.ok) setView({ status: 'ready', rows: r.rows, error: '', now: Date.now() });
160
+ if (r.ok)
161
+ setView({ status: 'ready', rows: r.rows, total: r.total, error: '', now: Date.now() });
145
162
  else setView((v) => ({ ...v, status: 'error', error: r.error }));
146
163
  }, [notifications, token, category]);
147
164
 
@@ -181,6 +198,8 @@ export function NoticeSection({ category, heading, emptyText }) {
181
198
 
182
199
  const unreadInList = view.rows.some((row) => !row.readAt);
183
200
  const unreadCount = category === 'personal' ? counts.personal : counts.announcement;
201
+ const more = moreText(view.total, view.rows.length);
202
+ const allHref = viewAllHref(category);
184
203
 
185
204
  return (
186
205
  <section aria-label={heading || undefined}>
@@ -197,6 +216,7 @@ export function NoticeSection({ category, heading, emptyText }) {
197
216
  {view.status === 'ready' &&
198
217
  view.rows.map((row) => {
199
218
  const unread = !row.readAt;
219
+ const severity = severityInfo(row.severity);
200
220
  return (
201
221
  <DropdownMenuItem
202
222
  key={row.uuid}
@@ -205,14 +225,11 @@ export function NoticeSection({ category, heading, emptyText }) {
205
225
  >
206
226
  <span
207
227
  aria-hidden="true"
208
- className={cn(
209
- 'mt-1.5 size-2 shrink-0 rounded-full',
210
- unread ? 'bg-primary' : 'bg-transparent',
211
- )}
228
+ className={cn('mt-1.5 size-2 shrink-0 rounded-full', severity.dotClass)}
212
229
  />
213
230
  <span className="min-w-0 flex-1">
214
231
  <span className={cn('block text-sm', unread ? 'font-semibold' : 'font-normal')}>
215
- {unread && <span className="sr-only">Unread: </span>}
232
+ <span className="sr-only">{`${severity.label}${unread ? ', unread' : ''}: `}</span>
216
233
  {row.title}
217
234
  </span>
218
235
  {row.body && (
@@ -244,6 +261,20 @@ export function NoticeSection({ category, heading, emptyText }) {
244
261
  Mark all read
245
262
  </DropdownMenuItem>
246
263
  )}
264
+ {view.status !== 'loading' && (
265
+ <div className="flex items-center justify-between gap-2 border-t border-border px-1 pt-1">
266
+ <DropdownMenuItem
267
+ onSelect={() => navigate(allHref)}
268
+ className="text-xs font-medium text-primary focus:text-primary"
269
+ >
270
+ <ListChecks aria-hidden="true" />
271
+ View all notifications
272
+ </DropdownMenuItem>
273
+ {view.status === 'ready' && more && (
274
+ <span className="px-2 text-xs text-muted-foreground">{more}</span>
275
+ )}
276
+ </div>
277
+ )}
247
278
  </section>
248
279
  );
249
280
  }
@@ -256,7 +287,7 @@ export default function NotificationsMenu() {
256
287
  <DropdownMenuContent align="end" className="w-96 max-w-[calc(100vw-2rem)]">
257
288
  <DropdownMenuLabel>Notifications</DropdownMenuLabel>
258
289
  <DropdownMenuSeparator />
259
- <NoticeSection category="personal" emptyText="You have no notifications." />
290
+ <NoticeSection category="personal" />
260
291
  </DropdownMenuContent>
261
292
  </DropdownMenu>
262
293
  );
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * notificationsView — React-free helpers for the header bell and Announcements menus (G-027
3
- * step 5). Kept pure so `node --test` covers them (notificationsView.test.mjs); the menus in
4
- * NotificationsMenu.jsx / AnnouncementsMenu.jsx only render what these return.
3
+ * step 5; unread-only lists, severity dot and "View all" footer, G-035). Kept pure so
4
+ * `node --test` covers them (notificationsView.test.mjs); the menus in NotificationsMenu.jsx /
5
+ * AnnouncementsMenu.jsx and the Notifications page only render what these return.
5
6
  */
6
7
 
7
8
  /** The counts poll interval (spec §10: 60 s, plus window focus). */
@@ -136,3 +137,41 @@ export function announcementsBadgeCount(counts) {
136
137
  const t = Number.isFinite(counts?.tasks) ? counts.tasks : 0;
137
138
  return a + t;
138
139
  }
140
+
141
+ // --- G-035 notification center: unread-only dropdowns, severity, "View all" -----------------
142
+
143
+ /** How many unread notices a dropdown loads (spec §10: 20 unread). */
144
+ export const DROPDOWN_LIMIT = 20;
145
+
146
+ /** The Notifications page tab for a category: `personal` (bell) or `announcement`. */
147
+ export function viewAllHref(category) {
148
+ const tab = category === 'announcement' ? 'announcement' : 'personal';
149
+ return `/notifications?tab=${tab}`;
150
+ }
151
+
152
+ /**
153
+ * The dropdown footer's "N more" (N = unread total − rows shown), or '' when every unread
154
+ * notice is already shown (or the numbers are not counts).
155
+ */
156
+ export function moreText(total, shown) {
157
+ if (!Number.isFinite(total) || !Number.isFinite(shown)) return '';
158
+ const n = Math.floor(total) - Math.floor(shown);
159
+ return n > 0 ? `${n} more` : '';
160
+ }
161
+
162
+ const SEVERITY = Object.freeze({
163
+ info: Object.freeze({ label: 'Info', dotClass: 'bg-primary' }),
164
+ success: Object.freeze({ label: 'Success', dotClass: 'bg-ok' }),
165
+ warning: Object.freeze({ label: 'Warning', dotClass: 'bg-warn' }),
166
+ });
167
+
168
+ /**
169
+ * A notice's severity dot: `{ label, dotClass }` (theme tokens only). Anything but `success` or
170
+ * `warning` reads as `info`, the default (spec §10). The label goes in sr-only text beside the
171
+ * dot, so colour is never the only signal.
172
+ */
173
+ export function severityInfo(severity) {
174
+ return Object.prototype.hasOwnProperty.call(SEVERITY, severity)
175
+ ? SEVERITY[severity]
176
+ : SEVERITY.info;
177
+ }
@@ -6,7 +6,11 @@
6
6
  import { test, describe } from 'node:test';
7
7
  import assert from 'node:assert/strict';
8
8
  import {
9
+ DROPDOWN_LIMIT,
9
10
  POLL_MS,
11
+ moreText,
12
+ severityInfo,
13
+ viewAllHref,
10
14
  announcementsBadgeCount,
11
15
  badgeLabel,
12
16
  badgeText,
@@ -180,3 +184,37 @@ describe('shouldPoll', () => {
180
184
  assert.equal(POLL_MS, 60_000);
181
185
  });
182
186
  });
187
+
188
+ describe('dropdown footer (G-035)', () => {
189
+ test('loads 20 unread', () => {
190
+ assert.equal(DROPDOWN_LIMIT, 20);
191
+ });
192
+ test('25 unread, 20 shown ⇒ "5 more" (spec §12)', () => {
193
+ assert.equal(moreText(25, 20), '5 more');
194
+ });
195
+ test('all shown, or not counts ⇒ no "N more"', () => {
196
+ assert.equal(moreText(20, 20), '');
197
+ assert.equal(moreText(3, 3), '');
198
+ assert.equal(moreText(0, 0), '');
199
+ assert.equal(moreText(undefined, 0), '');
200
+ assert.equal(moreText(5, NaN), '');
201
+ });
202
+ test('View all links to the category tab', () => {
203
+ assert.equal(viewAllHref('personal'), '/notifications?tab=personal');
204
+ assert.equal(viewAllHref('announcement'), '/notifications?tab=announcement');
205
+ assert.equal(viewAllHref('anything'), '/notifications?tab=personal');
206
+ });
207
+ });
208
+
209
+ describe('severityInfo', () => {
210
+ test('theme-token dot and an sr label per severity', () => {
211
+ assert.deepEqual({ ...severityInfo('info') }, { label: 'Info', dotClass: 'bg-primary' });
212
+ assert.deepEqual({ ...severityInfo('success') }, { label: 'Success', dotClass: 'bg-ok' });
213
+ assert.deepEqual({ ...severityInfo('warning') }, { label: 'Warning', dotClass: 'bg-warn' });
214
+ });
215
+ test('unknown or missing ⇒ info (the default)', () => {
216
+ for (const s of [undefined, null, '', 'error', 'toString']) {
217
+ assert.equal(severityInfo(s).label, 'Info');
218
+ }
219
+ });
220
+ });
@@ -190,7 +190,9 @@ NODE_ENV=development
190
190
 
191
191
  # --- OPTIONAL: in-app notification retention ---
192
192
  # Read notices are deleted after the first value (days, 1-3650), every notice after the second
193
- # (at least the first, at most 3650), counted from when the notice was posted.
193
+ # (at least the first, at most 3650), counted from when the notice was posted. Archived notices
194
+ # follow the same rule (archiving never keeps a notice longer). Unset ⇒ 90 and 365; an invalid
195
+ # value stops the server at start-up naming the setting. A clean-up runs every hour.
194
196
  # NOTIFICATION_READ_RETENTION_DAYS=90
195
197
  # NOTIFICATION_MAX_RETENTION_DAYS=365
196
198
 
@@ -23,10 +23,10 @@
23
23
  "dependencies": {
24
24
  "@astram/core": "1.14.0",
25
25
  "@astram/engine": "0.16.2",
26
- "@astram/guard": "0.26.0",
26
+ "@astram/guard": "0.27.0",
27
27
  "@astram/guide": "^0.2.8",
28
- "@astram/identity": "0.24.0",
29
- "@astram/messaging": "0.1.12",
28
+ "@astram/identity": "0.25.0",
29
+ "@astram/messaging": "0.1.13",
30
30
  "@astram/migrate": "0.7.4",
31
31
  "@astram/sdk": "^0.2.0",
32
32
  "@nestjs/common": "^11.2.7",
@@ -109,6 +109,19 @@ import { DemoController } from './health/demo.controller';
109
109
  * ],
110
110
  * The frontend ships the matching pages/widget (RecordPoliciesPage, ShareDialog)
111
111
  * already wired to `useAuth().recordPolicy` / `.recordShare`.
112
+ *
113
+ * OPTIONAL — YOUR OWN NOTIFICATIONS (with `auth: true`): register each kind you post, then post
114
+ * with `src/notifications/app-notifications.service.ts` (an example to copy). `personal` kinds
115
+ * show in the bell, `announcement` kinds under the Announcements icon; users see `label` as the
116
+ * type (≤ 40 characters; unset ⇒ made from the kind) and `severity` (info | success | warning)
117
+ * as a coloured dot. Kinds are dotted lower-case names; `shield.` is reserved:
118
+ * ShieldModule.forRoot({ auth: true, logoutRequiresProof: true,
119
+ * notificationKinds: [
120
+ * { kind: 'app.task_assigned', category: 'personal', label: 'Task assigned', severity: 'warning' },
121
+ * { kind: 'app.release_news', category: 'announcement', label: 'Release news' },
122
+ * ] }),
123
+ * How long notices are kept: NOTIFICATION_READ_RETENTION_DAYS / NOTIFICATION_MAX_RETENTION_DAYS
124
+ * in `.env` (see `_env.example`).
112
125
  */
113
126
  @Module({
114
127
  // Enforcement-only by default (boots with no config). For real login + the identity
@@ -0,0 +1,26 @@
1
+ import type { NotificationService } from '@astram/guard';
2
+ import { AppNotificationsService } from './app-notifications.service';
3
+
4
+ describe('AppNotificationsService (example)', () => {
5
+ it('posts the kind, title, body and link to the given users in the given company', async () => {
6
+ const post = vi.fn().mockResolvedValue({ created: 2, skipped: 0 });
7
+ const svc = new AppNotificationsService({ post } as unknown as NotificationService);
8
+
9
+ const result = await svc.notifyUsers('company-1', ['user-1', 'user-2'], {
10
+ kind: 'app.task_assigned',
11
+ title: 'Order 123 needs your approval',
12
+ link: '/orders/123',
13
+ });
14
+
15
+ expect(result).toEqual({ created: 2, skipped: 0 });
16
+ expect(post).toHaveBeenCalledWith({
17
+ tenantId: 'company-1',
18
+ recipients: ['user-1', 'user-2'],
19
+ kind: 'app.task_assigned',
20
+ title: 'Order 123 needs your approval',
21
+ body: null,
22
+ link: '/orders/123',
23
+ dedupeKey: null,
24
+ });
25
+ });
26
+ });
@@ -0,0 +1,41 @@
1
+ import { Inject, Injectable } from '@nestjs/common';
2
+ import { SHIELD_NOTIFICATIONS, type NotificationService } from '@astram/guard';
3
+
4
+ /**
5
+ * EXAMPLE — send your own notices to the bell and Announcements menus (copy and extend).
6
+ *
7
+ * Not registered by default: add `AppNotificationsService` to a module's `providers` once
8
+ * `ShieldModule.forRoot({ auth: true, notificationKinds: [...] })` is on (`SHIELD_NOTIFICATIONS`
9
+ * exists only with `auth: true`). Register every kind you post in `notificationKinds`
10
+ * (see `app.module.ts`): `category: 'personal'` shows in the bell, `'announcement'` under the
11
+ * Announcements icon; `label` is what users see as the type, `severity` colours the dot.
12
+ *
13
+ * Rules:
14
+ * - `tenantId` comes from the session (`principal.tenantId`) or the record you just saved, never
15
+ * from a request body or query.
16
+ * - `link` is an app path (`/orders/123`): clicking the notice opens it. The target page still
17
+ * checks access itself — a notice never grants anything.
18
+ * - Notices are plain text: no passwords, tokens or other users' data in the title or body.
19
+ * - Only active users with an active membership in `tenantId` receive it; the rest are skipped.
20
+ */
21
+ @Injectable()
22
+ export class AppNotificationsService {
23
+ constructor(@Inject(SHIELD_NOTIFICATIONS) private readonly notifications: NotificationService) {}
24
+
25
+ /** Post one notice to each user ⇒ `{ created, skipped }` (1–1000 users per call). */
26
+ async notifyUsers(
27
+ tenantId: string,
28
+ userIds: readonly string[],
29
+ notice: { kind: string; title: string; body?: string; link?: string; dedupeKey?: string },
30
+ ): Promise<{ created: number; skipped: number }> {
31
+ return this.notifications.post({
32
+ tenantId,
33
+ recipients: userIds,
34
+ kind: notice.kind,
35
+ title: notice.title,
36
+ body: notice.body ?? null,
37
+ link: notice.link ?? null,
38
+ dedupeKey: notice.dedupeKey ?? null,
39
+ });
40
+ }
41
+ }