placementt-core 1.400.1237 → 1.400.1239

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.
@@ -2,7 +2,7 @@ import {InstituteData} from "./typeDefinitions";
2
2
  import {ResolvedProducts} from "./productResolver";
3
3
  import {SurveyGroupScope} from "./surveys/types";
4
4
  import {activityLoggingState} from "./activityLogging";
5
- import {BREVO_TEMPLATE_UNSET, FEATURE_MARKETING_BY_KEY, FeatureMarketingCounts, FeatureMarketingKey, askForEngagementPlan, featurePlanStatus, isSelfServeSignUp} from "./featureMarketing";
5
+ import {FEATURE_MARKETING_BY_KEY, FeatureMarketingCounts, FeatureMarketingKey, askForEngagementPlan, featurePlanStatus, isSelfServeSignUp} from "./featureMarketing";
6
6
  import {hasWondeConnection} from "./studentData";
7
7
 
8
8
  /**
@@ -46,19 +46,13 @@ export const ONBOARDING_PATHWAY_KEYS: OnboardingPathwayKey[] = [
46
46
  * were onboarded by hand and get the plan board on their homepage instead. */
47
47
  export const JOIN_WINDOW_DAYS = 60;
48
48
 
49
- /** Days after a step's first email that its second and third go. The second
50
- * is shorter with an offer of a call, the third asks whether now is the right
51
- * time and carries the "pick a different term" link. */
52
- export const ONBOARDING_EMAIL_DAYS = [0, 7, 21];
53
-
54
- /** The shared second and third emails, the same for every step with the
55
- * step's name and link filled in. Unset falls back to the step's own template.
56
- *
57
- * Template ids from here on are labels for emails written in the backend
58
- * (awsSES/templates/engagement.ts), in the 9000s to stay clear of Brevo's. */
59
- export const ONBOARDING_REMINDER_TEMPLATE_IDS: {second: number, third: number} = {
60
- second: 9090,
61
- third: 9091,
49
+ /** The shared reminders in a step email's weekly reminder chain
50
+ * (sendEmailReminders): the first and third, with the step's name and link
51
+ * filled in. The second resends the step's own email. A step on the plan
52
+ * board gets the version with a "pick a different term" link. */
53
+ export const ONBOARDING_REMINDER_TEMPLATES = {
54
+ first: {plain: "engagementReminderDay7", plan: "engagementReminderDay7Plan"},
55
+ third: {plain: "engagementReminderDay21", plan: "engagementReminderDay21Plan"},
62
56
  };
63
57
 
64
58
  /** Everything marketing reads, plus the counts that only onboarding asks for. */
@@ -102,9 +96,10 @@ export type OnboardingStep = {
102
96
  /** Built for the current academic year. Absent on a once-only step. Only
103
97
  * consulted once `done` is true, so it need not repeat that check. */
104
98
  thisYear?: (ctx: OnboardingContext) => boolean,
105
- /** Brevo template ids by email variant. `resend` is only read on steps
106
- * with `thisYear`. `BREVO_TEMPLATE_UNSET` means not made yet: skip. */
107
- templateIds: {setup: number, resend?: number},
99
+ /** The admin email template (constants.ts emailTemplates) for each email
100
+ * variant. `resend` is only read on steps with `thisYear`. Absent means no
101
+ * email has been written for the step yet. */
102
+ emailTemplates?: {setup: string, resend?: string},
108
103
  /** The feature on the school's plan board this step sets up, when it is
109
104
  * not the pathway's. Nothing is sent about it before its planned term. */
110
105
  feature?: FeatureMarketingKey,
@@ -159,7 +154,7 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
159
154
  label: "Pick Your Features",
160
155
  description: "Choose what you'll use CareerThread for, and we'll set your account up around it.",
161
156
  path: "/home",
162
- templateIds: {setup: 9001},
157
+ emailTemplates: {setup: "engagementPickFeatures"},
163
158
  needs: ["students"],
164
159
  optional: true,
165
160
  // Accounts from before the picker that already have students are past this.
@@ -170,7 +165,7 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
170
165
  label: "Plan Your Year",
171
166
  description: "Drag each feature into the term you want to set it up in. We won't email about anything before then.",
172
167
  path: "/organisation/overview",
173
- templateIds: {setup: 9002},
168
+ emailTemplates: {setup: "engagementPlanYear"},
174
169
  needs: [],
175
170
  optional: true,
176
171
  // Asked once; a plan saved in any year counts.
@@ -182,7 +177,7 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
182
177
  label: "Invite Your Staff",
183
178
  description: "Give the rest of the careers team their own logins so the work is shared.",
184
179
  path: "/organisation/staff",
185
- templateIds: {setup: 9003},
180
+ emailTemplates: {setup: "engagementInviteStaff"},
186
181
  needs: ["staff"],
187
182
  emails: 1,
188
183
  optional: true,
@@ -200,7 +195,7 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
200
195
  label: "Choose How to Add Your Students",
201
196
  description: "Connect through Wonde, send us a file from your MIS, or upload a spreadsheet. Pick one and we'll send what you need.",
202
197
  path: "/students",
203
- templateIds: {setup: 9010},
198
+ emailTemplates: {setup: "engagementStudentRoute"},
204
199
  needs: ["students"],
205
200
  // A trust and a hub hold no students of their own; their
206
201
  // first step is linking the schools that do.
@@ -212,7 +207,7 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
212
207
  label: "Approve the Wonde Connection",
213
208
  description: "Approve CareerThread in your Wonde portal and your students sync automatically.",
214
209
  path: "/students",
215
- templateIds: {setup: 9011},
210
+ emailTemplates: {setup: "engagementWonde"},
216
211
  needs: ["students"],
217
212
  applies: studentRouteIs("wonde"),
218
213
  done: (ctx) => hasStudents(ctx) || hasWondeConnection(ctx.institute),
@@ -222,7 +217,7 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
222
217
  label: "Set Up Your MIS File Transfer",
223
218
  description: "Schedule an export from your MIS to us and your students stay up to date.",
224
219
  path: "/students",
225
- templateIds: {setup: 9012},
220
+ emailTemplates: {setup: "engagementMis"},
226
221
  needs: ["students"],
227
222
  applies: studentRouteIs("mis"),
228
223
  done: hasStudents,
@@ -232,7 +227,7 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
232
227
  label: "Add Your Students",
233
228
  description: "Upload a spreadsheet of your students and every student gets an account.",
234
229
  path: "/students",
235
- templateIds: {setup: 9013},
230
+ emailTemplates: {setup: "engagementUploadStudents"},
236
231
  needs: ["students"],
237
232
  applies: studentRouteIs("csv"),
238
233
  done: hasStudents,
@@ -249,7 +244,6 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
249
244
  label: "Link Your Schools",
250
245
  description: "Invite trust schools to CareerThread, to contribute to their careers provision.",
251
246
  path: "/schools",
252
- templateIds: {setup: BREVO_TEMPLATE_UNSET},
253
247
  needs: ["schools"],
254
248
  done: ({counts}) => (counts.schools || 0) > 0,
255
249
  },
@@ -265,7 +259,6 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
265
259
  label: "Add Your Schools",
266
260
  description: "Invite the schools in your area and they appear on your hub.",
267
261
  path: "/schools",
268
- templateIds: {setup: BREVO_TEMPLATE_UNSET},
269
262
  needs: ["schools"],
270
263
  done: ({counts}) => (counts.schools || 0) > 0,
271
264
  },
