@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,9 @@
1
+ import {registerSimpleMongoPreload} from "@terreno/test";
2
+
3
+ process.env.TERRENO_TEST_USE_MEMORY_MONGO = "true";
4
+
5
+ registerSimpleMongoPreload({
6
+ testEnv: {
7
+ tokenIssuer: "terreno-announcements.test",
8
+ },
9
+ });
@@ -0,0 +1,29 @@
1
+ import {describe, expect, it} from "bun:test";
2
+ import {excerptBody, matchesHelpQueries, toHelpSummary} from "../help";
3
+
4
+ describe("announcement help helpers", () => {
5
+ it("matches any active query against title or body", () => {
6
+ const doc = {body: "Markdown body about syncdb", title: "Welcome"};
7
+ expect(matchesHelpQueries(doc, ["syncdb"])).toBe(true);
8
+ expect(matchesHelpQueries(doc, ["missing"])).toBe(false);
9
+ expect(matchesHelpQueries(doc, [])).toBe(true);
10
+ });
11
+
12
+ it("builds excerpts and summaries", () => {
13
+ const summary = toHelpSummary({
14
+ _id: "507f1f77bcf86cd799439011",
15
+ acknowledgementPolicy: "dismiss-only",
16
+ body: "Line one\n\nLine two",
17
+ created: new Date(),
18
+ platforms: ["web"],
19
+ priority: 1,
20
+ status: "published",
21
+ title: "Hello",
22
+ updated: new Date(),
23
+ version: 2,
24
+ } as never);
25
+ expect(summary.excerpt).toBe(excerptBody("Line one\n\nLine two"));
26
+ expect(summary.title).toBe("Hello");
27
+ expect(summary.version).toBe(2);
28
+ });
29
+ });
@@ -0,0 +1,291 @@
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 {registerAnnouncementHelpRoutes} from "../helpRoutes";
17
+ import {Announcement} from "../models/announcement";
18
+
19
+ const buildApp = (options?: {
20
+ isStaff?: (user: unknown) => boolean;
21
+ matchAudience?: (user: unknown, announcement: unknown) => boolean;
22
+ }): express.Application => {
23
+ const app = getBaseServer();
24
+ setupAuth(app, UserModel as unknown as UserModelType);
25
+ addAuthRoutes(app, UserModel as unknown as UserModelType);
26
+ new AnnouncementsApp({help: {enabled: true}, ...options}).register(app);
27
+ app.use(apiUnauthorizedMiddleware);
28
+ app.use(apiErrorMiddleware);
29
+ return app;
30
+ };
31
+
32
+ describe("announcement help routes", () => {
33
+ let userAgent: TestAgent;
34
+ let publishedId: string;
35
+ let archivedId: string;
36
+
37
+ beforeEach(async () => {
38
+ await setupDb();
39
+ await Announcement.deleteMany({});
40
+ const app = buildApp();
41
+ userAgent = await authAsUser(app, "notAdmin");
42
+
43
+ const published = await Announcement.create({
44
+ body: "Published body about billing",
45
+ publishedAt: DateTime.utc().toJSDate(),
46
+ status: "published",
47
+ title: "Billing update",
48
+ });
49
+ publishedId = published._id.toString();
50
+
51
+ const archived = await Announcement.create({
52
+ archivedAt: DateTime.utc().toJSDate(),
53
+ body: "Archived migration guide",
54
+ publishedAt: DateTime.utc().minus({days: 30}).toJSDate(),
55
+ status: "archived",
56
+ title: "Legacy migration",
57
+ });
58
+ archivedId = archived._id.toString();
59
+ });
60
+
61
+ afterEach(async () => {
62
+ await Announcement.deleteMany({});
63
+ });
64
+
65
+ it("searches published notes by default", async () => {
66
+ const res = await userAgent.get("/announcements/help/search?q=billing").expect(200);
67
+ expect(res.body.data).toHaveLength(1);
68
+ expect(res.body.data[0].id).toBe(publishedId);
69
+ });
70
+
71
+ it("includes archived notes when requested", async () => {
72
+ const res = await userAgent
73
+ .get("/announcements/help/search?includeArchived=true&q=migration")
74
+ .expect(200);
75
+ expect(res.body.data).toHaveLength(1);
76
+ expect(res.body.data[0].id).toBe(archivedId);
77
+ expect(res.body.data[0].status).toBe("archived");
78
+ });
79
+
80
+ it("returns the full update note by id", async () => {
81
+ const res = await userAgent
82
+ .get(`/announcements/help/${archivedId}?includeArchived=true`)
83
+ .expect(200);
84
+ expect(res.body.data.title).toBe("Legacy migration");
85
+ expect(res.body.data.body).toContain("Archived migration guide");
86
+ expect(res.body.data.status).toBe("archived");
87
+ });
88
+
89
+ it("returns 404 for missing or draft notes", async () => {
90
+ const draft = await Announcement.create({
91
+ body: "Draft only",
92
+ status: "draft",
93
+ title: "Draft",
94
+ });
95
+ await userAgent.get(`/announcements/help/${draft._id.toString()}`).expect(404);
96
+ await userAgent.get("/announcements/help/000000000000000000000000").expect(404);
97
+ });
98
+
99
+ it("hides scheduled announcements until publishAt", async () => {
100
+ const scheduled = await Announcement.create({
101
+ body: "Future release notes",
102
+ publishAt: DateTime.utc().plus({days: 2}).toJSDate(),
103
+ publishedAt: DateTime.utc().toJSDate(),
104
+ status: "published",
105
+ title: "Future billing update",
106
+ });
107
+
108
+ const res = await userAgent.get("/announcements/help/search?q=future").expect(200);
109
+ expect(res.body.data).toHaveLength(0);
110
+
111
+ const detail = await userAgent
112
+ .get(`/announcements/help/${scheduled._id.toString()}`)
113
+ .expect(404);
114
+ expect(detail.body.title).toBeDefined();
115
+ });
116
+
117
+ it("supports array query params, includeArchived=1, and limit", async () => {
118
+ const res = await userAgent
119
+ .get("/announcements/help/search?includeArchived=1&limit=1&q=billing&q=legacy")
120
+ .expect(200);
121
+ expect(res.body.data.length).toBeLessThanOrEqual(1);
122
+ expect(res.body.total).toBeGreaterThanOrEqual(1);
123
+ });
124
+
125
+ it("hides help results below minBuildNumber when version is present", async () => {
126
+ await Announcement.deleteMany({});
127
+
128
+ await Announcement.create({
129
+ body: "Visible billing notes",
130
+ minBuildNumber: 10,
131
+ publishedAt: DateTime.utc().toJSDate(),
132
+ status: "published",
133
+ title: "Billing v10",
134
+ });
135
+ await Announcement.create({
136
+ body: "Future billing notes",
137
+ minBuildNumber: 20,
138
+ publishedAt: DateTime.utc().toJSDate(),
139
+ status: "published",
140
+ title: "Billing v20",
141
+ });
142
+
143
+ const hidden = await userAgent
144
+ .get("/announcements/help/search?q=billing&version=9")
145
+ .expect(200);
146
+ assert.lengthOf(hidden.body.data, 0);
147
+
148
+ const visible = await userAgent
149
+ .get("/announcements/help/search?q=billing&version=10")
150
+ .expect(200);
151
+ assert.lengthOf(visible.body.data, 1);
152
+ assert.strictEqual(visible.body.data[0].title, "Billing v10");
153
+
154
+ const omitted = await userAgent.get("/announcements/help/search?q=billing").expect(200);
155
+ assert.lengthOf(omitted.body.data, 2);
156
+ });
157
+
158
+ it("returns 401 for unauthenticated help search and detail", async () => {
159
+ const app = buildApp();
160
+ await supertest(app).get("/announcements/help/search?q=billing").expect(401);
161
+ await supertest(app).get(`/announcements/help/${publishedId}`).expect(401);
162
+ });
163
+
164
+ it("returns 404 for help detail when version is below minBuildNumber", async () => {
165
+ const gated = await Announcement.create({
166
+ body: "Gated release notes",
167
+ minBuildNumber: 25,
168
+ publishedAt: DateTime.utc().toJSDate(),
169
+ status: "published",
170
+ title: "Build 25 feature",
171
+ });
172
+
173
+ await userAgent.get(`/announcements/help/${gated._id.toString()}?version=24`).expect(404);
174
+ await userAgent.get(`/announcements/help/${gated._id.toString()}?version=25`).expect(200);
175
+ });
176
+
177
+ it("hides help results that fail audienceType or matchAudience", async () => {
178
+ await Announcement.deleteMany({});
179
+
180
+ const audienceApp = buildApp({
181
+ isStaff: (user) => (user as {admin?: boolean}).admin === true,
182
+ matchAudience: (_user, announcement) => {
183
+ const audience = announcement as {audience?: {include?: boolean}};
184
+ return audience.audience?.include !== false;
185
+ },
186
+ });
187
+ const patientAgent = await authAsUser(audienceApp, "notAdmin");
188
+
189
+ const staffOnly = await Announcement.create({
190
+ audience: {include: true},
191
+ audienceType: "staff",
192
+ body: "Staff billing notes",
193
+ publishedAt: DateTime.utc().toJSDate(),
194
+ status: "published",
195
+ title: "Staff billing",
196
+ });
197
+ await Announcement.create({
198
+ audience: {include: false},
199
+ audienceType: "all",
200
+ body: "Excluded billing notes",
201
+ publishedAt: DateTime.utc().toJSDate(),
202
+ status: "published",
203
+ title: "Excluded billing",
204
+ });
205
+
206
+ const search = await patientAgent.get("/announcements/help/search?q=billing").expect(200);
207
+ assert.lengthOf(search.body.data, 0);
208
+
209
+ await patientAgent.get(`/announcements/help/${staffOnly._id.toString()}`).expect(404);
210
+ });
211
+
212
+ it("uses default isStaff for staff and patient audienceType targeting", async () => {
213
+ await Announcement.deleteMany({});
214
+
215
+ const defaultHelpApp = getBaseServer();
216
+ setupAuth(defaultHelpApp, UserModel as unknown as UserModelType);
217
+ addAuthRoutes(defaultHelpApp, UserModel as unknown as UserModelType);
218
+ registerAnnouncementHelpRoutes({app: defaultHelpApp, basePath: "/announcements"});
219
+ defaultHelpApp.use(apiUnauthorizedMiddleware);
220
+ defaultHelpApp.use(apiErrorMiddleware);
221
+
222
+ const staffAgent = await authAsUser(defaultHelpApp, "admin");
223
+ const patientAgent = await authAsUser(defaultHelpApp, "notAdmin");
224
+
225
+ const staffOnly = await Announcement.create({
226
+ audienceType: "staff",
227
+ body: "Staff-only billing notes",
228
+ publishedAt: DateTime.utc().toJSDate(),
229
+ status: "published",
230
+ title: "Staff billing default",
231
+ });
232
+ const patientOnly = await Announcement.create({
233
+ audienceType: "patient",
234
+ body: "Patient-only billing notes",
235
+ publishedAt: DateTime.utc().toJSDate(),
236
+ status: "published",
237
+ title: "Patient billing default",
238
+ });
239
+
240
+ const staffSearch = await staffAgent.get("/announcements/help/search?q=billing").expect(200);
241
+ assert.lengthOf(staffSearch.body.data, 1);
242
+ assert.strictEqual(staffSearch.body.data[0].id, staffOnly._id.toString());
243
+
244
+ const patientSearch = await patientAgent
245
+ .get("/announcements/help/search?q=billing")
246
+ .expect(200);
247
+ assert.lengthOf(patientSearch.body.data, 1);
248
+ assert.strictEqual(patientSearch.body.data[0].id, patientOnly._id.toString());
249
+
250
+ await staffAgent.get(`/announcements/help/${patientOnly._id.toString()}`).expect(404);
251
+ await patientAgent.get(`/announcements/help/${staffOnly._id.toString()}`).expect(404);
252
+ });
253
+
254
+ it("ignores blank array query values when searching help", async () => {
255
+ const res = await userAgent.get("/announcements/help/search?q=&q=billing&q=%20").expect(200);
256
+ assert.lengthOf(res.body.data, 1);
257
+ assert.strictEqual(res.body.data[0].id, publishedId);
258
+ });
259
+
260
+ it("sorts help search results by priority then publishedAt", async () => {
261
+ await Announcement.deleteMany({});
262
+
263
+ await Announcement.create({
264
+ body: "Older high priority",
265
+ priority: 10,
266
+ publishedAt: DateTime.utc().minus({days: 5}).toJSDate(),
267
+ status: "published",
268
+ title: "Priority sort high old",
269
+ });
270
+ await Announcement.create({
271
+ body: "Newer high priority",
272
+ priority: 10,
273
+ publishedAt: DateTime.utc().minus({days: 1}).toJSDate(),
274
+ status: "published",
275
+ title: "Priority sort high new",
276
+ });
277
+ await Announcement.create({
278
+ body: "Low priority",
279
+ priority: 1,
280
+ publishedAt: DateTime.utc().toJSDate(),
281
+ status: "published",
282
+ title: "Priority sort low",
283
+ });
284
+
285
+ const res = await userAgent.get("/announcements/help/search?q=Priority%20sort").expect(200);
286
+ assert.lengthOf(res.body.data, 3);
287
+ assert.strictEqual(res.body.data[0].title, "Priority sort high new");
288
+ assert.strictEqual(res.body.data[1].title, "Priority sort high old");
289
+ assert.strictEqual(res.body.data[2].title, "Priority sort low");
290
+ });
291
+ });
@@ -0,0 +1,57 @@
1
+ import {describe, expect, it} from "bun:test";
2
+
3
+ import {
4
+ Announcement,
5
+ AnnouncementsApp,
6
+ excerptBody,
7
+ isAnnouncementPendingForUser,
8
+ resolveAcknowledgementPolicy,
9
+ toAnnouncementPublic,
10
+ } from "../index";
11
+
12
+ describe("@terreno/announcements package exports", () => {
13
+ it("re-exports the announcements plugin surface", () => {
14
+ expect(typeof AnnouncementsApp).toBe("function");
15
+ expect(Announcement.modelName).toBe("Announcement");
16
+ expect(excerptBody("hello world")).toBe("hello world");
17
+ expect(
18
+ isAnnouncementPendingForUser({
19
+ acknowledgements: [],
20
+ announcement: {
21
+ _id: "a",
22
+ acknowledgementPolicy: "required",
23
+ body: "Body",
24
+ priority: 1,
25
+ publishedAt: new Date(),
26
+ status: "published",
27
+ title: "Title",
28
+ version: 1,
29
+ } as never,
30
+ impressions: [],
31
+ })
32
+ ).toBe(true);
33
+ const dismissOnly = {
34
+ _id: "a",
35
+ acknowledgementPolicy: "dismiss-only",
36
+ body: "Body",
37
+ priority: 1,
38
+ publishedAt: new Date(),
39
+ status: "published",
40
+ title: "Title",
41
+ version: 1,
42
+ } as never;
43
+ const omittedPolicy = {
44
+ _id: "b",
45
+ body: "Body",
46
+ priority: 1,
47
+ publishedAt: new Date(),
48
+ status: "published",
49
+ title: "Title",
50
+ version: 1,
51
+ } as never;
52
+ expect(toAnnouncementPublic(dismissOnly).id).toBe("a");
53
+ expect(toAnnouncementPublic(dismissOnly).requiresAcknowledgement).toBe(false);
54
+ expect(toAnnouncementPublic(omittedPolicy, "required").requiresAcknowledgement).toBe(true);
55
+ expect(resolveAcknowledgementPolicy({announcement: dismissOnly})).toBe("dismiss-only");
56
+ });
57
+ });