@terreno/announcements 57.7.0 → 57.9.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/src/announcementsApp.ts
CHANGED
|
@@ -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
|
|
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
|
|
438
|
-
|
|
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
|
|
471
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|