placementt-core 1.400.993 → 1.400.995

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.
@@ -1,6 +1,6 @@
1
1
  import {z} from "zod";
2
2
  import {CallableGroup} from "./types";
3
- import {SURVEY_KINDS_LIST} from "../surveys/types";
3
+ import {SURVEY_COHORT_SCOPES, SURVEY_KINDS_LIST} from "../surveys/types";
4
4
 
5
5
  /**
6
6
  * Contracts for the unified survey engine
@@ -12,6 +12,7 @@ import {SURVEY_KINDS_LIST} from "../surveys/types";
12
12
  */
13
13
 
14
14
  const surveyKind = z.enum(SURVEY_KINDS_LIST as [string, ...string[]]);
15
+ const cohortScope = z.enum(SURVEY_COHORT_SCOPES as [string, ...string[]]);
15
16
 
16
17
  const raters = z.object({
17
18
  students: z.boolean().optional(),
@@ -25,7 +26,7 @@ export const surveyCallables = {
25
26
  oId: z.string(),
26
27
  kind: surveyKind,
27
28
  /** One cycle per year group. Omitted for institute-wide kinds. */
28
- yearGroups: z.array(z.number()).optional(),
29
+ yearGroups: z.array(z.string()).optional(),
29
30
  scheduledSendDate: z.string(), // ISO
30
31
  /** Overrides the year derived from the send date — the leaver thread's
31
32
  * Christmas leg sends in the year after the cohort's own. */
@@ -49,10 +50,10 @@ export const surveyCallables = {
49
50
  response: z.object({
50
51
  created: z.array(z.object({
51
52
  cycleId: z.string(),
52
- yearGroup: z.number().optional(),
53
+ yearGroup: z.string().optional(),
53
54
  invited: z.number(),
54
55
  })),
55
- skippedEmpty: z.array(z.number()),
56
+ skippedEmpty: z.array(z.string()),
56
57
  skippedNoEmail: z.number(),
57
58
  academicYear: z.string(),
58
59
  }),
@@ -65,7 +66,7 @@ export const surveyCallables = {
65
66
  }),
66
67
  response: z.object({
67
68
  cycleId: z.string(),
68
- yearGroup: z.number().optional(),
69
+ yearGroup: z.string().optional(),
69
70
  invited: z.number(),
70
71
  skippedNoEmail: z.number().optional(),
71
72
  alreadyScheduled: z.boolean().optional(),
@@ -116,6 +117,44 @@ export const surveyCallables = {
116
117
  request: z.object({cycleId: z.string(), jobId: z.string().optional()}),
117
118
  response: z.object({deleted: z.number()}),
118
119
  },
120
+ "surveys-saveSurveyCohortSetup": {
121
+ request: z.object({
122
+ oId: z.string(),
123
+ scope: cohortScope,
124
+ /** Year group ("9") or skills group id ("all", "yg9", a mapping id). */
125
+ cohort: z.string(),
126
+ /** JSON-serialised DataViewerSelectionBackend. */
127
+ studentSelection: z.string().optional(),
128
+ selectionSummary: z.string().optional(),
129
+ /** null clears the cohort's template and falls back to the default copy. */
130
+ emailTemplateId: z.string().nullable().optional(),
131
+ /** false → store the answer for future years only, leaving the cycles
132
+ * already provisioned alone. Defaults to true. */
133
+ applyToExisting: z.boolean().optional(),
134
+ }),
135
+ response: z.object({
136
+ saved: z.boolean(),
137
+ /** Cycles that picked the answer up, and how many people they now reach. */
138
+ updatedCycles: z.number(),
139
+ invited: z.number(),
140
+ }),
141
+ },
142
+ "surveys-provisionSurveysNow": {
143
+ request: z.object({
144
+ oId: z.string(),
145
+ /** Omit for every programme the institute runs. */
146
+ scope: cohortScope.optional(),
147
+ /** Provision the upcoming academic year rather than the current one. */
148
+ nextYear: z.boolean().optional(),
149
+ }),
150
+ response: z.object({
151
+ created: z.number(),
152
+ academicYear: z.string(),
153
+ /** True when the current year had nothing left to create and the next
154
+ * academic year was provisioned instead. */
155
+ rolledForward: z.boolean(),
156
+ }),
157
+ },
119
158
  "surveys-getMySurveyRequest": {
120
159
  request: z.object({requestId: z.string()}),
121
160
  // Shape varies by kind (see HYDRATE in surveys.ts), so left open.
package/src/constants.ts CHANGED
@@ -980,6 +980,12 @@ export const emailTemplates:{[key: string]: EmailTemplateConfig} = {
980
980
  button: {text: "Connect with Employers", link: "/login"},
981
981
  sections: ["employerDatabaseSummarySection"],
982
982
  },
983
+ skillsInvite: {
984
+ description: "Sent to students, parents and employers inviting them to complete a skills assessment.",
985
+ params: ["studentForename", "instituteName"],
986
+ type: "skillsInvite",
987
+ button: {text: "Complete Assessment", link: "/institutes/surveys/skills/{{requestId}}"},
988
+ },
983
989
  aspirationInvite: {
984
990
  description: "Sent to students inviting them to complete the career aspirations survey.",
985
991
  params: ["studentForename", "instituteName"],
@@ -1513,3 +1519,13 @@ export const JOB_VALUES: string[] = [
1513
1519
  "Working with technology",
1514
1520
  ];
1515
1521
 
1522
+
1523
+ /**
1524
+ * The `UserData.details` key holding a student's year group tag.
1525
+ *
1526
+ * A defined standard key rather than one of the dynamic upload columns: the Wonde
1527
+ * sync writes it, the manual spreadsheet upload always offers it, and every survey's
1528
+ * recipient filter resolves a cohort by matching `details.year` against a tag from
1529
+ * `InstituteData.yearGroups`. The tag is opaque — matched as a string, never parsed.
1530
+ */
1531
+ export const STUDENT_YEAR_FIELD = "year";
@@ -21,7 +21,7 @@ import {
21
21
  StudentPlacementData,
22
22
  UserData,
23
23
  } from "./typeDefinitions";
24
- import {skillLabelColours} from "./constants";
24
+ import {skillColour} from "./constants";
25
25
 
26
26
  // ── Brand ────────────────────────────────────────────────────────────────────
27
27
  // The portfolio uses the CareerThread platform palette, which is distinct from
@@ -120,7 +120,10 @@ export const ACTIVITY_TYPE_TO_PFA: Record<StudentActivity["activityType"], PfaPa
120
120
  // Self-Management would naturally sit under Independent living and Attitude &
121
121
  // Resilience under Good health, but those pathways aren't rendered yet, so they
122
122
  // are folded into the two reported ones so no skill silently disappears.
123
- export const SKILL_CATEGORY_TO_PFA: Record<SkillLabel["category"], PfaPathway> = {
123
+ // Categories a framework brings with it are mapped here too — anything unmapped
124
+ // (e.g. a school's own category) falls back to PFA_DEFAULT_PATHWAY.
125
+ export const SKILL_CATEGORY_TO_PFA: Record<string, PfaPathway> = {
126
+ // Platform categories.
124
127
  ["Communication"]: "communityInclusion",
125
128
  ["Teamwork"]: "communityInclusion",
126
129
  ["Self-Management"]: "employmentAndFurtherLearning",
@@ -128,6 +131,10 @@ export const SKILL_CATEGORY_TO_PFA: Record<SkillLabel["category"], PfaPathway> =
128
131
  ["Attitude & Resilience"]: "communityInclusion",
129
132
  ["Work Readiness"]: "employmentAndFurtherLearning",
130
133
  ["Career Development"]: "employmentAndFurtherLearning",
134
+ // SDS meta-skills wheel pillars ("Self-Management" is shared with the platform
135
+ // set above and maps the same way).
136
+ ["Social Intelligence"]: "communityInclusion",
137
+ ["Innovation"]: "employmentAndFurtherLearning",
131
138
  };
132
139
 
133
140
  // ── Aggregate output shape ────────────────────────────────────────────────────
@@ -316,7 +323,7 @@ function buildSkill(
316
323
  skillId,
317
324
  label: label.label,
318
325
  category: label.category,
319
- color: skillLabelColours[label.category],
326
+ color: skillColour(label),
320
327
  latestScore: latestStudent,
321
328
  firstScore,
322
329
  distanceTravelled: Math.round((latestStudent - firstScore) * 100) / 100,
@@ -94,8 +94,9 @@ export type SurveyCycle = {
94
94
  * Legacy leaverGroups used the slash form ("2026/27") and are converted on migration. */
95
95
  academicYear: string,
96
96
  /** The single year group this cycle targets. Absent for alumni check-in cycles,
97
- * which are institute-wide. Replaces AspirationCycle.yearGroupsIncluded. */
98
- yearGroup?: number,
97
+ * which are institute-wide. Replaces AspirationCycle.yearGroupsIncluded. Opaque:
98
+ * whatever tag the school's import wrote to details.year, matched as a string. */
99
+ yearGroup?: string,
99
100
  /** SkillsMapping.id ("all" for the All students group) — skills cycles only. */
100
101
  groupId?: string,
101
102
  /** Chains the cycles of one journey: the three leaver kinds for a cohort share
@@ -186,7 +187,7 @@ export type SurveyRequest = {
186
187
  email: string,
187
188
  /** Post-leaving contact address; where invites go once the school email dies. */
188
189
  personalEmail?: string,
189
- yearGroup?: number,
190
+ yearGroup?: string,
190
191
  status: SurveyRequestStatus,
191
192
  completed: boolean,
192
193
  /** ISO — when the invite was emailed. */
@@ -221,3 +222,84 @@ export type SurveyRequest = {
221
222
  parentToken?: string,
222
223
  parentTokenExpiry?: string,
223
224
  };
225
+
226
+ // ─── Per-cohort setup ─────────────────────────────────────────────────────────
227
+
228
+ /**
229
+ * The two questions a school can only answer once per cohort — who is in it, and
230
+ * which email its invitations use.
231
+ *
232
+ * A survey's SCHEDULE holds for every year to come, so setup asks for it once. Its
233
+ * COHORT does not: no school's student records agree on how a year group is written,
234
+ * so provisioning cannot resolve "Year 9" on its own, and until someone says who
235
+ * Year 9 is, that year group's surveys have nobody to send to. Rather than ask again
236
+ * every time a survey is provisioned, the answer is stored here and every later
237
+ * survey for that cohort inherits it — including next year's, whose cohort is the
238
+ * same query against a new set of students.
239
+ *
240
+ * Stored on institutes/{oId}.surveyCohorts, keyed by surveyCohortKey().
241
+ */
242
+ export type SurveyCohortSetup = {
243
+ /** JSON-serialised DataViewerSelectionBackend — a FILTER, not a frozen list, so
244
+ * it re-resolves against whoever is in the year group at each send. */
245
+ studentSelection?: string,
246
+ /** Human summary of the selection ("All students matching Year group: 9"), for
247
+ * the setup screens — the JSON is not readable enough to show staff. */
248
+ selectionSummary?: string,
249
+ /** How many students it resolved to when it was saved. Indicative only. */
250
+ resolvedCount?: number,
251
+ /** emailTemplates doc id used for this cohort's invitations. Absent = the
252
+ * platform's default copy for the kind. */
253
+ emailTemplateId?: string,
254
+ savedAt?: string,
255
+ /** uid of the staff member who answered. */
256
+ savedBy?: string,
257
+ };
258
+
259
+ /**
260
+ * A cohort's scope. Kinds that share a cohort share a scope, so answering once for
261
+ * Year 11 leavers covers all three legs of their journey.
262
+ */
263
+ export type SurveyCohortScope = "aspirations"|"leavers"|"skills";
264
+
265
+ /** Every scope as a runtime array — for validation. */
266
+ export const SURVEY_COHORT_SCOPES: SurveyCohortScope[] = ["aspirations", "leavers", "skills"];
267
+
268
+ /**
269
+ * The key one cohort's setup is stored under: the scope plus the cohort within it —
270
+ * a year group for aspirations and leavers ("aspirations_y9"), a skills group id for
271
+ * skills ("skills_all", "skills_yg9", "skills_<mappingId>").
272
+ *
273
+ * @param {SurveyCohortScope} scope which programme the cohort belongs to.
274
+ * @param {string} cohort the year group tag, or the skills group id.
275
+ * @return {string} the storage key.
276
+ */
277
+ export const surveyCohortKey = (scope: SurveyCohortScope, cohort: string): string =>
278
+ `${scope}_${surveyCohortSlug(cohort)}`;
279
+
280
+ /**
281
+ * A year group tag reduced to characters that are safe in a Firestore field path and
282
+ * document id.
283
+ *
284
+ * A tag is whatever the school's MIS calls it — "9", "Year 9", "Yr 9 (KS4)" — and it
285
+ * is never parsed or renumbered. But a key segment can't hold a slash or a dot, so
286
+ * anything outside [A-Za-z0-9_-] collapses to a hyphen for id purposes only. The tag
287
+ * itself is stored and matched verbatim.
288
+ *
289
+ * @param {string} tag the year group tag or group id.
290
+ * @return {string} the tag as a key segment.
291
+ */
292
+ export const surveyCohortSlug = (tag: string): string =>
293
+ String(tag).trim().replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
294
+
295
+ /** The scope a survey kind's cohorts are keyed under. */
296
+ export const SURVEY_KIND_COHORT_SCOPE: {[k in SurveyKind]?: SurveyCohortScope} = {
297
+ aspirations: "aspirations",
298
+ skills: "skills",
299
+ leaverIntentions: "leavers",
300
+ leaverResults: "leavers",
301
+ leaverChristmas: "leavers",
302
+ };
303
+
304
+ /** Every cohort's setup, as stored on the institute. */
305
+ export type SurveyCohortSetupMap = {[key: string]: SurveyCohortSetup};
@@ -1,8 +1,8 @@
1
1
  import {DocumentData, DocumentReference, OrderByDirection, Timestamp, WhereFilterOp} from "firebase/firestore";
2
2
  import {Descendant} from "slate";
3
- import {emailTemplates, skillLabelColours} from "./constants";
3
+ import {emailTemplates} from "./constants";
4
4
  import type {ChecklistItem, GiftHistory} from "./customerCareConfig";
5
- import type {SurveyKind, SurveySlot} from "./surveys/types";
5
+ import type {SurveyCohortSetupMap, SurveyKind, SurveySlot} from "./surveys/types";
6
6
 
7
7
  export type FilterObject = {
8
8
  [key: string]: {
@@ -847,6 +847,12 @@ export type InstituteData = {
847
847
  students: number,
848
848
  staff: number,
849
849
  studentsFields: string[],
850
+ /** The year group tags present in this school's student records, in order, as
851
+ * written by whichever importer put them there (the Wonde sync, or a manual
852
+ * spreadsheet upload). Opaque strings — a tag is whatever the school calls it.
853
+ * Every year-group picker offers this list, and a selected tag resolves students
854
+ * by an exact string match on details.year. */
855
+ yearGroups?: string[],
850
856
  staffFields: string[],
851
857
  employerFields?: string[],
852
858
  studentsActive: number,
@@ -934,6 +940,10 @@ export type InstituteData = {
934
940
  // Written by the setup wizard (aspirations-saveDestinationsConfig); read by
935
941
  // destinationsProvisionCron each September to create that year's surveys/groups.
936
942
  destinationsConfig?: DestinationsConfig,
943
+ /** Who is in each survey cohort, and which email template it uses — the two
944
+ * answers a schedule can't supply on its own. Keyed by surveyCohortKey(); read
945
+ * by provisioning so every year's surveys inherit the answer. */
946
+ surveyCohorts?: SurveyCohortSetupMap,
937
947
  acceptingAlumni?: boolean,
938
948
  approveAlumni?: boolean,
939
949
  shareAlumni?: boolean
@@ -976,6 +986,11 @@ export type InstituteData = {
976
986
  /** Annual survey schedule for the default "All students" group. Auto-provisioned
977
987
  * into each academic year by the daily cron. */
978
988
  skillsSurveys?: SkillsSurveySlot[],
989
+ /** Which of the school's year group tags (InstituteData.yearGroups) take part in
990
+ * skills surveys. Provisioning creates one cycle per tag from `skillsSurveys`, so
991
+ * staff chase Year 9 separately from Year 10. Empty/absent = one survey for
992
+ * everyone. Tags are matched against details.year, never parsed. */
993
+ skillsYearGroups?: string[],
979
994
  scheduledSkillsAssessments?: {
980
995
  [key in "yearStart" | "yearEnd" | "eachTerm" | "preWorkExperience" | "postWorkExperience" | "employerInteraction" | "extracurricularActivity"]?: {
981
996
  students?: boolean,
@@ -1532,7 +1547,7 @@ export type EmailTemplateConfig = {
1532
1547
  button?: {text: string, link: string},
1533
1548
  secondaryButton?: {text: string, link: string},
1534
1549
  optionalButtons?: {text: string, link: string}[],
1535
- type: "workflow"|"feedback"|"event"|"campaign"|"database"|"onboarding"|"aspirationInvite"|"leaverInvite"|"leaverResultsInvite"|"leaverChristmasInvite",
1550
+ type: "workflow"|"feedback"|"event"|"campaign"|"database"|"onboarding"|"skillsInvite"|"aspirationInvite"|"leaverInvite"|"leaverResultsInvite"|"leaverChristmasInvite",
1536
1551
  sections?: EmailSectionNames[],
1537
1552
  }
1538
1553
 
@@ -2053,7 +2068,8 @@ export type DestinationsConfig = {
2053
2068
  // ── Aspirations (Form A) ──
2054
2069
  aspirations: {
2055
2070
  enabled: boolean,
2056
- yearGroups: number[], // e.g. [7,8,9,10,11]
2071
+ /** Year group tags from InstituteData.yearGroups. Matched, never parsed. */
2072
+ yearGroups: string[],
2057
2073
  /** Shared schedule applied to every selected year group. Any number of sends
2058
2074
  * per year — a school running one a term is as valid as one a year. */
2059
2075
  surveys: AspirationSurveySlot[],
@@ -2067,7 +2083,8 @@ export type DestinationsConfig = {
2067
2083
  // ── Leavers (Form B + checkpoints) ──
2068
2084
  leavers: {
2069
2085
  enabled: boolean,
2070
- yearGroups: number[], // subset of [11, 13]
2086
+ /** Year group tags from InstituteData.yearGroups. Matched, never parsed. */
2087
+ yearGroups: string[],
2071
2088
  // Each leg is scheduled by relative rule with the MM-DD as its materialised
2072
2089
  // fallback, the same pair aspiration and skills slots use.
2073
2090
  intentionsSendMMDD: string, // Form B, before they leave — default "06-15"
@@ -2872,7 +2889,12 @@ export type SkillsFrameworkKey = "custom" | "skillsBuilder" | "sdsMetaskills";
2872
2889
  export type SkillLabel = {
2873
2890
  label: string,
2874
2891
  description: string,
2875
- category: keyof typeof skillLabelColours,
2892
+ /** The group this skill sits under, stored on the document — pickers and reports
2893
+ * head skills by it. A framework brings its own set (the SDS meta-skills wheel has
2894
+ * exactly three: Self-Management, Social Intelligence, Innovation); generic and
2895
+ * school-created labels use the platform set in `skillLabelColours`. Free text, so
2896
+ * a framework is never forced into the platform categories. */
2897
+ category: string,
2876
2898
  /** Display colour stored on the document. Seeded via the admin
2877
2899
  * "Seed skill labels & colours" action (framework labels carry their own
2878
2900
  * scheme, e.g. the SDS meta-skills wheel pillars). Read via skillColour(),