@terreno/announcements 57.4.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/biome.jsonc +20 -0
  4. package/bunfig.toml +2 -0
  5. package/dist/announcementsApp.d.ts +9 -0
  6. package/dist/announcementsApp.js +820 -0
  7. package/dist/help.d.ts +30 -0
  8. package/dist/help.js +72 -0
  9. package/dist/helpRoutes.d.ts +8 -0
  10. package/dist/helpRoutes.js +233 -0
  11. package/dist/index.d.ts +9 -0
  12. package/dist/index.js +34 -0
  13. package/dist/models/announcement.d.ts +3 -0
  14. package/dist/models/announcement.js +214 -0
  15. package/dist/models/announcementAcknowledgement.d.ts +2 -0
  16. package/dist/models/announcementAcknowledgement.js +41 -0
  17. package/dist/models/announcementClickEvent.d.ts +3 -0
  18. package/dist/models/announcementClickEvent.js +56 -0
  19. package/dist/models/announcementImpression.d.ts +3 -0
  20. package/dist/models/announcementImpression.js +50 -0
  21. package/dist/overview.d.ts +14 -0
  22. package/dist/overview.js +310 -0
  23. package/dist/pending.d.ts +64 -0
  24. package/dist/pending.js +242 -0
  25. package/dist/types.d.ts +135 -0
  26. package/dist/types.js +2 -0
  27. package/package.json +57 -0
  28. package/src/announcementsApp.ts +722 -0
  29. package/src/help.ts +98 -0
  30. package/src/helpRoutes.ts +160 -0
  31. package/src/index.ts +45 -0
  32. package/src/models/announcement.ts +189 -0
  33. package/src/models/announcementAcknowledgement.ts +50 -0
  34. package/src/models/announcementClickEvent.ts +68 -0
  35. package/src/models/announcementImpression.ts +62 -0
  36. package/src/overview.ts +228 -0
  37. package/src/pending.ts +286 -0
  38. package/src/tests/announcementsApp.test.ts +844 -0
  39. package/src/tests/bunSetup.ts +9 -0
  40. package/src/tests/help.test.ts +29 -0
  41. package/src/tests/helpRoutes.test.ts +291 -0
  42. package/src/tests/index.test.ts +57 -0
  43. package/src/tests/overview.test.ts +351 -0
  44. package/src/tests/pending.test.ts +262 -0
  45. package/src/types.ts +157 -0
  46. package/tsconfig.json +33 -0
