create-astram-app 0.13.0 → 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.
- package/package.json +1 -1
- package/template/README.md +18 -0
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/App.jsx +12 -0
- package/template/frontend/src/pages/notifications/NotificationsPage.jsx +550 -0
- package/template/frontend/src/pages/notifications/notificationList.js +268 -0
- package/template/frontend/src/pages/notifications/notificationList.test.mjs +273 -0
- package/template/frontend/src/shell/AnnouncementsMenu.jsx +3 -6
- package/template/frontend/src/shell/NotificationsMenu.jsx +50 -19
- package/template/frontend/src/shell/notificationsView.js +41 -2
- package/template/frontend/src/shell/notificationsView.test.mjs +38 -0
- package/template/server/_env.example +3 -1
- package/template/server/package.json +3 -3
- package/template/server/src/app.module.ts +16 -0
- package/template/server/src/notifications/app-notifications.service.spec.ts +26 -0
- package/template/server/src/notifications/app-notifications.service.ts +41 -0
|
@@ -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
|
|
4
|
-
* the menu opens; opening marks nothing read.
|
|
5
|
-
*
|
|
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 {
|
|
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
|
|
117
|
-
*
|
|
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, {
|
|
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)
|
|
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
|
-
|
|
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"
|
|
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
|
|
4
|
-
*
|
|
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
|
+
"@astram/guard": "0.27.0",
|
|
27
27
|
"@astram/guide": "^0.2.8",
|
|
28
|
-
"@astram/identity": "0.
|
|
29
|
-
"@astram/messaging": "0.1.
|
|
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",
|
|
@@ -88,6 +88,9 @@ import { DemoController } from './health/demo.controller';
|
|
|
88
88
|
* `recordAuthorization` or `decisionAuthority` to `ShieldModule`: boot fails (one source).
|
|
89
89
|
* Every route is `@Classified` — enforced by the same deny-by-default guard. Omit the
|
|
90
90
|
* module and login still works; the admin pages 404.
|
|
91
|
+
* Company hierarchy comes with it: a parent-company admin administers the companies below
|
|
92
|
+
* theirs (the session's subtree is resolved at sign-in). No `scopeSubtreeResolver` option
|
|
93
|
+
* is needed; pass one to `ShieldModule.forRoot` only to replace the framework's.
|
|
91
94
|
*
|
|
92
95
|
* ⛔ IF YOU REGISTER YOUR OWN OWNED OBJECT TYPES (e.g. a `Note`): pass them to
|
|
93
96
|
* `IdentityFeatureModule.forRoot({ extraObjects: [Note] })` (the ONE place: their action
|
|
@@ -106,6 +109,19 @@ import { DemoController } from './health/demo.controller';
|
|
|
106
109
|
* ],
|
|
107
110
|
* The frontend ships the matching pages/widget (RecordPoliciesPage, ShareDialog)
|
|
108
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`).
|
|
109
125
|
*/
|
|
110
126
|
@Module({
|
|
111
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
|
+
}
|