@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,228 @@
1
+ import type {PipelineStage, Types} from "mongoose";
2
+ import {Announcement} from "./models/announcement";
3
+ import {AnnouncementAcknowledgement} from "./models/announcementAcknowledgement";
4
+ import {AnnouncementClickEvent} from "./models/announcementClickEvent";
5
+ import {AnnouncementImpression} from "./models/announcementImpression";
6
+ import {resolveAcknowledgementPolicy, resolveAudienceType, resolveDisplayMode} from "./pending";
7
+ import type {
8
+ AcknowledgementPolicy,
9
+ AnnouncementDocument,
10
+ AnnouncementOverviewMetrics,
11
+ AnnouncementOverviewResponse,
12
+ AnnouncementOverviewRow,
13
+ AnnouncementOverviewTotals,
14
+ } from "./types";
15
+
16
+ const NOT_DELETED = {deleted: {$ne: true}} as const;
17
+
18
+ export interface OverviewPagination {
19
+ limit: number;
20
+ page: number;
21
+ }
22
+
23
+ export const parseOverviewPagination = (query: {
24
+ limit?: unknown;
25
+ page?: unknown;
26
+ }): OverviewPagination => {
27
+ const parsedLimit = Number(query.limit ?? 20);
28
+ const parsedPage = Number(query.page ?? 1);
29
+ const limit = Number.isFinite(parsedLimit)
30
+ ? Math.min(Math.max(Math.trunc(parsedLimit), 1), 100)
31
+ : 20;
32
+ const page = Number.isFinite(parsedPage) ? Math.max(Math.trunc(parsedPage), 1) : 1;
33
+ return {limit, page};
34
+ };
35
+
36
+ interface MetricCountRow {
37
+ _id: Types.ObjectId;
38
+ count: number;
39
+ }
40
+
41
+ const countEventTotals = async (): Promise<{
42
+ acknowledgements: number;
43
+ clicks: number;
44
+ impressions: number;
45
+ }> => {
46
+ const [impressionRows, acknowledgementRows, clickRows] = await Promise.all([
47
+ AnnouncementImpression.aggregate<{count: number}>([{$match: NOT_DELETED}, {$count: "count"}]),
48
+ AnnouncementAcknowledgement.aggregate<{count: number}>([
49
+ {$match: NOT_DELETED},
50
+ {$count: "count"},
51
+ ]),
52
+ AnnouncementClickEvent.aggregate<{count: number}>([{$match: NOT_DELETED}, {$count: "count"}]),
53
+ ]);
54
+
55
+ return {
56
+ acknowledgements: acknowledgementRows[0]?.count ?? 0,
57
+ clicks: clickRows[0]?.count ?? 0,
58
+ impressions: impressionRows[0]?.count ?? 0,
59
+ };
60
+ };
61
+
62
+ const countMetricsByAnnouncement = async (
63
+ announcementIds: Types.ObjectId[]
64
+ ): Promise<Map<string, AnnouncementOverviewMetrics>> => {
65
+ const metrics = new Map<string, AnnouncementOverviewMetrics>();
66
+ for (const id of announcementIds) {
67
+ metrics.set(id.toString(), {acknowledgements: 0, clicks: 0, impressions: 0});
68
+ }
69
+
70
+ if (announcementIds.length === 0) {
71
+ return metrics;
72
+ }
73
+
74
+ const matchByAnnouncement: PipelineStage.Match = {
75
+ $match: {
76
+ ...NOT_DELETED,
77
+ announcementId: {$in: announcementIds},
78
+ },
79
+ };
80
+
81
+ const [impressionCounts, acknowledgementCounts, clickCounts] = await Promise.all([
82
+ AnnouncementImpression.aggregate<MetricCountRow>([
83
+ matchByAnnouncement,
84
+ {$group: {_id: "$announcementId", count: {$sum: 1}}},
85
+ ]),
86
+ AnnouncementAcknowledgement.aggregate<MetricCountRow>([
87
+ matchByAnnouncement,
88
+ {$group: {_id: "$announcementId", count: {$sum: 1}}},
89
+ ]),
90
+ AnnouncementClickEvent.aggregate<MetricCountRow>([
91
+ matchByAnnouncement,
92
+ {$group: {_id: "$announcementId", count: {$sum: 1}}},
93
+ ]),
94
+ ]);
95
+
96
+ for (const row of impressionCounts) {
97
+ const existing = metrics.get(row._id.toString()) ?? {
98
+ acknowledgements: 0,
99
+ clicks: 0,
100
+ impressions: 0,
101
+ };
102
+ existing.impressions = row.count;
103
+ metrics.set(row._id.toString(), existing);
104
+ }
105
+
106
+ for (const row of acknowledgementCounts) {
107
+ const existing = metrics.get(row._id.toString()) ?? {
108
+ acknowledgements: 0,
109
+ clicks: 0,
110
+ impressions: 0,
111
+ };
112
+ existing.acknowledgements = row.count;
113
+ metrics.set(row._id.toString(), existing);
114
+ }
115
+
116
+ for (const row of clickCounts) {
117
+ const existing = metrics.get(row._id.toString()) ?? {
118
+ acknowledgements: 0,
119
+ clicks: 0,
120
+ impressions: 0,
121
+ };
122
+ existing.clicks = row.count;
123
+ metrics.set(row._id.toString(), existing);
124
+ }
125
+
126
+ return metrics;
127
+ };
128
+
129
+ const toOverviewRow = ({
130
+ announcement,
131
+ defaultAcknowledgementPolicy,
132
+ metrics,
133
+ }: {
134
+ announcement: AnnouncementDocument;
135
+ defaultAcknowledgementPolicy: AcknowledgementPolicy;
136
+ metrics: AnnouncementOverviewMetrics;
137
+ }): AnnouncementOverviewRow => ({
138
+ _id: announcement._id.toString(),
139
+ acknowledgementPolicy: resolveAcknowledgementPolicy({
140
+ announcement,
141
+ defaultAcknowledgementPolicy,
142
+ }),
143
+ audienceType: resolveAudienceType(announcement),
144
+ displayMode: resolveDisplayMode(announcement),
145
+ expiresAt: announcement.expiresAt?.toISOString(),
146
+ metrics,
147
+ priority: announcement.priority,
148
+ publishedAt: announcement.publishedAt?.toISOString(),
149
+ status: announcement.status,
150
+ title: announcement.title,
151
+ version: announcement.version,
152
+ });
153
+
154
+ export const fetchAnnouncementOverview = async ({
155
+ defaultAcknowledgementPolicy,
156
+ limit,
157
+ page,
158
+ }: {
159
+ defaultAcknowledgementPolicy: AcknowledgementPolicy;
160
+ limit: number;
161
+ page: number;
162
+ }): Promise<AnnouncementOverviewResponse> => {
163
+ const skip = (page - 1) * limit;
164
+
165
+ const [facetResult, eventTotals] = await Promise.all([
166
+ Announcement.aggregate<{
167
+ paginated: AnnouncementDocument[];
168
+ statusCounts: Array<{_id: AnnouncementDocument["status"]; count: number}>;
169
+ totalCount: Array<{count: number}>;
170
+ }>([
171
+ {$match: NOT_DELETED},
172
+ {
173
+ $facet: {
174
+ paginated: [
175
+ // biome-ignore assist/source/useSortedKeys: MongoDB applies sort keys in insertion order
176
+ {$sort: {priority: -1, publishedAt: -1, _id: -1}},
177
+ {$skip: skip},
178
+ {$limit: limit},
179
+ ],
180
+ statusCounts: [{$group: {_id: "$status", count: {$sum: 1}}}],
181
+ totalCount: [{$count: "count"}],
182
+ },
183
+ },
184
+ ]),
185
+ countEventTotals(),
186
+ ]);
187
+
188
+ const facet = facetResult[0];
189
+ const paginated = facet?.paginated ?? [];
190
+ const total = facet?.totalCount[0]?.count ?? 0;
191
+ const statusCounts = facet?.statusCounts ?? [];
192
+
193
+ const statusCountMap = new Map(statusCounts.map((row) => [row._id, row.count]));
194
+ const totals: AnnouncementOverviewTotals = {
195
+ acknowledgements: eventTotals.acknowledgements,
196
+ announcements: total,
197
+ archived: statusCountMap.get("archived") ?? 0,
198
+ clicks: eventTotals.clicks,
199
+ draft: statusCountMap.get("draft") ?? 0,
200
+ impressions: eventTotals.impressions,
201
+ published: statusCountMap.get("published") ?? 0,
202
+ };
203
+
204
+ const metricsByAnnouncement = await countMetricsByAnnouncement(
205
+ paginated.map((announcement) => announcement._id)
206
+ );
207
+
208
+ const data = paginated.map((announcement) =>
209
+ toOverviewRow({
210
+ announcement,
211
+ defaultAcknowledgementPolicy,
212
+ metrics: metricsByAnnouncement.get(announcement._id.toString()) ?? {
213
+ acknowledgements: 0,
214
+ clicks: 0,
215
+ impressions: 0,
216
+ },
217
+ })
218
+ );
219
+
220
+ return {
221
+ data,
222
+ limit,
223
+ more: skip + data.length < total,
224
+ page,
225
+ total,
226
+ totals,
227
+ };
228
+ };
package/src/pending.ts ADDED
@@ -0,0 +1,286 @@
1
+ import {DateTime} from "luxon";
2
+ import type {
3
+ AcknowledgementPolicy,
4
+ AnnouncementAudienceType,
5
+ AnnouncementDisplayMode,
6
+ AnnouncementDocument,
7
+ AnnouncementPlatform,
8
+ } from "./types";
9
+
10
+ export interface AnnouncementAckState {
11
+ announcementId: string;
12
+ version: number;
13
+ }
14
+
15
+ export interface AnnouncementImpressionState {
16
+ announcementId: string;
17
+ version: number;
18
+ }
19
+
20
+ type AnnouncementWithLegacyAck = AnnouncementDocument & {
21
+ requiresAcknowledgement?: boolean;
22
+ };
23
+
24
+ export const resolveAcknowledgementPolicy = ({
25
+ announcement,
26
+ defaultAcknowledgementPolicy = "dismiss-only",
27
+ }: {
28
+ announcement: AnnouncementDocument;
29
+ defaultAcknowledgementPolicy?: AcknowledgementPolicy;
30
+ }): AcknowledgementPolicy => {
31
+ if (announcement.acknowledgementPolicy) {
32
+ return announcement.acknowledgementPolicy;
33
+ }
34
+
35
+ const legacyRequiresAck = (announcement as AnnouncementWithLegacyAck).requiresAcknowledgement;
36
+ if (legacyRequiresAck === true) {
37
+ return "required";
38
+ }
39
+
40
+ return defaultAcknowledgementPolicy;
41
+ };
42
+
43
+ export const requiresAcknowledgementForAnnouncement = ({
44
+ announcement,
45
+ defaultAcknowledgementPolicy = "dismiss-only",
46
+ }: {
47
+ announcement: AnnouncementDocument;
48
+ defaultAcknowledgementPolicy?: AcknowledgementPolicy;
49
+ }): boolean => {
50
+ const policy = resolveAcknowledgementPolicy({announcement, defaultAcknowledgementPolicy});
51
+ return policy === "required";
52
+ };
53
+
54
+ export const resolveDisplayMode = (announcement: AnnouncementDocument): AnnouncementDisplayMode => {
55
+ const mode = announcement.displayMode;
56
+ if (mode === "modal" || mode === "banner" || mode === "feed") {
57
+ return mode;
58
+ }
59
+ return "modal";
60
+ };
61
+
62
+ export const resolveAudienceType = (
63
+ announcement: AnnouncementDocument
64
+ ): AnnouncementAudienceType => {
65
+ const audienceType = announcement.audienceType;
66
+ if (audienceType === "staff" || audienceType === "patient" || audienceType === "all") {
67
+ return audienceType;
68
+ }
69
+ return "all";
70
+ };
71
+
72
+ export const matchAudienceByType = ({
73
+ announcement,
74
+ isStaff,
75
+ user,
76
+ }: {
77
+ announcement: AnnouncementDocument;
78
+ isStaff: (user: unknown) => boolean;
79
+ user: unknown;
80
+ }): boolean => {
81
+ const audienceType = resolveAudienceType(announcement);
82
+ if (audienceType === "all") {
83
+ return true;
84
+ }
85
+
86
+ const staff = isStaff(user);
87
+ if (audienceType === "staff") {
88
+ return staff;
89
+ }
90
+
91
+ return !staff;
92
+ };
93
+
94
+ export const parseQueryVersion = (raw: unknown): number | undefined => {
95
+ if (raw === undefined || raw === null || raw === "") {
96
+ return undefined;
97
+ }
98
+
99
+ const parsed = Number(raw);
100
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
101
+ return undefined;
102
+ }
103
+
104
+ return parsed;
105
+ };
106
+
107
+ export const passesMinBuildNumber = ({
108
+ announcement,
109
+ queryVersion,
110
+ }: {
111
+ announcement: AnnouncementDocument;
112
+ queryVersion?: number;
113
+ }): boolean => {
114
+ const minBuildNumber = announcement.minBuildNumber;
115
+ if (minBuildNumber === undefined || minBuildNumber === null || minBuildNumber === 0) {
116
+ return true;
117
+ }
118
+
119
+ if (queryVersion === undefined) {
120
+ return true;
121
+ }
122
+
123
+ return queryVersion >= minBuildNumber;
124
+ };
125
+
126
+ export const isInterruptDisplayMode = (announcement: AnnouncementDocument): boolean => {
127
+ const displayMode = resolveDisplayMode(announcement);
128
+ return displayMode === "modal" || displayMode === "banner";
129
+ };
130
+
131
+ export const isAnnouncementVisibleNow = ({
132
+ announcement,
133
+ now = DateTime.utc(),
134
+ }: {
135
+ announcement: AnnouncementDocument;
136
+ now?: DateTime;
137
+ }): boolean => {
138
+ if (announcement.status !== "published") {
139
+ return false;
140
+ }
141
+
142
+ if (announcement.publishAt) {
143
+ const publishAt = DateTime.fromJSDate(announcement.publishAt);
144
+ if (publishAt > now) {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ if (announcement.expiresAt) {
150
+ const expiresAt = DateTime.fromJSDate(announcement.expiresAt);
151
+ if (expiresAt <= now) {
152
+ return false;
153
+ }
154
+ }
155
+
156
+ return true;
157
+ };
158
+
159
+ export const matchesPlatform = ({
160
+ announcement,
161
+ platform,
162
+ }: {
163
+ announcement: AnnouncementDocument;
164
+ platform: AnnouncementPlatform;
165
+ }): boolean => {
166
+ const platforms = announcement.platforms?.length
167
+ ? announcement.platforms
168
+ : (["ios", "android", "web"] as AnnouncementPlatform[]);
169
+ return platforms.includes(platform);
170
+ };
171
+
172
+ export const isAnnouncementPendingForUser = ({
173
+ acknowledgements,
174
+ announcement,
175
+ defaultAcknowledgementPolicy = "dismiss-only",
176
+ impressions,
177
+ }: {
178
+ acknowledgements: AnnouncementAckState[];
179
+ announcement: AnnouncementDocument;
180
+ defaultAcknowledgementPolicy?: AcknowledgementPolicy;
181
+ impressions: AnnouncementImpressionState[];
182
+ }): boolean => {
183
+ const announcementId = announcement._id.toString();
184
+ const needsAck = requiresAcknowledgementForAnnouncement({
185
+ announcement,
186
+ defaultAcknowledgementPolicy,
187
+ });
188
+
189
+ if (needsAck) {
190
+ const hasAck = acknowledgements.some(
191
+ (ack) => ack.announcementId === announcementId && ack.version === announcement.version
192
+ );
193
+ return !hasAck;
194
+ }
195
+
196
+ const hasImpression = impressions.some(
197
+ (impression) =>
198
+ impression.announcementId === announcementId && impression.version === announcement.version
199
+ );
200
+ return !hasImpression;
201
+ };
202
+
203
+ export const sortAnnouncementsForQueue = (
204
+ announcements: AnnouncementDocument[]
205
+ ): AnnouncementDocument[] =>
206
+ [...announcements].sort((left, right) => {
207
+ if (right.priority !== left.priority) {
208
+ return right.priority - left.priority;
209
+ }
210
+ const rightPublished = right.publishedAt?.getTime() ?? 0;
211
+ const leftPublished = left.publishedAt?.getTime() ?? 0;
212
+ return rightPublished - leftPublished;
213
+ });
214
+
215
+ export const selectPendingAnnouncements = ({
216
+ acknowledgements,
217
+ announcements,
218
+ defaultAcknowledgementPolicy = "dismiss-only",
219
+ impressions,
220
+ now,
221
+ platform,
222
+ queryVersion,
223
+ }: {
224
+ acknowledgements: AnnouncementAckState[];
225
+ announcements: AnnouncementDocument[];
226
+ defaultAcknowledgementPolicy?: AcknowledgementPolicy;
227
+ impressions: AnnouncementImpressionState[];
228
+ now?: DateTime;
229
+ platform: AnnouncementPlatform;
230
+ queryVersion?: number;
231
+ }): AnnouncementDocument[] => {
232
+ const visible = announcements.filter((announcement) => {
233
+ if (!isAnnouncementVisibleNow({announcement, now})) {
234
+ return false;
235
+ }
236
+ if (!matchesPlatform({announcement, platform})) {
237
+ return false;
238
+ }
239
+ if (!isInterruptDisplayMode(announcement)) {
240
+ return false;
241
+ }
242
+ if (!passesMinBuildNumber({announcement, queryVersion})) {
243
+ return false;
244
+ }
245
+ return isAnnouncementPendingForUser({
246
+ acknowledgements,
247
+ announcement,
248
+ defaultAcknowledgementPolicy,
249
+ impressions,
250
+ });
251
+ });
252
+
253
+ return sortAnnouncementsForQueue(visible);
254
+ };
255
+
256
+ export const isAnnouncementVisibleToUser = async ({
257
+ announcement,
258
+ isStaff,
259
+ matchAudience,
260
+ now,
261
+ platform,
262
+ queryVersion,
263
+ user,
264
+ }: {
265
+ announcement: AnnouncementDocument;
266
+ isStaff: (user: unknown) => boolean;
267
+ matchAudience: (user: unknown, announcement: AnnouncementDocument) => boolean | Promise<boolean>;
268
+ now?: DateTime;
269
+ platform: AnnouncementPlatform;
270
+ queryVersion?: number;
271
+ user: unknown;
272
+ }): Promise<boolean> => {
273
+ if (!isAnnouncementVisibleNow({announcement, now})) {
274
+ return false;
275
+ }
276
+ if (!matchesPlatform({announcement, platform})) {
277
+ return false;
278
+ }
279
+ if (!passesMinBuildNumber({announcement, queryVersion})) {
280
+ return false;
281
+ }
282
+ if (!matchAudienceByType({announcement, isStaff, user})) {
283
+ return false;
284
+ }
285
+ return matchAudience(user, announcement);
286
+ };