@@ -274,7 +267,6 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
274
267
  label: "Add Your Employers",
275
268
  description: "Import the employers you already work with.",
276
269
  path: "/network",
277
- templateIds: {setup: BREVO_TEMPLATE_UNSET},
278
270
  needs: ["employers"],
279
271
  done: ({counts}) => (counts.employers || 0) > 0,
280
272
  },
@@ -283,7 +275,6 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
283
275
  label: "Enable Your Public Events Page",
284
276
  description: "Let employers find and sign up to your events themselves.",
285
277
  path: "/network/overview#employerSettings",
286
- templateIds: {setup: BREVO_TEMPLATE_UNSET},
287
278
  needs: [],
288
279
  done: ({institute}) => Boolean(institute.publicEventPage),
289
280
  },
@@ -292,7 +283,6 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
292
283
  label: "Send Your First Email Campaign",
293
284
  description: "Reach every employer on the hub in one go.",
294
285
  path: "/network/emailCampaigns",
295
- templateIds: {setup: BREVO_TEMPLATE_UNSET},
296
286
  needs: ["emailCampaigns"],
297
287
  done: ({counts}) => (counts.emailCampaigns || 0) > 0,
298
288
  },
@@ -301,7 +291,6 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
301
291
  label: "Create Your First Event",
