create-astram-app 0.13.1 → 0.14.1
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/auth/AuthContext.jsx +17 -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/platform/agentStreamTransport.js +168 -0
- package/template/frontend/src/platform/agentStreamTransport.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 +6 -6
- package/template/server/src/app.module.ts +13 -0
- package/template/server/src/main.ts +7 -1
- package/template/server/src/notifications/app-notifications.service.spec.ts +26 -0
- package/template/server/src/notifications/app-notifications.service.ts +41 -0
|
@@ -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
|
|
|
@@ -21,13 +21,13 @@
|
|
|
21
21
|
"postinstall": "node scripts/sync-astram-guide.mjs"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@astram/core": "1.
|
|
25
|
-
"@astram/engine": "0.
|
|
26
|
-
"@astram/guard": "0.
|
|
24
|
+
"@astram/core": "1.15.0",
|
|
25
|
+
"@astram/engine": "0.17.0",
|
|
26
|
+
"@astram/guard": "0.28.0",
|
|
27
27
|
"@astram/guide": "^0.2.8",
|
|
28
|
-
"@astram/identity": "0.
|
|
29
|
-
"@astram/messaging": "0.1.
|
|
30
|
-
"@astram/migrate": "0.7.
|
|
28
|
+
"@astram/identity": "0.26.0",
|
|
29
|
+
"@astram/messaging": "0.1.14",
|
|
30
|
+
"@astram/migrate": "0.7.5",
|
|
31
31
|
"@astram/sdk": "^0.2.0",
|
|
32
32
|
"@nestjs/common": "^11.2.7",
|
|
33
33
|
"@nestjs/core": "^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
|
|
@@ -22,7 +22,13 @@ export async function bootstrap(): Promise<void> {
|
|
|
22
22
|
await resolveBootSecrets();
|
|
23
23
|
const { AppModule } = await import('./app.module');
|
|
24
24
|
const config = loadAppConfig();
|
|
25
|
-
|
|
25
|
+
// `rawBody: true` keeps the request bytes in `req.rawBody` beside the parsed JSON. `@astram/agent`
|
|
26
|
+
// parses its own bodies from those bytes (strict JSON, duplicate keys refused); without it every
|
|
27
|
+
// agent POST answers 503 agent_unavailable. Other routes are unaffected.
|
|
28
|
+
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
|
29
|
+
bufferLogs: false,
|
|
30
|
+
rawBody: true,
|
|
31
|
+
});
|
|
26
32
|
// On SIGTERM/SIGINT (PM2 restart, deploy) run the framework's shutdown hooks: background jobs
|
|
27
33
|
// stop, the HTTP server finishes its in-flight requests, then every DB pool and Redis client
|
|
28
34
|
// closes. Without this line Node exits with no hook run. Give your process manager a stop
|
|
@@ -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
|
+
}
|