@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,722 @@
1
+ import {
2
+ type AdminContribution,
3
+ APIError,
4
+ asyncHandler,
5
+ authenticateMiddleware,
6
+ createOpenApiBuilder,
7
+ findOneOrNoneFor,
8
+ logger,
9
+ type ModelRouterOptions,
10
+ modelRouter,
11
+ type OpenApiMiddleware,
12
+ type PermissionMethod,
13
+ Permissions,
14
+ type TerrenoPlugin,
15
+ } from "@terreno/api";
16
+ import {type Application, type Request, type Response, Router} from "express";
17
+ import {DateTime} from "luxon";
18
+ import type {Model} from "mongoose";
19
+ import {registerAnnouncementHelpRoutes} from "./helpRoutes";
20
+ import {Announcement, toAnnouncementPublic} from "./models/announcement";
21
+ import {AnnouncementAcknowledgement} from "./models/announcementAcknowledgement";
22
+ import {AnnouncementClickEvent, isValidClickAction} from "./models/announcementClickEvent";
23
+ import {AnnouncementImpression, isValidPlatform} from "./models/announcementImpression";
24
+ import {fetchAnnouncementOverview, parseOverviewPagination} from "./overview";
25
+ import {
26
+ isAnnouncementVisibleNow,
27
+ isAnnouncementVisibleToUser,
28
+ matchAudienceByType,
29
+ matchesPlatform,
30
+ parseQueryVersion,
31
+ passesMinBuildNumber,
32
+ selectPendingAnnouncements,
33
+ } from "./pending";
34
+ import type {
35
+ AcknowledgementPolicy,
36
+ AnnouncementDocument,
37
+ AnnouncementPlatform,
38
+ AnnouncementsOptions,
39
+ } from "./types";
40
+
41
+ const DEFAULT_BASE_PATH = "/announcements";
42
+
43
+ const defaultIsStaff = (user: unknown): boolean => (user as {admin?: boolean}).admin === true;
44
+
45
+ const parsePlatform = (req: Request): AnnouncementPlatform => {
46
+ const queryPlatform = req.query.platform;
47
+ if (isValidPlatform(queryPlatform)) {
48
+ return queryPlatform;
49
+ }
50
+
51
+ const userAgent = req.get("user-agent")?.toLowerCase() ?? "";
52
+ if (userAgent.includes("iphone") || userAgent.includes("ipad")) {
53
+ return "ios";
54
+ }
55
+ if (userAgent.includes("android")) {
56
+ return "android";
57
+ }
58
+ return "web";
59
+ };
60
+
61
+ const requireAdmin = (user: {_id?: unknown; admin?: boolean} | undefined): void => {
62
+ if (!user?.admin) {
63
+ throw new APIError({status: 403, title: "Admin access required"});
64
+ }
65
+ };
66
+
67
+ const requireOverviewAccess = async ({
68
+ permissions,
69
+ user,
70
+ }: {
71
+ permissions: PermissionMethod<AnnouncementDocument>[];
72
+ user: unknown;
73
+ }): Promise<void> => {
74
+ if (permissions.length === 0) {
75
+ throw new APIError({status: 403, title: "Admin access required"});
76
+ }
77
+ for (const permission of permissions) {
78
+ const isAllowed = await permission("list", user as never);
79
+ if (!isAllowed) {
80
+ throw new APIError({status: 403, title: "Admin access required"});
81
+ }
82
+ }
83
+ };
84
+
85
+ const getUserId = (user: {_id?: unknown; id?: string}): string => {
86
+ const userId = user._id ?? user.id;
87
+ if (!userId) {
88
+ throw new APIError({status: 401, title: "Authentication required"});
89
+ }
90
+ return String(userId);
91
+ };
92
+
93
+ const hasPrimaryAction = (announcement: AnnouncementDocument): boolean => {
94
+ const label = announcement.primaryAction?.label?.trim();
95
+ const url = announcement.primaryAction?.url?.trim();
96
+ return Boolean(label && url);
97
+ };
98
+
99
+ const resolveClickPlatform = (req: Request, bodyPlatform: unknown): AnnouncementPlatform => {
100
+ if (bodyPlatform !== undefined && bodyPlatform !== null) {
101
+ if (!isValidPlatform(bodyPlatform)) {
102
+ throw new APIError({status: 400, title: "Invalid platform"});
103
+ }
104
+ return bodyPlatform;
105
+ }
106
+ return parsePlatform(req);
107
+ };
108
+
109
+ const requireVisibleAnnouncement = async ({
110
+ announcement,
111
+ isStaff,
112
+ matchAudience,
113
+ platform,
114
+ queryVersion,
115
+ user,
116
+ }: {
117
+ announcement: AnnouncementDocument | null;
118
+ isStaff: (user: unknown) => boolean;
119
+ matchAudience: (user: unknown, announcement: AnnouncementDocument) => boolean | Promise<boolean>;
120
+ platform: AnnouncementPlatform;
121
+ queryVersion?: number;
122
+ user: unknown;
123
+ }): Promise<AnnouncementDocument> => {
124
+ if (!announcement) {
125
+ throw new APIError({status: 404, title: "Announcement not found"});
126
+ }
127
+
128
+ const visible = await isAnnouncementVisibleToUser({
129
+ announcement,
130
+ isStaff,
131
+ matchAudience,
132
+ platform,
133
+ queryVersion,
134
+ user,
135
+ });
136
+ if (!visible) {
137
+ throw new APIError({status: 404, title: "Announcement not found"});
138
+ }
139
+
140
+ return announcement;
141
+ };
142
+
143
+ export class AnnouncementsApp implements TerrenoPlugin {
144
+ private options: AnnouncementsOptions;
145
+
146
+ constructor(options?: AnnouncementsOptions) {
147
+ this.options = options ?? {};
148
+ }
149
+
150
+ adminContribution(): AdminContribution {
151
+ return {
152
+ customScreens: [
153
+ {
154
+ displayName: "Overview",
155
+ group: "Announcements",
156
+ icon: "bullhorn",
157
+ name: "announcements",
158
+ },
159
+ ],
160
+ models: [
161
+ {
162
+ admin: {
163
+ defaultSort: "-priority,-publishedAt",
164
+ displayName: "All announcements",
165
+ group: "Announcements",
166
+ listDisplayLinks: ["title"],
167
+ listFields: [
168
+ "title",
169
+ "status",
170
+ "priority",
171
+ "version",
172
+ "publishedAt",
173
+ "expiresAt",
174
+ "acknowledgementPolicy",
175
+ ],
176
+ searchFields: ["title"],
177
+ sortableFields: ["title", "status", "priority", "version", "publishedAt", "created"],
178
+ },
179
+ model: Announcement as Model<AnnouncementDocument>,
180
+ routePath: "/announcements",
181
+ },
182
+ {
183
+ admin: {
184
+ adminPermissions: {
185
+ create: [],
186
+ delete: [],
187
+ list: [Permissions.IsAdmin],
188
+ read: [Permissions.IsAdmin],
189
+ update: [],
190
+ },
191
+ defaultSort: "-acknowledgedAt",
192
+ displayName: "Acknowledgements",
193
+ group: "Announcements",
194
+ listFields: ["userId", "announcementId", "version", "acknowledgedAt"],
195
+ },
196
+ model: AnnouncementAcknowledgement as Model<unknown>,
197
+ routePath: "/announcement-acknowledgements",
198
+ },
199
+ {
200
+ admin: {
201
+ adminPermissions: {
202
+ create: [],
203
+ delete: [],
204
+ list: [Permissions.IsAdmin],
205
+ read: [Permissions.IsAdmin],
206
+ update: [],
207
+ },
208
+ defaultSort: "-viewedAt",
209
+ displayName: "Impressions",
210
+ group: "Announcements",
211
+ listFields: ["userId", "announcementId", "version", "viewedAt", "platform"],
212
+ },
213
+ model: AnnouncementImpression as Model<unknown>,
214
+ routePath: "/announcement-impressions",
215
+ },
216
+ {
217
+ admin: {
218
+ adminPermissions: {
219
+ create: [],
220
+ delete: [],
221
+ list: [Permissions.IsAdmin],
222
+ read: [Permissions.IsAdmin],
223
+ update: [],
224
+ },
225
+ defaultSort: "-clickedAt",
226
+ displayName: "Click events",
227
+ group: "Announcements",
228
+ listFields: ["userId", "announcementId", "version", "action", "clickedAt", "platform"],
229
+ },
230
+ model: AnnouncementClickEvent as Model<unknown>,
231
+ routePath: "/announcement-click-events",
232
+ },
233
+ ],
234
+ };
235
+ }
236
+
237
+ register(app: Application, openApi?: unknown): void {
238
+ const basePath = this.options.basePath ?? DEFAULT_BASE_PATH;
239
+ const defaultAcknowledgementPolicy: AcknowledgementPolicy =
240
+ this.options.defaultAcknowledgementPolicy ?? "dismiss-only";
241
+ const overviewPermissions = this.options.adminOverviewPermissions ?? [Permissions.IsAdmin];
242
+ const matchAudience = this.options.matchAudience ?? (() => true);
243
+ const isStaff = this.options.isStaff ?? defaultIsStaff;
244
+
245
+ const matchesAnnouncementAudience = async (
246
+ user: unknown,
247
+ announcement: AnnouncementDocument
248
+ ): Promise<boolean> => {
249
+ if (!matchAudienceByType({announcement, isStaff, user})) {
250
+ return false;
251
+ }
252
+ return matchAudience(user, announcement);
253
+ };
254
+
255
+ const routerOptions: ModelRouterOptions<AnnouncementDocument> = {
256
+ ...(openApi ? {openApi: openApi as OpenApiMiddleware} : {}),
257
+ permissions: {
258
+ create: [Permissions.IsAdmin],
259
+ delete: [Permissions.IsAdmin],
260
+ list: [Permissions.IsAdmin],
261
+ read: [Permissions.IsAdmin],
262
+ update: [Permissions.IsAdmin],
263
+ ...this.options.permissions,
264
+ },
265
+ queryFields: ["status", "priority", "title"],
266
+ sort: "-priority,-publishedAt",
267
+ };
268
+
269
+ const userRouter = Router();
270
+
271
+ userRouter.get(
272
+ "/pending",
273
+ authenticateMiddleware(),
274
+ asyncHandler(async (req: Request, res: Response) => {
275
+ const user = req.user;
276
+ if (!user) {
277
+ throw new APIError({status: 401, title: "Authentication required"});
278
+ }
279
+
280
+ const userId = getUserId(user as {_id?: unknown; id?: string});
281
+ const platform = parsePlatform(req);
282
+ const queryVersion = parseQueryVersion(req.query.version);
283
+ const published = (await Announcement.find({status: "published"}).lean()) as Array<
284
+ AnnouncementDocument & {requiresAcknowledgement?: boolean}
285
+ >;
286
+ const audienceFiltered: AnnouncementDocument[] = [];
287
+ for (const announcement of published) {
288
+ const matches = await matchesAnnouncementAudience(user, announcement);
289
+ if (matches) {
290
+ audienceFiltered.push(announcement);
291
+ }
292
+ }
293
+
294
+ const acknowledgements = await AnnouncementAcknowledgement.find({userId}).select(
295
+ "announcementId version"
296
+ );
297
+ const impressions = await AnnouncementImpression.find({userId}).select(
298
+ "announcementId version"
299
+ );
300
+
301
+ const pending = selectPendingAnnouncements({
302
+ acknowledgements: acknowledgements.map((ack) => ({
303
+ announcementId: ack.announcementId.toString(),
304
+ version: ack.version,
305
+ })),
306
+ announcements: audienceFiltered,
307
+ defaultAcknowledgementPolicy,
308
+ impressions: impressions.map((impression) => ({
309
+ announcementId: impression.announcementId.toString(),
310
+ version: impression.version,
311
+ })),
312
+ platform,
313
+ queryVersion,
314
+ });
315
+
316
+ const current = pending[0]
317
+ ? toAnnouncementPublic(pending[0], defaultAcknowledgementPolicy)
318
+ : null;
319
+ const remainingCount = Math.max(pending.length - (current ? 1 : 0), 0);
320
+
321
+ logger.info("Pending announcements fetched", {
322
+ currentId: current?.id,
323
+ platform,
324
+ remainingCount,
325
+ userId,
326
+ });
327
+
328
+ return res.json({data: {current, remainingCount}});
329
+ })
330
+ );
331
+
332
+ userRouter.get(
333
+ "/feed",
334
+ authenticateMiddleware(),
335
+ asyncHandler(async (req: Request, res: Response) => {
336
+ const user = req.user;
337
+ if (!user) {
338
+ throw new APIError({status: 401, title: "Authentication required"});
339
+ }
340
+
341
+ const platform = parsePlatform(req);
342
+ const queryVersion = parseQueryVersion(req.query.version);
343
+ const limit = Math.min(Math.max(Number(req.query.limit ?? 20), 1), 100);
344
+ const page = Math.max(Number(req.query.page ?? 1), 1);
345
+ const published = (await Announcement.find({status: "published"}).lean()) as Array<
346
+ AnnouncementDocument & {requiresAcknowledgement?: boolean}
347
+ >;
348
+ const visible: AnnouncementDocument[] = [];
349
+
350
+ for (const announcement of published) {
351
+ const matchesAudience = await matchesAnnouncementAudience(user, announcement);
352
+ if (!matchesAudience) {
353
+ continue;
354
+ }
355
+ if (!isAnnouncementVisibleNow({announcement})) {
356
+ continue;
357
+ }
358
+ if (!matchesPlatform({announcement, platform})) {
359
+ continue;
360
+ }
361
+ if (!passesMinBuildNumber({announcement, queryVersion})) {
362
+ continue;
363
+ }
364
+ visible.push(announcement);
365
+ }
366
+
367
+ const sorted = visible.sort((left, right) => {
368
+ if (right.priority !== left.priority) {
369
+ return right.priority - left.priority;
370
+ }
371
+ const rightPublished = right.publishedAt?.getTime() ?? 0;
372
+ const leftPublished = left.publishedAt?.getTime() ?? 0;
373
+ return rightPublished - leftPublished;
374
+ });
375
+
376
+ const start = (page - 1) * limit;
377
+ const pageItems = sorted
378
+ .slice(start, start + limit)
379
+ .map((announcement) => toAnnouncementPublic(announcement, defaultAcknowledgementPolicy));
380
+
381
+ return res.json({
382
+ data: pageItems,
383
+ page,
384
+ total: sorted.length,
385
+ });
386
+ })
387
+ );
388
+
389
+ userRouter.post(
390
+ "/:id/acknowledge",
391
+ authenticateMiddleware(),
392
+ asyncHandler(async (req: Request, res: Response) => {
393
+ const user = req.user;
394
+ if (!user) {
395
+ throw new APIError({status: 401, title: "Authentication required"});
396
+ }
397
+
398
+ const userId = getUserId(user as {_id?: unknown; id?: string});
399
+ const announcement = await requireVisibleAnnouncement({
400
+ announcement: await Announcement.findById(req.params.id),
401
+ isStaff,
402
+ matchAudience,
403
+ platform: parsePlatform(req),
404
+ queryVersion: parseQueryVersion(req.query.version),
405
+ user,
406
+ });
407
+
408
+ const existing = await findOneOrNoneFor(AnnouncementAcknowledgement, {
409
+ announcementId: announcement._id,
410
+ userId,
411
+ version: announcement.version,
412
+ });
413
+
414
+ if (!existing) {
415
+ await AnnouncementAcknowledgement.create({
416
+ acknowledgedAt: DateTime.utc().toJSDate(),
417
+ announcementId: announcement._id,
418
+ userId,
419
+ version: announcement.version,
420
+ });
421
+ }
422
+
423
+ return res.json({data: {acknowledged: true}});
424
+ })
425
+ );
426
+
427
+ userRouter.post(
428
+ "/:id/impression",
429
+ authenticateMiddleware(),
430
+ asyncHandler(async (req: Request, res: Response) => {
431
+ const user = req.user;
432
+ if (!user) {
433
+ throw new APIError({status: 401, title: "Authentication required"});
434
+ }
435
+
436
+ const userId = getUserId(user as {_id?: unknown; id?: string});
437
+ const bodyPlatform = (req.body as {platform?: unknown})?.platform;
438
+ const platform = isValidPlatform(bodyPlatform) ? bodyPlatform : parsePlatform(req);
439
+ const announcement = await requireVisibleAnnouncement({
440
+ announcement: await Announcement.findById(req.params.id),
441
+ isStaff,
442
+ matchAudience,
443
+ platform,
444
+ queryVersion: parseQueryVersion(req.query.version),
445
+ user,
446
+ });
447
+
448
+ await AnnouncementImpression.create({
449
+ announcementId: announcement._id,
450
+ platform,
451
+ userId,
452
+ version: announcement.version,
453
+ viewedAt: DateTime.utc().toJSDate(),
454
+ });
455
+
456
+ return res.json({data: {recorded: true}});
457
+ })
458
+ );
459
+
460
+ userRouter.post(
461
+ "/:id/click",
462
+ authenticateMiddleware(),
463
+ asyncHandler(async (req: Request, res: Response) => {
464
+ const user = req.user;
465
+ if (!user) {
466
+ throw new APIError({status: 401, title: "Authentication required"});
467
+ }
468
+
469
+ const userId = getUserId(user as {_id?: unknown; id?: string});
470
+ const body = req.body as {action?: unknown; platform?: unknown};
471
+ const platform = resolveClickPlatform(req, body.platform);
472
+ const announcement = await requireVisibleAnnouncement({
473
+ announcement: await Announcement.findById(req.params.id),
474
+ isStaff,
475
+ matchAudience,
476
+ platform,
477
+ queryVersion: parseQueryVersion(req.query.version),
478
+ user,
479
+ });
480
+
481
+ if (!isValidClickAction(body.action)) {
482
+ throw new APIError({status: 400, title: "Invalid click action"});
483
+ }
484
+
485
+ if (!hasPrimaryAction(announcement)) {
486
+ throw new APIError({status: 400, title: "Announcement has no primary action"});
487
+ }
488
+
489
+ await AnnouncementClickEvent.create({
490
+ action: body.action,
491
+ announcementId: announcement._id,
492
+ clickedAt: DateTime.utc().toJSDate(),
493
+ platform,
494
+ userId,
495
+ version: announcement.version,
496
+ });
497
+
498
+ return res.json({data: {recorded: true}});
499
+ })
500
+ );
501
+
502
+ const adminRouter = Router();
503
+ const routeOpenApi = openApi ? {openApi: openApi as OpenApiMiddleware} : undefined;
504
+
505
+ adminRouter.get(
506
+ "/config",
507
+ [
508
+ authenticateMiddleware(),
509
+ ...(routeOpenApi
510
+ ? [
511
+ createOpenApiBuilder(routeOpenApi)
512
+ .withTags(["announcements"])
513
+ .withSummary("Get announcement plugin configuration")
514
+ .withResponse(200, {
515
+ data: {
516
+ properties: {
517
+ defaultAcknowledgementPolicy: {
518
+ description: '"required" or "dismiss-only"',
519
+ type: "string",
520
+ },
521
+ },
522
+ type: "object",
523
+ },
524
+ })
525
+ .build(),
526
+ ]
527
+ : []),
528
+ ],
529
+ asyncHandler(async (req: Request, res: Response) => {
530
+ requireAdmin(req.user as {_id?: unknown; admin?: boolean} | undefined);
531
+ return res.json({data: {defaultAcknowledgementPolicy}});
532
+ })
533
+ );
534
+
535
+ adminRouter.get(
536
+ "/overview",
537
+ [
538
+ authenticateMiddleware(),
539
+ ...(routeOpenApi
540
+ ? [
541
+ createOpenApiBuilder(routeOpenApi)
542
+ .withTags(["announcements"])
543
+ .withSummary("Admin overview of announcements with aggregate metrics")
544
+ .withQueryParameter("page", {type: "number"}, {required: false})
545
+ .withQueryParameter("limit", {type: "number"}, {required: false})
546
+ .withResponse(200, {
547
+ data: {
548
+ items: {
549
+ properties: {
550
+ _id: {type: "string"},
551
+ acknowledgementPolicy: {type: "string"},
552
+ audienceType: {type: "string"},
553
+ displayMode: {type: "string"},
554
+ expiresAt: {type: "string"},
555
+ metrics: {
556
+ properties: {
557
+ acknowledgements: {type: "number"},
558
+ clicks: {type: "number"},
559
+ impressions: {type: "number"},
560
+ },
561
+ type: "object",
562
+ },
563
+ priority: {type: "number"},
564
+ publishedAt: {type: "string"},
565
+ status: {type: "string"},
566
+ title: {type: "string"},
567
+ version: {type: "number"},
568
+ },
569
+ type: "object",
570
+ },
571
+ type: "array",
572
+ },
573
+ limit: {type: "number"},
574
+ more: {type: "boolean"},
575
+ page: {type: "number"},
576
+ total: {type: "number"},
577
+ totals: {
578
+ properties: {
579
+ acknowledgements: {type: "number"},
580
+ announcements: {type: "number"},
581
+ archived: {type: "number"},
582
+ clicks: {type: "number"},
583
+ draft: {type: "number"},
584
+ impressions: {type: "number"},
585
+ published: {type: "number"},
586
+ },
587
+ type: "object",
588
+ },
589
+ })
590
+ .build(),
591
+ ]
592
+ : []),
593
+ ],
594
+ asyncHandler(async (req: Request, res: Response) => {
595
+ await requireOverviewAccess({permissions: overviewPermissions, user: req.user});
596
+ const {limit, page} = parseOverviewPagination(req.query);
597
+ const overview = await fetchAnnouncementOverview({
598
+ defaultAcknowledgementPolicy,
599
+ limit,
600
+ page,
601
+ });
602
+ return res.json(overview);
603
+ })
604
+ );
605
+
606
+ adminRouter.post(
607
+ "/:id/publish",
608
+ authenticateMiddleware(),
609
+ asyncHandler(async (req: Request, res: Response) => {
610
+ requireAdmin(req.user as {_id?: unknown; admin?: boolean} | undefined);
611
+ const announcement = await Announcement.findById(req.params.id);
612
+ if (!announcement) {
613
+ throw new APIError({status: 404, title: "Announcement not found"});
614
+ }
615
+ if (announcement.status !== "draft") {
616
+ throw new APIError({
617
+ status: 400,
618
+ title: "Only draft announcements can be published",
619
+ });
620
+ }
621
+
622
+ announcement.status = "published";
623
+ announcement.publishedAt = DateTime.utc().toJSDate();
624
+ await announcement.save();
625
+
626
+ logger.info("Announcement published", {announcementId: announcement._id.toString()});
627
+ return res.json({data: announcement});
628
+ })
629
+ );
630
+
631
+ adminRouter.post(
632
+ "/:id/archive",
633
+ authenticateMiddleware(),
634
+ asyncHandler(async (req: Request, res: Response) => {
635
+ requireAdmin(req.user as {_id?: unknown; admin?: boolean} | undefined);
636
+ const announcement = await Announcement.findById(req.params.id);
637
+ if (!announcement) {
638
+ throw new APIError({status: 404, title: "Announcement not found"});
639
+ }
640
+ if (announcement.status !== "published") {
641
+ throw new APIError({
642
+ status: 400,
643
+ title: "Only published announcements can be archived",
644
+ });
645
+ }
646
+
647
+ announcement.status = "archived";
648
+ announcement.archivedAt = DateTime.utc().toJSDate();
649
+ await announcement.save();
650
+
651
+ logger.info("Announcement archived", {announcementId: announcement._id.toString()});
652
+ return res.json({data: announcement});
653
+ })
654
+ );
655
+
656
+ app.use(basePath, userRouter);
657
+ app.use(basePath, adminRouter);
658
+ if (this.options.help?.enabled) {
659
+ registerAnnouncementHelpRoutes({
660
+ app,
661
+ basePath,
662
+ defaultAcknowledgementPolicy,
663
+ isStaff,
664
+ matchAudience,
665
+ });
666
+ }
667
+ app.use(basePath, modelRouter(Announcement as Model<AnnouncementDocument>, routerOptions));
668
+
669
+ app.use(
670
+ "/announcement-acknowledgements",
671
+ modelRouter(AnnouncementAcknowledgement as Model<unknown>, {
672
+ permissions: {
673
+ create: [],
674
+ delete: [],
675
+ list: [Permissions.IsAdmin],
676
+ read: [Permissions.IsAdmin],
677
+ update: [],
678
+ },
679
+ populatePaths: [
680
+ {fields: ["title", "version", "status"], path: "announcementId"},
681
+ {fields: ["email", "name"], path: "userId"},
682
+ ],
683
+ })
684
+ );
685
+
686
+ app.use(
687
+ "/announcement-impressions",
688
+ modelRouter(AnnouncementImpression as Model<unknown>, {
689
+ permissions: {
690
+ create: [],
691
+ delete: [],
692
+ list: [Permissions.IsAdmin],
693
+ read: [Permissions.IsAdmin],
694
+ update: [],
695
+ },
696
+ populatePaths: [
697
+ {fields: ["title", "version", "status"], path: "announcementId"},
698
+ {fields: ["email", "name"], path: "userId"},
699
+ ],
700
+ })
701
+ );
702
+
703
+ app.use(
704
+ "/announcement-click-events",
705
+ modelRouter(AnnouncementClickEvent as Model<unknown>, {
706
+ permissions: {
707
+ create: [],
708
+ delete: [],
709
+ list: [Permissions.IsAdmin],
710
+ read: [Permissions.IsAdmin],
711
+ update: [],
712
+ },
713
+ populatePaths: [
714
+ {fields: ["title", "version", "status"], path: "announcementId"},
715
+ {fields: ["email", "name"], path: "userId"},
716
+ ],
717
+ })
718
+ );
719
+
720
+ logger.info("AnnouncementsApp registered", {basePath, defaultAcknowledgementPolicy});
721
+ }
722
+ }