@@ -0,0 +1,844 @@
1
+ import {afterEach, beforeEach, describe, expect, it} from "bun:test";
2
+ import {
3
+ addAuthRoutes,
4
+ apiErrorMiddleware,
5
+ apiUnauthorizedMiddleware,
6
+ setupAuth,
7
+ type UserModel as UserModelType,
8
+ } from "@terreno/api";
9
+ import {authAsUser, getBaseServer, setupDb, UserModel} from "@terreno/api/testing";
10
+ import {assert} from "chai";
11
+ import type express from "express";
12
+ import {DateTime} from "luxon";
13
+ import supertest from "supertest";
14
+ import type TestAgent from "supertest/lib/agent";
15
+ import {AnnouncementsApp} from "../announcementsApp";
16
+ import {Announcement} from "../models/announcement";
17
+ import {AnnouncementAcknowledgement} from "../models/announcementAcknowledgement";
18
+ import {AnnouncementClickEvent} from "../models/announcementClickEvent";
19
+ import {AnnouncementImpression} from "../models/announcementImpression";
20
+
21
+ const buildApp = (options?: {
22
+ basePath?: string;
23
+ defaultAcknowledgementPolicy?: "required" | "dismiss-only";
24
+ isStaff?: (user: unknown) => boolean;
25
+ matchAudience?: (user: unknown, announcement: unknown) => boolean;
26
+ }): express.Application => {
27
+ const app = getBaseServer();
28
+ setupAuth(app, UserModel as unknown as UserModelType);
29
+ addAuthRoutes(app, UserModel as unknown as UserModelType);
30
+
31
+ const plugin = new AnnouncementsApp(options);
32
+ plugin.register(app);
33
+
34
+ app.use(apiUnauthorizedMiddleware);
35
+ app.use(apiErrorMiddleware);
36
+ return app;
37
+ };
38
+
39
+ describe("AnnouncementsApp", () => {
40
+ let app: express.Application;
41
+ let adminAgent: TestAgent;
42
+ let userAgent: TestAgent;
43
+
44
+ beforeEach(async () => {
45
+ await setupDb();
46
+ await Announcement.deleteMany({});
47
+ await AnnouncementAcknowledgement.deleteMany({});
48
+ await AnnouncementImpression.deleteMany({});
49
+ await AnnouncementClickEvent.deleteMany({});
50
+ app = buildApp();
51
+ adminAgent = await authAsUser(app, "admin");
52
+ userAgent = await authAsUser(app, "notAdmin");
53
+ });
54
+
55
+ afterEach(async () => {
56
+ await Announcement.deleteMany({});
57
+ await AnnouncementAcknowledgement.deleteMany({});
58
+ await AnnouncementImpression.deleteMany({});
59
+ await AnnouncementClickEvent.deleteMany({});
60
+ });
61
+
62
+ it("contributes announcement admin models and grouped overview screen", () => {
63
+ const contribution = new AnnouncementsApp().adminContribution();
64
+ expect(contribution.models?.length).toBe(4);
65
+ expect(contribution.models?.[0]?.routePath).toBe("/announcements");
66
+ expect(contribution.models?.[0]?.admin.displayName).toBe("All announcements");
67
+ expect(contribution.models?.every((entry) => entry.admin.group === "Announcements")).toBe(true);
68
+ for (const eventModel of contribution.models?.slice(1) ?? []) {
69
+ assert.deepEqual(eventModel.admin.adminPermissions?.create, []);
70
+ assert.deepEqual(eventModel.admin.adminPermissions?.delete, []);
71
+ assert.deepEqual(eventModel.admin.adminPermissions?.update, []);
72
+ }
73
+ expect(contribution.customScreens).toEqual([
74
+ {
75
+ displayName: "Overview",
76
+ group: "Announcements",
77
+ icon: "bullhorn",
78
+ name: "announcements",
79
+ },
80
+ ]);
81
+ });
82
+
83
+ it("publishes draft announcements and returns them as pending", async () => {
84
+ const createRes = await adminAgent
85
+ .post("/announcements")
86
+ .send({
87
+ acknowledgementPolicy: "required",
88
+ body: "## Welcome\n\nCheck out the new feature.",
89
+ title: "New feature",
90
+ })
91
+ .expect(201);
92
+
93
+ const announcementId = createRes.body.data._id as string;
94
+ await adminAgent.post(`/announcements/${announcementId}/publish`).expect(200);
95
+
96
+ const pendingRes = await userAgent.get("/announcements/pending?platform=web").expect(200);
97
+ expect(pendingRes.body.data.current?.id).toBe(announcementId);
98
+ expect(pendingRes.body.data.current?.requiresAcknowledgement).toBe(true);
99
+ expect(pendingRes.body.data.remainingCount).toBe(0);
100
+ });
101
+
102
+ it("resolves requiresAcknowledgement on GET pending for each policy case", async () => {
103
+ const requiredApp = buildApp();
104
+ const requiredUser = await authAsUser(requiredApp, "notAdmin");
105
+
106
+ const requiredAnnouncement = await Announcement.create({
107
+ acknowledgementPolicy: "required",
108
+ body: "Required body",
109
+ publishedAt: DateTime.utc().toJSDate(),
110
+ status: "published",
111
+ title: "Required ack",
112
+ version: 1,
113
+ });
114
+
115
+ const requiredRes = await requiredUser.get("/announcements/pending?platform=web").expect(200);
116
+ expect(requiredRes.body.data.current?.id).toBe(requiredAnnouncement._id.toString());
117
+ expect(requiredRes.body.data.current?.requiresAcknowledgement).toBe(true);
118
+
119
+ await Announcement.deleteMany({});
120
+
121
+ const dismissAnnouncement = await Announcement.create({
122
+ acknowledgementPolicy: "dismiss-only",
123
+ body: "Dismiss body",
124
+ publishedAt: DateTime.utc().toJSDate(),
125
+ status: "published",
126
+ title: "Dismiss only",
127
+ version: 1,
128
+ });
129
+
130
+ const dismissRes = await requiredUser.get("/announcements/pending?platform=web").expect(200);
131
+ expect(dismissRes.body.data.current?.id).toBe(dismissAnnouncement._id.toString());
132
+ expect(dismissRes.body.data.current?.requiresAcknowledgement).toBe(false);
133
+
134
+ await Announcement.deleteMany({});
135
+
136
+ const defaultApp = buildApp({defaultAcknowledgementPolicy: "required"});
137
+ const defaultUser = await authAsUser(defaultApp, "notAdmin");
138
+ const omittedAnnouncement = await Announcement.create({
139
+ body: "Default policy body",
140
+ publishedAt: DateTime.utc().toJSDate(),
141
+ status: "published",
142
+ title: "Omitted policy",
143
+ version: 1,
144
+ });
145
+
146
+ const defaultRes = await defaultUser.get("/announcements/pending?platform=web").expect(200);
147
+ expect(defaultRes.body.data.current?.id).toBe(omittedAnnouncement._id.toString());
148
+ expect(defaultRes.body.data.current?.requiresAcknowledgement).toBe(true);
149
+
150
+ await Announcement.deleteMany({});
151
+
152
+ const legacyAnnouncement = await Announcement.collection.insertOne({
153
+ body: "Legacy body",
154
+ created: DateTime.utc().toJSDate(),
155
+ platforms: ["web"],
156
+ priority: 0,
157
+ publishedAt: DateTime.utc().toJSDate(),
158
+ requiresAcknowledgement: true,
159
+ status: "published",
160
+ title: "Legacy boolean",
161
+ updated: DateTime.utc().toJSDate(),
162
+ version: 1,
163
+ });
164
+
165
+ const legacyRes = await requiredUser.get("/announcements/pending?platform=web").expect(200);
166
+ expect(legacyRes.body.data.current?.id).toBe(legacyAnnouncement.insertedId.toString());
167
+ expect(legacyRes.body.data.current?.requiresAcknowledgement).toBe(true);
168
+ });
169
+
170
+ it("clears pending after acknowledgement", async () => {
171
+ const announcement = await Announcement.create({
172
+ acknowledgementPolicy: "required",
173
+ body: "Body",
174
+ publishedAt: DateTime.utc().toJSDate(),
175
+ status: "published",
176
+ title: "Shipped",
177
+ version: 1,
178
+ });
179
+
180
+ await userAgent.get("/announcements/pending?platform=web").expect(200);
181
+ await userAgent.post(`/announcements/${announcement._id.toString()}/acknowledge`).expect(200);
182
+
183
+ const pendingRes = await userAgent.get("/announcements/pending?platform=web").expect(200);
184
+ expect(pendingRes.body.data.current).toBeNull();
185
+ });
186
+
187
+ it("bumps version when published title/body changes", async () => {
188
+ const announcement = await Announcement.create({
189
+ acknowledgementPolicy: "required",
190
+ body: "Body v1",
191
+ publishedAt: DateTime.utc().toJSDate(),
192
+ status: "published",
193
+ title: "Title v1",
194
+ version: 1,
195
+ });
196
+
197
+ await userAgent.post(`/announcements/${announcement._id.toString()}/acknowledge`).expect(200);
198
+
199
+ announcement.body = "Body v2";
200
+ await announcement.save();
201
+
202
+ const pendingRes = await userAgent.get("/announcements/pending?platform=web").expect(200);
203
+ expect(pendingRes.body.data.current?.version).toBe(2);
204
+ });
205
+
206
+ it("rejects non-admins from admin CRUD", async () => {
207
+ await userAgent.get("/announcements").expect(405);
208
+ });
209
+
210
+ it("rejects invalid publish and archive transitions", async () => {
211
+ const published = await Announcement.create({
212
+ body: "Body",
213
+ publishedAt: DateTime.utc().toJSDate(),
214
+ status: "published",
215
+ title: "Already published",
216
+ version: 1,
217
+ });
218
+
219
+ await adminAgent.post(`/announcements/${published._id.toString()}/publish`).expect(400);
220
+
221
+ const draft = await Announcement.create({
222
+ body: "Draft body",
223
+ status: "draft",
224
+ title: "Draft",
225
+ version: 1,
226
+ });
227
+
228
+ await adminAgent.post(`/announcements/${draft._id.toString()}/archive`).expect(400);
229
+ });
230
+
231
+ it("records impressions and keeps duplicate acknowledgements idempotent", async () => {
232
+ const announcement = await Announcement.create({
233
+ acknowledgementPolicy: "required",
234
+ body: "Body",
235
+ publishedAt: DateTime.utc().toJSDate(),
236
+ status: "published",
237
+ title: "Track views",
238
+ version: 1,
239
+ });
240
+
241
+ const announcementId = announcement._id.toString();
242
+ await userAgent
243
+ .post(`/announcements/${announcementId}/impression`)
244
+ .send({platform: "web"})
245
+ .expect(200);
246
+
247
+ const impressions = await AnnouncementImpression.find({announcementId: announcement._id});
248
+ expect(impressions.length).toBe(1);
249
+ expect(impressions[0]?.platform).toBe("web");
250
+
251
+ await userAgent.post(`/announcements/${announcementId}/acknowledge`).expect(200);
252
+ await userAgent.post(`/announcements/${announcementId}/acknowledge`).expect(200);
253
+
254
+ const acknowledgements = await AnnouncementAcknowledgement.find({
255
+ announcementId: announcement._id,
256
+ });
257
+ expect(acknowledgements.length).toBe(1);
258
+ });
259
+
260
+ it("POST acknowledge and impression return 404 when the announcement is not visible", async () => {
261
+ await Announcement.deleteMany({});
262
+ await AnnouncementAcknowledgement.deleteMany({});
263
+ await AnnouncementImpression.deleteMany({});
264
+
265
+ const visibilityApp = buildApp({
266
+ isStaff: (user) => (user as {admin?: boolean}).admin === true,
267
+ matchAudience: (_user, announcement) => {
268
+ const audience = announcement as {audience?: {include?: boolean}};
269
+ return audience.audience?.include !== false;
270
+ },
271
+ });
272
+ const staffAgent = await authAsUser(visibilityApp, "admin");
273
+ const patientAgent = await authAsUser(visibilityApp, "notAdmin");
274
+
275
+ const staffOnly = await Announcement.create({
276
+ audienceType: "staff",
277
+ body: "Staff",
278
+ publishedAt: DateTime.utc().toJSDate(),
279
+ status: "published",
280
+ title: "Staff only",
281
+ version: 1,
282
+ });
283
+ await patientAgent.post(`/announcements/${staffOnly._id.toString()}/acknowledge`).expect(404);
284
+ await patientAgent
285
+ .post(`/announcements/${staffOnly._id.toString()}/impression`)
286
+ .send({platform: "web"})
287
+ .expect(404);
288
+
289
+ const excluded = await Announcement.create({
290
+ audience: {include: false},
291
+ audienceType: "all",
292
+ body: "Hidden",
293
+ publishedAt: DateTime.utc().toJSDate(),
294
+ status: "published",
295
+ title: "Custom excluded",
296
+ version: 1,
297
+ });
298
+ await staffAgent
299
+ .post(`/announcements/${excluded._id.toString()}/acknowledge?platform=web`)
300
+ .expect(404);
301
+ await staffAgent
302
+ .post(`/announcements/${excluded._id.toString()}/impression?platform=web`)
303
+ .send({platform: "web"})
304
+ .expect(404);
305
+
306
+ const expired = await Announcement.create({
307
+ body: "Expired",
308
+ expiresAt: DateTime.utc().minus({days: 1}).toJSDate(),
309
+ publishedAt: DateTime.utc().minus({days: 2}).toJSDate(),
310
+ status: "published",
311
+ title: "Expired",
312
+ version: 1,
313
+ });
314
+ await patientAgent.post(`/announcements/${expired._id.toString()}/acknowledge`).expect(404);
315
+ await patientAgent
316
+ .post(`/announcements/${expired._id.toString()}/impression`)
317
+ .send({platform: "web"})
318
+ .expect(404);
319
+
320
+ const gated = await Announcement.create({
321
+ body: "Gated",
322
+ minBuildNumber: 10,
323
+ publishedAt: DateTime.utc().toJSDate(),
324
+ status: "published",
325
+ title: "Min build",
326
+ version: 1,
327
+ });
328
+ await patientAgent
329
+ .post(`/announcements/${gated._id.toString()}/acknowledge?platform=web&version=9`)
330
+ .expect(404);
331
+ await patientAgent
332
+ .post(`/announcements/${gated._id.toString()}/impression?platform=web&version=9`)
333
+ .send({platform: "web"})
334
+ .expect(404);
335
+
336
+ assert.lengthOf(await AnnouncementAcknowledgement.find({}), 0);
337
+ assert.lengthOf(await AnnouncementImpression.find({}), 0);
338
+ });
339
+
340
+ it("filters pending announcements with matchAudience", async () => {
341
+ await Announcement.deleteMany({});
342
+ await AnnouncementImpression.deleteMany({});
343
+ await AnnouncementAcknowledgement.deleteMany({});
344
+
345
+ const targetedApp = buildApp({
346
+ matchAudience: (_user, announcement) => {
347
+ const audience = announcement as {audience?: {include?: boolean}};
348
+ return audience.audience?.include !== false;
349
+ },
350
+ });
351
+ const targetedUserAgent = await authAsUser(targetedApp, "notAdmin");
352
+
353
+ await Announcement.create({
354
+ acknowledgementPolicy: "required",
355
+ audience: {include: true},
356
+ body: "Visible",
357
+ publishedAt: DateTime.utc().toJSDate(),
358
+ status: "published",
359
+ title: "Included",
360
+ version: 1,
361
+ });
362
+ await Announcement.create({
363
+ acknowledgementPolicy: "required",
364
+ audience: {include: false},
365
+ body: "Hidden",
366
+ publishedAt: DateTime.utc().toJSDate(),
367
+ status: "published",
368
+ title: "Excluded",
369
+ version: 1,
370
+ });
371
+
372
+ const pendingRes = await targetedUserAgent
373
+ .get("/announcements/pending?platform=web")
374
+ .expect(200);
375
+
376
+ expect(pendingRes.body.data.current?.title).toBe("Included");
377
+ expect(pendingRes.body.data.remainingCount).toBe(0);
378
+ });
379
+
380
+ it("returns a paginated announcement feed", async () => {
381
+ await Announcement.create({
382
+ body: "Feed body",
383
+ publishedAt: DateTime.utc().toJSDate(),
384
+ status: "published",
385
+ title: "Feed item",
386
+ version: 1,
387
+ });
388
+
389
+ const feedRes = await userAgent
390
+ .get("/announcements/feed?page=1&limit=10&platform=web")
391
+ .expect(200);
392
+ expect(feedRes.body.data.length).toBeGreaterThanOrEqual(1);
393
+ expect(feedRes.body.total).toBeGreaterThanOrEqual(1);
394
+ expect(feedRes.body.page).toBe(1);
395
+ });
396
+
397
+ it("GET pending omits feed displayMode and GET feed includes feed-only items", async () => {
398
+ await Announcement.deleteMany({});
399
+
400
+ const modal = await Announcement.create({
401
+ acknowledgementPolicy: "required",
402
+ body: "Modal body",
403
+ displayMode: "modal",
404
+ publishedAt: DateTime.utc().toJSDate(),
405
+ status: "published",
406
+ title: "Modal item",
407
+ version: 1,
408
+ });
409
+ await Announcement.create({
410
+ acknowledgementPolicy: "required",
411
+ body: "Feed body",
412
+ displayMode: "feed",
413
+ publishedAt: DateTime.utc().toJSDate(),
414
+ status: "published",
415
+ title: "Feed only",
416
+ version: 1,
417
+ });
418
+
419
+ const pendingRes = await userAgent.get("/announcements/pending?platform=web").expect(200);
420
+ assert.strictEqual(pendingRes.body.data.current?.id, modal._id.toString());
421
+ assert.strictEqual(pendingRes.body.data.current?.displayMode, "modal");
422
+
423
+ const feedRes = await userAgent
424
+ .get("/announcements/feed?page=1&limit=10&platform=web")
425
+ .expect(200);
426
+ const feedTitles = feedRes.body.data.map((item: {title: string}) => item.title);
427
+ assert.include(feedTitles, "Modal item");
428
+ assert.include(feedTitles, "Feed only");
429
+ });
430
+
431
+ it("GET pending and feed honor minBuildNumber when version query is present", async () => {
432
+ await Announcement.deleteMany({});
433
+
434
+ const visible = await Announcement.create({
435
+ acknowledgementPolicy: "required",
436
+ body: "Visible body",
437
+ minBuildNumber: 10,
438
+ publishedAt: DateTime.utc().toJSDate(),
439
+ status: "published",
440
+ title: "Visible at 10",
441
+ version: 1,
442
+ });
443
+ await Announcement.create({
444
+ acknowledgementPolicy: "required",
445
+ body: "Hidden body",
446
+ minBuildNumber: 20,
447
+ publishedAt: DateTime.utc().toJSDate(),
448
+ status: "published",
449
+ title: "Hidden below 20",
450
+ version: 1,
451
+ });
452
+
453
+ const pendingHidden = await userAgent
454
+ .get("/announcements/pending?platform=web&version=9")
455
+ .expect(200);
456
+ assert.isNull(pendingHidden.body.data.current);
457
+
458
+ const pendingVisible = await userAgent
459
+ .get("/announcements/pending?platform=web&version=10")
460
+ .expect(200);
461
+ assert.strictEqual(pendingVisible.body.data.current?.id, visible._id.toString());
462
+
463
+ const feedHidden = await userAgent
464
+ .get("/announcements/feed?platform=web&version=9")
465
+ .expect(200);
466
+ assert.lengthOf(feedHidden.body.data, 0);
467
+
468
+ const feedOmitted = await userAgent.get("/announcements/feed?platform=web").expect(200);
469
+ assert.isAtLeast(feedOmitted.body.data.length, 2);
470
+ });
471
+
472
+ it("filters pending by audienceType composed with matchAudience", async () => {
473
+ await Announcement.deleteMany({});
474
+
475
+ const audienceApp = buildApp({
476
+ isStaff: (user) => (user as {admin?: boolean}).admin === true,
477
+ matchAudience: (_user, announcement) => {
478
+ const audience = announcement as {audience?: {include?: boolean}};
479
+ return audience.audience?.include !== false;
480
+ },
481
+ });
482
+ const staffAgent = await authAsUser(audienceApp, "admin");
483
+ const patientAgent = await authAsUser(audienceApp, "notAdmin");
484
+
485
+ await Announcement.create({
486
+ acknowledgementPolicy: "required",
487
+ audience: {include: true},
488
+ audienceType: "staff",
489
+ body: "Staff only",
490
+ publishedAt: DateTime.utc().toJSDate(),
491
+ status: "published",
492
+ title: "Staff modal",
493
+ version: 1,
494
+ });
495
+ await Announcement.create({
496
+ acknowledgementPolicy: "required",
497
+ audience: {include: true},
498
+ audienceType: "patient",
499
+ body: "Patient only",
500
+ publishedAt: DateTime.utc().toJSDate(),
501
+ status: "published",
502
+ title: "Patient modal",
503
+ version: 1,
504
+ });
505
+
506
+ const staffPending = await staffAgent.get("/announcements/pending?platform=web").expect(200);
507
+ assert.strictEqual(staffPending.body.data.current?.title, "Staff modal");
508
+
509
+ const patientPending = await patientAgent
510
+ .get("/announcements/pending?platform=web")
511
+ .expect(200);
512
+ assert.strictEqual(patientPending.body.data.current?.title, "Patient modal");
513
+ });
514
+
515
+ it("POST click records primaryAction with version and optional platform", async () => {
516
+ const announcement = await Announcement.create({
517
+ body: "Body",
518
+ platforms: ["web"],
519
+ primaryAction: {label: "Learn more", url: "https://example.com"},
520
+ publishedAt: DateTime.utc().toJSDate(),
521
+ status: "published",
522
+ title: "Clickable",
523
+ version: 3,
524
+ });
525
+
526
+ const announcementId = announcement._id.toString();
527
+ await userAgent
528
+ .post(`/announcements/${announcementId}/click`)
529
+ .send({action: "primaryAction", platform: "web"})
530
+ .expect(200);
531
+
532
+ const clicks = await AnnouncementClickEvent.find({announcementId: announcement._id});
533
+ assert.lengthOf(clicks, 1);
534
+ assert.strictEqual(clicks[0]?.action, "primaryAction");
535
+ assert.strictEqual(clicks[0]?.version, 3);
536
+ assert.strictEqual(clicks[0]?.platform, "web");
537
+ assert.isOk(clicks[0]?.clickedAt);
538
+ });
539
+
540
+ it("POST click inserts a new row on each repeat click", async () => {
541
+ const announcement = await Announcement.create({
542
+ body: "Body",
543
+ primaryAction: {label: "Go", url: "https://example.com/go"},
544
+ publishedAt: DateTime.utc().toJSDate(),
545
+ status: "published",
546
+ title: "Repeat clicks",
547
+ version: 1,
548
+ });
549
+
550
+ const announcementId = announcement._id.toString();
551
+ await userAgent
552
+ .post(`/announcements/${announcementId}/click`)
553
+ .send({action: "primaryAction"})
554
+ .expect(200);
555
+ await userAgent
556
+ .post(`/announcements/${announcementId}/click`)
557
+ .send({action: "primaryAction"})
558
+ .expect(200);
559
+
560
+ const clicks = await AnnouncementClickEvent.find({announcementId: announcement._id});
561
+ assert.lengthOf(clicks, 2);
562
+ });
563
+
564
+ it("POST click returns 400 when primaryAction is missing or action is unknown", async () => {
565
+ await Announcement.deleteMany({});
566
+ await AnnouncementClickEvent.deleteMany({});
567
+
568
+ const withoutPrimary = await Announcement.create({
569
+ body: "Body",
570
+ publishedAt: DateTime.utc().toJSDate(),
571
+ status: "published",
572
+ title: "No CTA",
573
+ version: 1,
574
+ });
575
+
576
+ await userAgent
577
+ .post(`/announcements/${withoutPrimary._id.toString()}/click`)
578
+ .send({action: "primaryAction"})
579
+ .expect(400);
580
+
581
+ const withPrimary = await Announcement.create({
582
+ body: "Body",
583
+ primaryAction: {label: "Go", url: "https://example.com"},
584
+ publishedAt: DateTime.utc().toJSDate(),
585
+ status: "published",
586
+ title: "Has CTA",
587
+ version: 1,
588
+ });
589
+
590
+ await userAgent
591
+ .post(`/announcements/${withPrimary._id.toString()}/click`)
592
+ .send({action: "secondaryAction"})
593
+ .expect(400);
594
+ });
595
+
596
+ it("POST click returns 404 for non-visible announcements before validating action or CTA", async () => {
597
+ await Announcement.deleteMany({});
598
+ await AnnouncementClickEvent.deleteMany({});
599
+
600
+ const visibilityApp = buildApp({
601
+ isStaff: (user) => (user as {admin?: boolean}).admin === true,
602
+ });
603
+ const patientAgent = await authAsUser(visibilityApp, "notAdmin");
604
+
605
+ const staffOnly = await Announcement.create({
606
+ audienceType: "staff",
607
+ body: "Staff",
608
+ primaryAction: {label: "Go", url: "https://example.com"},
609
+ publishedAt: DateTime.utc().toJSDate(),
610
+ status: "published",
611
+ title: "Staff only",
612
+ version: 1,
613
+ });
614
+
615
+ const invalidActionRes = await patientAgent
616
+ .post(`/announcements/${staffOnly._id.toString()}/click?platform=web`)
617
+ .send({action: "secondaryAction"});
618
+ assert.strictEqual(invalidActionRes.status, 404);
619
+
620
+ const staffOnlyNoCta = await Announcement.create({
621
+ audienceType: "staff",
622
+ body: "Staff no CTA",
623
+ publishedAt: DateTime.utc().toJSDate(),
624
+ status: "published",
625
+ title: "Staff no CTA",
626
+ version: 1,
627
+ });
628
+
629
+ const missingCtaRes = await patientAgent
630
+ .post(`/announcements/${staffOnlyNoCta._id.toString()}/click?platform=web`)
631
+ .send({action: "primaryAction"});
632
+ assert.strictEqual(missingCtaRes.status, 404);
633
+ });
634
+
635
+ it("POST click returns 400 when platform is supplied but invalid", async () => {
636
+ await Announcement.deleteMany({});
637
+ await AnnouncementClickEvent.deleteMany({});
638
+
639
+ const announcement = await Announcement.create({
640
+ body: "Body",
641
+ primaryAction: {label: "Go", url: "https://example.com"},
642
+ publishedAt: DateTime.utc().toJSDate(),
643
+ status: "published",
644
+ title: "Visible",
645
+ version: 1,
646
+ });
647
+
648
+ await userAgent
649
+ .post(`/announcements/${announcement._id.toString()}/click`)
650
+ .send({action: "primaryAction", platform: "desktop"})
651
+ .expect(400);
652
+
653
+ const clicks = await AnnouncementClickEvent.find({announcementId: announcement._id});
654
+ assert.lengthOf(clicks, 0);
655
+ });
656
+
657
+ it("POST click requires authentication", async () => {
658
+ const announcement = await Announcement.create({
659
+ body: "Body",
660
+ primaryAction: {label: "Go", url: "https://example.com"},
661
+ publishedAt: DateTime.utc().toJSDate(),
662
+ status: "published",
663
+ title: "Auth required",
664
+ version: 1,
665
+ });
666
+
667
+ await supertest(app)
668
+ .post(`/announcements/${announcement._id.toString()}/click`)
669
+ .send({action: "primaryAction"})
670
+ .expect(401);
671
+ });
672
+
673
+ it("POST click returns 404 when announcement is not visible to the user", async () => {
674
+ await Announcement.deleteMany({});
675
+ await AnnouncementClickEvent.deleteMany({});
676
+
677
+ const visibilityApp = buildApp({
678
+ isStaff: (user) => (user as {admin?: boolean}).admin === true,
679
+ matchAudience: (_user, announcement) => {
680
+ const audience = announcement as {audience?: {include?: boolean}};
681
+ return audience.audience?.include !== false;
682
+ },
683
+ });
684
+ const staffAgent = await authAsUser(visibilityApp, "admin");
685
+ const patientAgent = await authAsUser(visibilityApp, "notAdmin");
686
+
687
+ const draft = await Announcement.create({
688
+ body: "Draft",
689
+ primaryAction: {label: "Go", url: "https://example.com"},
690
+ status: "draft",
691
+ title: "Draft",
692
+ version: 1,
693
+ });
694
+ await patientAgent
695
+ .post(`/announcements/${draft._id.toString()}/click`)
696
+ .send({action: "primaryAction"})
697
+ .expect(404);
698
+
699
+ const expired = await Announcement.create({
700
+ body: "Expired",
701
+ expiresAt: DateTime.utc().minus({days: 1}).toJSDate(),
702
+ primaryAction: {label: "Go", url: "https://example.com"},
703
+ publishedAt: DateTime.utc().minus({days: 2}).toJSDate(),
704
+ status: "published",
705
+ title: "Expired",
706
+ version: 1,
707
+ });
708
+ await patientAgent
709
+ .post(`/announcements/${expired._id.toString()}/click`)
710
+ .send({action: "primaryAction"})
711
+ .expect(404);
712
+
713
+ const scheduled = await Announcement.create({
714
+ body: "Future",
715
+ primaryAction: {label: "Go", url: "https://example.com"},
716
+ publishAt: DateTime.utc().plus({days: 1}).toJSDate(),
717
+ publishedAt: DateTime.utc().toJSDate(),
718
+ status: "published",
719
+ title: "Scheduled",
720
+ version: 1,
721
+ });
722
+ await patientAgent
723
+ .post(`/announcements/${scheduled._id.toString()}/click`)
724
+ .send({action: "primaryAction"})
725
+ .expect(404);
726
+
727
+ const iosOnly = await Announcement.create({
728
+ body: "iOS",
729
+ platforms: ["ios"],
730
+ primaryAction: {label: "Go", url: "https://example.com"},
731
+ publishedAt: DateTime.utc().toJSDate(),
732
+ status: "published",
733
+ title: "iOS only",
734
+ version: 1,
735
+ });
736
+ await patientAgent
737
+ .post(`/announcements/${iosOnly._id.toString()}/click?platform=web`)
738
+ .send({action: "primaryAction"})
739
+ .expect(404);
740
+
741
+ const gated = await Announcement.create({
742
+ body: "Gated",
743
+ minBuildNumber: 10,
744
+ primaryAction: {label: "Go", url: "https://example.com"},
745
+ publishedAt: DateTime.utc().toJSDate(),
746
+ status: "published",
747
+ title: "Min build",
748
+ version: 1,
749
+ });
750
+ await patientAgent
751
+ .post(`/announcements/${gated._id.toString()}/click?platform=web&version=9`)
752
+ .send({action: "primaryAction"})
753
+ .expect(404);
754
+
755
+ const staffOnly = await Announcement.create({
756
+ audience: {include: true},
757
+ audienceType: "staff",
758
+ body: "Staff",
759
+ primaryAction: {label: "Go", url: "https://example.com"},
760
+ publishedAt: DateTime.utc().toJSDate(),
761
+ status: "published",
762
+ title: "Staff only",
763
+ version: 1,
764
+ });
765
+ await patientAgent
766
+ .post(`/announcements/${staffOnly._id.toString()}/click?platform=web`)
767
+ .send({action: "primaryAction"})
768
+ .expect(404);
769
+
770
+ const excluded = await Announcement.create({
771
+ audience: {include: false},
772
+ audienceType: "all",
773
+ body: "Hidden",
774
+ primaryAction: {label: "Go", url: "https://example.com"},
775
+ publishedAt: DateTime.utc().toJSDate(),
776
+ status: "published",
777
+ title: "Custom excluded",
778
+ version: 1,
779
+ });
780
+ await staffAgent
781
+ .post(`/announcements/${excluded._id.toString()}/click?platform=web`)
782
+ .send({action: "primaryAction"})
783
+ .expect(404);
784
+ });
785
+
786
+ it("GET /config returns defaultAcknowledgementPolicy for admins", async () => {
787
+ const defaultApp = buildApp();
788
+ const defaultAdmin = await authAsUser(defaultApp, "admin");
789
+ const defaultRes = await defaultAdmin.get("/announcements/config").expect(200);
790
+ assert.strictEqual(defaultRes.body.data.defaultAcknowledgementPolicy, "dismiss-only");
791
+
792
+ const requiredApp = buildApp({defaultAcknowledgementPolicy: "required"});
793
+ const requiredAdmin = await authAsUser(requiredApp, "admin");
794
+ const requiredRes = await requiredAdmin.get("/announcements/config").expect(200);
795
+ assert.strictEqual(requiredRes.body.data.defaultAcknowledgementPolicy, "required");
796
+
797
+ const dismissApp = buildApp({defaultAcknowledgementPolicy: "dismiss-only"});
798
+ const dismissAdmin = await authAsUser(dismissApp, "admin");
799
+ const dismissRes = await dismissAdmin.get("/announcements/config").expect(200);
800
+ assert.strictEqual(dismissRes.body.data.defaultAcknowledgementPolicy, "dismiss-only");
801
+ });
802
+
803
+ it("GET /config returns 403 for non-admins", async () => {
804
+ const res = await userAgent.get("/announcements/config").expect(403);
805
+ assert.strictEqual(res.body.title, "Admin access required");
806
+ });
807
+
808
+ it("GET /config requires authentication", async () => {
809
+ await supertest(app).get("/announcements/config").expect(401);
810
+ });
811
+
812
+ it("admin can list announcement click events and non-admins cannot", async () => {
813
+ await Announcement.deleteMany({});
814
+ await AnnouncementClickEvent.deleteMany({});
815
+
816
+ const announcement = await Announcement.create({
817
+ body: "Body",
818
+ primaryAction: {label: "Go", url: "https://example.com"},
819
+ publishedAt: DateTime.utc().toJSDate(),
820
+ status: "published",
821
+ title: "Tracked click",
822
+ version: 2,
823
+ });
824
+
825
+ await userAgent
826
+ .post(`/announcements/${announcement._id.toString()}/click`)
827
+ .send({action: "primaryAction", platform: "web"})
828
+ .expect(200);
829
+
830
+ const listRes = await adminAgent.get("/announcement-click-events").expect(200);
831
+ assert.isAtLeast(listRes.body.data.length, 1);
832
+ const listed = listRes.body.data.find((row: {announcementId: string | {_id: string}}) => {
833
+ const announcementRef = row.announcementId;
834
+ const id =
835
+ typeof announcementRef === "string" ? announcementRef : announcementRef?._id?.toString();
836
+ return id === announcement._id.toString();
837
+ });
838
+ assert.isOk(listed);
839
+ assert.strictEqual(listed.action, "primaryAction");
840
+ assert.strictEqual(listed.version, 2);
841
+
842
+ await userAgent.get("/announcement-click-events").expect(405);
843
+ });
844
+ });