302
292
  description: "Invite schools and employers and track who turns up.",
303
293
  path: "/events",
304
- templateIds: {setup: BREVO_TEMPLATE_UNSET},
305
294
  needs: ["events"],
306
295
  done: marketingAdopted("events"),
307
296
  },
@@ -320,7 +309,7 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
320
309
  label: "Request Your First Event",
321
310
  description: "Pick the kind of employers you want and we handle the invites.",
322
311
  path: "/events",
323
- templateIds: {setup: 9030},
312
+ emailTemplates: {setup: "engagementHubFirstEvent"},
324
313
  needs: ["events"],
325
314
  done: ({counts}) => (counts.events || 0) > 0,
326
315
  },
@@ -337,7 +326,7 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
337
326
  label: "Set Up a Work Experience Group",
338
327
  description: "Create a recurring group and we'll make the cohort every year.",
339
328
  path: "/cohorts",
340
- templateIds: {setup: 9020, resend: 9021},
329
+ emailTemplates: {setup: "engagementWexGroups", resend: "engagementWexCohort"},
341
330
  needs: ["cohorts", "cohortsThisYear"],
342
331
  done: marketingAdopted("wexCohort"),
343
332
  thisYear: ({counts}) => (counts.cohortsThisYear || 0) > 0,
@@ -347,7 +336,7 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
347
336
  label: "Build a Sign-Off Workflow",
348
337
  description: "Decide who signs off each placement, in what order, and which forms they see.",
349
338
  path: "/cohorts",
350
- templateIds: {setup: 9022},
339
+ emailTemplates: {setup: "engagementWexWorkflow"},
351
340
  needs: [],
352
341
  done: ({institute}) =>
353
342
  Object.values(institute.cohortConfig || {}).some((g) => Boolean(g.workflowId)),
@@ -357,7 +346,7 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
357
346
  label: "Activate Your Students",
358
347
  description: "Send them their logins and they can start adding placements.",
359
348
  path: "/students",
360
- templateIds: {setup: 9023},
349
+ emailTemplates: {setup: "engagementWexActivate"},
361
350
  needs: ["activatedStudents"],
362
351
  done: ({counts}) => (counts.activatedStudents || 0) > 0,
363
352
  },
@@ -366,7 +355,7 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
366
355
  label: "Add Your First Placements",
367
356
  description: "Students or staff add placements and the sign-off workflow takes it from there.",
368
357
  path: "/cohorts",
369
- templateIds: {setup: 9024},
358
+ emailTemplates: {setup: "engagementWexPlacements"},
370
359
  needs: ["placements"],
371
360
  done: ({counts}) => (counts.placements || 0) > 0,
372
361
  },
@@ -375,7 +364,7 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
375
364
  label: "Set Up Placement Feedback",
376
365
  description: "Ask students and employers how it went, automatically, when each placement ends.",
377
366
  path: "/cohorts",
378
- templateIds: {setup: 9025},
367
+ emailTemplates: {setup: "engagementWexFeedback"},
379
368
  needs: [],
380
369
  done: ({institute}) =>
381
370
  Object.values(institute.cohortConfig || {}).some((g) => Boolean(g.feedback)),
@@ -418,7 +407,6 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
418
407
  label: "Import Your Employers",
419
408
  description: "Bring in the employers you already work with, so your first event has people to invite.",
420
409
  path: "/network",
421
- templateIds: {setup: BREVO_TEMPLATE_UNSET},
422
410
  needs: ["employers"],
423
411
  done: ({counts}) => (counts.employers || 0) > 0,
424
412
  },
@@ -427,7 +415,6 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
427
415
  label: "Open Your Employer Page",
428
416
  description: "Let employers sign up to your network and see your events themselves.",
429
417
  path: "/network/overview#employerSettings",
430
- templateIds: {setup: BREVO_TEMPLATE_UNSET},
431
418
  needs: [],
432
419
  optional: true,
433
420
  done: ({institute}) => Boolean(institute.publicEventPage),
@@ -437,7 +424,6 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
437
424
  label: "Create Your First Event",
438
425
  description: "Invite students, staff and employers and we'll track invites and attendance.",
