placementt-core 1.400.999 → 1.400.1000

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/lib/index.d.ts CHANGED
@@ -11,6 +11,7 @@ export * from "./surveys/types";
11
11
  export * from "./surveys/registry";
12
12
  export * from "./surveys/schedule";
13
13
  export * from "./skillsResolver";
14
+ export * from "./productResolver";
14
15
  export * from "./emailSections";
15
16
  export * from "./exportTransformers";
16
17
  export * from "./compassExport";
package/lib/index.js CHANGED
@@ -31,6 +31,7 @@ __exportStar(require("./surveys/types"), exports);
31
31
  __exportStar(require("./surveys/registry"), exports);
32
32
  __exportStar(require("./surveys/schedule"), exports);
33
33
  __exportStar(require("./skillsResolver"), exports);
34
+ __exportStar(require("./productResolver"), exports);
34
35
  __exportStar(require("./emailSections"), exports);
35
36
  __exportStar(require("./exportTransformers"), exports);
36
37
  __exportStar(require("./compassExport"), exports);
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AACA,sDAAoC;AACpC,gEAA8C;AAC9C,0DAAwC;AACxC,2DAAyC;AACzC,kDAAgC;AAChC,uDAAqC;AACrC,4DAA0C;AAC1C,2DAAyC;AAEzC,oDAAkC;AAClC,kDAAgC;AAChC,qDAAmC;AACnC,qDAAmC;AACnC,mDAAiC;AACjC,kDAAgC;AAChC,uDAAqC;AACrC,kDAAgC;AAChC,2DAAyC;AACzC,uDAAqC;AACrC,iDAA+B;AAC/B,8CAA4B;AAC5B,0CAAwB;AACxB,yCAAuB;AACvB,4DAA0C;AAC1C,kDAAgC;AAChC,iDAA+B;AAC/B,2CAAyB;AACzB,4DAA0C;AAC1C,oEAAiD;AACjD,yEAAuD;AACvD,kEAAgD;AAChD,kEAAgD;AAChD,oEAAkD;AAClD,yGAAuF;AACvF,0GAAwF;AACxF,+FAA6E;AAC7E,4DAA0C;AAC1C,oEAAkD;AAClD,gEAA8C;AAC9C,2EAAyD;AACzD,6CAA2B;AAC3B,+CAA6B;AAC7B,8CAA4B;AAC5B,6DAAuE;AAA9D,2IAAA,OAAO,OAAuB"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AACA,sDAAoC;AACpC,gEAA8C;AAC9C,0DAAwC;AACxC,2DAAyC;AACzC,kDAAgC;AAChC,uDAAqC;AACrC,4DAA0C;AAC1C,2DAAyC;AAEzC,oDAAkC;AAClC,kDAAgC;AAChC,qDAAmC;AACnC,qDAAmC;AACnC,mDAAiC;AACjC,oDAAkC;AAClC,kDAAgC;AAChC,uDAAqC;AACrC,kDAAgC;AAChC,2DAAyC;AACzC,uDAAqC;AACrC,iDAA+B;AAC/B,8CAA4B;AAC5B,0CAAwB;AACxB,yCAAuB;AACvB,4DAA0C;AAC1C,kDAAgC;AAChC,iDAA+B;AAC/B,2CAAyB;AACzB,4DAA0C;AAC1C,oEAAiD;AACjD,yEAAuD;AACvD,kEAAgD;AAChD,kEAAgD;AAChD,oEAAkD;AAClD,yGAAuF;AACvF,0GAAwF;AACxF,+FAA6E;AAC7E,4DAA0C;AAC1C,oEAAkD;AAClD,gEAA8C;AAC9C,2EAAyD;AACzD,6CAA2B;AAC3B,+CAA6B;AAC7B,8CAA4B;AAC5B,6DAAuE;AAA9D,2IAAA,OAAO,OAAuB"}
@@ -0,0 +1,53 @@
1
+ import { InstituteData } from "./typeDefinitions";
2
+ /**
3
+ * Which products an institute actually has.
4
+ *
5
+ * The flags on InstituteData are typed `boolean | undefined`, which reads as
6
+ * "absent means off". Four of them mean the opposite: `network`, `careersEvents`,
7
+ * `trackExtracurriculars` and `skillsAssessmentsProduct` are tested `!== false`
8
+ * across the app, so an institute that has never touched the toggle has them ON.
9
+ * Three of those are also sub-flags that only apply when `employerEvents` (or the
10
+ * careersHub package) is switched on.
11
+ *
12
+ * Every surface used to re-derive those rules by hand, and they had drifted:
13
+ * the student home rendered skills cards on `skillsAssessmentsProduct !== false`
14
+ * with no parent check, while UserProfile required the parent — so a student saw
15
+ * skills on their dashboard that staff could not see on their profile. Wex was
16
+ * opt-in for staff (`workExperience &&`) and opt-out for students (`!== false`).
17
+ *
18
+ * This is the single answer. Nav, home, student surfaces and the backend all read
19
+ * it, so they cannot disagree about what a customer bought. Dependency-free.
20
+ */
21
+ /** The product a surface can gate on. */
22
+ export type ProductKey = "wex" | "network" | "events" | "skills" | "activities" | "destinations" | "alumni";
23
+ export type ResolvedProducts = Record<ProductKey, boolean> & {
24
+ /** Package shape, so callers stop testing `package ===` themselves. */
25
+ isTrust: boolean;
26
+ isHub: boolean;
27
+ isHubSchool: boolean;
28
+ /** An approved careersHubLinks entry — a school inside someone else's hub. */
29
+ hubLinked: boolean;
30
+ /** Ids of the hubs this institute has accepted. Empty unless hubLinked. */
31
+ hubIds: string[];
32
+ /** Products resolved true, in display order. `length` drives the home's
33
+ * single-product vs multi-product layout. */
34
+ enabled: ProductKey[];
35
+ };
36
+ /** Just enough of InstituteData to resolve, so callers can pass partials
37
+ * (a trust school row, a customer-success record) without casting. */
38
+ export type ProductResolverInput = Partial<Pick<InstituteData, "package" | "workExperience" | "employerEvents" | "network" | "careersEvents" | "skillsAssessmentsProduct" | "trackExtracurriculars" | "destinationTracking" | "alumniManagement" | "careersHubLinks">>;
39
+ /**
40
+ * Resolve an institute's products.
41
+ *
42
+ * @param {ProductResolverInput} [institute] the institute, or a partial of it.
43
+ * @param {object} [options] extra context.
44
+ * @param {ProductResolverInput[]} [options.trustSchools] schools beneath a trust.
45
+ * A trust shows a product when any of its schools has it, matching the nav's
46
+ * existing destinations rule.
47
+ * @return {ResolvedProducts} what this institute has.
48
+ */
49
+ export declare function resolveProduct(institute?: ProductResolverInput, options?: {
50
+ trustSchools?: ProductResolverInput[];
51
+ }): ResolvedProducts;
52
+ /** Human labels, for checklists, settings and the trust product strip. */
53
+ export declare const PRODUCT_LABELS: Record<ProductKey, string>;
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PRODUCT_LABELS = void 0;
4
+ exports.resolveProduct = resolveProduct;
5
+ const PRODUCT_ORDER = ["wex", "destinations", "events", "skills", "activities", "network", "alumni"];
6
+ /** Opt-out flag: undefined means on, only an explicit `false` switches it off. */
7
+ const on = (flag) => flag !== false;
8
+ /**
9
+ * Resolve an institute's products.
10
+ *
11
+ * @param {ProductResolverInput} [institute] the institute, or a partial of it.
12
+ * @param {object} [options] extra context.
13
+ * @param {ProductResolverInput[]} [options.trustSchools] schools beneath a trust.
14
+ * A trust shows a product when any of its schools has it, matching the nav's
15
+ * existing destinations rule.
16
+ * @return {ResolvedProducts} what this institute has.
17
+ */
18
+ function resolveProduct(institute, options) {
19
+ const i = institute || {};
20
+ const pkg = i.package;
21
+ const isTrust = pkg === "institutes-two";
22
+ const isHub = pkg === "careersHub";
23
+ const isHubSchool = pkg === "careersSchool";
24
+ // Hub and hub-school accounts don't run their own work experience or skills;
25
+ // they consume the hub's events and employer network.
26
+ const runsOwnStudents = !isHub && !isHubSchool;
27
+ // `employerEvents` is the billing container the events, skills and
28
+ // extracurricular modules hang off. A hub gets the events module by package.
29
+ const eventsContainer = Boolean(i.employerEvents) || isHub;
30
+ const schools = options?.trustSchools || [];
31
+ /** A trust has a product if it has it itself, or any of its schools does. */
32
+ const orSchools = (self, pick) => self || (isTrust && schools.some(pick));
33
+ const wex = orSchools(runsOwnStudents && i.workExperience === true, (s) => s.workExperience === true);
34
+ const destinations = orSchools(i.destinationTracking === true, (s) => s.destinationTracking === true);
35
+ const alumni = orSchools(i.alumniManagement === true, (s) => s.alumniManagement === true);
36
+ const events = orSchools(eventsContainer && on(i.careersEvents), (s) => Boolean(s.employerEvents) && on(s.careersEvents));
37
+ const skills = orSchools(runsOwnStudents && eventsContainer && on(i.skillsAssessmentsProduct), (s) => Boolean(s.employerEvents) && on(s.skillsAssessmentsProduct));
38
+ const activities = orSchools(runsOwnStudents && eventsContainer && on(i.trackExtracurriculars), (s) => Boolean(s.employerEvents) && on(s.trackExtracurriculars));
39
+ // Opt-out and ungated: every institute gets the employer network unless it is
40
+ // explicitly switched off. Matches Institutes.tsx and the staff home today.
41
+ const network = on(i.network);
42
+ const hubIds = Object.entries(i.careersHubLinks || {})
43
+ .filter(([, link]) => link?.status === "approved")
44
+ .map(([oId]) => oId);
45
+ const products = { wex, network, events, skills, activities, destinations, alumni };
46
+ return {
47
+ ...products,
48
+ isTrust,
49
+ isHub,
50
+ isHubSchool,
51
+ hubLinked: hubIds.length > 0,
52
+ hubIds,
53
+ enabled: PRODUCT_ORDER.filter((k) => products[k]),
54
+ };
55
+ }
56
+ /** Human labels, for checklists, settings and the trust product strip. */
57
+ exports.PRODUCT_LABELS = {
58
+ wex: "Work experience",
59
+ destinations: "Destinations",
60
+ events: "Events",
61
+ skills: "Skills",
62
+ activities: "Activities",
63
+ network: "Employer network",
64
+ alumni: "Alumni",
65
+ };
66
+ //# sourceMappingURL=productResolver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"productResolver.js","sourceRoot":"","sources":["../src/productResolver.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const productResolver_1 = require("./productResolver");
4
+ /**
5
+ * The defaults are the point of these tests. Four flags are opt-out, so a refactor
6
+ * that "tidies" `!== false` into a truthy check would silently switch four products
7
+ * off for every institute that has never touched the toggle — which is most of them.
8
+ */
9
+ describe("resolveProduct", () => {
10
+ describe("opt-out defaults", () => {
11
+ it("gives a bare institute the employer network", () => {
12
+ expect((0, productResolver_1.resolveProduct)({ package: "institutes-one" }).network).toBe(true);
13
+ });
14
+ it("switches the network off only on an explicit false", () => {
15
+ expect((0, productResolver_1.resolveProduct)({ network: false }).network).toBe(false);
16
+ expect((0, productResolver_1.resolveProduct)({ network: undefined }).network).toBe(true);
17
+ });
18
+ it("turns on events, skills and activities when employerEvents is set and the sub-flags are undefined", () => {
19
+ const p = (0, productResolver_1.resolveProduct)({ package: "institutes-one", employerEvents: true });
20
+ expect(p.events).toBe(true);
21
+ expect(p.skills).toBe(true);
22
+ expect(p.activities).toBe(true);
23
+ });
24
+ it("lets each sub-flag be switched off independently", () => {
25
+ const p = (0, productResolver_1.resolveProduct)({
26
+ package: "institutes-one",
27
+ employerEvents: true,
28
+ careersEvents: false,
29
+ trackExtracurriculars: false,
30
+ });
31
+ expect(p.events).toBe(false);
32
+ expect(p.activities).toBe(false);
33
+ expect(p.skills).toBe(true);
34
+ });
35
+ });
36
+ describe("sub-flags need their parent", () => {
37
+ it("does not give skills or activities to a school without employerEvents", () => {
38
+ const p = (0, productResolver_1.resolveProduct)({ package: "institutes-one", workExperience: true });
39
+ expect(p.skills).toBe(false);
40
+ expect(p.activities).toBe(false);
41
+ expect(p.events).toBe(false);
42
+ });
43
+ it("does not resurrect them from an explicit true on the sub-flag alone", () => {
44
+ const p = (0, productResolver_1.resolveProduct)({
45
+ package: "institutes-one",
46
+ skillsAssessmentsProduct: true,
47
+ trackExtracurriculars: true,
48
+ });
49
+ expect(p.skills).toBe(false);
50
+ expect(p.activities).toBe(false);
51
+ });
52
+ });
53
+ describe("opt-in products", () => {
54
+ it("leaves wex, destinations and alumni off by default", () => {
55
+ const p = (0, productResolver_1.resolveProduct)({ package: "institutes-one" });
56
+ expect(p.wex).toBe(false);
57
+ expect(p.destinations).toBe(false);
58
+ expect(p.alumni).toBe(false);
59
+ });
60
+ it("turns them on when explicitly true", () => {
61
+ const p = (0, productResolver_1.resolveProduct)({
62
+ package: "institutes-one",
63
+ workExperience: true,
64
+ destinationTracking: true,
65
+ alumniManagement: true,
66
+ });
67
+ expect(p.wex).toBe(true);
68
+ expect(p.destinations).toBe(true);
69
+ expect(p.alumni).toBe(true);
70
+ });
71
+ });
72
+ describe("packages", () => {
73
+ it("gives a careersHub events without employerEvents, but no wex or skills", () => {
74
+ const p = (0, productResolver_1.resolveProduct)({ package: "careersHub" });
75
+ expect(p.events).toBe(true);
76
+ expect(p.isHub).toBe(true);
77
+ expect(p.wex).toBe(false);
78
+ expect(p.skills).toBe(false);
79
+ expect(p.activities).toBe(false);
80
+ });
81
+ it("does not give a careersHub school its own wex even with the flag set", () => {
82
+ const p = (0, productResolver_1.resolveProduct)({ package: "careersSchool", workExperience: true });
83
+ expect(p.wex).toBe(false);
84
+ expect(p.isHubSchool).toBe(true);
85
+ });
86
+ it("rolls a trust's products up from its schools", () => {
87
+ const p = (0, productResolver_1.resolveProduct)({ package: "institutes-two" }, {
88
+ trustSchools: [
89
+ { destinationTracking: true },
90
+ { employerEvents: true, careersEvents: false },
91
+ ],
92
+ });
93
+ expect(p.isTrust).toBe(true);
94
+ expect(p.destinations).toBe(true);
95
+ expect(p.skills).toBe(true); // sub-flag undefined at the school
96
+ expect(p.events).toBe(false); // explicitly off at the only events school
97
+ expect(p.wex).toBe(false);
98
+ });
99
+ it("does not roll schools up for a non-trust", () => {
100
+ const p = (0, productResolver_1.resolveProduct)({ package: "institutes-one" }, {
101
+ trustSchools: [{ destinationTracking: true }],
102
+ });
103
+ expect(p.destinations).toBe(false);
104
+ });
105
+ });
106
+ describe("hub links", () => {
107
+ it("counts only approved links", () => {
108
+ const p = (0, productResolver_1.resolveProduct)({
109
+ careersHubLinks: {
110
+ hubA: { exists: true, invited: "2026-01-01", status: "approved" },
111
+ hubB: { exists: true, invited: "2026-01-01", status: "invited" },
112
+ hubC: { exists: true, invited: "2026-01-01", status: "declined" },
113
+ },
114
+ });
115
+ expect(p.hubLinked).toBe(true);
116
+ expect(p.hubIds).toEqual(["hubA"]);
117
+ });
118
+ it("is not hub-linked with no links", () => {
119
+ expect((0, productResolver_1.resolveProduct)({}).hubLinked).toBe(false);
120
+ });
121
+ });
122
+ describe("enabled", () => {
123
+ it("lists products in display order and drives the layout mode", () => {
124
+ const skillsOnly = (0, productResolver_1.resolveProduct)({
125
+ package: "institutes-one",
126
+ employerEvents: true,
127
+ careersEvents: false,
128
+ network: false,
129
+ });
130
+ expect(skillsOnly.enabled).toEqual(["skills", "activities"]);
131
+ const fullStack = (0, productResolver_1.resolveProduct)({
132
+ package: "institutes-one",
133
+ workExperience: true,
134
+ employerEvents: true,
135
+ destinationTracking: true,
136
+ alumniManagement: true,
137
+ });
138
+ expect(fullStack.enabled).toEqual(["wex", "destinations", "events", "skills", "activities", "network", "alumni"]);
139
+ });
140
+ });
141
+ });
142
+ //# sourceMappingURL=productResolver.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"productResolver.test.js","sourceRoot":"","sources":["../src/productResolver.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "private": false,
3
3
  "name": "placementt-core",
