@terreno/announcements 57.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/biome.jsonc +20 -0
- package/bunfig.toml +2 -0
- package/dist/announcementsApp.d.ts +9 -0
- package/dist/announcementsApp.js +820 -0
- package/dist/help.d.ts +30 -0
- package/dist/help.js +72 -0
- package/dist/helpRoutes.d.ts +8 -0
- package/dist/helpRoutes.js +233 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +34 -0
- package/dist/models/announcement.d.ts +3 -0
- package/dist/models/announcement.js +214 -0
- package/dist/models/announcementAcknowledgement.d.ts +2 -0
- package/dist/models/announcementAcknowledgement.js +41 -0
- package/dist/models/announcementClickEvent.d.ts +3 -0
- package/dist/models/announcementClickEvent.js +56 -0
- package/dist/models/announcementImpression.d.ts +3 -0
- package/dist/models/announcementImpression.js +50 -0
- package/dist/overview.d.ts +14 -0
- package/dist/overview.js +310 -0
- package/dist/pending.d.ts +64 -0
- package/dist/pending.js +242 -0
- package/dist/types.d.ts +135 -0
- package/dist/types.js +2 -0
- package/package.json +57 -0
- package/src/announcementsApp.ts +722 -0
- package/src/help.ts +98 -0
- package/src/helpRoutes.ts +160 -0
- package/src/index.ts +45 -0
- package/src/models/announcement.ts +189 -0
- package/src/models/announcementAcknowledgement.ts +50 -0
- package/src/models/announcementClickEvent.ts +68 -0
- package/src/models/announcementImpression.ts +62 -0
- package/src/overview.ts +228 -0
- package/src/pending.ts +286 -0
- package/src/tests/announcementsApp.test.ts +844 -0
- package/src/tests/bunSetup.ts +9 -0
- package/src/tests/help.test.ts +29 -0
- package/src/tests/helpRoutes.test.ts +291 -0
- package/src/tests/index.test.ts +57 -0
- package/src/tests/overview.test.ts +351 -0
- package/src/tests/pending.test.ts +262 -0
- package/src/types.ts +157 -0
- package/tsconfig.json +33 -0
package/src/help.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {requiresAcknowledgementForAnnouncement} from "./pending";
|
|
2
|
+
import type {AcknowledgementPolicy, AnnouncementDocument, AnnouncementStatus} from "./types";
|
|
3
|
+
|
|
4
|
+
export interface AnnouncementHelpSummary {
|
|
5
|
+
archivedAt?: string;
|
|
6
|
+
excerpt: string;
|
|
7
|
+
id: string;
|
|
8
|
+
publishedAt?: string;
|
|
9
|
+
status: AnnouncementStatus;
|
|
10
|
+
title: string;
|
|
11
|
+
version: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AnnouncementHelpDetail {
|
|
15
|
+
archivedAt?: string;
|
|
16
|
+
body: string;
|
|
17
|
+
expiresAt?: string;
|
|
18
|
+
id: string;
|
|
19
|
+
platforms: AnnouncementDocument["platforms"];
|
|
20
|
+
primaryAction?: AnnouncementDocument["primaryAction"];
|
|
21
|
+
priority: number;
|
|
22
|
+
publishAt?: string;
|
|
23
|
+
publishedAt?: string;
|
|
24
|
+
requiresAcknowledgement: boolean;
|
|
25
|
+
status: AnnouncementStatus;
|
|
26
|
+
title: string;
|
|
27
|
+
version: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const buildHelpStatusFilter = (includeArchived: boolean): AnnouncementStatus[] => {
|
|
31
|
+
if (includeArchived) {
|
|
32
|
+
return ["published", "archived"];
|
|
33
|
+
}
|
|
34
|
+
return ["published"];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const excerptBody = (body: string, maxLength = 240): string => {
|
|
38
|
+
const normalized = body.replace(/\s+/g, " ").trim();
|
|
39
|
+
if (normalized.length <= maxLength) {
|
|
40
|
+
return normalized;
|
|
41
|
+
}
|
|
42
|
+
return `${normalized.slice(0, maxLength)}…`;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const matchesSingleQuery = (
|
|
46
|
+
doc: Pick<AnnouncementDocument, "body" | "title">,
|
|
47
|
+
query: string
|
|
48
|
+
): boolean => {
|
|
49
|
+
const normalized = query.trim().toLowerCase();
|
|
50
|
+
if (!normalized) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
const haystack = `${doc.title}\n${doc.body}`.toLowerCase();
|
|
54
|
+
return haystack.includes(normalized);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const matchesHelpQueries = (
|
|
58
|
+
doc: Pick<AnnouncementDocument, "body" | "title">,
|
|
59
|
+
queries: string[]
|
|
60
|
+
): boolean => {
|
|
61
|
+
const activeQueries = queries.map((query) => query.trim()).filter(Boolean);
|
|
62
|
+
if (activeQueries.length === 0) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
return activeQueries.some((query) => matchesSingleQuery(doc, query));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const toHelpSummary = (doc: AnnouncementDocument): AnnouncementHelpSummary => ({
|
|
69
|
+
archivedAt: doc.archivedAt?.toISOString(),
|
|
70
|
+
excerpt: excerptBody(doc.body),
|
|
71
|
+
id: doc._id.toString(),
|
|
72
|
+
publishedAt: doc.publishedAt?.toISOString(),
|
|
73
|
+
status: doc.status,
|
|
74
|
+
title: doc.title,
|
|
75
|
+
version: doc.version,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export const toHelpDetail = (
|
|
79
|
+
doc: AnnouncementDocument,
|
|
80
|
+
defaultAcknowledgementPolicy: AcknowledgementPolicy = "dismiss-only"
|
|
81
|
+
): AnnouncementHelpDetail => ({
|
|
82
|
+
archivedAt: doc.archivedAt?.toISOString(),
|
|
83
|
+
body: doc.body,
|
|
84
|
+
expiresAt: doc.expiresAt?.toISOString(),
|
|
85
|
+
id: doc._id.toString(),
|
|
86
|
+
platforms: doc.platforms,
|
|
87
|
+
primaryAction: doc.primaryAction,
|
|
88
|
+
priority: doc.priority,
|
|
89
|
+
publishAt: doc.publishAt?.toISOString(),
|
|
90
|
+
publishedAt: doc.publishedAt?.toISOString(),
|
|
91
|
+
requiresAcknowledgement: requiresAcknowledgementForAnnouncement({
|
|
92
|
+
announcement: doc,
|
|
93
|
+
defaultAcknowledgementPolicy,
|
|
94
|
+
}),
|
|
95
|
+
status: doc.status,
|
|
96
|
+
title: doc.title,
|
|
97
|
+
version: doc.version,
|
|
98
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import {APIError, asyncHandler, authenticateMiddleware} from "@terreno/api";
|
|
2
|
+
import {type Request, type Response, Router} from "express";
|
|
3
|
+
import {buildHelpStatusFilter, matchesHelpQueries, toHelpDetail, toHelpSummary} from "./help";
|
|
4
|
+
import {Announcement} from "./models/announcement";
|
|
5
|
+
import {
|
|
6
|
+
isAnnouncementVisibleNow,
|
|
7
|
+
matchAudienceByType,
|
|
8
|
+
parseQueryVersion,
|
|
9
|
+
passesMinBuildNumber,
|
|
10
|
+
} from "./pending";
|
|
11
|
+
import type {AcknowledgementPolicy, AnnouncementDocument, MatchAudienceFunction} from "./types";
|
|
12
|
+
|
|
13
|
+
const defaultIsStaff = (user: unknown): boolean => (user as {admin?: boolean}).admin === true;
|
|
14
|
+
|
|
15
|
+
const parseHelpQueries = (req: Request): string[] => {
|
|
16
|
+
const raw = req.query.q;
|
|
17
|
+
if (typeof raw === "string" && raw.trim()) {
|
|
18
|
+
return [raw.trim()];
|
|
19
|
+
}
|
|
20
|
+
if (Array.isArray(raw)) {
|
|
21
|
+
return raw.filter(
|
|
22
|
+
(value): value is string => typeof value === "string" && value.trim().length > 0
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return [];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const parseIncludeArchived = (req: Request): boolean => {
|
|
29
|
+
const raw = req.query.includeArchived;
|
|
30
|
+
if (raw === "true" || raw === "1") {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const parseLimit = (req: Request): number => {
|
|
37
|
+
const parsed = Number(req.query.limit ?? 10);
|
|
38
|
+
return Math.min(Math.max(parsed, 1), 50);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const isAnnouncementHelpVisible = (announcement: AnnouncementDocument): boolean => {
|
|
42
|
+
if (announcement.status === "archived") {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return isAnnouncementVisibleNow({announcement});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const sortHelpResults = (docs: AnnouncementDocument[]): AnnouncementDocument[] => {
|
|
49
|
+
return [...docs].sort((left, right) => {
|
|
50
|
+
if (right.priority !== left.priority) {
|
|
51
|
+
return right.priority - left.priority;
|
|
52
|
+
}
|
|
53
|
+
const rightPublished = right.publishedAt?.getTime() ?? 0;
|
|
54
|
+
const leftPublished = left.publishedAt?.getTime() ?? 0;
|
|
55
|
+
return rightPublished - leftPublished;
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const registerAnnouncementHelpRoutes = ({
|
|
60
|
+
app,
|
|
61
|
+
basePath,
|
|
62
|
+
defaultAcknowledgementPolicy = "dismiss-only",
|
|
63
|
+
isStaff = defaultIsStaff,
|
|
64
|
+
matchAudience,
|
|
65
|
+
}: {
|
|
66
|
+
app: import("express").Application;
|
|
67
|
+
basePath: string;
|
|
68
|
+
defaultAcknowledgementPolicy?: AcknowledgementPolicy;
|
|
69
|
+
isStaff?: (user: unknown) => boolean;
|
|
70
|
+
matchAudience?: MatchAudienceFunction;
|
|
71
|
+
}): void => {
|
|
72
|
+
const audienceMatcher = matchAudience ?? (() => true);
|
|
73
|
+
|
|
74
|
+
const matchesAnnouncementAudience = async (
|
|
75
|
+
user: unknown,
|
|
76
|
+
announcement: AnnouncementDocument
|
|
77
|
+
): Promise<boolean> => {
|
|
78
|
+
if (!matchAudienceByType({announcement, isStaff, user})) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return audienceMatcher(user, announcement);
|
|
82
|
+
};
|
|
83
|
+
const router = Router();
|
|
84
|
+
|
|
85
|
+
router.get(
|
|
86
|
+
"/help/search",
|
|
87
|
+
authenticateMiddleware(),
|
|
88
|
+
asyncHandler(async (req: Request, res: Response) => {
|
|
89
|
+
const user = req.user;
|
|
90
|
+
if (!user) {
|
|
91
|
+
throw new APIError({status: 401, title: "Authentication required"});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const queries = parseHelpQueries(req);
|
|
95
|
+
const includeArchived = parseIncludeArchived(req);
|
|
96
|
+
const limit = parseLimit(req);
|
|
97
|
+
const queryVersion = parseQueryVersion(req.query.version);
|
|
98
|
+
const statuses = buildHelpStatusFilter(includeArchived);
|
|
99
|
+
|
|
100
|
+
const candidates = (await Announcement.find({status: {$in: statuses}}).lean()) as Array<
|
|
101
|
+
AnnouncementDocument & {requiresAcknowledgement?: boolean}
|
|
102
|
+
>;
|
|
103
|
+
const matched: AnnouncementDocument[] = [];
|
|
104
|
+
for (const doc of candidates) {
|
|
105
|
+
if (!isAnnouncementHelpVisible(doc)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!passesMinBuildNumber({announcement: doc, queryVersion})) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const matchesAudience = await matchesAnnouncementAudience(user, doc);
|
|
112
|
+
if (!matchesAudience) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!matchesHelpQueries(doc, queries)) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
matched.push(doc);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const page = sortHelpResults(matched).slice(0, limit).map(toHelpSummary);
|
|
122
|
+
|
|
123
|
+
return res.json({data: page, total: matched.length});
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
router.get(
|
|
128
|
+
"/help/:id",
|
|
129
|
+
authenticateMiddleware(),
|
|
130
|
+
asyncHandler(async (req: Request, res: Response) => {
|
|
131
|
+
const user = req.user;
|
|
132
|
+
if (!user) {
|
|
133
|
+
throw new APIError({status: 401, title: "Authentication required"});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const includeArchived = parseIncludeArchived(req);
|
|
137
|
+
const queryVersion = parseQueryVersion(req.query.version);
|
|
138
|
+
const statuses = buildHelpStatusFilter(includeArchived);
|
|
139
|
+
const announcement = (await Announcement.findById(req.params.id).lean()) as
|
|
140
|
+
| (AnnouncementDocument & {requiresAcknowledgement?: boolean})
|
|
141
|
+
| null;
|
|
142
|
+
if (!announcement || !statuses.includes(announcement.status)) {
|
|
143
|
+
throw new APIError({status: 404, title: "Update note not found"});
|
|
144
|
+
}
|
|
145
|
+
if (!isAnnouncementHelpVisible(announcement)) {
|
|
146
|
+
throw new APIError({status: 404, title: "Update note not found"});
|
|
147
|
+
}
|
|
148
|
+
if (!passesMinBuildNumber({announcement, queryVersion})) {
|
|
149
|
+
throw new APIError({status: 404, title: "Update note not found"});
|
|
150
|
+
}
|
|
151
|
+
const matchesAudience = await matchesAnnouncementAudience(user, announcement);
|
|
152
|
+
if (!matchesAudience) {
|
|
153
|
+
throw new APIError({status: 404, title: "Update note not found"});
|
|
154
|
+
}
|
|
155
|
+
return res.json({data: toHelpDetail(announcement, defaultAcknowledgementPolicy)});
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
app.use(basePath, router);
|
|
160
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export {AnnouncementsApp} from "./announcementsApp";
|
|
2
|
+
export type {AnnouncementHelpDetail, AnnouncementHelpSummary} from "./help";
|
|
3
|
+
export {
|
|
4
|
+
buildHelpStatusFilter,
|
|
5
|
+
excerptBody,
|
|
6
|
+
matchesHelpQueries,
|
|
7
|
+
toHelpDetail,
|
|
8
|
+
toHelpSummary,
|
|
9
|
+
} from "./help";
|
|
10
|
+
export {Announcement, toAnnouncementPublic} from "./models/announcement";
|
|
11
|
+
export {AnnouncementAcknowledgement} from "./models/announcementAcknowledgement";
|
|
12
|
+
export {AnnouncementClickEvent} from "./models/announcementClickEvent";
|
|
13
|
+
export {AnnouncementImpression} from "./models/announcementImpression";
|
|
14
|
+
export {
|
|
15
|
+
isAnnouncementPendingForUser,
|
|
16
|
+
isAnnouncementVisibleNow,
|
|
17
|
+
isAnnouncementVisibleToUser,
|
|
18
|
+
isInterruptDisplayMode,
|
|
19
|
+
matchAudienceByType,
|
|
20
|
+
parseQueryVersion,
|
|
21
|
+
passesMinBuildNumber,
|
|
22
|
+
requiresAcknowledgementForAnnouncement,
|
|
23
|
+
resolveAcknowledgementPolicy,
|
|
24
|
+
resolveAudienceType,
|
|
25
|
+
resolveDisplayMode,
|
|
26
|
+
selectPendingAnnouncements,
|
|
27
|
+
sortAnnouncementsForQueue,
|
|
28
|
+
} from "./pending";
|
|
29
|
+
export type {
|
|
30
|
+
AcknowledgementPolicy,
|
|
31
|
+
AnnouncementAudienceType,
|
|
32
|
+
AnnouncementClickAction,
|
|
33
|
+
AnnouncementDisplayMode,
|
|
34
|
+
AnnouncementDocument,
|
|
35
|
+
AnnouncementOverviewMetrics,
|
|
36
|
+
AnnouncementOverviewResponse,
|
|
37
|
+
AnnouncementOverviewRow,
|
|
38
|
+
AnnouncementOverviewTotals,
|
|
39
|
+
AnnouncementPlatform,
|
|
40
|
+
AnnouncementPrimaryAction,
|
|
41
|
+
AnnouncementPublic,
|
|
42
|
+
AnnouncementsHelpOptions,
|
|
43
|
+
AnnouncementsOptions,
|
|
44
|
+
MatchAudienceFunction,
|
|
45
|
+
} from "./types";
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {
|
|
2
|
+
APIError,
|
|
3
|
+
createdUpdatedPlugin,
|
|
4
|
+
findExactlyOne,
|
|
5
|
+
findOneOrNone,
|
|
6
|
+
isDeletedPlugin,
|
|
7
|
+
} from "@terreno/api";
|
|
8
|
+
import mongoose from "mongoose";
|
|
9
|
+
import {requiresAcknowledgementForAnnouncement, resolveDisplayMode} from "../pending";
|
|
10
|
+
import type {
|
|
11
|
+
AcknowledgementPolicy,
|
|
12
|
+
AnnouncementDocument,
|
|
13
|
+
AnnouncementModel,
|
|
14
|
+
AnnouncementPlatform,
|
|
15
|
+
AnnouncementPrimaryAction,
|
|
16
|
+
AnnouncementPublic,
|
|
17
|
+
} from "../types";
|
|
18
|
+
|
|
19
|
+
const ALL_PLATFORMS: AnnouncementPlatform[] = ["ios", "android", "web"];
|
|
20
|
+
|
|
21
|
+
const announcementSchema = new mongoose.Schema<AnnouncementDocument, AnnouncementModel>(
|
|
22
|
+
{
|
|
23
|
+
acknowledgementPolicy: {
|
|
24
|
+
description:
|
|
25
|
+
"Whether users must acknowledge (required) or may dismiss with an impression only (dismiss-only). Omitted values resolve from the plugin defaultAcknowledgementPolicy at read time.",
|
|
26
|
+
enum: ["required", "dismiss-only"],
|
|
27
|
+
type: String,
|
|
28
|
+
},
|
|
29
|
+
archivedAt: {
|
|
30
|
+
description: "When the announcement was archived",
|
|
31
|
+
type: Date,
|
|
32
|
+
},
|
|
33
|
+
audience: {
|
|
34
|
+
default: {},
|
|
35
|
+
description: "Opaque targeting metadata consumed by matchAudience callback",
|
|
36
|
+
type: mongoose.Schema.Types.Mixed,
|
|
37
|
+
},
|
|
38
|
+
audienceType: {
|
|
39
|
+
default: "all",
|
|
40
|
+
description:
|
|
41
|
+
"First-class audience targeting: staff, patient, or all. Composed with matchAudience via matchAudienceByType.",
|
|
42
|
+
enum: ["staff", "patient", "all"],
|
|
43
|
+
type: String,
|
|
44
|
+
},
|
|
45
|
+
body: {
|
|
46
|
+
description: "Markdown body shown in the announcement modal",
|
|
47
|
+
required: true,
|
|
48
|
+
trim: true,
|
|
49
|
+
type: String,
|
|
50
|
+
},
|
|
51
|
+
displayMode: {
|
|
52
|
+
default: "modal",
|
|
53
|
+
description:
|
|
54
|
+
"Where the announcement appears: blocking modal, non-blocking banner, or feed-only changelog entry",
|
|
55
|
+
enum: ["modal", "banner", "feed"],
|
|
56
|
+
type: String,
|
|
57
|
+
},
|
|
58
|
+
expiresAt: {
|
|
59
|
+
description: "Optional expiry — hidden from pending/feed after this time",
|
|
60
|
+
type: Date,
|
|
61
|
+
},
|
|
62
|
+
minBuildNumber: {
|
|
63
|
+
description:
|
|
64
|
+
"Optional minimum client build number. Hidden from pending, feed, and help when query version is a finite integer below this value",
|
|
65
|
+
min: 1,
|
|
66
|
+
type: Number,
|
|
67
|
+
},
|
|
68
|
+
platforms: {
|
|
69
|
+
default: ALL_PLATFORMS,
|
|
70
|
+
description: "Platforms that should receive this announcement",
|
|
71
|
+
enum: ALL_PLATFORMS,
|
|
72
|
+
type: [String],
|
|
73
|
+
},
|
|
74
|
+
primaryAction: {
|
|
75
|
+
label: {
|
|
76
|
+
description: "Button label for optional primary action",
|
|
77
|
+
type: String,
|
|
78
|
+
},
|
|
79
|
+
url: {
|
|
80
|
+
description: "Deep link or external URL opened by the primary action button",
|
|
81
|
+
type: String,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
priority: {
|
|
85
|
+
default: 0,
|
|
86
|
+
description: "Higher priority announcements appear first in the modal queue",
|
|
87
|
+
type: Number,
|
|
88
|
+
},
|
|
89
|
+
publishAt: {
|
|
90
|
+
description: "Optional scheduled publish time — hidden until this instant",
|
|
91
|
+
type: Date,
|
|
92
|
+
},
|
|
93
|
+
publishedAt: {
|
|
94
|
+
description: "When the announcement was first published",
|
|
95
|
+
type: Date,
|
|
96
|
+
},
|
|
97
|
+
status: {
|
|
98
|
+
default: "draft",
|
|
99
|
+
description: "Lifecycle status: draft, published, or archived",
|
|
100
|
+
enum: ["draft", "published", "archived"],
|
|
101
|
+
required: true,
|
|
102
|
+
type: String,
|
|
103
|
+
},
|
|
104
|
+
title: {
|
|
105
|
+
description: "Announcement title shown in modal and changelog feed",
|
|
106
|
+
required: true,
|
|
107
|
+
trim: true,
|
|
108
|
+
type: String,
|
|
109
|
+
},
|
|
110
|
+
version: {
|
|
111
|
+
default: 1,
|
|
112
|
+
description: "Content version — increments when published title/body changes",
|
|
113
|
+
min: 1,
|
|
114
|
+
type: Number,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
announcementSchema.plugin(createdUpdatedPlugin);
|
|
121
|
+
announcementSchema.plugin(isDeletedPlugin);
|
|
122
|
+
announcementSchema.plugin(findExactlyOne);
|
|
123
|
+
announcementSchema.plugin(findOneOrNone);
|
|
124
|
+
|
|
125
|
+
announcementSchema.index({publishAt: 1, status: 1});
|
|
126
|
+
announcementSchema.index({priority: -1, publishedAt: -1, status: 1});
|
|
127
|
+
|
|
128
|
+
announcementSchema.pre("save", async function bumpVersionOnPublishedEdit() {
|
|
129
|
+
if (this.isNew) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (this.status !== "published") {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!this.isModified("title") && !this.isModified("body")) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const previous = await Announcement.findById(this._id).select("title body status version");
|
|
140
|
+
if (previous?.status !== "published") {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (previous.title === this.title && previous.body === this.body) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this.version = (previous.version ?? 1) + 1;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
announcementSchema.pre("validate", function validatePrimaryAction() {
|
|
151
|
+
const action = this.primaryAction as AnnouncementPrimaryAction | undefined;
|
|
152
|
+
if (!action) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const hasLabel = Boolean(action.label?.trim());
|
|
156
|
+
const hasUrl = Boolean(action.url?.trim());
|
|
157
|
+
if (!hasLabel && !hasUrl) {
|
|
158
|
+
this.primaryAction = undefined;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (!hasLabel || !hasUrl) {
|
|
162
|
+
throw new APIError({
|
|
163
|
+
status: 400,
|
|
164
|
+
title: "primaryAction requires both label and url when provided",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
export const Announcement =
|
|
170
|
+
(mongoose.models.Announcement as AnnouncementModel) ??
|
|
171
|
+
mongoose.model<AnnouncementDocument, AnnouncementModel>("Announcement", announcementSchema);
|
|
172
|
+
|
|
173
|
+
export const toAnnouncementPublic = (
|
|
174
|
+
doc: AnnouncementDocument,
|
|
175
|
+
defaultAcknowledgementPolicy: AcknowledgementPolicy = "dismiss-only"
|
|
176
|
+
): AnnouncementPublic => ({
|
|
177
|
+
body: doc.body,
|
|
178
|
+
displayMode: resolveDisplayMode(doc),
|
|
179
|
+
id: doc._id.toString(),
|
|
180
|
+
primaryAction: doc.primaryAction,
|
|
181
|
+
priority: doc.priority,
|
|
182
|
+
publishedAt: doc.publishedAt?.toISOString(),
|
|
183
|
+
requiresAcknowledgement: requiresAcknowledgementForAnnouncement({
|
|
184
|
+
announcement: doc,
|
|
185
|
+
defaultAcknowledgementPolicy,
|
|
186
|
+
}),
|
|
187
|
+
title: doc.title,
|
|
188
|
+
version: doc.version,
|
|
189
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {createdUpdatedPlugin, findExactlyOne, findOneOrNone, isDeletedPlugin} from "@terreno/api";
|
|
2
|
+
import mongoose from "mongoose";
|
|
3
|
+
import type {AnnouncementAcknowledgementDocument, AnnouncementAcknowledgementModel} from "../types";
|
|
4
|
+
|
|
5
|
+
const announcementAcknowledgementSchema = new mongoose.Schema<
|
|
6
|
+
AnnouncementAcknowledgementDocument,
|
|
7
|
+
AnnouncementAcknowledgementModel
|
|
8
|
+
>(
|
|
9
|
+
{
|
|
10
|
+
acknowledgedAt: {
|
|
11
|
+
description: "When the user acknowledged this announcement version",
|
|
12
|
+
required: true,
|
|
13
|
+
type: Date,
|
|
14
|
+
},
|
|
15
|
+
announcementId: {
|
|
16
|
+
description: "Announcement that was acknowledged",
|
|
17
|
+
index: true,
|
|
18
|
+
ref: "Announcement",
|
|
19
|
+
required: true,
|
|
20
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
21
|
+
},
|
|
22
|
+
userId: {
|
|
23
|
+
description: "User who acknowledged the announcement",
|
|
24
|
+
index: true,
|
|
25
|
+
ref: "User",
|
|
26
|
+
required: true,
|
|
27
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
28
|
+
},
|
|
29
|
+
version: {
|
|
30
|
+
description: "Announcement version acknowledged by the user",
|
|
31
|
+
required: true,
|
|
32
|
+
type: Number,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
announcementAcknowledgementSchema.plugin(createdUpdatedPlugin);
|
|
39
|
+
announcementAcknowledgementSchema.plugin(isDeletedPlugin);
|
|
40
|
+
announcementAcknowledgementSchema.plugin(findExactlyOne);
|
|
41
|
+
announcementAcknowledgementSchema.plugin(findOneOrNone);
|
|
42
|
+
|
|
43
|
+
announcementAcknowledgementSchema.index({announcementId: 1, userId: 1, version: 1}, {unique: true});
|
|
44
|
+
|
|
45
|
+
export const AnnouncementAcknowledgement =
|
|
46
|
+
(mongoose.models.AnnouncementAcknowledgement as AnnouncementAcknowledgementModel) ??
|
|
47
|
+
mongoose.model<AnnouncementAcknowledgementDocument, AnnouncementAcknowledgementModel>(
|
|
48
|
+
"AnnouncementAcknowledgement",
|
|
49
|
+
announcementAcknowledgementSchema
|
|
50
|
+
);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {createdUpdatedPlugin, findExactlyOne, findOneOrNone, isDeletedPlugin} from "@terreno/api";
|
|
2
|
+
import mongoose from "mongoose";
|
|
3
|
+
import type {
|
|
4
|
+
AnnouncementClickAction,
|
|
5
|
+
AnnouncementClickEventDocument,
|
|
6
|
+
AnnouncementClickEventModel,
|
|
7
|
+
} from "../types";
|
|
8
|
+
|
|
9
|
+
const announcementClickEventSchema = new mongoose.Schema<
|
|
10
|
+
AnnouncementClickEventDocument,
|
|
11
|
+
AnnouncementClickEventModel
|
|
12
|
+
>(
|
|
13
|
+
{
|
|
14
|
+
action: {
|
|
15
|
+
description: "Which announcement action the user clicked",
|
|
16
|
+
enum: ["primaryAction"],
|
|
17
|
+
required: true,
|
|
18
|
+
type: String,
|
|
19
|
+
},
|
|
20
|
+
announcementId: {
|
|
21
|
+
description: "Announcement whose primary action was clicked",
|
|
22
|
+
index: true,
|
|
23
|
+
ref: "Announcement",
|
|
24
|
+
required: true,
|
|
25
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
26
|
+
},
|
|
27
|
+
clickedAt: {
|
|
28
|
+
description: "When the primary action was clicked",
|
|
29
|
+
required: true,
|
|
30
|
+
type: Date,
|
|
31
|
+
},
|
|
32
|
+
platform: {
|
|
33
|
+
description: "Client platform where the click occurred",
|
|
34
|
+
enum: ["ios", "android", "web"],
|
|
35
|
+
type: String,
|
|
36
|
+
},
|
|
37
|
+
userId: {
|
|
38
|
+
description: "User who clicked the announcement action",
|
|
39
|
+
index: true,
|
|
40
|
+
ref: "User",
|
|
41
|
+
required: true,
|
|
42
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
43
|
+
},
|
|
44
|
+
version: {
|
|
45
|
+
description: "Announcement version at click time",
|
|
46
|
+
required: true,
|
|
47
|
+
type: Number,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
announcementClickEventSchema.plugin(createdUpdatedPlugin);
|
|
54
|
+
announcementClickEventSchema.plugin(isDeletedPlugin);
|
|
55
|
+
announcementClickEventSchema.plugin(findExactlyOne);
|
|
56
|
+
announcementClickEventSchema.plugin(findOneOrNone);
|
|
57
|
+
|
|
58
|
+
announcementClickEventSchema.index({announcementId: 1, clickedAt: -1});
|
|
59
|
+
|
|
60
|
+
export const AnnouncementClickEvent =
|
|
61
|
+
(mongoose.models.AnnouncementClickEvent as AnnouncementClickEventModel) ??
|
|
62
|
+
mongoose.model<AnnouncementClickEventDocument, AnnouncementClickEventModel>(
|
|
63
|
+
"AnnouncementClickEvent",
|
|
64
|
+
announcementClickEventSchema
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
export const isValidClickAction = (value: unknown): value is AnnouncementClickAction =>
|
|
68
|
+
value === "primaryAction";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {createdUpdatedPlugin, findExactlyOne, findOneOrNone, isDeletedPlugin} from "@terreno/api";
|
|
2
|
+
import mongoose from "mongoose";
|
|
3
|
+
import type {
|
|
4
|
+
AnnouncementImpressionDocument,
|
|
5
|
+
AnnouncementImpressionModel,
|
|
6
|
+
AnnouncementPlatform,
|
|
7
|
+
} from "../types";
|
|
8
|
+
|
|
9
|
+
const announcementImpressionSchema = new mongoose.Schema<
|
|
10
|
+
AnnouncementImpressionDocument,
|
|
11
|
+
AnnouncementImpressionModel
|
|
12
|
+
>(
|
|
13
|
+
{
|
|
14
|
+
announcementId: {
|
|
15
|
+
description: "Announcement that was viewed",
|
|
16
|
+
index: true,
|
|
17
|
+
ref: "Announcement",
|
|
18
|
+
required: true,
|
|
19
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
20
|
+
},
|
|
21
|
+
platform: {
|
|
22
|
+
description: "Client platform where the impression occurred",
|
|
23
|
+
enum: ["ios", "android", "web"],
|
|
24
|
+
type: String,
|
|
25
|
+
},
|
|
26
|
+
userId: {
|
|
27
|
+
description: "User who viewed the announcement",
|
|
28
|
+
index: true,
|
|
29
|
+
ref: "User",
|
|
30
|
+
required: true,
|
|
31
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
32
|
+
},
|
|
33
|
+
version: {
|
|
34
|
+
description: "Announcement version viewed by the user",
|
|
35
|
+
required: true,
|
|
36
|
+
type: Number,
|
|
37
|
+
},
|
|
38
|
+
viewedAt: {
|
|
39
|
+
description: "When the announcement was viewed",
|
|
40
|
+
required: true,
|
|
41
|
+
type: Date,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
announcementImpressionSchema.plugin(createdUpdatedPlugin);
|
|
48
|
+
announcementImpressionSchema.plugin(isDeletedPlugin);
|
|
49
|
+
announcementImpressionSchema.plugin(findExactlyOne);
|
|
50
|
+
announcementImpressionSchema.plugin(findOneOrNone);
|
|
51
|
+
|
|
52
|
+
announcementImpressionSchema.index({announcementId: 1, viewedAt: -1});
|
|
53
|
+
|
|
54
|
+
export const AnnouncementImpression =
|
|
55
|
+
(mongoose.models.AnnouncementImpression as AnnouncementImpressionModel) ??
|
|
56
|
+
mongoose.model<AnnouncementImpressionDocument, AnnouncementImpressionModel>(
|
|
57
|
+
"AnnouncementImpression",
|
|
58
|
+
announcementImpressionSchema
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
export const isValidPlatform = (value: unknown): value is AnnouncementPlatform =>
|
|
62
|
+
value === "ios" || value === "android" || value === "web";
|