@terreno/announcements 57.7.0 → 57.8.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.
@@ -19,7 +19,7 @@ import type {Model} from "mongoose";
19
19
  import {registerAnnouncementHelpRoutes} from "./helpRoutes";
20
20
  import {Announcement, toAnnouncementPublic} from "./models/announcement";
21
21
  import {AnnouncementAcknowledgement} from "./models/announcementAcknowledgement";
22
- import {AnnouncementClickEvent, isValidClickAction} from "./models/announcementClickEvent";
22
+ import {AnnouncementClickEvent} from "./models/announcementClickEvent";
23
23
  import {AnnouncementImpression, isValidPlatform} from "./models/announcementImpression";
24
24
  import {fetchAnnouncementOverview, parseOverviewPagination} from "./overview";
25
25
  import {
@@ -31,6 +31,17 @@ import {
31
31
  passesMinBuildNumber,
32
32
  selectPendingAnnouncements,
33
33
  } from "./pending";
34
+ import {
35
+ announcementReleaseImportSchema,
36
+ importAnnouncementRelease,
37
+ requireAnnouncementUploadAccess,
38
+ } from "./releaseImport";
39
+ import {
40
+ announcementClickBodySchema,
41
+ announcementClickPlatformSchema,
42
+ announcementImpressionBodySchema,
43
+ parseAnnouncementBody,
44
+ } from "./requestBodies";
34
45
  import type {
35
46
  AcknowledgementPolicy,
36
47
  AnnouncementDocument,
@@ -96,16 +107,6 @@ const hasPrimaryAction = (announcement: AnnouncementDocument): boolean => {
96
107
  return Boolean(label && url);
97
108
  };
98
109
 
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
110
  const requireVisibleAnnouncement = async ({
110
111
  announcement,
111
112
  isStaff,
@@ -254,6 +255,22 @@ export class AnnouncementsApp implements TerrenoPlugin {
254
255
 
255
256
  const routerOptions: ModelRouterOptions<AnnouncementDocument> = {
256
257
  ...(openApi ? {openApi: openApi as OpenApiMiddleware} : {}),
258
+ collectionActions: {
259
+ "import-release": {
260
+ allowAnonymous: true,
261
+ body: announcementReleaseImportSchema,
262
+ description:
263
+ "Idempotently create or update a release pack. Defaults to drafts; set publish=true to publish.",
264
+ handler: async ({body, req}) => {
265
+ requireAnnouncementUploadAccess({req, uploadToken: this.options.uploadToken});
266
+ return importAnnouncementRelease({input: body});
267
+ },
268
+ method: "POST",
269
+ permissions: [Permissions.IsAny],
270
+ summary: "Import a product release announcement pack",
271
+ tag: "announcements",
272
+ },
273
+ },
257
274
  permissions: {
258
275
  create: [Permissions.IsAdmin],
259
276
  delete: [Permissions.IsAdmin],
@@ -434,8 +451,11 @@ export class AnnouncementsApp implements TerrenoPlugin {
434
451
  }
435
452
 
436
453
  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);
454
+ const body = parseAnnouncementBody({
455
+ body: req.body,
456
+ schema: announcementImpressionBodySchema,
457
+ });
458
+ const platform = body.platform ?? parsePlatform(req);
439
459
  const announcement = await requireVisibleAnnouncement({
440
460
  announcement: await Announcement.findById(req.params.id),
441
461
  isStaff,
@@ -467,8 +487,11 @@ export class AnnouncementsApp implements TerrenoPlugin {
467
487
  }
468
488
 
469
489
  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);
490
+ const {platform: bodyPlatform} = parseAnnouncementBody({
491
+ body: req.body,
492
+ schema: announcementClickPlatformSchema,
493
+ });
494
+ const platform = bodyPlatform ?? parsePlatform(req);
472
495
  const announcement = await requireVisibleAnnouncement({
473
496
  announcement: await Announcement.findById(req.params.id),
474
497
  isStaff,
@@ -478,9 +501,7 @@ export class AnnouncementsApp implements TerrenoPlugin {
478
501
  user,
479
502
  });
480
503
 
481
- if (!isValidClickAction(body.action)) {
482
- throw new APIError({status: 400, title: "Invalid click action"});
483
- }
504
+ const body = parseAnnouncementBody({body: req.body, schema: announcementClickBodySchema});
484
505
 
485
506
  if (!hasPrimaryAction(announcement)) {
486
507
  throw new APIError({status: 400, title: "Announcement has no primary action"});
package/src/index.ts CHANGED
@@ -26,6 +26,16 @@ export {
26
26
  selectPendingAnnouncements,
27
27
  sortAnnouncementsForQueue,
28
28
  } from "./pending";
29
+ export type {
30
+ AnnouncementReleaseImportInput,
31
+ AnnouncementReleaseImportResult,
32
+ AnnouncementReleaseItemInput,
33
+ } from "./releaseImport";
34
+ export {
35
+ announcementReleaseImportSchema,
36
+ importAnnouncementRelease,
37
+ requireAnnouncementUploadAccess,
38
+ } from "./releaseImport";
29
39
  export type {
30
40
  AcknowledgementPolicy,
31
41
  AnnouncementAudienceType,
@@ -39,6 +49,7 @@ export type {
39
49
  AnnouncementPlatform,
40
50
  AnnouncementPrimaryAction,
41
51
  AnnouncementPublic,
52
+ AnnouncementRelease,
42
53
  AnnouncementsHelpOptions,
43
54
  AnnouncementsOptions,
44
55
  MatchAudienceFunction,
@@ -3,6 +3,7 @@ import {
3
3
  createdUpdatedPlugin,
4
4
  findExactlyOne,
5
5
  findOneOrNone,
6
+ findOneOrNoneFor,
6
7
  isDeletedPlugin,
7
8
  } from "@terreno/api";
8
9
  import mongoose from "mongoose";
@@ -94,6 +95,33 @@ const announcementSchema = new mongoose.Schema<AnnouncementDocument, Announcemen
94
95
  description: "When the announcement was first published",
95
96
  type: Date,
96
97
  },
98
+ release: {
99
+ buildNumber: {
100
+ description: "Client build number associated with the imported product release",
101
+ min: 1,
102
+ type: Number,
103
+ },
104
+ channel: {
105
+ description: "Release channel associated with the imported announcement",
106
+ trim: true,
107
+ type: String,
108
+ },
109
+ product: {
110
+ description: "Product identifier associated with the imported announcement",
111
+ trim: true,
112
+ type: String,
113
+ },
114
+ version: {
115
+ description: "User-facing product version associated with the imported announcement",
116
+ trim: true,
117
+ type: String,
118
+ },
119
+ },
120
+ releaseSlug: {
121
+ description: "Stable announcement identifier within an imported product release",
122
+ trim: true,
123
+ type: String,
124
+ },
97
125
  status: {
98
126
  default: "draft",
99
127
  description: "Lifecycle status: draft, published, or archived",
@@ -124,6 +152,18 @@ announcementSchema.plugin(findOneOrNone);
124
152
 
125
153
  announcementSchema.index({publishAt: 1, status: 1});
126
154
  announcementSchema.index({priority: -1, publishedAt: -1, status: 1});
155
+ announcementSchema.index(
156
+ {"release.channel": 1, "release.product": 1, "release.version": 1, releaseSlug: 1},
157
+ {
158
+ partialFilterExpression: {
159
+ "release.channel": {$type: "string"},
160
+ "release.product": {$type: "string"},
161
+ "release.version": {$type: "string"},
162
+ releaseSlug: {$type: "string"},
163
+ },
164
+ unique: true,
165
+ }
166
+ );
127
167
 
128
168
  announcementSchema.pre("save", async function bumpVersionOnPublishedEdit() {
129
169
  if (this.isNew) {
@@ -136,7 +176,12 @@ announcementSchema.pre("save", async function bumpVersionOnPublishedEdit() {
136
176
  return;
137
177
  }
138
178
 
139
- const previous = await Announcement.findById(this._id).select("title body status version");
179
+ // A restored import clears `deleted` on this document while the stored row is still
180
+ // soft-deleted, so the default filter would hide the version being edited.
181
+ const previous = await findOneOrNoneFor(Announcement, {
182
+ _id: this._id,
183
+ deleted: {$in: [true, false]},
184
+ });
140
185
  if (previous?.status !== "published") {
141
186
  return;
142
187
  }
@@ -1,10 +1,6 @@
1
1
  import {createdUpdatedPlugin, findExactlyOne, findOneOrNone, isDeletedPlugin} from "@terreno/api";
2
2
  import mongoose from "mongoose";
3
- import type {
4
- AnnouncementClickAction,
5
- AnnouncementClickEventDocument,
6
- AnnouncementClickEventModel,
7
- } from "../types";
3
+ import type {AnnouncementClickEventDocument, AnnouncementClickEventModel} from "../types";
8
4
 
9
5
  const announcementClickEventSchema = new mongoose.Schema<
10
6
  AnnouncementClickEventDocument,
@@ -64,6 +60,3 @@ export const AnnouncementClickEvent =
64
60
  announcementClickEventSchema,
65
61
  "announcementclickevents"
66
62
  );
67
-
68
- export const isValidClickAction = (value: unknown): value is AnnouncementClickAction =>
69
- value === "primaryAction";
@@ -0,0 +1,258 @@
1
+ import {timingSafeEqual} from "node:crypto";
2
+ import {APIError, findOneOrNoneFor, z} from "@terreno/api";
3
+ import type {Request} from "express";
4
+ import {DateTime} from "luxon";
5
+ import {Announcement} from "./models/announcement";
6
+ import {announcementPlatformSchema, parseAnnouncementBody} from "./requestBodies";
7
+ import type {AnnouncementDocument, AnnouncementRelease} from "./types";
8
+
9
+ const announcementDefaultsSchema = z
10
+ .object({
11
+ acknowledgementPolicy: z.enum(["required", "dismiss-only"]).optional(),
12
+ audience: z.unknown().optional(),
13
+ audienceType: z.enum(["staff", "patient", "all"]).optional(),
14
+ displayMode: z.enum(["modal", "banner", "feed"]).optional(),
15
+ expiresAt: z.string().datetime().optional(),
16
+ minBuildNumber: z.number().int().positive().optional(),
17
+ platforms: z.array(announcementPlatformSchema).min(1).optional(),
18
+ priority: z.number().optional(),
19
+ publishAt: z.string().datetime().optional(),
20
+ })
21
+ .strict();
22
+
23
+ export const announcementReleaseImportSchema = z
24
+ .object({
25
+ announcements: z
26
+ .array(
27
+ announcementDefaultsSchema.extend({
28
+ body: z.string().min(1),
29
+ primaryAction: z
30
+ .object({
31
+ label: z.string().min(1),
32
+ url: z.string().url(),
33
+ })
34
+ .strict()
35
+ .optional(),
36
+ slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
37
+ title: z.string().min(1),
38
+ })
39
+ )
40
+ .min(1)
41
+ .max(100),
42
+ defaults: announcementDefaultsSchema.optional(),
43
+ publish: z.boolean().default(false),
44
+ release: z
45
+ .object({
46
+ buildNumber: z.number().int().positive().optional(),
47
+ channel: z.string().min(1).default("production"),
48
+ product: z.string().min(1),
49
+ version: z.string().min(1),
50
+ })
51
+ .strict(),
52
+ })
53
+ .strict()
54
+ .superRefine((input, context) => {
55
+ const seenSlugs = new Set<string>();
56
+ for (const [index, item] of input.announcements.entries()) {
57
+ if (seenSlugs.has(item.slug)) {
58
+ context.addIssue({
59
+ code: "custom",
60
+ message: "Release announcement slugs must be unique",
61
+ path: ["announcements", index, "slug"],
62
+ });
63
+ }
64
+ seenSlugs.add(item.slug);
65
+ }
66
+ });
67
+
68
+ type AnnouncementDefaultsInput = z.output<typeof announcementDefaultsSchema>;
69
+
70
+ export type AnnouncementReleaseImportInput = z.output<typeof announcementReleaseImportSchema>;
71
+
72
+ export type AnnouncementReleaseItemInput = AnnouncementReleaseImportInput["announcements"][number];
73
+
74
+ export interface AnnouncementReleaseImportResult {
75
+ created: number;
76
+ published: number;
77
+ unchanged: number;
78
+ updated: number;
79
+ announcements: Array<{
80
+ id: string;
81
+ slug: string;
82
+ status: "draft" | "published" | "archived";
83
+ version: number;
84
+ }>;
85
+ }
86
+
87
+ const safeTokenEquals = ({expected, provided}: {expected: string; provided: string}): boolean => {
88
+ const expectedBuffer = Buffer.from(expected, "utf8");
89
+ const providedBuffer = Buffer.from(provided, "utf8");
90
+ if (expectedBuffer.length !== providedBuffer.length) {
91
+ return false;
92
+ }
93
+ return timingSafeEqual(expectedBuffer, providedBuffer);
94
+ };
95
+
96
+ const readBearerToken = (req: Request): string | undefined => {
97
+ const authorization = req.get("authorization");
98
+ if (!authorization?.startsWith("Bearer ")) {
99
+ return undefined;
100
+ }
101
+ return authorization.slice("Bearer ".length);
102
+ };
103
+
104
+ export const requireAnnouncementUploadAccess = ({
105
+ req,
106
+ uploadToken,
107
+ }: {
108
+ req: Request;
109
+ uploadToken?: string;
110
+ }): void => {
111
+ if ((req.user as {admin?: boolean} | undefined)?.admin) {
112
+ return;
113
+ }
114
+ const providedToken = readBearerToken(req);
115
+ if (!uploadToken || !providedToken) {
116
+ throw new APIError({status: 401, title: "Announcement upload authentication required"});
117
+ }
118
+ if (!safeTokenEquals({expected: uploadToken, provided: providedToken})) {
119
+ throw new APIError({status: 401, title: "Invalid announcement upload token"});
120
+ }
121
+ };
122
+
123
+ const parseOptionalDate = (value?: string): Date | undefined => {
124
+ if (!value) {
125
+ return undefined;
126
+ }
127
+ return DateTime.fromISO(value, {setZone: true}).toJSDate();
128
+ };
129
+
130
+ const applyImportedValues = ({
131
+ announcement,
132
+ defaults,
133
+ item,
134
+ publish,
135
+ release,
136
+ }: {
137
+ announcement: AnnouncementDocument;
138
+ defaults?: AnnouncementDefaultsInput;
139
+ item: AnnouncementReleaseItemInput;
140
+ publish: boolean;
141
+ release: AnnouncementRelease;
142
+ }): void => {
143
+ const merged = {...defaults, ...item};
144
+ announcement.acknowledgementPolicy = merged.acknowledgementPolicy;
145
+ announcement.audience = merged.audience ?? {};
146
+ announcement.audienceType = merged.audienceType ?? "all";
147
+ announcement.body = item.body;
148
+ announcement.displayMode = merged.displayMode ?? "feed";
149
+ announcement.expiresAt = parseOptionalDate(merged.expiresAt);
150
+ announcement.minBuildNumber = merged.minBuildNumber ?? release.buildNumber;
151
+ announcement.platforms = merged.platforms ?? ["ios", "android", "web"];
152
+ announcement.primaryAction = item.primaryAction;
153
+ announcement.priority = merged.priority ?? 0;
154
+ announcement.publishAt = parseOptionalDate(merged.publishAt);
155
+ announcement.release = release;
156
+ announcement.releaseSlug = item.slug;
157
+ announcement.title = item.title;
158
+ if (!publish) {
159
+ return;
160
+ }
161
+ announcement.archivedAt = undefined;
162
+ announcement.publishedAt ??= DateTime.utc().toJSDate();
163
+ announcement.status = "published";
164
+ };
165
+
166
+ const snapshotImportedValues = (announcement: AnnouncementDocument): string =>
167
+ JSON.stringify({
168
+ acknowledgementPolicy: announcement.acknowledgementPolicy,
169
+ archivedAt: announcement.archivedAt,
170
+ audience: announcement.audience,
171
+ audienceType: announcement.audienceType,
172
+ body: announcement.body,
173
+ deleted: announcement.deleted,
174
+ displayMode: announcement.displayMode,
175
+ expiresAt: announcement.expiresAt,
176
+ minBuildNumber: announcement.minBuildNumber,
177
+ platforms: announcement.platforms,
178
+ primaryAction: announcement.primaryAction,
179
+ priority: announcement.priority,
180
+ publishAt: announcement.publishAt,
181
+ publishedAt: announcement.publishedAt,
182
+ release: announcement.release,
183
+ releaseSlug: announcement.releaseSlug,
184
+ status: announcement.status,
185
+ title: announcement.title,
186
+ });
187
+
188
+ export const importAnnouncementRelease = async ({
189
+ input: rawInput,
190
+ }: {
191
+ input: unknown;
192
+ }): Promise<AnnouncementReleaseImportResult> => {
193
+ const input = parseAnnouncementBody({body: rawInput, schema: announcementReleaseImportSchema});
194
+
195
+ const result: AnnouncementReleaseImportResult = {
196
+ announcements: [],
197
+ created: 0,
198
+ published: 0,
199
+ unchanged: 0,
200
+ updated: 0,
201
+ };
202
+
203
+ for (const item of input.announcements) {
204
+ const existing = await findOneOrNoneFor(Announcement, {
205
+ deleted: {$in: [true, false]},
206
+ "release.channel": input.release.channel,
207
+ "release.product": input.release.product,
208
+ "release.version": input.release.version,
209
+ releaseSlug: item.slug,
210
+ });
211
+ const announcement =
212
+ existing ??
213
+ new Announcement({
214
+ audience: {},
215
+ body: item.body,
216
+ priority: 0,
217
+ release: input.release,
218
+ releaseSlug: item.slug,
219
+ status: "draft",
220
+ title: item.title,
221
+ version: 1,
222
+ });
223
+ const wasCreated = announcement.isNew;
224
+ const previousValues = snapshotImportedValues(announcement);
225
+
226
+ announcement.deleted = false;
227
+ applyImportedValues({
228
+ announcement,
229
+ defaults: input.defaults,
230
+ item,
231
+ publish: input.publish,
232
+ release: input.release,
233
+ });
234
+ const hasChanges = wasCreated || previousValues !== snapshotImportedValues(announcement);
235
+ if (hasChanges) {
236
+ await announcement.save();
237
+ }
238
+
239
+ if (wasCreated) {
240
+ result.created += 1;
241
+ } else if (hasChanges) {
242
+ result.updated += 1;
243
+ } else {
244
+ result.unchanged += 1;
245
+ }
246
+ if (input.publish && announcement.status === "published") {
247
+ result.published += 1;
248
+ }
249
+ result.announcements.push({
250
+ id: announcement._id.toString(),
251
+ slug: item.slug,
252
+ status: announcement.status,
253
+ version: announcement.version,
254
+ });
255
+ }
256
+
257
+ return result;
258
+ };
@@ -0,0 +1,50 @@
1
+ import {ValidationError, z} from "@terreno/api";
2
+
3
+ export const announcementPlatformSchema = z.enum(["ios", "android", "web"]);
4
+
5
+ export const announcementImpressionBodySchema = z
6
+ .object({
7
+ platform: announcementPlatformSchema.optional(),
8
+ })
9
+ .strict();
10
+
11
+ export const announcementClickBodySchema = z
12
+ .object({
13
+ action: z.literal("primaryAction"),
14
+ platform: announcementPlatformSchema.optional(),
15
+ })
16
+ .strict();
17
+
18
+ // Visibility checks need the platform before the rest of the click body is validated, so hidden
19
+ // announcements return 404 regardless of the action.
20
+ export const announcementClickPlatformSchema = announcementClickBodySchema
21
+ .pick({platform: true})
22
+ .loose();
23
+
24
+ const toFieldErrors = (error: z.ZodError): Record<string, string> => {
25
+ const fields: Record<string, string> = {};
26
+ for (const issue of error.issues) {
27
+ const key = issue.path.length > 0 ? issue.path.join(".") : "body";
28
+ fields[key] ??= issue.message;
29
+ }
30
+ return fields;
31
+ };
32
+
33
+ export const parseAnnouncementBody = <TSchema extends z.ZodType>({
34
+ body,
35
+ schema,
36
+ }: {
37
+ body: unknown;
38
+ schema: TSchema;
39
+ }): z.output<TSchema> => {
40
+ const parsed = schema.safeParse(body ?? {});
41
+ if (!parsed.success) {
42
+ throw new ValidationError({
43
+ code: "announcement-body-validation-failed",
44
+ detail: "The request body did not match the announcement schema",
45
+ fields: toFieldErrors(parsed.error),
46
+ title: "Validation failed",
47
+ });
48
+ }
49
+ return parsed.data;
50
+ };