@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.
- package/dist/announcementsApp.js +28 -16
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -1
- package/dist/models/announcement.js +42 -1
- package/dist/models/announcementClickEvent.d.ts +1 -2
- package/dist/models/announcementClickEvent.js +1 -3
- package/dist/releaseImport.d.ts +91 -0
- package/dist/releaseImport.js +204 -0
- package/dist/requestBodies.d.ts +32 -0
- package/dist/requestBodies.js +42 -0
- package/dist/types.d.ts +11 -0
- package/package.json +3 -3
- package/src/announcementsApp.ts +39 -18
- package/src/index.ts +11 -0
- package/src/models/announcement.ts +46 -1
- package/src/models/announcementClickEvent.ts +1 -8
- package/src/releaseImport.ts +258 -0
- package/src/requestBodies.ts +50 -0
- package/src/tests/announcementsApp.test.ts +281 -0
- package/src/types.ts +12 -0
package/dist/announcementsApp.js
CHANGED
|
@@ -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
|
|
326
|
-
|
|
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
|
|
351
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
56
|
+
"version": "57.8.0"
|
|
57
57
|
}
|