439
426
  path: "/events",
440
- templateIds: {setup: BREVO_TEMPLATE_UNSET},
441
427
  needs: ["events"],
442
428
  done: marketingAdopted("events"),
443
429
  },
@@ -446,7 +432,6 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
446
432
  label: "Invite Employers to an Event",
447
433
  description: "Pick them from your network by sector and we chase the replies.",
448
434
  path: "/events",
449
- templateIds: {setup: BREVO_TEMPLATE_UNSET},
450
435
  needs: ["eventAttendees"],
451
436
  done: ({counts}) => (counts.eventAttendees || 0) > 0,
452
437
  },
@@ -471,7 +456,6 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
471
456
  label: "Set Up Career Aspiration Surveys",
472
457
  description: "What students want to do next, asked every year and compared with where they go.",
473
458
  path: "/destinations",
474
- templateIds: {setup: BREVO_TEMPLATE_UNSET, resend: BREVO_TEMPLATE_UNSET},
475
459
  needs: ["surveyScopesThisYear"],
476
460
  done: marketingAdopted("aspirations"),
477
461
  thisYear: hasScope("aspirations"),
@@ -482,7 +466,6 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
482
466
  label: "Set Up Leaver Tracking",
483
467
  description: "Intentions in spring, destinations in autumn, for every year that leaves.",
484
468
  path: "/destinations/leavers",
485
- templateIds: {setup: BREVO_TEMPLATE_UNSET, resend: BREVO_TEMPLATE_UNSET},
486
469
  needs: ["surveyScopesThisYear"],
487
470
  done: marketingAdopted("leavers"),
488
471
  thisYear: hasScope("leavers"),
@@ -493,7 +476,6 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
493
476
  label: "Add Your Alumni",
494
477
  description: "Import the leavers you're in touch with, or let this year's leavers opt in.",
495
478
  path: "/network/alumni",
496
- templateIds: {setup: BREVO_TEMPLATE_UNSET},
497
479
  needs: ["alumni"],
498
480
  applies: ({products}) => products.alumni,
499
481
  done: marketingAdopted("alumni"),
@@ -511,7 +493,6 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
511
493
  label: "Set Up Skills Tracking",
512
494
  description: "Choose the skills you assess and when, and the surveys go out on schedule.",
513
495
  path: "/development/assessments",
514
- templateIds: {setup: BREVO_TEMPLATE_UNSET, resend: BREVO_TEMPLATE_UNSET},
515
496
  needs: ["surveyScopesThisYear"],
516
497
  done: marketingAdopted("skills"),
517
498
  thisYear: hasScope("skills"),
@@ -522,7 +503,6 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
522
503
  label: "Set Up 1:1 Careers Guidance",
523
504
  description: "Schedule guidance meetings per year group and record them for Gatsby.",
524
505
  path: "/development/guidance",
525
- templateIds: {setup: BREVO_TEMPLATE_UNSET, resend: BREVO_TEMPLATE_UNSET},
526
506
  needs: ["surveyScopesThisYear"],
527
507
  done: marketingAdopted("guidance"),
528
508
  thisYear: hasScope("guidance"),
@@ -540,7 +520,6 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
540
520
  label: "Launch It to a Year Group",
541
521
  description: "Nothing is shown to a student until you say which years have been told.",
542
522
  path: "/development/activities",
543
- templateIds: {setup: BREVO_TEMPLATE_UNSET, resend: BREVO_TEMPLATE_UNSET},
544
523
  needs: [],
545
524
  // A launch is made for one academic year unless the school said
546
525
  // it rolls over, so last year's is set up but not this year's. Setup
@@ -561,7 +540,6 @@ export const ONBOARDING_PATHWAYS: OnboardingPathway[] = [
561
540
  label: "Invite Your Parents",
562
541
  description: "Write the invitation, upload the list, and the database builds from the replies.",
563
542
  path: "/network/parents",
564
- templateIds: {setup: BREVO_TEMPLATE_UNSET},
565
543
  needs: ["parents"],
566
544
  done: marketingAdopted("parents"),
567
545
  },
