placementt-core 1.400.1092 → 1.400.1093

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.
@@ -0,0 +1,148 @@
1
+ import { InstituteData } from "./typeDefinitions";
2
+ import { ResolvedProducts } from "./productResolver";
3
+ /**
4
+ * Feature marketing: the things a customer already pays for but has never set
5
+ * up, and whether now is the time to tell them about one.
6
+ *
7
+ * Every feature has a wizard behind a tab that nobody finds, and the way a
8
+ * school learns it exists is an email in the right month with a card on the
9
+ * dashboard that says the same thing. This module is the registry those two
10
+ * surfaces share, and the detectors that decide "set up" from the database
11
+ * rather than from whether anyone opened the last email.
12
+ *
13
+ * "Adopted" means ever set up, never "built for this year". A school whose
14
+ * aspiration surveys ran last year and are not built for this one is not a
15
+ * marketing prospect, it is a school with a yearly step outstanding, and that
16
+ * is onboarding's to chase (onboardingPathways.ts, state `yearMissing`).
17
+ * Marketing pitches `unconfigured` and nothing else.
18
+ *
19
+ * It is deliberately pure. Adoption is judged from the institute document plus
20
+ * a handful of counts the caller fetches, and each entry says which counts it
21
+ * needs (`needs`) so the backend cron fetches nothing a feature does not ask
22
+ * for. Campaign state, cooling-off and sending live in the backend; the
23
+ * dashboard reads the campaign it is handed. Neither side works adoption out
24
+ * for itself.
25
+ */
26
+ /** Every feature the engine can market. Stable keys: they name campaign documents. */
27
+ export type FeatureMarketingKey = "wexCohort" | "alumni" | "leavers" | "aspirations" | "parents" | "skills" | "activities" | "careersLibrary" | "guidance" | "events" | "employerPortal";
28
+ export declare const FEATURE_MARKETING_KEYS: FeatureMarketingKey[];
29
+ /**
30
+ * Counts a detector may need that are not on the institute document. The
31
+ * backend fetches only the ones the features under consideration list in
32
+ * `needs`; a missing count reads as zero, so a feature whose count was not
33
+ * fetched is judged not adopted, never the other way round.
34
+ */
35
+ export type FeatureMarketingCounts = {
36
+ /** `cohorts` where oId. Work experience has been run at least once. */
37
+ cohorts?: number;
38
+ /** `parents` where oId. The parent database exists once anyone is in it. */
39
+ parents?: number;
40
+ /** `alumni` where oId. Likewise for alumni. */
41
+ alumni?: number;
42
+ /** `externalEvents` where oId. */
43
+ events?: number;
44
+ /** `providerContacts` where savedBy.{oId}.exists and uid set: employers in
45
+ * the network who hold a provider account. */
46
+ linkedProviders?: number;
47
+ /** `users` where oId and lmiViews != null: students who have opened a job
48
+ * profile in the careers library. */
49
+ careersLibraryViewers?: number;
50
+ };
51
+ export type FeatureMarketingCountKey = keyof FeatureMarketingCounts;
52
+ export type FeatureMarketingContext = {
53
+ institute: InstituteData;
54
+ products: ResolvedProducts;
55
+ counts: FeatureMarketingCounts;
56
+ now?: Date;
57
+ };
58
+ /** A window in which a campaign for the feature may START. "MM-DD" inclusive
59
+ * at both ends; wraps the year boundary when start > end. A touch already in
60
+ * flight carries on past the end of the window. */
61
+ export type FeatureMarketingSeason = {
62
+ start: string;
63
+ end: string;
64
+ };
65
+ export type FeatureMarketingEntry = {
66
+ key: FeatureMarketingKey;
67
+ /** What staff call it, on the card and in the subject line. */
68
+ label: string;
69
+ /** One line on what setting it up gets them. */
70
+ description: string;
71
+ /** The counts `adopted` reads. Anything not listed is not fetched. */
72
+ needs: FeatureMarketingCountKey[];
73
+ /** Whether this account has the feature at all. A feature nobody pays for
74
+ * is not marketed, and neither is one an account type cannot run. */
75
+ offered: (ctx: FeatureMarketingContext) => boolean;
76
+ /** Whether it has been set up, from the database and nothing else. */
77
+ adopted: (ctx: FeatureMarketingContext) => boolean;
78
+ /** When it is worth pitching. Empty means any time. */
79
+ seasons: FeatureMarketingSeason[];
80
+ /** Tie-break between features in season together. Lower goes first. */
81
+ priority: number;
82
+ /** Where the call to action lands, under the org's url prefix. */
83
+ path: string;
84
+ /** Brevo template ids, one per touch, in sending order. The first is the
85
+ * pitch, the rest are follow-ups sent only while the feature is still not
86
+ * adopted. `BREVO_TEMPLATE_UNSET` means the template has not been made
87
+ * yet and that touch is skipped, not sent blank. */
88
+ templateIds: number[];
89
+ };
90
+ /** Placeholder for a Brevo template that has not been created yet. */
91
+ export declare const BREVO_TEMPLATE_UNSET = 0;
92
+ /** Days after the campaign starts that each touch goes out. Index-matched to
93
+ * `templateIds`; a feature with fewer templates just stops sooner. */
94
+ export declare const FEATURE_MARKETING_TOUCH_DELAYS_DAYS: number[];
95
+ /**
96
+ * Whether a date falls inside any of a feature's seasons.
97
+ *
98
+ * @param {FeatureMarketingSeason[]} seasons the feature's windows; none means always.
99
+ * @param {Date} [now] the date to test.
100
+ * @return {boolean} whether a campaign could start today.
101
+ */
102
+ export declare const inFeatureMarketingSeason: (seasons: FeatureMarketingSeason[], now?: Date) => boolean;
103
+ /**
104
+ * The registry, in the order a school is asked about them when two are in
105
+ * season together. Work experience first because it is the product most
106
+ * schools bought; the careers-year programmes next in the order the September
107
+ * setup asks; the employer side last because it needs the network to exist.
108
+ */
109
+ export declare const FEATURE_MARKETING: FeatureMarketingEntry[];
110
+ export declare const FEATURE_MARKETING_BY_KEY: Record<FeatureMarketingKey, FeatureMarketingEntry>;
111
+ /**
112
+ * Which counts to fetch before judging an institute, given only what is on its
113
+ * document. Features the account does not have are skipped, so a hub is never
114
+ * charged a cohort count for a product it cannot run.
115
+ *
116
+ * @param {InstituteData} institute the institute.
117
+ * @param {ResolvedProducts} products its resolved products.
118
+ * @return {FeatureMarketingCountKey[]} the counts the offered features read.
119
+ */
120
+ export declare const featureMarketingCountsNeeded: (institute: InstituteData, products: ResolvedProducts) => FeatureMarketingCountKey[];
121
+ /** Where one feature stands for an institute. */
122
+ export type FeatureAdoption = {
123
+ key: FeatureMarketingKey;
124
+ label: string;
125
+ offered: boolean;
126
+ adopted: boolean;
127
+ inSeason: boolean;
128
+ /** Offered, not adopted and in season: a campaign could start today. */
129
+ candidate: boolean;
130
+ };
131
+ /**
132
+ * Every feature in the registry, judged for one institute.
133
+ *
134
+ * The report is the whole truth in one place: the backend picks a campaign
135
+ * from `candidate`, the admin panel shows adoption per feature, and a test can
136
+ * assert on any one line without re-running the detectors.
137
+ *
138
+ * @param {FeatureMarketingContext} ctx the institute, its products and counts.
139
+ * @return {FeatureAdoption[]} one line per feature, in registry order.
140
+ */
141
+ export declare const evaluateFeatureAdoption: (ctx: FeatureMarketingContext) => FeatureAdoption[];
142
+ /**
143
+ * The features a campaign could start for today, best first.
144
+ *
145
+ * @param {FeatureMarketingContext} ctx the institute, its products and counts.
146
+ * @return {FeatureMarketingEntry[]} candidates in priority order.
147
+ */
148
+ export declare const featureMarketingCandidates: (ctx: FeatureMarketingContext) => FeatureMarketingEntry[];
@@ -0,0 +1,243 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.featureMarketingCandidates = exports.evaluateFeatureAdoption = exports.featureMarketingCountsNeeded = exports.FEATURE_MARKETING_BY_KEY = exports.FEATURE_MARKETING = exports.inFeatureMarketingSeason = exports.FEATURE_MARKETING_TOUCH_DELAYS_DAYS = exports.BREVO_TEMPLATE_UNSET = exports.FEATURE_MARKETING_KEYS = void 0;
4
+ const activityLogging_1 = require("./activityLogging");
5
+ exports.FEATURE_MARKETING_KEYS = [
6
+ "wexCohort", "alumni", "leavers", "aspirations", "parents", "skills",
7
+ "activities", "careersLibrary", "guidance", "events", "employerPortal",
8
+ ];
9
+ /** Placeholder for a Brevo template that has not been created yet. */
10
+ exports.BREVO_TEMPLATE_UNSET = 0;
11
+ /** Days after the campaign starts that each touch goes out. Index-matched to
12
+ * `templateIds`; a feature with fewer templates just stops sooner. */
13
+ exports.FEATURE_MARKETING_TOUCH_DELAYS_DAYS = [0, 7, 21];
14
+ const mmdd = (date) => `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
15
+ /**
16
+ * Whether a date falls inside any of a feature's seasons.
17
+ *
18
+ * @param {FeatureMarketingSeason[]} seasons the feature's windows; none means always.
19
+ * @param {Date} [now] the date to test.
20
+ * @return {boolean} whether a campaign could start today.
21
+ */
22
+ const inFeatureMarketingSeason = (seasons, now = new Date()) => {
23
+ if (!seasons.length)
24
+ return true;
25
+ const today = mmdd(now);
26
+ return seasons.some(({ start, end }) => start <= end ? (today >= start && today <= end) : (today >= start || today <= end));
27
+ };
28
+ exports.inFeatureMarketingSeason = inFeatureMarketingSeason;
29
+ /** The careers-programme features are set up by the school that runs the
30
+ * students. A trust, a hub and a hub school all drive somebody else's. */
31
+ const runsOwnCareers = ({ products }) => products.runsOwnStudents && !products.isTrust;
32
+ const destinationsSetUp = (institute) => Boolean(institute.destinationsConfig?.setupCompletedAt);
33
+ /**
34
+ * The registry, in the order a school is asked about them when two are in
35
+ * season together. Work experience first because it is the product most
36
+ * schools bought; the careers-year programmes next in the order the September
37
+ * setup asks; the employer side last because it needs the network to exist.
38
+ */
39
+ exports.FEATURE_MARKETING = [
40
+ {
41
+ key: "wexCohort",
42
+ label: "Work Experience Cohort",
43
+ description: "Create a recurring group and we'll make the cohort, chase the forms and collect the feedback every year.",
44
+ needs: ["cohorts"],
45
+ offered: (ctx) => ctx.products.wex && runsOwnCareers(ctx),
46
+ adopted: ({ institute, counts }) => Object.keys(institute.cohortConfig || {}).length > 0 || (counts.cohorts || 0) > 0,
47
+ // Two runs at it: November, once the careers year is bedded in, and
48
+ // January for a school that let it slide over Christmas. Later than
49
+ // that and there is no time to get the placements sorted.
50
+ seasons: [{ start: "11-01", end: "11-30" }, { start: "01-01", end: "01-31" }],
51
+ priority: 10,
52
+ path: "/cohorts",
53
+ templateIds: [exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET],
54
+ },
55
+ {
56
+ key: "aspirations",
57
+ label: "Career Aspiration Surveys",
58
+ description: "What students want to do next, tracked across the year and compared against where they end up.",
59
+ needs: [],
60
+ offered: (ctx) => ctx.products.destinations && runsOwnCareers(ctx),
61
+ adopted: ({ institute }) => destinationsSetUp(institute) && Boolean(institute.destinationsConfig?.aspirations?.enabled),
62
+ seasons: [{ start: "09-01", end: "10-31" }, { start: "01-05", end: "02-15" }],
63
+ priority: 20,
64
+ path: "/destinations",
65
+ templateIds: [exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET],
66
+ },
67
+ {
68
+ key: "guidance",
69
+ label: "1:1 Careers Guidance",
70
+ description: "Guidance meetings and careers conversations, scheduled per year group and recorded for Gatsby.",
71
+ needs: [],
72
+ offered: (ctx) => (ctx.products.skills || ctx.products.activities) && runsOwnCareers(ctx),
73
+ adopted: ({ institute }) => Boolean(institute.guidanceConfig?.setupCompletedAt),
74
+ seasons: [{ start: "09-01", end: "11-30" }],
75
+ priority: 30,
76
+ path: "/development/guidance",
77
+ templateIds: [exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET],
78
+ },
79
+ {
80
+ key: "skills",
81
+ label: "Skills Tracking",
82
+ description: "Skills surveys for students, and for the parents and employers who see them at work.",
83
+ needs: [],
84
+ offered: (ctx) => ctx.products.skills && runsOwnCareers(ctx),
85
+ adopted: ({ institute }) => Boolean(institute.skillsAssessments),
86
+ seasons: [{ start: "09-01", end: "10-15" }, { start: "01-05", end: "02-15" }],
87
+ priority: 40,
88
+ path: "/development/assessments",
89
+ templateIds: [exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET],
90
+ },
91
+ {
92
+ key: "activities",
93
+ label: "Extracurricular Activities",
94
+ description: "Let students log what they do outside lessons and reflect on the skills it built.",
95
+ needs: [],
96
+ offered: (ctx) => ctx.products.activities && runsOwnCareers(ctx),
97
+ // Launched at least once. A lapsed launch is a "set up this year" card,
98
+ // which the careers-year prompt already handles; it is not a pitch.
99
+ adopted: ({ institute, now }) => (0, activityLogging_1.activityLoggingState)(institute, now) !== "unconfigured",
100
+ seasons: [{ start: "09-01", end: "10-31" }, { start: "01-05", end: "01-31" }],
101
+ priority: 50,
102
+ path: "/development/activities",
103
+ templateIds: [exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET],
104
+ },
105
+ {
106
+ key: "careersLibrary",
107
+ label: "Careers Library",
108
+ description: "Job profiles with real pay, prospects and routes in, for students to browse on their own.",
109
+ needs: ["careersLibraryViewers"],
110
+ offered: (ctx) => ctx.products.destinations && runsOwnCareers(ctx),
111
+ // There is no switch to flip for the library, so "set up" is students
112
+ // using it: a school that has told a year group about it will have views.
113
+ adopted: ({ counts }) => (counts.careersLibraryViewers || 0) > 0,
114
+ seasons: [],
115
+ priority: 60,
116
+ path: "/destinations",
117
+ templateIds: [exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET],
118
+ },
119
+ {
120
+ key: "parents",
121
+ label: "Parent Network",
122
+ description: "Ask every parent whether they'd help with careers events, and build the database from the answers.",
123
+ needs: ["parents"],
124
+ // The parent database hangs off employer events; it has no flag of its
125
+ // own, so "offered" is the container and "adopted" is anyone being in it.
126
+ offered: (ctx) => ctx.products.employerEvents && runsOwnCareers(ctx),
127
+ adopted: ({ counts }) => (counts.parents || 0) > 0,
128
+ seasons: [{ start: "09-01", end: "10-31" }],
129
+ priority: 70,
130
+ path: "/network/parents",
131
+ templateIds: [exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET],
132
+ },
133
+ {
134
+ key: "leavers",
135
+ label: "Leaver Tracking",
136
+ description: "Destinations for the years that leave you, from intentions in spring through to the October census.",
137
+ needs: [],
138
+ offered: (ctx) => ctx.products.destinations && runsOwnCareers(ctx),
139
+ adopted: ({ institute }) => destinationsSetUp(institute) && Boolean(institute.destinationsConfig?.leavers?.enabled),
140
+ // March: the intentions form has to go out before the leavers leave.
141
+ seasons: [{ start: "03-01", end: "03-31" }],
142
+ priority: 80,
143
+ path: "/destinations/leavers",
144
+ templateIds: [exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET],
145
+ },
146
+ {
147
+ key: "alumni",
148
+ label: "Alumni Database",
149
+ description: "Keep in touch with leavers each year and find out how they'd like to come back and help.",
150
+ needs: ["alumni"],
151
+ offered: (ctx) => ctx.products.alumni && runsOwnCareers(ctx),
152
+ adopted: ({ counts }) => (counts.alumni || 0) > 0,
153
+ // Before the leavers go, and again once they have settled.
154
+ seasons: [{ start: "05-01", end: "07-15" }, { start: "09-15", end: "10-31" }],
155
+ priority: 90,
156
+ path: "/network/alumni",
157
+ templateIds: [exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET],
158
+ },
159
+ {
160
+ key: "events",
161
+ label: "Careers Events",
162
+ description: "Add an event, invite the right employers from your network and track who turns up.",
163
+ needs: ["events"],
164
+ offered: (ctx) => ctx.products.events && !ctx.products.isTrust,
165
+ adopted: ({ counts }) => (counts.events || 0) > 0,
166
+ seasons: [],
167
+ priority: 100,
168
+ path: "/events",
169
+ templateIds: [exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET],
170
+ },
171
+ {
172
+ key: "employerPortal",
173
+ label: "Employer Accounts",
174
+ description: "Let employers sign up, keep their own details current and find your events themselves.",
175
+ needs: ["linkedProviders"],
176
+ offered: (ctx) => ctx.products.network && !ctx.products.isTrust,
177
+ // Either door counts: the public page is the employer side switched on
178
+ // from the school's end, a linked provider is an employer who came in.
179
+ adopted: ({ institute, counts }) => Boolean(institute.publicEventPage) || (counts.linkedProviders || 0) > 0,
180
+ seasons: [],
181
+ priority: 110,
182
+ path: "/network/overview#employerSettings",
183
+ templateIds: [exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET, exports.BREVO_TEMPLATE_UNSET],
184
+ },
185
+ ];
186
+ exports.FEATURE_MARKETING_BY_KEY = Object.fromEntries(exports.FEATURE_MARKETING.map((f) => [f.key, f]));
187
+ /**
188
+ * Which counts to fetch before judging an institute, given only what is on its
189
+ * document. Features the account does not have are skipped, so a hub is never
190
+ * charged a cohort count for a product it cannot run.
191
+ *
192
+ * @param {InstituteData} institute the institute.
193
+ * @param {ResolvedProducts} products its resolved products.
194
+ * @return {FeatureMarketingCountKey[]} the counts the offered features read.
195
+ */
196
+ const featureMarketingCountsNeeded = (institute, products) => {
197
+ const ctx = { institute, products, counts: {} };
198
+ const needed = new Set();
199
+ exports.FEATURE_MARKETING.filter((f) => f.offered(ctx)).forEach((f) => f.needs.forEach((n) => needed.add(n)));
200
+ return Array.from(needed);
201
+ };
202
+ exports.featureMarketingCountsNeeded = featureMarketingCountsNeeded;
203
+ /**
204
+ * Every feature in the registry, judged for one institute.
205
+ *
206
+ * The report is the whole truth in one place: the backend picks a campaign
207
+ * from `candidate`, the admin panel shows adoption per feature, and a test can
208
+ * assert on any one line without re-running the detectors.
209
+ *
210
+ * @param {FeatureMarketingContext} ctx the institute, its products and counts.
211
+ * @return {FeatureAdoption[]} one line per feature, in registry order.
212
+ */
213
+ const evaluateFeatureAdoption = (ctx) => {
214
+ const now = ctx.now || new Date();
215
+ return exports.FEATURE_MARKETING.map((f) => {
216
+ const offered = f.offered(ctx);
217
+ const adopted = offered && f.adopted(ctx);
218
+ const inSeason = (0, exports.inFeatureMarketingSeason)(f.seasons, now);
219
+ return {
220
+ key: f.key,
221
+ label: f.label,
222
+ offered,
223
+ adopted,
224
+ inSeason,
225
+ candidate: offered && !adopted && inSeason,
226
+ };
227
+ });
228
+ };
229
+ exports.evaluateFeatureAdoption = evaluateFeatureAdoption;
230
+ /**
231
+ * The features a campaign could start for today, best first.
232
+ *
233
+ * @param {FeatureMarketingContext} ctx the institute, its products and counts.
234
+ * @return {FeatureMarketingEntry[]} candidates in priority order.
235
+ */
236
+ const featureMarketingCandidates = (ctx) => {
237
+ const candidates = new Set((0, exports.evaluateFeatureAdoption)(ctx).filter((a) => a.candidate).map((a) => a.key));
238
+ return exports.FEATURE_MARKETING
239
+ .filter((f) => candidates.has(f.key))
240
+ .sort((a, b) => a.priority - b.priority);
241
+ };
242
+ exports.featureMarketingCandidates = featureMarketingCandidates;
243
+ //# sourceMappingURL=featureMarketing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"featureMarketing.js","sourceRoot":"","sources":["../src/featureMarketing.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,223 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ jest.mock("firebase/firestore", () => ({ where: jest.fn() }));
4
+ jest.mock("./firebase/firebaseQuery", () => ({ __esModule: true, default: class {
5
+ } }));
6
+ const productResolver_1 = require("./productResolver");
7
+ const featureMarketing_1 = require("./featureMarketing");
8
+ /**
9
+ * The detectors read the database and nothing else, so every test is a school
10
+ * document plus a few counts, and the question is only ever "does this read as
11
+ * set up". Season tests pin the date so a run in July does not disagree with
12
+ * one in January.
13
+ */
14
+ const OCTOBER = new Date("2026-10-06T09:00:00Z");
15
+ const JANUARY = new Date("2027-01-20T09:00:00Z");
16
+ const MARCH = new Date("2027-03-10T09:00:00Z");
17
+ const JUNE = new Date("2027-06-10T09:00:00Z");
18
+ /** A school that has bought everything and set up nothing. */
19
+ const school = (overrides = {}) => ({
20
+ name: "Calder Park Academy",
21
+ package: "institutes-one",
22
+ workExperience: true,
23
+ employerEvents: true,
24
+ destinationTracking: true,
25
+ alumniManagement: true,
26
+ ...overrides,
27
+ });
28
+ const ctx = (institute, counts = {}, now = OCTOBER, parentCount = counts.parents || 0) => ({
29
+ institute,
30
+ products: (0, productResolver_1.resolveProducts)(institute, { parentCount }),
31
+ counts,
32
+ now,
33
+ });
34
+ const adoption = (c, key) => (0, featureMarketing_1.evaluateFeatureAdoption)(c).find((a) => a.key === key);
35
+ describe("registry", () => {
36
+ it("lists every key exactly once, in the order the keys constant gives", () => {
37
+ expect(featureMarketing_1.FEATURE_MARKETING.map((f) => f.key).sort()).toEqual([...featureMarketing_1.FEATURE_MARKETING_KEYS].sort());
38
+ expect(new Set(featureMarketing_1.FEATURE_MARKETING.map((f) => f.key)).size).toBe(featureMarketing_1.FEATURE_MARKETING.length);
39
+ featureMarketing_1.FEATURE_MARKETING_KEYS.forEach((k) => expect(featureMarketing_1.FEATURE_MARKETING_BY_KEY[k].key).toBe(k));
40
+ });
41
+ it("gives every feature a distinct priority, so two in season never tie", () => {
42
+ const priorities = featureMarketing_1.FEATURE_MARKETING.map((f) => f.priority);
43
+ expect(new Set(priorities).size).toBe(priorities.length);
44
+ });
45
+ it("gives every feature a template per touch, no more touches than there are delays", () => {
46
+ featureMarketing_1.FEATURE_MARKETING.forEach((f) => {
47
+ expect(f.templateIds.length).toBeGreaterThan(0);
48
+ expect(f.templateIds.length).toBeLessThanOrEqual(featureMarketing_1.FEATURE_MARKETING_TOUCH_DELAYS_DAYS.length);
49
+ });
50
+ });
51
+ it("only lists counts a detector can actually read", () => {
52
+ const known = ["cohorts", "parents", "alumni", "events", "linkedProviders", "careersLibraryViewers"];
53
+ featureMarketing_1.FEATURE_MARKETING.forEach((f) => f.needs.forEach((n) => expect(known).toContain(n)));
54
+ });
55
+ });
56
+ describe("inFeatureMarketingSeason", () => {
57
+ it("is always in season with no windows", () => {
58
+ expect((0, featureMarketing_1.inFeatureMarketingSeason)([], JUNE)).toBe(true);
59
+ });
60
+ it("is inclusive at both ends", () => {
61
+ const season = [{ start: "09-01", end: "10-31" }];
62
+ expect((0, featureMarketing_1.inFeatureMarketingSeason)(season, new Date("2026-09-01T00:00:00"))).toBe(true);
63
+ expect((0, featureMarketing_1.inFeatureMarketingSeason)(season, new Date("2026-10-31T23:00:00"))).toBe(true);
64
+ expect((0, featureMarketing_1.inFeatureMarketingSeason)(season, new Date("2026-11-01T00:00:00"))).toBe(false);
65
+ expect((0, featureMarketing_1.inFeatureMarketingSeason)(season, new Date("2026-08-31T00:00:00"))).toBe(false);
66
+ });
67
+ it("wraps the year boundary when start is after end", () => {
68
+ const season = [{ start: "12-01", end: "03-31" }];
69
+ expect((0, featureMarketing_1.inFeatureMarketingSeason)(season, new Date("2026-12-15T00:00:00"))).toBe(true);
70
+ expect((0, featureMarketing_1.inFeatureMarketingSeason)(season, new Date("2027-02-01T00:00:00"))).toBe(true);
71
+ expect((0, featureMarketing_1.inFeatureMarketingSeason)(season, new Date("2027-04-01T00:00:00"))).toBe(false);
72
+ expect((0, featureMarketing_1.inFeatureMarketingSeason)(season, new Date("2026-11-30T00:00:00"))).toBe(false);
73
+ });
74
+ });
75
+ describe("offered", () => {
76
+ it("offers a fully bought school every feature", () => {
77
+ const report = (0, featureMarketing_1.evaluateFeatureAdoption)(ctx(school()));
78
+ expect(report.every((a) => a.offered)).toBe(true);
79
+ });
80
+ it("does not offer what the school has not bought", () => {
81
+ const report = (0, featureMarketing_1.evaluateFeatureAdoption)(ctx(school({
82
+ workExperience: false, destinationTracking: false, alumniManagement: false,
83
+ })));
84
+ const offered = report.filter((a) => a.offered).map((a) => a.key);
85
+ expect(offered).not.toContain("wexCohort");
86
+ expect(offered).not.toContain("aspirations");
87
+ expect(offered).not.toContain("leavers");
88
+ expect(offered).not.toContain("careersLibrary");
89
+ expect(offered).not.toContain("alumni");
90
+ // Still hanging off employerEvents.
91
+ expect(offered).toEqual(expect.arrayContaining(["skills", "activities", "guidance", "parents", "events", "employerPortal"]));
92
+ });
93
+ it("offers a hub only the employer side", () => {
94
+ const hub = school({ package: "careersHub" });
95
+ const offered = (0, featureMarketing_1.evaluateFeatureAdoption)(ctx(hub)).filter((a) => a.offered).map((a) => a.key);
96
+ expect(offered.sort()).toEqual(["employerPortal", "events"]);
97
+ });
98
+ it("offers a trust nothing: its schools run the programmes", () => {
99
+ const trust = school({ package: "institutes-two" });
100
+ expect((0, featureMarketing_1.evaluateFeatureAdoption)(ctx(trust)).some((a) => a.offered)).toBe(false);
101
+ });
102
+ it("never marks something adopted that is not offered", () => {
103
+ const hub = school({ package: "careersHub", skillsAssessments: true, cohortConfig: { g: { name: "Y10" } } });
104
+ const report = (0, featureMarketing_1.evaluateFeatureAdoption)(ctx(hub, { cohorts: 3 }));
105
+ report.filter((a) => !a.offered).forEach((a) => expect(a.adopted).toBe(false));
106
+ });
107
+ });
108
+ describe("adopted", () => {
109
+ const expectAdopted = (key, c, value) => expect(adoption(c, key).adopted).toBe(value);
110
+ it("work experience: a recurring group or any cohort ever", () => {
111
+ expectAdopted("wexCohort", ctx(school()), false);
112
+ expectAdopted("wexCohort", ctx(school({ cohortConfig: { g: { name: "Year 10" } } })), true);
113
+ expectAdopted("wexCohort", ctx(school(), { cohorts: 1 }), true);
114
+ });
115
+ it("aspirations and leavers: destinations set up with that programme enabled", () => {
116
+ const half = school({ destinationsConfig: { setupCompletedAt: "2026-09-02", aspirations: { enabled: true } } });
117
+ expectAdopted("aspirations", ctx(half), true);
118
+ expectAdopted("leavers", ctx(half), false);
119
+ // Enabled but the wizard was never finished does not count.
120
+ const unfinished = school({ destinationsConfig: { aspirations: { enabled: true }, leavers: { enabled: true } } });
121
+ expectAdopted("aspirations", ctx(unfinished), false);
122
+ expectAdopted("leavers", ctx(unfinished), false);
123
+ });
124
+ it("guidance and skills: their setup flags", () => {
125
+ expectAdopted("guidance", ctx(school()), false);
126
+ expectAdopted("guidance", ctx(school({ guidanceConfig: { setupCompletedAt: "2026-09-02" } })), true);
127
+ expectAdopted("skills", ctx(school()), false);
128
+ expectAdopted("skills", ctx(school({ skillsAssessments: true })), true);
129
+ });
130
+ it("activities: launched at least once, even if that launch has lapsed", () => {
131
+ expectAdopted("activities", ctx(school()), false);
132
+ const lapsed = school({ activityLogging: {
133
+ setupCompletedAt: "2025-09-02", launchedYear: "2025-26", yearGroups: { "Year 9": true },
134
+ } });
135
+ expectAdopted("activities", ctx(lapsed, {}, OCTOBER), true);
136
+ // Set up but launched to nobody is not set up.
137
+ const nobody = school({ activityLogging: { setupCompletedAt: "2026-09-02", launchedYear: "2026-27", yearGroups: {} } });
138
+ expectAdopted("activities", ctx(nobody), false);
139
+ });
140
+ it("counts: parents, alumni, events, employer accounts and library viewers", () => {
141
+ expectAdopted("parents", ctx(school()), false);
142
+ expectAdopted("parents", ctx(school(), { parents: 12 }), true);
143
+ expectAdopted("alumni", ctx(school(), { alumni: 1 }), true);
144
+ expectAdopted("events", ctx(school(), { events: 1 }), true);
145
+ expectAdopted("careersLibrary", ctx(school(), { careersLibraryViewers: 1 }), true);
146
+ expectAdopted("employerPortal", ctx(school(), { linkedProviders: 1 }), true);
147
+ });
148
+ it("employer accounts: the public events page counts too", () => {
149
+ expectAdopted("employerPortal", ctx(school()), false);
150
+ expectAdopted("employerPortal", ctx(school({ publicEventPage: true })), true);
151
+ });
152
+ it("reads a count that was not fetched as not adopted", () => {
153
+ expectAdopted("alumni", ctx(school(), {}), false);
154
+ });
155
+ });
156
+ describe("featureMarketingCountsNeeded", () => {
157
+ it("asks for every count a full school's detectors read", () => {
158
+ const s = school();
159
+ expect((0, featureMarketing_1.featureMarketingCountsNeeded)(s, (0, productResolver_1.resolveProducts)(s)).sort())
160
+ .toEqual(["alumni", "careersLibraryViewers", "cohorts", "events", "linkedProviders", "parents"]);
161
+ });
162
+ it("asks a hub for nothing about students", () => {
163
+ const hub = school({ package: "careersHub" });
164
+ expect((0, featureMarketing_1.featureMarketingCountsNeeded)(hub, (0, productResolver_1.resolveProducts)(hub)).sort())
165
+ .toEqual(["events", "linkedProviders"]);
166
+ });
167
+ it("asks a trust for nothing", () => {
168
+ const trust = school({ package: "institutes-two" });
169
+ expect((0, featureMarketing_1.featureMarketingCountsNeeded)(trust, (0, productResolver_1.resolveProducts)(trust))).toEqual([]);
170
+ });
171
+ });
172
+ describe("candidates", () => {
173
+ it("are offered, not adopted and in season, best first", () => {
174
+ const keys = (0, featureMarketing_1.featureMarketingCandidates)(ctx(school(), {}, OCTOBER)).map((f) => f.key);
175
+ // October: no work experience (November, January), no leavers (March).
176
+ expect(keys).not.toContain("wexCohort");
177
+ expect(keys).not.toContain("leavers");
178
+ expect(keys).toEqual(["aspirations", "guidance", "skills", "activities", "careersLibrary", "parents", "alumni", "events", "employerPortal"]);
179
+ });
180
+ it("moves with the calendar", () => {
181
+ const january = (0, featureMarketing_1.featureMarketingCandidates)(ctx(school(), {}, JANUARY)).map((f) => f.key);
182
+ expect(january[0]).toBe("wexCohort");
183
+ expect(january).not.toContain("leavers");
184
+ expect(january).not.toContain("parents");
185
+ expect(january).not.toContain("guidance");
186
+ const march = (0, featureMarketing_1.featureMarketingCandidates)(ctx(school(), {}, MARCH)).map((f) => f.key);
187
+ expect(march).toContain("leavers");
188
+ expect(march).not.toContain("wexCohort");
189
+ });
190
+ it("drops what has been set up", () => {
191
+ const done = school({ skillsAssessments: true, guidanceConfig: { setupCompletedAt: "2026-09-02" } });
192
+ const keys = (0, featureMarketing_1.featureMarketingCandidates)(ctx(done, { events: 4, parents: 30 }, OCTOBER)).map((f) => f.key);
193
+ expect(keys).toEqual(["aspirations", "activities", "careersLibrary", "alumni", "employerPortal"]);
194
+ });
195
+ it("never pitches something set up in a past year and not built for this one", () => {
196
+ // Everything configured, nothing provisioned this year, an old activity
197
+ // launch that has lapsed, one cohort from last year. That is a school
198
+ // with yearly steps outstanding, which is onboarding's job, not a pitch.
199
+ const lastYear = school({
200
+ cohortConfig: { g: { name: "Year 10" } },
201
+ skillsAssessments: true,
202
+ guidanceConfig: { setupCompletedAt: "2025-09-02" },
203
+ destinationsConfig: { setupCompletedAt: "2025-09-02", aspirations: { enabled: true }, leavers: { enabled: true } },
204
+ activityLogging: { setupCompletedAt: "2025-09-02", launchedYear: "2025-26", yearGroups: { "Year 9": true } },
205
+ });
206
+ const keys = (0, featureMarketing_1.featureMarketingCandidates)(ctx(lastYear, { cohorts: 1 }, OCTOBER)).map((f) => f.key);
207
+ ["wexCohort", "aspirations", "leavers", "skills", "guidance", "activities"]
208
+ .forEach((k) => expect(keys).not.toContain(k));
209
+ });
210
+ it("is empty for a school with everything running", () => {
211
+ const done = school({
212
+ cohortConfig: { g: { name: "Year 10" } },
213
+ skillsAssessments: true,
214
+ publicEventPage: true,
215
+ guidanceConfig: { setupCompletedAt: "2026-09-02" },
216
+ destinationsConfig: { setupCompletedAt: "2026-09-02", aspirations: { enabled: true }, leavers: { enabled: true } },
217
+ activityLogging: { setupCompletedAt: "2026-09-02", launchedYear: "2026-27", yearGroups: { "Year 9": true } },
218
+ });
219
+ const counts = { parents: 5, alumni: 5, events: 5, careersLibraryViewers: 5 };
220
+ expect((0, featureMarketing_1.featureMarketingCandidates)(ctx(done, counts, JUNE))).toEqual([]);
221
+ });
222
+ });
223
+ //# sourceMappingURL=featureMarketing.test.js.map