@terreno/announcements 57.6.1 → 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.
@@ -11,6 +11,8 @@ const announcementClickEvent_1 = require("./models/announcementClickEvent");
11
11
  const announcementImpression_1 = require("./models/announcementImpression");
12
12
  const overview_1 = require("./overview");
13
13
  const pending_1 = require("./pending");
14
+ const releaseImport_1 = require("./releaseImport");
15
+ const requestBodies_1 = require("./requestBodies");
14
16
  const DEFAULT_BASE_PATH = "/announcements";
15
17
  const defaultIsStaff = (user) => user.admin === true;
16
18
  const parsePlatform = (req) => {
@@ -55,15 +57,6 @@ const hasPrimaryAction = (announcement) => {
55
57
  const url = announcement.primaryAction?.url?.trim();
56
58
  return Boolean(label && url);
57
59
  };
58
- const resolveClickPlatform = (req, bodyPlatform) => {
59
- if (bodyPlatform !== undefined && bodyPlatform !== null) {
60
- if (!(0, announcementImpression_1.isValidPlatform)(bodyPlatform)) {
61
- throw new api_1.APIError({ status: 400, title: "Invalid platform" });
62
- }
63
- return bodyPlatform;
64
- }
65
- return parsePlatform(req);
66
- };
67
60
  const requireVisibleAnnouncement = async ({ announcement, isStaff, matchAudience, platform, queryVersion, user, }) => {
68
61
  if (!announcement) {
69
62
  throw new api_1.APIError({ status: 404, title: "Announcement not found" });
@@ -186,6 +179,21 @@ class AnnouncementsApp {
186
179
  };
187
180
  const routerOptions = {
188
181
  ...(openApi ? { openApi: openApi } : {}),
182
+ collectionActions: {
183
+ "import-release": {
184
+ allowAnonymous: true,
185
+ body: releaseImport_1.announcementReleaseImportSchema,
186
+ description: "Idempotently create or update a release pack. Defaults to drafts; set publish=true to publish.",
187
+ handler: async ({ body, req }) => {
188
+ (0, releaseImport_1.requireAnnouncementUploadAccess)({ req, uploadToken: this.options.uploadToken });
189
+ return (0, releaseImport_1.importAnnouncementRelease)({ input: body });
190
+ },
191
+ method: "POST",
192
+ permissions: [api_1.Permissions.IsAny],
193
+ summary: "Import a product release announcement pack",
194
+ tag: "announcements",
195
+ },
196
+ },
189
197
  permissions: {
190
198
  create: [api_1.Permissions.IsAdmin],
191
199
  delete: [api_1.Permissions.IsAdmin],
@@ -322,8 +330,11 @@ class AnnouncementsApp {
322
330
  throw new api_1.APIError({ status: 401, title: "Authentication required" });
323
331
  }
324
332
  const userId = getUserId(user);
325
- const bodyPlatform = req.body?.platform;
326
- const platform = (0, announcementImpression_1.isValidPlatform)(bodyPlatform) ? bodyPlatform : parsePlatform(req);
333
+ const body = (0, requestBodies_1.parseAnnouncementBody)({
334
+ body: req.body,
335
+ schema: requestBodies_1.announcementImpressionBodySchema,
336
+ });
337
+ const platform = body.platform ?? parsePlatform(req);
327
338
  const announcement = await requireVisibleAnnouncement({
328
339
  announcement: await announcement_1.Announcement.findById(req.params.id),
329
340
  isStaff,
@@ -347,8 +358,11 @@ class AnnouncementsApp {
347
358
  throw new api_1.APIError({ status: 401, title: "Authentication required" });
348
359
  }
349
360
  const userId = getUserId(user);
350
- const body = req.body;
351
- const platform = resolveClickPlatform(req, body.platform);
361
+ const { platform: bodyPlatform } = (0, requestBodies_1.parseAnnouncementBody)({
362
+ body: req.body,
363
+ schema: requestBodies_1.announcementClickPlatformSchema,
364
+ });
365
+ const platform = bodyPlatform ?? parsePlatform(req);
352
366
  const announcement = await requireVisibleAnnouncement({
353
367
  announcement: await announcement_1.Announcement.findById(req.params.id),
354
368
  isStaff,
@@ -357,9 +371,7 @@ class AnnouncementsApp {
357
371
  queryVersion: (0, pending_1.parseQueryVersion)(req.query.version),
358
372
  user,
359
373
  });
360
- if (!(0, announcementClickEvent_1.isValidClickAction)(body.action)) {
361
- throw new api_1.APIError({ status: 400, title: "Invalid click action" });
362
- }
374
+ const body = (0, requestBodies_1.parseAnnouncementBody)({ body: req.body, schema: requestBodies_1.announcementClickBodySchema });
363
375
  if (!hasPrimaryAction(announcement)) {
364
376
  throw new api_1.APIError({ status: 400, title: "Announcement has no primary action" });
365
377
  }
package/dist/index.d.ts CHANGED
@@ -6,4 +6,6 @@ export { AnnouncementAcknowledgement } from "./models/announcementAcknowledgemen
6
6
  export { AnnouncementClickEvent } from "./models/announcementClickEvent";
7
7
  export { AnnouncementImpression } from "./models/announcementImpression";
8
8
  export { isAnnouncementPendingForUser, isAnnouncementVisibleNow, isAnnouncementVisibleToUser, isInterruptDisplayMode, matchAudienceByType, parseQueryVersion, passesMinBuildNumber, requiresAcknowledgementForAnnouncement, resolveAcknowledgementPolicy, resolveAudienceType, resolveDisplayMode, selectPendingAnnouncements, sortAnnouncementsForQueue, } from "./pending";
9
- export type { AcknowledgementPolicy, AnnouncementAudienceType, AnnouncementClickAction, AnnouncementDisplayMode, AnnouncementDocument, AnnouncementOverviewMetrics, AnnouncementOverviewResponse, AnnouncementOverviewRow, AnnouncementOverviewTotals, AnnouncementPlatform, AnnouncementPrimaryAction, AnnouncementPublic, AnnouncementsHelpOptions, AnnouncementsOptions, MatchAudienceFunction, } from "./types";
9
+ export type { AnnouncementReleaseImportInput, AnnouncementReleaseImportResult, AnnouncementReleaseItemInput, } from "./releaseImport";
10
+ export { announcementReleaseImportSchema, importAnnouncementRelease, requireAnnouncementUploadAccess, } from "./releaseImport";
11
+ export type { AcknowledgementPolicy, AnnouncementAudienceType, AnnouncementClickAction, AnnouncementDisplayMode, AnnouncementDocument, AnnouncementOverviewMetrics, AnnouncementOverviewResponse, AnnouncementOverviewRow, AnnouncementOverviewTotals, AnnouncementPlatform, AnnouncementPrimaryAction, AnnouncementPublic, AnnouncementRelease, AnnouncementsHelpOptions, AnnouncementsOptions, MatchAudienceFunction, } from "./types";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.sortAnnouncementsForQueue = exports.selectPendingAnnouncements = exports.resolveDisplayMode = exports.resolveAudienceType = exports.resolveAcknowledgementPolicy = exports.requiresAcknowledgementForAnnouncement = exports.passesMinBuildNumber = exports.parseQueryVersion = exports.matchAudienceByType = exports.isInterruptDisplayMode = exports.isAnnouncementVisibleToUser = exports.isAnnouncementVisibleNow = exports.isAnnouncementPendingForUser = exports.AnnouncementImpression = exports.AnnouncementClickEvent = exports.AnnouncementAcknowledgement = exports.toAnnouncementPublic = exports.Announcement = exports.toHelpSummary = exports.toHelpDetail = exports.matchesHelpQueries = exports.excerptBody = exports.buildHelpStatusFilter = exports.AnnouncementsApp = void 0;
3
+ exports.requireAnnouncementUploadAccess = exports.importAnnouncementRelease = exports.announcementReleaseImportSchema = exports.sortAnnouncementsForQueue = exports.selectPendingAnnouncements = exports.resolveDisplayMode = exports.resolveAudienceType = exports.resolveAcknowledgementPolicy = exports.requiresAcknowledgementForAnnouncement = exports.passesMinBuildNumber = exports.parseQueryVersion = exports.matchAudienceByType = exports.isInterruptDisplayMode = exports.isAnnouncementVisibleToUser = exports.isAnnouncementVisibleNow = exports.isAnnouncementPendingForUser = exports.AnnouncementImpression = exports.AnnouncementClickEvent = exports.AnnouncementAcknowledgement = exports.toAnnouncementPublic = exports.Announcement = exports.toHelpSummary = exports.toHelpDetail = exports.matchesHelpQueries = exports.excerptBody = exports.buildHelpStatusFilter = exports.AnnouncementsApp = void 0;
4
4
  var announcementsApp_1 = require("./announcementsApp");
5
5
  Object.defineProperty(exports, "AnnouncementsApp", { enumerable: true, get: function () { return announcementsApp_1.AnnouncementsApp; } });
6
6
  var help_1 = require("./help");
@@ -32,3 +32,7 @@ Object.defineProperty(exports, "resolveAudienceType", { enumerable: true, get: f
32
32
  Object.defineProperty(exports, "resolveDisplayMode", { enumerable: true, get: function () { return pending_1.resolveDisplayMode; } });
33
33
  Object.defineProperty(exports, "selectPendingAnnouncements", { enumerable: true, get: function () { return pending_1.selectPendingAnnouncements; } });
34
34
  Object.defineProperty(exports, "sortAnnouncementsForQueue", { enumerable: true, get: function () { return pending_1.sortAnnouncementsForQueue; } });
35
+ var releaseImport_1 = require("./releaseImport");
36
+ Object.defineProperty(exports, "announcementReleaseImportSchema", { enumerable: true, get: function () { return releaseImport_1.announcementReleaseImportSchema; } });
37
+ Object.defineProperty(exports, "importAnnouncementRelease", { enumerable: true, get: function () { return releaseImport_1.importAnnouncementRelease; } });
38
+ Object.defineProperty(exports, "requireAnnouncementUploadAccess", { enumerable: true, get: function () { return releaseImport_1.requireAnnouncementUploadAccess; } });
@@ -79,6 +79,33 @@ const announcementSchema = new mongoose_1.default.Schema({
79
79
  description: "When the announcement was first published",
80
80
  type: Date,
81
81
  },
82
+ release: {
83
+ buildNumber: {
84
+ description: "Client build number associated with the imported product release",
85
+ min: 1,
86
+ type: Number,
87
+ },
88
+ channel: {
89
+ description: "Release channel associated with the imported announcement",
90
+ trim: true,
91
+ type: String,
92
+ },
93
+ product: {
94
+ description: "Product identifier associated with the imported announcement",
95
+ trim: true,
96
+ type: String,
97
+ },
98
+ version: {
99
+ description: "User-facing product version associated with the imported announcement",
100
+ trim: true,
101
+ type: String,
102
+ },
103
+ },
104
+ releaseSlug: {
105
+ description: "Stable announcement identifier within an imported product release",
106
+ trim: true,
107
+ type: String,
108
+ },
82
109
  status: {
83
110
  default: "draft",
84
111
  description: "Lifecycle status: draft, published, or archived",
@@ -105,6 +132,15 @@ announcementSchema.plugin(api_1.findExactlyOne);
105
132
  announcementSchema.plugin(api_1.findOneOrNone);
106
133
  announcementSchema.index({ publishAt: 1, status: 1 });
107
134
  announcementSchema.index({ priority: -1, publishedAt: -1, status: 1 });
135
+ announcementSchema.index({ "release.channel": 1, "release.product": 1, "release.version": 1, releaseSlug: 1 }, {
136
+ partialFilterExpression: {
137
+ "release.channel": { $type: "string" },
138
+ "release.product": { $type: "string" },
139
+ "release.version": { $type: "string" },
140
+ releaseSlug: { $type: "string" },
141
+ },
142
+ unique: true,
143
+ });
108
144
  announcementSchema.pre("save", async function bumpVersionOnPublishedEdit() {
109
145
  if (this.isNew) {
110
146
  return;
@@ -115,7 +151,12 @@ announcementSchema.pre("save", async function bumpVersionOnPublishedEdit() {
115
151
  if (!this.isModified("title") && !this.isModified("body")) {
116
152
  return;
117
153
  }
118
- const previous = await exports.Announcement.findById(this._id).select("title body status version");
154
+ // A restored import clears `deleted` on this document while the stored row is still
155
+ // soft-deleted, so the default filter would hide the version being edited.
156
+ const previous = await (0, api_1.findOneOrNoneFor)(exports.Announcement, {
157
+ _id: this._id,
158
+ deleted: { $in: [true, false] },
159
+ });
119
160
  if (previous?.status !== "published") {
120
161
  return;
121
162
  }
@@ -1,3 +1,2 @@
1
- import type { AnnouncementClickAction, AnnouncementClickEventModel } from "../types";
1
+ import type { AnnouncementClickEventModel } from "../types";
2
2
  export declare const AnnouncementClickEvent: AnnouncementClickEventModel;
3
- export declare const isValidClickAction: (value: unknown) => value is AnnouncementClickAction;
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.isValidClickAction = exports.AnnouncementClickEvent = void 0;
6
+ exports.AnnouncementClickEvent = void 0;
7
7
  const api_1 = require("@terreno/api");
8
8
  const mongoose_1 = __importDefault(require("mongoose"));
9
9
  const announcementClickEventSchema = new mongoose_1.default.Schema({
@@ -50,5 +50,3 @@ announcementClickEventSchema.plugin(api_1.findOneOrNone);
50
50
  announcementClickEventSchema.index({ announcementId: 1, clickedAt: -1 });
51
51
  exports.AnnouncementClickEvent = mongoose_1.default.models.TerrenoAnnouncementClickEvent ??
52
52
  mongoose_1.default.model("TerrenoAnnouncementClickEvent", announcementClickEventSchema, "announcementclickevents");
53
- const isValidClickAction = (value) => value === "primaryAction";
54
- exports.isValidClickAction = isValidClickAction;
@@ -0,0 +1,91 @@
1
+ import { z } from "@terreno/api";
2
+ import type { Request } from "express";
3
+ export declare const announcementReleaseImportSchema: z.ZodObject<{
4
+ announcements: z.ZodArray<z.ZodObject<{
5
+ acknowledgementPolicy: z.ZodOptional<z.ZodEnum<{
6
+ required: "required";
7
+ "dismiss-only": "dismiss-only";
8
+ }>>;
9
+ audience: z.ZodOptional<z.ZodUnknown>;
10
+ audienceType: z.ZodOptional<z.ZodEnum<{
11
+ staff: "staff";
12
+ patient: "patient";
13
+ all: "all";
14
+ }>>;
15
+ displayMode: z.ZodOptional<z.ZodEnum<{
16
+ modal: "modal";
17
+ banner: "banner";
18
+ feed: "feed";
19
+ }>>;
20
+ expiresAt: z.ZodOptional<z.ZodString>;
21
+ minBuildNumber: z.ZodOptional<z.ZodNumber>;
22
+ platforms: z.ZodOptional<z.ZodArray<z.ZodEnum<{
23
+ ios: "ios";
24
+ android: "android";
25
+ web: "web";
26
+ }>>>;
27
+ priority: z.ZodOptional<z.ZodNumber>;
28
+ publishAt: z.ZodOptional<z.ZodString>;
29
+ body: z.ZodString;
30
+ primaryAction: z.ZodOptional<z.ZodObject<{
31
+ label: z.ZodString;
32
+ url: z.ZodString;
33
+ }, z.core.$strict>>;
34
+ slug: z.ZodString;
35
+ title: z.ZodString;
36
+ }, z.core.$strict>>;
37
+ defaults: z.ZodOptional<z.ZodObject<{
38
+ acknowledgementPolicy: z.ZodOptional<z.ZodEnum<{
39
+ required: "required";
40
+ "dismiss-only": "dismiss-only";
41
+ }>>;
42
+ audience: z.ZodOptional<z.ZodUnknown>;
43
+ audienceType: z.ZodOptional<z.ZodEnum<{
44
+ staff: "staff";
45
+ patient: "patient";
46
+ all: "all";
47
+ }>>;
48
+ displayMode: z.ZodOptional<z.ZodEnum<{
49
+ modal: "modal";
50
+ banner: "banner";
51
+ feed: "feed";
52
+ }>>;
53
+ expiresAt: z.ZodOptional<z.ZodString>;
54
+ minBuildNumber: z.ZodOptional<z.ZodNumber>;
55
+ platforms: z.ZodOptional<z.ZodArray<z.ZodEnum<{
56
+ ios: "ios";
57
+ android: "android";
58
+ web: "web";
59
+ }>>>;
60
+ priority: z.ZodOptional<z.ZodNumber>;
61
+ publishAt: z.ZodOptional<z.ZodString>;
62
+ }, z.core.$strict>>;
63
+ publish: z.ZodDefault<z.ZodBoolean>;
64
+ release: z.ZodObject<{
65
+ buildNumber: z.ZodOptional<z.ZodNumber>;
66
+ channel: z.ZodDefault<z.ZodString>;
67
+ product: z.ZodString;
68
+ version: z.ZodString;
69
+ }, z.core.$strict>;
70
+ }, z.core.$strict>;
71
+ export type AnnouncementReleaseImportInput = z.output<typeof announcementReleaseImportSchema>;
72
+ export type AnnouncementReleaseItemInput = AnnouncementReleaseImportInput["announcements"][number];
73
+ export interface AnnouncementReleaseImportResult {
74
+ created: number;
75
+ published: number;
76
+ unchanged: number;
77
+ updated: number;
78
+ announcements: Array<{
79
+ id: string;
80
+ slug: string;
81
+ status: "draft" | "published" | "archived";
82
+ version: number;
83
+ }>;
84
+ }
85
+ export declare const requireAnnouncementUploadAccess: ({ req, uploadToken, }: {
86
+ req: Request;
87
+ uploadToken?: string;
88
+ }) => void;
89
+ export declare const importAnnouncementRelease: ({ input: rawInput, }: {
90
+ input: unknown;
91
+ }) => Promise<AnnouncementReleaseImportResult>;
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.importAnnouncementRelease = exports.requireAnnouncementUploadAccess = exports.announcementReleaseImportSchema = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const api_1 = require("@terreno/api");
6
+ const luxon_1 = require("luxon");
7
+ const announcement_1 = require("./models/announcement");
8
+ const requestBodies_1 = require("./requestBodies");
9
+ const announcementDefaultsSchema = api_1.z
10
+ .object({
11
+ acknowledgementPolicy: api_1.z.enum(["required", "dismiss-only"]).optional(),
12
+ audience: api_1.z.unknown().optional(),
13
+ audienceType: api_1.z.enum(["staff", "patient", "all"]).optional(),
14
+ displayMode: api_1.z.enum(["modal", "banner", "feed"]).optional(),
15
+ expiresAt: api_1.z.string().datetime().optional(),
16
+ minBuildNumber: api_1.z.number().int().positive().optional(),
17
+ platforms: api_1.z.array(requestBodies_1.announcementPlatformSchema).min(1).optional(),
18
+ priority: api_1.z.number().optional(),
19
+ publishAt: api_1.z.string().datetime().optional(),
20
+ })
21
+ .strict();
22
+ exports.announcementReleaseImportSchema = api_1.z
23
+ .object({
24
+ announcements: api_1.z
25
+ .array(announcementDefaultsSchema.extend({
26
+ body: api_1.z.string().min(1),
27
+ primaryAction: api_1.z
28
+ .object({
29
+ label: api_1.z.string().min(1),
30
+ url: api_1.z.string().url(),
31
+ })
32
+ .strict()
33
+ .optional(),
34
+ slug: api_1.z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
35
+ title: api_1.z.string().min(1),
36
+ }))
37
+ .min(1)
38
+ .max(100),
39
+ defaults: announcementDefaultsSchema.optional(),
40
+ publish: api_1.z.boolean().default(false),
41
+ release: api_1.z
42
+ .object({
43
+ buildNumber: api_1.z.number().int().positive().optional(),
44
+ channel: api_1.z.string().min(1).default("production"),
45
+ product: api_1.z.string().min(1),
46
+ version: api_1.z.string().min(1),
47
+ })
48
+ .strict(),
49
+ })
50
+ .strict()
51
+ .superRefine((input, context) => {
52
+ const seenSlugs = new Set();
53
+ for (const [index, item] of input.announcements.entries()) {
54
+ if (seenSlugs.has(item.slug)) {
55
+ context.addIssue({
56
+ code: "custom",
57
+ message: "Release announcement slugs must be unique",
58
+ path: ["announcements", index, "slug"],
59
+ });
60
+ }
61
+ seenSlugs.add(item.slug);
62
+ }
63
+ });
64
+ const safeTokenEquals = ({ expected, provided }) => {
65
+ const expectedBuffer = Buffer.from(expected, "utf8");
66
+ const providedBuffer = Buffer.from(provided, "utf8");
67
+ if (expectedBuffer.length !== providedBuffer.length) {
68
+ return false;
69
+ }
70
+ return (0, node_crypto_1.timingSafeEqual)(expectedBuffer, providedBuffer);
71
+ };
72
+ const readBearerToken = (req) => {
73
+ const authorization = req.get("authorization");
74
+ if (!authorization?.startsWith("Bearer ")) {
75
+ return undefined;
76
+ }
77
+ return authorization.slice("Bearer ".length);
78
+ };
79
+ const requireAnnouncementUploadAccess = ({ req, uploadToken, }) => {
80
+ if (req.user?.admin) {
81
+ return;
82
+ }
83
+ const providedToken = readBearerToken(req);
84
+ if (!uploadToken || !providedToken) {
85
+ throw new api_1.APIError({ status: 401, title: "Announcement upload authentication required" });
86
+ }
87
+ if (!safeTokenEquals({ expected: uploadToken, provided: providedToken })) {
88
+ throw new api_1.APIError({ status: 401, title: "Invalid announcement upload token" });
89
+ }
90
+ };
91
+ exports.requireAnnouncementUploadAccess = requireAnnouncementUploadAccess;
92
+ const parseOptionalDate = (value) => {
93
+ if (!value) {
94
+ return undefined;
95
+ }
96
+ return luxon_1.DateTime.fromISO(value, { setZone: true }).toJSDate();
97
+ };
98
+ const applyImportedValues = ({ announcement, defaults, item, publish, release, }) => {
99
+ const merged = { ...defaults, ...item };
100
+ announcement.acknowledgementPolicy = merged.acknowledgementPolicy;
101
+ announcement.audience = merged.audience ?? {};
102
+ announcement.audienceType = merged.audienceType ?? "all";
103
+ announcement.body = item.body;
104
+ announcement.displayMode = merged.displayMode ?? "feed";
105
+ announcement.expiresAt = parseOptionalDate(merged.expiresAt);
106
+ announcement.minBuildNumber = merged.minBuildNumber ?? release.buildNumber;
107
+ announcement.platforms = merged.platforms ?? ["ios", "android", "web"];
108
+ announcement.primaryAction = item.primaryAction;
109
+ announcement.priority = merged.priority ?? 0;
110
+ announcement.publishAt = parseOptionalDate(merged.publishAt);
111
+ announcement.release = release;
112
+ announcement.releaseSlug = item.slug;
113
+ announcement.title = item.title;
114
+ if (!publish) {
115
+ return;
116
+ }
117
+ announcement.archivedAt = undefined;
118
+ announcement.publishedAt ??= luxon_1.DateTime.utc().toJSDate();
119
+ announcement.status = "published";
120
+ };
121
+ const snapshotImportedValues = (announcement) => JSON.stringify({
122
+ acknowledgementPolicy: announcement.acknowledgementPolicy,
123
+ archivedAt: announcement.archivedAt,
124
+ audience: announcement.audience,
125
+ audienceType: announcement.audienceType,
126
+ body: announcement.body,
127
+ deleted: announcement.deleted,
128
+ displayMode: announcement.displayMode,
129
+ expiresAt: announcement.expiresAt,
130
+ minBuildNumber: announcement.minBuildNumber,
131
+ platforms: announcement.platforms,
132
+ primaryAction: announcement.primaryAction,
133
+ priority: announcement.priority,
134
+ publishAt: announcement.publishAt,
135
+ publishedAt: announcement.publishedAt,
136
+ release: announcement.release,
137
+ releaseSlug: announcement.releaseSlug,
138
+ status: announcement.status,
139
+ title: announcement.title,
140
+ });
141
+ const importAnnouncementRelease = async ({ input: rawInput, }) => {
142
+ const input = (0, requestBodies_1.parseAnnouncementBody)({ body: rawInput, schema: exports.announcementReleaseImportSchema });
143
+ const result = {
144
+ announcements: [],
145
+ created: 0,
146
+ published: 0,
147
+ unchanged: 0,
148
+ updated: 0,
149
+ };
150
+ for (const item of input.announcements) {
151
+ const existing = await (0, api_1.findOneOrNoneFor)(announcement_1.Announcement, {
152
+ deleted: { $in: [true, false] },
153
+ "release.channel": input.release.channel,
154
+ "release.product": input.release.product,
155
+ "release.version": input.release.version,
156
+ releaseSlug: item.slug,
157
+ });
158
+ const announcement = existing ??
159
+ new announcement_1.Announcement({
160
+ audience: {},
161
+ body: item.body,
162
+ priority: 0,
163
+ release: input.release,
164
+ releaseSlug: item.slug,
165
+ status: "draft",
166
+ title: item.title,
167
+ version: 1,
168
+ });
169
+ const wasCreated = announcement.isNew;
170
+ const previousValues = snapshotImportedValues(announcement);
171
+ announcement.deleted = false;
172
+ applyImportedValues({
173
+ announcement,
174
+ defaults: input.defaults,
175
+ item,
176
+ publish: input.publish,
177
+ release: input.release,
178
+ });
179
+ const hasChanges = wasCreated || previousValues !== snapshotImportedValues(announcement);
180
+ if (hasChanges) {
181
+ await announcement.save();
182
+ }
183
+ if (wasCreated) {
184
+ result.created += 1;
185
+ }
186
+ else if (hasChanges) {
187
+ result.updated += 1;
188
+ }
189
+ else {
190
+ result.unchanged += 1;
191
+ }
192
+ if (input.publish && announcement.status === "published") {
193
+ result.published += 1;
194
+ }
195
+ result.announcements.push({
196
+ id: announcement._id.toString(),
197
+ slug: item.slug,
198
+ status: announcement.status,
199
+ version: announcement.version,
200
+ });
201
+ }
202
+ return result;
203
+ };
204
+ exports.importAnnouncementRelease = importAnnouncementRelease;
@@ -0,0 +1,32 @@
1
+ import { z } from "@terreno/api";
2
+ export declare const announcementPlatformSchema: z.ZodEnum<{
3
+ ios: "ios";
4
+ android: "android";
5
+ web: "web";
6
+ }>;
7
+ export declare const announcementImpressionBodySchema: z.ZodObject<{
8
+ platform: z.ZodOptional<z.ZodEnum<{
9
+ ios: "ios";
10
+ android: "android";
11
+ web: "web";
12
+ }>>;
13
+ }, z.core.$strict>;
14
+ export declare const announcementClickBodySchema: z.ZodObject<{
15
+ action: z.ZodLiteral<"primaryAction">;
16
+ platform: z.ZodOptional<z.ZodEnum<{
17
+ ios: "ios";
18
+ android: "android";
19
+ web: "web";
20
+ }>>;
21
+ }, z.core.$strict>;
22
+ export declare const announcementClickPlatformSchema: z.ZodObject<{
23
+ platform: z.ZodOptional<z.ZodEnum<{
24
+ ios: "ios";
25
+ android: "android";
26
+ web: "web";
27
+ }>>;
28
+ }, z.core.$loose>;
29
+ export declare const parseAnnouncementBody: <TSchema extends z.ZodType>({ body, schema, }: {
30
+ body: unknown;
31
+ schema: TSchema;
32
+ }) => z.output<TSchema>;
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseAnnouncementBody = exports.announcementClickPlatformSchema = exports.announcementClickBodySchema = exports.announcementImpressionBodySchema = exports.announcementPlatformSchema = void 0;
4
+ const api_1 = require("@terreno/api");
5
+ exports.announcementPlatformSchema = api_1.z.enum(["ios", "android", "web"]);
6
+ exports.announcementImpressionBodySchema = api_1.z
7
+ .object({
8
+ platform: exports.announcementPlatformSchema.optional(),
9
+ })
10
+ .strict();
11
+ exports.announcementClickBodySchema = api_1.z
12
+ .object({
13
+ action: api_1.z.literal("primaryAction"),
14
+ platform: exports.announcementPlatformSchema.optional(),
15
+ })
16
+ .strict();
17
+ // Visibility checks need the platform before the rest of the click body is validated, so hidden
18
+ // announcements return 404 regardless of the action.
19
+ exports.announcementClickPlatformSchema = exports.announcementClickBodySchema
20
+ .pick({ platform: true })
21
+ .loose();
22
+ const toFieldErrors = (error) => {
23
+ const fields = {};
24
+ for (const issue of error.issues) {
25
+ const key = issue.path.length > 0 ? issue.path.join(".") : "body";
26
+ fields[key] ??= issue.message;
27
+ }
28
+ return fields;
29
+ };
30
+ const parseAnnouncementBody = ({ body, schema, }) => {
31
+ const parsed = schema.safeParse(body ?? {});
32
+ if (!parsed.success) {
33
+ throw new api_1.ValidationError({
34
+ code: "announcement-body-validation-failed",
35
+ detail: "The request body did not match the announcement schema",
36
+ fields: toFieldErrors(parsed.error),
37
+ title: "Validation failed",
38
+ });
39
+ }
40
+ return parsed.data;
41
+ };
42
+ exports.parseAnnouncementBody = parseAnnouncementBody;
package/dist/types.d.ts CHANGED
@@ -9,8 +9,15 @@ export interface AnnouncementPrimaryAction {
9
9
  label: string;
10
10
  url: string;
11
11
  }
12
+ export interface AnnouncementRelease {
13
+ buildNumber?: number;
14
+ channel: string;
15
+ product: string;
16
+ version: string;
17
+ }
12
18
  export interface AnnouncementDocument {
13
19
  _id: Types.ObjectId;
20
+ deleted: boolean;
14
21
  title: string;
15
22
  body: string;
16
23
  status: AnnouncementStatus;
@@ -25,6 +32,8 @@ export interface AnnouncementDocument {
25
32
  expiresAt?: Date;
26
33
  platforms: AnnouncementPlatform[];
27
34
  primaryAction?: AnnouncementPrimaryAction;
35
+ release?: AnnouncementRelease;
36
+ releaseSlug?: string;
28
37
  publishedAt?: Date;
29
38
  archivedAt?: Date;
30
39
  created: Date;
@@ -90,6 +99,8 @@ export interface AnnouncementsOptions {
90
99
  /** When omitted, staff targeting uses `user.admin === true`. */
91
100
  isStaff?: (user: unknown) => boolean;
92
101
  matchAudience?: MatchAudienceFunction;
102
+ /** Dedicated bearer token accepted by POST /announcements/import-release. */
103
+ uploadToken?: string;
93
104
  permissions?: Partial<{
94
105
  create: PermissionMethod<AnnouncementDocument>[];
95
106
  delete: PermissionMethod<AnnouncementDocument>[];
package/package.json CHANGED
@@ -4,14 +4,14 @@
4
4
  "url": "https://github.com/TerrenoLabs/terreno/issues"
5
5
  },
6
6
  "dependencies": {
7
- "@terreno/api": "57.6.1",
7
+ "@terreno/api": "57.8.0",
8
8
  "express": "^5.2.1",
9
9
  "luxon": "^3.7.2"
10
10
  },
11
11
  "description": "In-app product update announcements plugin for @terreno/api — part of Terreno, the batteries-included TypeScript framework for universal apps.",
12
12
  "devDependencies": {
13
13
  "@biomejs/biome": "^2.3.6",
14
- "@terreno/test": "57.6.1",
14
+ "@terreno/test": "57.8.0",
15
15
  "@types/bun": "^1.4.0",
16
16
  "@types/express": "^5.0.6",
17
17
  "@types/supertest": "^7.2.0",
@@ -53,5 +53,5 @@
53
53
  "test:coverage": "bun run ../scripts/check-coverage.ts"
54
54
  },
55
55
  "types": "dist/index.d.ts",
56
- "version": "57.6.1"
56
+ "version": "57.8.0"
57
57
  }