4
4
  "author": "Placementt",
5
- "version": "1.400.999",
5
+ "version": "1.400.1000",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
8
8
  "scripts": {
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export * from "./surveys/types";
13
13
  export * from "./surveys/registry";
14
14
  export * from "./surveys/schedule";
15
15
  export * from "./skillsResolver";
16
+ export * from "./productResolver";
16
17
  export * from "./emailSections";
17
18
  export * from "./exportTransformers";
18
19
  export * from "./compassExport";
@@ -0,0 +1,156 @@
1
+ import {resolveProduct} from "./productResolver";
2
+
3
+ /**
4
+ * The defaults are the point of these tests. Four flags are opt-out, so a refactor
5
+ * that "tidies" `!== false` into a truthy check would silently switch four products
6
+ * off for every institute that has never touched the toggle — which is most of them.
7
+ */
8
+ describe("resolveProduct", () => {
9
+ describe("opt-out defaults", () => {
10
+ it("gives a bare institute the employer network", () => {
11
+ expect(resolveProduct({package: "institutes-one"}).network).toBe(true);
12
+ });
13
+
14
+ it("switches the network off only on an explicit false", () => {
15
+ expect(resolveProduct({network: false}).network).toBe(false);
16
+ expect(resolveProduct({network: undefined}).network).toBe(true);
17
+ });
18
+
19
+ it("turns on events, skills and activities when employerEvents is set and the sub-flags are undefined", () => {
20
+ const p = resolveProduct({package: "institutes-one", employerEvents: true});
21
+ expect(p.events).toBe(true);
22
+ expect(p.skills).toBe(true);
23
+ expect(p.activities).toBe(true);
24
+ });
25
+
26
+ it("lets each sub-flag be switched off independently", () => {
27
+ const p = resolveProduct({
28
+ package: "institutes-one",
29
+ employerEvents: true,
30
+ careersEvents: false,
31
+ trackExtracurriculars: false,
32
+ });
33
+ expect(p.events).toBe(false);
34
+ expect(p.activities).toBe(false);
35
+ expect(p.skills).toBe(true);
36
+ });
37
+ });
38
+
39
+ describe("sub-flags need their parent", () => {
40
+ it("does not give skills or activities to a school without employerEvents", () => {
41
+ const p = resolveProduct({package: "institutes-one", workExperience: true});
42
+ expect(p.skills).toBe(false);
43
+ expect(p.activities).toBe(false);
44
+ expect(p.events).toBe(false);
45
+ });
46
+
47
+ it("does not resurrect them from an explicit true on the sub-flag alone", () => {
48
+ const p = resolveProduct({
49
+ package: "institutes-one",
50
+ skillsAssessmentsProduct: true,
51
+ trackExtracurriculars: true,
52
+ });
53
+ expect(p.skills).toBe(false);
54
+ expect(p.activities).toBe(false);
55
+ });
56
+ });
57
+
58
+ describe("opt-in products", () => {
59
+ it("leaves wex, destinations and alumni off by default", () => {
60
+ const p = resolveProduct({package: "institutes-one"});
61
+ expect(p.wex).toBe(false);
62
+ expect(p.destinations).toBe(false);
63
+ expect(p.alumni).toBe(false);
64
+ });
65
+
66
+ it("turns them on when explicitly true", () => {
67
+ const p = resolveProduct({
68
+ package: "institutes-one",
69
+ workExperience: true,
70
+ destinationTracking: true,
71
+ alumniManagement: true,
72
+ });
73
+ expect(p.wex).toBe(true);
74
+ expect(p.destinations).toBe(true);
75
+ expect(p.alumni).toBe(true);
76
+ });
77
+ });
78
+
79
+ describe("packages", () => {
80
+ it("gives a careersHub events without employerEvents, but no wex or skills", () => {
81
+ const p = resolveProduct({package: "careersHub"});
82
+ expect(p.events).toBe(true);
83
+ expect(p.isHub).toBe(true);
84
+ expect(p.wex).toBe(false);
85
+ expect(p.skills).toBe(false);
86
+ expect(p.activities).toBe(false);
87
+ });
88
+
89
+ it("does not give a careersHub school its own wex even with the flag set", () => {
90
+ const p = resolveProduct({package: "careersSchool", workExperience: true});
91
+ expect(p.wex).toBe(false);
92
+ expect(p.isHubSchool).toBe(true);
93
+ });
94
+
95
+ it("rolls a trust's products up from its schools", () => {
96
+ const p = resolveProduct({package: "institutes-two"}, {
97
+ trustSchools: [
98
+ {destinationTracking: true},
99
+ {employerEvents: true, careersEvents: false},
100
+ ],
101
+ });
102
+ expect(p.isTrust).toBe(true);
103
+ expect(p.destinations).toBe(true);
104
+ expect(p.skills).toBe(true); // sub-flag undefined at the school
105
+ expect(p.events).toBe(false); // explicitly off at the only events school
106
+ expect(p.wex).toBe(false);
107
+ });
108
+
109
+ it("does not roll schools up for a non-trust", () => {
110
+ const p = resolveProduct({package: "institutes-one"}, {
111
+ trustSchools: [{destinationTracking: true}],
112
+ });
113
+ expect(p.destinations).toBe(false);
114
+ });
115
+ });
116
+
117
+ describe("hub links", () => {
118
+ it("counts only approved links", () => {
119
+ const p = resolveProduct({
120
+ careersHubLinks: {
121
+ hubA: {exists: true, invited: "2026-01-01", status: "approved"},
122
+ hubB: {exists: true, invited: "2026-01-01", status: "invited"},
123
+ hubC: {exists: true, invited: "2026-01-01", status: "declined"},
124
+ },
125
+ });
126
+ expect(p.hubLinked).toBe(true);
127
+ expect(p.hubIds).toEqual(["hubA"]);
128
+ });
129
+
130
+ it("is not hub-linked with no links", () => {
131
+ expect(resolveProduct({}).hubLinked).toBe(false);
132
+ });
133
+ });
134
+
135
+ describe("enabled", () => {
136
+ it("lists products in display order and drives the layout mode", () => {
137
+ const skillsOnly = resolveProduct({
138
+ package: "institutes-one",
139
+ employerEvents: true,
140
+ careersEvents: false,
141
+ network: false,
142
+ });
143
+ expect(skillsOnly.enabled).toEqual(["skills", "activities"]);
144
+
145
+ const fullStack = resolveProduct({
146
+ package: "institutes-one",
147
+ workExperience: true,
148
+ employerEvents: true,
149
+ destinationTracking: true,
150
+ alumniManagement: true,
151
+ });
152
+ expect(fullStack.enabled).toEqual(
153
+ ["wex", "destinations", "events", "skills", "activities", "network", "alumni"]);
154
+ });
155
+ });
156
+ });
@@ -0,0 +1,145 @@
1
+ import {InstituteData} from "./typeDefinitions";
2
+
3
+ /**
4
+ * Which products an institute actually has.
5
+ *
6
+ * The flags on InstituteData are typed `boolean | undefined`, which reads as
7
+ * "absent means off". Four of them mean the opposite: `network`, `careersEvents`,
8
+ * `trackExtracurriculars` and `skillsAssessmentsProduct` are tested `!== false`
9
+ * across the app, so an institute that has never touched the toggle has them ON.
10
+ * Three of those are also sub-flags that only apply when `employerEvents` (or the
11
+ * careersHub package) is switched on.
12
+ *
13
+ * Every surface used to re-derive those rules by hand, and they had drifted:
14
+ * the student home rendered skills cards on `skillsAssessmentsProduct !== false`
15
+ * with no parent check, while UserProfile required the parent — so a student saw
16
+ * skills on their dashboard that staff could not see on their profile. Wex was
17
+ * opt-in for staff (`workExperience &&`) and opt-out for students (`!== false`).
18
+ *
19
+ * This is the single answer. Nav, home, student surfaces and the backend all read
20
+ * it, so they cannot disagree about what a customer bought. Dependency-free.
21
+ */
22
+
23
+ /** The product a surface can gate on. */
24
+ export type ProductKey =
25
+ "wex"|"network"|"events"|"skills"|"activities"|"destinations"|"alumni";
26
+
27
+ export type ResolvedProducts = Record<ProductKey, boolean> & {
28
+ /** Package shape, so callers stop testing `package ===` themselves. */
29
+ isTrust: boolean,
30
+ isHub: boolean,
31
+ isHubSchool: boolean,
32
+ /** An approved careersHubLinks entry — a school inside someone else's hub. */
33
+ hubLinked: boolean,
34
+ /** Ids of the hubs this institute has accepted. Empty unless hubLinked. */
35
+ hubIds: string[],
36
+ /** Products resolved true, in display order. `length` drives the home's
37
+ * single-product vs multi-product layout. */
38
+ enabled: ProductKey[],
39
+ };
40
+
41
+ /** Just enough of InstituteData to resolve, so callers can pass partials
42
+ * (a trust school row, a customer-success record) without casting. */
43
+ export type ProductResolverInput = Partial<Pick<InstituteData,
44
+ "package"|"workExperience"|"employerEvents"|"network"|"careersEvents"|
45
+ "skillsAssessmentsProduct"|"trackExtracurriculars"|"destinationTracking"|
46
+ "alumniManagement"|"careersHubLinks">>;
47
+
48
+ const PRODUCT_ORDER: ProductKey[] =
49
+ ["wex", "destinations", "events", "skills", "activities", "network", "alumni"];
50
+
51
+ /** Opt-out flag: undefined means on, only an explicit `false` switches it off. */
52
+ const on = (flag?: boolean) => flag !== false;
53
+
54
+ /**
55
+ * Resolve an institute's products.
56
+ *
57
+ * @param {ProductResolverInput} [institute] the institute, or a partial of it.
58
+ * @param {object} [options] extra context.
59
+ * @param {ProductResolverInput[]} [options.trustSchools] schools beneath a trust.
60
+ * A trust shows a product when any of its schools has it, matching the nav's
61
+ * existing destinations rule.
62
+ * @return {ResolvedProducts} what this institute has.
63
+ */
64
+ export function resolveProduct(
65
+ institute?: ProductResolverInput,
66
+ options?: {trustSchools?: ProductResolverInput[]},
67
+ ): ResolvedProducts {
68
+ const i = institute || {};
69
+ const pkg = i.package;
70
+
71
+ const isTrust = pkg === "institutes-two";
72
+ const isHub = pkg === "careersHub";
73
+ const isHubSchool = pkg === "careersSchool";
74
+
75
+ // Hub and hub-school accounts don't run their own work experience or skills;
76
+ // they consume the hub's events and employer network.
77
+ const runsOwnStudents = !isHub && !isHubSchool;
78
+
79
+ // `employerEvents` is the billing container the events, skills and
80
+ // extracurricular modules hang off. A hub gets the events module by package.
81
+ const eventsContainer = Boolean(i.employerEvents) || isHub;
82
+
83
+ const schools = options?.trustSchools || [];
84
+ /** A trust has a product if it has it itself, or any of its schools does. */
85
+ const orSchools = (
86
+ self: boolean,
87
+ pick: (s: ProductResolverInput) => boolean,
88
+ ) => self || (isTrust && schools.some(pick));
89
+
90
+ const wex = orSchools(
91
+ runsOwnStudents && i.workExperience === true,
92
+ (s) => s.workExperience === true);
93
+
94
+ const destinations = orSchools(
95
+ i.destinationTracking === true,
96
+ (s) => s.destinationTracking === true);
97
+
98
+ const alumni = orSchools(
99
+ i.alumniManagement === true,
100
+ (s) => s.alumniManagement === true);
101
+
102
+ const events = orSchools(
103
+ eventsContainer && on(i.careersEvents),
104
+ (s) => Boolean(s.employerEvents) && on(s.careersEvents));
105
+
106
+ const skills = orSchools(
107
+ runsOwnStudents && eventsContainer && on(i.skillsAssessmentsProduct),
108
+ (s) => Boolean(s.employerEvents) && on(s.skillsAssessmentsProduct));
109
+
110
+ const activities = orSchools(
111
+ runsOwnStudents && eventsContainer && on(i.trackExtracurriculars),
112
+ (s) => Boolean(s.employerEvents) && on(s.trackExtracurriculars));
113
+
114
+ // Opt-out and ungated: every institute gets the employer network unless it is
115
+ // explicitly switched off. Matches Institutes.tsx and the staff home today.
116
+ const network = on(i.network);
117
+
118
+ const hubIds = Object.entries(i.careersHubLinks || {})
119
+ .filter(([, link]) => link?.status === "approved")
120
+ .map(([oId]) => oId);
121
+
122
+ const products: Record<ProductKey, boolean> =
123
+ {wex, network, events, skills, activities, destinations, alumni};
124
+
125
+ return {
126
+ ...products,
127
+ isTrust,
128
+ isHub,
129
+ isHubSchool,
130
+ hubLinked: hubIds.length > 0,
131
+ hubIds,
132
+ enabled: PRODUCT_ORDER.filter((k) => products[k]),
133
+ };
134
+ }
135
+
136
+ /** Human labels, for checklists, settings and the trust product strip. */
137
+ export const PRODUCT_LABELS: Record<ProductKey, string> = {
138
+ wex: "Work experience",
139
+ destinations: "Destinations",
140
+ events: "Events",
141
+ skills: "Skills",
142
+ activities: "Activities",
143
+ network: "Employer network",
144
+ alumni: "Alumni",
145
+ };