@@ -600,9 +578,8 @@ export type OnboardingStepProgress = {
600
578
  done: boolean,
601
579
  /** Which email this step would get, were it the one sent. */
602
580
  variant: OnboardingEmailVariant,
603
- /** The Brevo template for that variant. `BREVO_TEMPLATE_UNSET` when it
604
- * has not been made yet, in which case nothing is sent. */
605
- templateId: number,
581
+ /** The admin email template for that variant, when one has been written. */
582
+ emailTemplate?: string,
606
583
  /** The plan board feature it sets up: its own, or its pathway's. */
607
584
  feature?: FeatureMarketingKey,
608
585
  /** How many emails it gets before it counts as passed or stuck. */
@@ -650,7 +627,7 @@ export const evaluateOnboarding = (ctx: OnboardingContext): OnboardingPathwayPro
650
627
  state,
651
628
  done: state === "done",
652
629
  variant,
653
- templateId: s.templateIds[variant] ?? BREVO_TEMPLATE_UNSET,
630
+ emailTemplate: s.emailTemplates?.[variant],
654
631
  feature: s.feature ?? p.feature,
655
632
  emails: s.emails ?? 3,
656
633
  optional: Boolean(s.optional),
@@ -729,3 +706,35 @@ export const planFeatureOf = (kind: "marketing"|"onboarding"|"renewal", key: str
729
706
  const pathway = ONBOARDING_PATHWAYS.find((p) => p.steps.some((s) => s.key === key));
730
707
  return pathway?.steps.find((s) => s.key === key)?.feature ?? pathway?.feature;
731
708
  };
709
+
710
+ /**
711
+ * Whether one step is done, judged on its own: an email sent by hand may be
712
+ * about a step that doesn't apply to the school (the Wonde email to a school
713
+ * on the spreadsheet route), so `applies` is not consulted. A step with
714
+ * `thisYear` is done once it is built for this year, and an optional step
715
+ * once it has been passed.
716
+ *
717
+ * @param {OnboardingContext} ctx the institute, its products and counts.
718
+ * @param {string} stepKey the step.
719
+ * @return {boolean|undefined} whether it is done, or undefined for an unknown step.
720
+ */
721
+ export const onboardingStepDone = (ctx: OnboardingContext, stepKey: string): boolean|undefined => {
722
+ const step = ONBOARDING_PATHWAYS.flatMap((p) => p.steps).find((s) => s.key === stepKey);
723
+ if (!step) return undefined;
724
+ if (step.optional && ctx.institute.engagement?.onboarding?.passed?.[step.key]) return true;
725
+ return step.done(ctx) && (!step.thisYear || step.thisYear(ctx));
726
+ };
727
+
728
+ /**
729
+ * The step an admin email template is the email for.
730
+ *
731
+ * @param {string} templateKey the email template.
732
+ * @return {object|undefined} the step and its plan board feature.
733
+ */
734
+ export const onboardingStepOfTemplate = (templateKey: string): {step: OnboardingStep, feature?: FeatureMarketingKey}|undefined => {
735
+ for (const pathway of ONBOARDING_PATHWAYS) {
736
+ const step = pathway.steps.find((s) => s.emailTemplates?.setup === templateKey || s.emailTemplates?.resend === templateKey);
737
+ if (step) return {step, feature: step.feature ?? pathway.feature};
738
+ }
739
+ return undefined;
740
+ };
@@ -3,7 +3,7 @@ jest.mock("./firebase/firebaseQuery", () => ({__esModule: true, default: class {
3
3
 
4
4
  import {InstituteData} from "./typeDefinitions";
5
5
  import {resolveProducts} from "./productResolver";
6
- import {RenewalStep, renewalSituation} from "./renewals";
6
+ import {RenewalStep, renewalSituation, renewalTaskSchedule} from "./renewals";
7
7
 
8
8
  const school = (renewalDate?: string, overrides: Partial<InstituteData> = {}): InstituteData => ({
9
9
  name: "Calder Park Academy",
@@ -61,3 +61,23 @@ describe("renewals", () => {
61
61
  expect(at(thanked, "2026-11-10").due).toBeUndefined();
62
62
  });
63
63
  });
64
+
65
+ describe("renewalTaskSchedule", () => {
66
+ it("dates each renewal task from the renewal date", () => {
67
+ expect(renewalTaskSchedule("2027-10-01", "2026-11-03")).toEqual([
68
+ {step: "headsUp", due: "2027-04-20"},
69
+ {step: "yearInReview", due: "2027-07-01"},
70
+ {step: "sixtyDay", due: "2027-08-02"},
71
+ {step: "thirtyDay", due: "2027-09-01"},
72
+ {step: "lapsed", due: "2027-10-02"},
73
+ ]);
74
+ });
75
+
76
+ it("keeps only the step a late date is in, of the ones already gone", () => {
77
+ expect(renewalTaskSchedule("2026-12-15", "2026-11-03").map((t) => t.step)).toEqual(["sixtyDay", "thirtyDay", "lapsed"]);
78
+ });
79
+
80
+ it("leaves out a heads-up or year in review more than a year before the 60-day email", () => {
81
+ expect(renewalTaskSchedule("2027-06-01", "2026-11-03").map((t) => t.step)).toEqual(["yearInReview", "sixtyDay", "thirtyDay", "lapsed"]);
82
+ });
83
+ });
package/src/renewals.ts CHANGED
@@ -8,9 +8,8 @@ import {londonYmd} from "./outreachRules";
8
8
  * Renewal is commercial and per customer, so it is kept apart from
9
9
  * re-onboarding, which is academic and per feature (the September plan board
10
10
  * and the "new year" steps). Tom renews every customer himself: each email
11
- * here is a personal draft from him, approved in the queue, and anything that
12
- * needs a person (no renewal date, a lapsed date, raising the invoice) is a
13
- * task for him rather than an email.
11
+ * here is a task for him, made when the renewal date is set
12
+ * (renewalTaskSchedule), that he sends from the task with the email in hand.
14
13
  *
15
14
  * The year runs from `cs.renewalDate`:
16
15
  * - headsUp: late April to May, before budgets for next year are set. Next
@@ -44,14 +43,13 @@ export const RENEWAL_STEP_LABELS: Record<RenewalStep, string> = {
44
43
  /** Steps that copy in the finance contact as well as the school's contacts. */
45
44
  export const RENEWAL_FINANCE_STEPS: RenewalStep[] = ["headsUp", "sixtyDay", "thirtyDay"];
46
45
 
47
- /** Brevo-style ids for the renewal emails, written in the backend
48
- * (awsSES/templates/engagement.ts) like the onboarding ones. */
49
- export const RENEWAL_TEMPLATE_IDS: Record<RenewalStep, number> = {
50
- headsUp: 9101,
51
- yearInReview: 9102,
52
- sixtyDay: 9103,
53
- thirtyDay: 9104,
54
- thanks: 9105,
46
+ /** The admin email template (constants.ts emailTemplates) for each step. */
47
+ export const RENEWAL_EMAIL_TEMPLATES: Record<RenewalStep, string> = {
48
+ headsUp: "engagementRenewalPrice",
49
+ yearInReview: "engagementRenewalReview",
50
+ sixtyDay: "engagementRenewalDue",
51
+ thirtyDay: "engagementRenewalInvoice",
52
+ thanks: "engagementRenewalThanks",
55
53
  };
56
54
 
57
55
  export type RenewalSituation = {
@@ -136,3 +134,39 @@ export const renewalSituation = ({institute, products, now}: {institute: Institu
136
134
  }
137
135
  return {...base, lapsed: false, stage: "early"};
138
136
  };
137
+
138
+
139
+ const addDays = (ymd: string, days: number): string => new Date(ymdToMs(ymd) + days * DAY_MS).toISOString().slice(0, 10);
140
+
141
+ /**
142
+ * The renewal tasks for one renewal date, each with the day it is due: the
143
+ * same timings as renewalSituation, worked out once when the date is set
144
+ * rather than checked daily. The heads-up is the last 20 April and the year
145
+ * in review the last 1 July that fall within the year before the 60-day
146
+ * email. A date set late skips the steps already gone, but keeps the most
147
+ * recent one, since that's the step the customer is in. The thanks is not
148
+ * here: it is due when the date moves on.
149
+ *
150
+ * @param {string} renewalDate YYYY-MM-DD.
151
+ * @param {string} today YYYY-MM-DD, London.
152
+ * @return {object[]} each step and its due date, plus "lapsed" the day after renewal.
153
+ */
154
+ export const renewalTaskSchedule = (renewalDate: string, today: string): {step: Exclude<RenewalStep, "thanks">|"lapsed", due: string}[] => {
155
+ const sixty = addDays(renewalDate, -60);
156
+ const yearBefore = addDays(renewalDate, -365);
157
+ const lastBefore = (md: string) => {
158
+ const year = Number(sixty.slice(0, 4));
159
+ const candidate = `${year}-${md}` < sixty ? `${year}-${md}` : `${year - 1}-${md}`;
160
+ return candidate >= yearBefore ? candidate : undefined;
161
+ };
162
+ const all = ([
163
+ {step: "headsUp", due: lastBefore("04-20")},
164
+ {step: "yearInReview", due: lastBefore("07-01")},
165
+ {step: "sixtyDay", due: sixty},
166
+ {step: "thirtyDay", due: addDays(renewalDate, -30)},
167
+ ] as {step: Exclude<RenewalStep, "thanks">, due?: string}[])
168
+ .filter((t): t is {step: Exclude<RenewalStep, "thanks">, due: string} => Boolean(t.due))
169
+ .sort((a, b) => a.due.localeCompare(b.due));
170
+ const gone = all.filter((t) => t.due < today);
171
+ return [...all.filter((t) => t.due >= today || t === gone[gone.length - 1]), {step: "lapsed", due: addDays(renewalDate, 1)}];
172
+ };
@@ -2012,7 +2012,7 @@ export type EmailTemplateConfig = {
2012
2012
  button?: {text: string, link: string},
2013
2013
  secondaryButton?: {text: string, link: string},
2014
2014
  optionalButtons?: {text: string, link: string}[],
2015
- type: "workflow"|"feedback"|"event"|"campaign"|"database"|"onboarding"|"skillsInvite"|"aspirationInvite"|"leaverInvite"|"leaverResultsInvite"|"leaverChristmasInvite",
2015
+ type: "workflow"|"feedback"|"event"|"campaign"|"database"|"onboarding"|"skillsInvite"|"aspirationInvite"|"leaverInvite"|"leaverResultsInvite"|"leaverChristmasInvite"|"engagement",
2016
2016
  product: EmailTemplateProduct,
2017
2017
  sections?: EmailSectionNames[],
2018
2018
  group?: string, // Templates sharing a group are listed together, e.g. the three feedback requests.
@@ -2027,7 +2027,8 @@ export type EmailTemplateConfig = {
2027
2027
  // Which product a resource (email template, guidance doc, default form) belongs to, so customers only see resources for products they have.
2028
2028
  // Any ProductKey from the product resolver, "employerEvents" for the events container itself, or "hub" for careers hub only.
2029
2029
  export type ProductTag = ProductKey|"employerEvents"|"hub";
2030
- export type EmailTemplateProduct = ProductTag;
2030
+ // "admin": CareerThread's own emails to customers (onboarding, renewals), only selectable by us.
2031
+ export type EmailTemplateProduct = ProductTag|"admin";
2031
2032
 
2032
2033
  export type EmailCampaign = {
2033
2034
  id: string,
@@ -3812,6 +3813,7 @@ export type YearScoreData = {
3812
3813
 
3813
3814
  type MessageTypes =
3814
3815
  "hubOrTrustInviteSchool" |
3816
+ "engagementEmail" | // an admin email template sent to a school (featureEngagement.ts)
3815
3817
  "sendPlacementActionRequired" |
3816
3818
  "sendPlacementAdditionalInformationRequired" |
3817
3819
  "sendFeedbackReminder" |
@@ -3862,7 +3864,8 @@ export type SurveyMessageType =
3862
3864
  export type ReminderFallbackType =
3863
3865
  "checkInTrustedContact" | // legacy name for the below; live interaction docs still carry it
3864
3866
  "surveyParentFallback" | // chase the parent/trusted contact when the subject ignores their whole chain
3865
- "alumniCheckInFallback"; // the same, for the annual alumni check-in, which is not a survey
3867
+ "alumniCheckInFallback" | // the same, for the annual alumni check-in, which is not a survey
3868
+ "engagementStuck"; // an onboarding email's reminders ran out with the step still not done
3866
3869
 
3867
3870
  export type EmailInteraction = {
3868
3871
  messageId: string,
@@ -4637,20 +4640,11 @@ export type CustomerSuccessData = {
4637
4640
  };
4638
4641
 
4639
4642
  // ─── Engagement emails ───────────────────────────────────────────────────────
4640
- // Onboarding nudges and feature marketing (onboardingPathways.ts and
4641
- // featureMarketing.ts decide what to say; the backend cron decides when).
4643
+ // Onboarding and renewal emails, sent by an admin (onboardingPathways.ts and
4644
+ // renewals.ts say which; the emails are admin email templates).
4642
4645
 
4643
4646
  export type InstituteEngagement = {
4644
- /** Last engagement email of any kind, for the one-per-week cap. */
4645
- lastEmailAt?: string,
4646
4647
  onboarding?: {
4647
- /** The step last nudged, so the same step is not sent twice in a row
4648
- * without a change (the cron caps repeats of one step). */
4649
- lastStepKey?: string,
4650
- lastVariant?: "setup"|"resend",
4651
- lastSentAt?: string,
4652
- /** How many times in a row the same step has been sent. */
4653
- repeats?: number,
4654
4648
  /** Optional steps whose emails ran out unanswered, keyed by step, with
4655
4649
  * the date. They count as done so the next step can start. */
4656
4650
  passed?: {[stepKey: string]: string},
@@ -4709,22 +4703,6 @@ export type FeatureCampaign = {
4709
4703
  endedAt?: string,
4710
4704
  };
4711
4705
 
4712
- /** `engagementEmails/{autoId}`: one flat log document per send, for the
4713
- * admin view. */
4714
- export type EngagementEmailLog = {
4715
- oId: string,
4716
- kind: "marketing"|"onboarding"|"renewal",
4717
- key: string,
4718
- variant?: "setup"|"resend",
4719
- touch?: number,
4720
- templateId: number,
4721
- recipients: string[],
4722
- sentAt: string,
4723
- campaignId?: string,
4724
- /** The approved draft it was sent from. */
4725
- draftId?: string,
4726
- };
4727
-
4728
4706
  export type StudentRoute = {
4729
4707
  route: "wonde"|"mis"|"csv",
4730
4708
  chosenAt: string,
@@ -4749,46 +4727,6 @@ export type EngagementPlan = {
4749
4727
  savedBy: string,
4750
4728
  };
4751
4729
 
4752
- /** Where an engagement email is in its approval: written as a draft, approved
4753
- * by an admin for the next school morning, then sent; or skipped by an admin,
4754
- * expired unapproved, or dropped because the school did the step first. */
4755
- export type EngagementDraftStatus = "draft"|"approved"|"sent"|"skipped"|"expired"|"dropped";
4756
-
4757
- /** `engagementDrafts/{autoId}`: one engagement email to one institute. The
4758
- * daily check writes every email as one of these and nothing sends until an
4759
- * admin approves it. */
4760
- export type EngagementDraft = {
4761
- oId: string,
4762
- instituteName: string,
4763
- kind: "marketing"|"onboarding"|"renewal",
4764
- key: string,
4765
- variant?: "setup"|"resend",
4766
- touch?: number,
4767
- templateId: number,
4768
- label: string,
4769
- description: string,
4770
- path: string,
4771
- campaignId?: string,
4772
- /** Onboarding: `engagement.onboarding.repeats` once this is sent. */
4773
- repeats?: number,
4774
- recipients: {email: string, forename: string, surname: string}[],
4775
- /** Drafts with the same template and step, approved together. */
4776
- batchKey: string,
4777
- status: EngagementDraftStatus,
4778
- createdAt: string,
4779
- approvedAt?: string,
4780
- approvedBy?: string,
4781
- /** 7:30am London on the school day after approval. */
4782
- sendAt?: string,
4783
- sentAt?: string,
4784
- sentTo?: string[],
4785
- /** When it was skipped, expired or dropped, and why. */
4786
- closedAt?: string,
4787
- closedReason?: string,
4788
- /** An admin changed the description or recipients. */
4789
- edited?: boolean,
4790
- };
4791
-
4792
4730
  export type CustomerTask = {
4793
4731
  id: string;
4794
4732
  customerId: string;
@@ -4806,6 +4744,8 @@ export type CustomerTask = {
4806
4744
  completedBy?: string;
4807
4745
  triggerType: "manual" | "renewalLogged" | "timeBased" | "metricThreshold" | "calendar";
4808
4746
  triggerKey?: string;
4747
+ /** The admin email template (constants.ts emailTemplates) this task sends, from the task. */
4748
+ emailTemplate?: string;
4809
4749
  commId?: string;
4810
4750
  commType?: "users";
4811
4751
  metadata?: {