placementt-core 1.400.1123 → 1.400.1125

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,13 +1,4 @@
1
1
  import { z } from "zod";
2
- /**
3
- * Contracts for the end-to-end and UX-audit harness
4
- * (`Placementt-Backend/functions/src/e2e.ts`).
5
- *
6
- * `redeemSignInLink` is public: the token is the credential. Links are only
7
- * ever minted by the harness (service account, straight into Firestore) and
8
- * only ever sign in a reserved `e2e.*@careerthread.co.uk` user, so the UX audit
9
- * can walk a registered school without anyone typing a password.
10
- */
11
2
  export declare const e2eCallables: {
12
3
  "e2e-redeemSignInLink": {
13
4
  request: z.ZodObject<{
@@ -30,4 +21,154 @@ export declare const e2eCallables: {
30
21
  authToken?: string | undefined;
31
22
  }>;
32
23
  };
24
+ /**
25
+ * Admin only. Tears down one institute whose billing email is a reserved
26
+ * E2E address. Each step reports true (cleared), false (nothing to do) or
27
+ * the error message.
28
+ */
29
+ "e2e-deleteE2EAccount": {
30
+ request: z.ZodObject<{
31
+ oId: z.ZodString;
32
+ }, "strip", z.ZodTypeAny, {
33
+ oId: string;
34
+ }, {
35
+ oId: string;
36
+ }>;
37
+ response: z.ZodObject<{
38
+ deleted: z.ZodLiteral<true>;
39
+ oId: z.ZodString;
40
+ email: z.ZodString;
41
+ steps: z.ZodRecord<z.ZodString, z.ZodUnion<[z.ZodBoolean, z.ZodString]>>;
42
+ }, "strip", z.ZodTypeAny, {
43
+ deleted: true;
44
+ email: string;
45
+ oId: string;
46
+ steps: Record<string, string | boolean>;
47
+ }, {
48
+ deleted: true;
49
+ email: string;
50
+ oId: string;
51
+ steps: Record<string, string | boolean>;
52
+ }>;
53
+ };
54
+ /**
55
+ * Admin only. Tears down a whole run: every institute billed to
56
+ * `e2e.<runId>@` or `e2e.<runId>-*@`, their careers invites and hub links,
57
+ * the run's employers, provider accounts, event requests (and PDFs),
58
+ * attendees, Auth users and Brevo contacts. Institute steps are reported
59
+ * as `<oId>/<step>`.
60
+ */
61
+ "e2e-deleteE2ERun": {
62
+ request: z.ZodObject<{
63
+ runId: z.ZodString;
64
+ }, "strip", z.ZodTypeAny, {
65
+ runId: string;
66
+ }, {
67
+ runId: string;
68
+ }>;
69
+ response: z.ZodObject<{
70
+ deleted: z.ZodLiteral<true>;
71
+ runId: z.ZodString;
72
+ institutes: z.ZodArray<z.ZodObject<{
73
+ oId: z.ZodString;
74
+ email: z.ZodString;
75
+ }, "strip", z.ZodTypeAny, {
76
+ email: string;
77
+ oId: string;
78
+ }, {
79
+ email: string;
80
+ oId: string;
81
+ }>, "many">;
82
+ providerContactIds: z.ZodArray<z.ZodString, "many">;
83
+ steps: z.ZodRecord<z.ZodString, z.ZodUnion<[z.ZodBoolean, z.ZodString]>>;
84
+ }, "strip", z.ZodTypeAny, {
85
+ deleted: true;
86
+ institutes: {
87
+ email: string;
88
+ oId: string;
89
+ }[];
90
+ runId: string;
91
+ steps: Record<string, string | boolean>;
92
+ providerContactIds: string[];
93
+ }, {
94
+ deleted: true;
95
+ institutes: {
96
+ email: string;
97
+ oId: string;
98
+ }[];
99
+ runId: string;
100
+ steps: Record<string, string | boolean>;
101
+ providerContactIds: string[];
102
+ }>;
103
+ };
104
+ /**
105
+ * Admin only. Seeds an active careers hub for a run
106
+ * (`e2e.<runId>-hub@careerthread.co.uk`) with North and South groups and
107
+ * six employers, five approved and one awaiting review.
108
+ */
109
+ "e2e-seedCareersHub": {
110
+ request: z.ZodObject<{
111
+ runId: z.ZodString;
112
+ /** Defaults to the harness password, `E2e!<runId>Aa1`. */
113
+ password: z.ZodOptional<z.ZodString>;
114
+ /** A public externalActivities id. Defaults to the first public employer activity. */
115
+ activityId: z.ZodOptional<z.ZodString>;
116
+ }, "strip", z.ZodTypeAny, {
117
+ runId: string;
118
+ password?: string | undefined;
119
+ activityId?: string | undefined;
120
+ }, {
121
+ runId: string;
122
+ password?: string | undefined;
123
+ activityId?: string | undefined;
124
+ }>;
125
+ response: z.ZodObject<{
126
+ hubOId: z.ZodString;
127
+ adminUid: z.ZodString;
128
+ adminEmail: z.ZodString;
129
+ groupIds: z.ZodArray<z.ZodString, "many">;
130
+ providerContactIds: z.ZodArray<z.ZodString, "many">;
131
+ activityId: z.ZodString;
132
+ }, "strip", z.ZodTypeAny, {
133
+ providerContactIds: string[];
134
+ activityId: string;
135
+ hubOId: string;
136
+ adminUid: string;
137
+ adminEmail: string;
138
+ groupIds: string[];
139
+ }, {
140
+ providerContactIds: string[];
141
+ activityId: string;
142
+ hubOId: string;
143
+ adminUid: string;
144
+ adminEmail: string;
145
+ groupIds: string[];
146
+ }>;
147
+ };
148
+ /**
149
+ * Admin only. Sends one event's post-event feedback requests now, instead
150
+ * of waiting for the daily cron. The event's institute must be an E2E account.
151
+ */
152
+ "e2e-runEventFeedback": {
153
+ request: z.ZodObject<{
154
+ eventId: z.ZodString;
155
+ }, "strip", z.ZodTypeAny, {
156
+ eventId: string;
157
+ }, {
158
+ eventId: string;
159
+ }>;
160
+ response: z.ZodObject<{
161
+ eventId: z.ZodString;
162
+ oId: z.ZodString;
163
+ sent: z.ZodLiteral<true>;
164
+ }, "strip", z.ZodTypeAny, {
165
+ oId: string;
166
+ sent: true;
167
+ eventId: string;
168
+ }, {
169
+ oId: string;
170
+ sent: true;
171
+ eventId: string;
172
+ }>;
173
+ };
33
174
  };
@@ -11,6 +11,10 @@ const zod_1 = require("zod");
11
11
  * only ever sign in a reserved `e2e.*@careerthread.co.uk` user, so the UX audit
12
12
  * can walk a registered school without anyone typing a password.
13
13
  */
14
+ /** A run id as the harness derives it from E2E_RUN_ID. */
15
+ const e2eRunId = zod_1.z.string().min(1).max(100).regex(/^[a-z0-9-]+$/);
16
+ /** Per-step teardown outcome: true cleared, false nothing to do, string the error. */
17
+ const teardownSteps = zod_1.z.record(zod_1.z.string(), zod_1.z.union([zod_1.z.boolean(), zod_1.z.string()]));
14
18
  exports.e2eCallables = {
15
19
  "e2e-redeemSignInLink": {
16
20
  request: zod_1.z.object({
@@ -23,5 +27,70 @@ exports.e2eCallables = {
23
27
  authToken: zod_1.z.string().optional(),
24
28
  }),
25
29
  },
30
+ /**
31
+ * Admin only. Tears down one institute whose billing email is a reserved
32
+ * E2E address. Each step reports true (cleared), false (nothing to do) or
33
+ * the error message.
34
+ */
35
+ "e2e-deleteE2EAccount": {
36
+ request: zod_1.z.object({ oId: zod_1.z.string().min(1) }),
37
+ response: zod_1.z.object({
38
+ deleted: zod_1.z.literal(true),
39
+ oId: zod_1.z.string(),
40
+ email: zod_1.z.string(),
41
+ steps: teardownSteps,
42
+ }),
43
+ },
44
+ /**
45
+ * Admin only. Tears down a whole run: every institute billed to
46
+ * `e2e.<runId>@` or `e2e.<runId>-*@`, their careers invites and hub links,
47
+ * the run's employers, provider accounts, event requests (and PDFs),
48
+ * attendees, Auth users and Brevo contacts. Institute steps are reported
49
+ * as `<oId>/<step>`.
50
+ */
51
+ "e2e-deleteE2ERun": {
52
+ request: zod_1.z.object({ runId: e2eRunId }),
53
+ response: zod_1.z.object({
54
+ deleted: zod_1.z.literal(true),
55
+ runId: zod_1.z.string(),
56
+ institutes: zod_1.z.array(zod_1.z.object({ oId: zod_1.z.string(), email: zod_1.z.string() })),
57
+ providerContactIds: zod_1.z.array(zod_1.z.string()),
58
+ steps: teardownSteps,
59
+ }),
60
+ },
61
+ /**
62
+ * Admin only. Seeds an active careers hub for a run
63
+ * (`e2e.<runId>-hub@careerthread.co.uk`) with North and South groups and
64
+ * six employers, five approved and one awaiting review.
65
+ */
66
+ "e2e-seedCareersHub": {
67
+ request: zod_1.z.object({
68
+ runId: e2eRunId,
69
+ /** Defaults to the harness password, `E2e!<runId>Aa1`. */
70
+ password: zod_1.z.string().min(8).max(128).optional(),
71
+ /** A public externalActivities id. Defaults to the first public employer activity. */
72
+ activityId: zod_1.z.string().regex(/^[A-Za-z0-9_-]{1,128}$/).optional(),
73
+ }),
74
+ response: zod_1.z.object({
75
+ hubOId: zod_1.z.string(),
76
+ adminUid: zod_1.z.string(),
77
+ adminEmail: zod_1.z.string(),
78
+ groupIds: zod_1.z.array(zod_1.z.string()),
79
+ providerContactIds: zod_1.z.array(zod_1.z.string()),
80
+ activityId: zod_1.z.string(),
81
+ }),
82
+ },
83
+ /**
84
+ * Admin only. Sends one event's post-event feedback requests now, instead
85
+ * of waiting for the daily cron. The event's institute must be an E2E account.
86
+ */
87
+ "e2e-runEventFeedback": {
88
+ request: zod_1.z.object({ eventId: zod_1.z.string().regex(/^[A-Za-z0-9_-]{1,128}$/) }),
89
+ response: zod_1.z.object({
90
+ eventId: zod_1.z.string(),
91
+ oId: zod_1.z.string(),
92
+ sent: zod_1.z.literal(true),
93
+ }),
94
+ },
26
95
  };
27
96
  //# sourceMappingURL=e2e.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"e2e.js","sourceRoot":"","sources":["../../src/callables/e2e.ts"],"names":[],"mappings":";;;AAAA,6BAAsB;AAGtB;;;;;;;;GAQG;AACU,QAAA,YAAY,GAAG;IACxB,sBAAsB,EAAE;QACpB,OAAO,EAAE,OAAC,CAAC,MAAM,CAAC;YACd,6EAA6E;YAC7E,KAAK,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,kBAAkB,CAAC;SAC/D,CAAC;QACF,QAAQ,EAAE,OAAC,CAAC,MAAM,CAAC;YACf,IAAI,EAAE,OAAC,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,UAAU,EAAE,SAAS,CAAC,CAAC;YAC3C,iFAAiF;YACjF,SAAS,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;SACnC,CAAC;KACL;CACoB,CAAC"}
1
+ {"version":3,"file":"e2e.js","sourceRoot":"","sources":["../../src/callables/e2e.ts"],"names":[],"mappings":"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"}
@@ -44,4 +44,26 @@ export declare const eventsCallables: {
44
44
  geohash?: string | undefined;
45
45
  }>, "many">;
46
46
  };
47
+ "events-employerDeclineEvent": {
48
+ request: z.ZodObject<{
49
+ oId: z.ZodString;
50
+ eventId: z.ZodString;
51
+ attendeeId: z.ZodString;
52
+ }, "strip", z.ZodTypeAny, {
53
+ oId: string;
54
+ eventId: string;
55
+ attendeeId: string;
56
+ }, {
57
+ oId: string;
58
+ eventId: string;
59
+ attendeeId: string;
60
+ }>;
61
+ response: z.ZodObject<{
62
+ code: z.ZodEnum<["declined", "notFound"]>;
63
+ }, "strip", z.ZodTypeAny, {
64
+ code: "declined" | "notFound";
65
+ }, {
66
+ code: "declined" | "notFound";
67
+ }>;
68
+ };
47
69
  };
@@ -24,5 +24,17 @@ exports.eventsCallables = {
24
24
  country: zod_1.z.string().optional(),
25
25
  })),
26
26
  },
27
+ // Public: the invited attendee id in the email link is the credential,
28
+ // as for events-employerJoinEvent. Only an "invited" attendee can decline.
29
+ "events-employerDeclineEvent": {
30
+ request: zod_1.z.object({
31
+ oId: zod_1.z.string().min(1),
32
+ eventId: zod_1.z.string().min(1),
33
+ attendeeId: zod_1.z.string().min(1),
34
+ }),
35
+ response: zod_1.z.object({
36
+ code: zod_1.z.enum(["declined", "notFound"]),
37
+ }),
38
+ },
27
39
  };
28
40
  //# sourceMappingURL=events.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"events.js","sourceRoot":"","sources":["../../src/callables/events.ts"],"names":[],"mappings":";;;AAAA,6BAAsB;AAGtB;;;GAGG;AACU,QAAA,eAAe,GAAG;IAC3B,qBAAqB,EAAE;QACnB,OAAO,EAAE,OAAC,CAAC,MAAM,CAAC;YACd,GAAG,EAAE,OAAC,CAAC,MAAM,EAAE;YACf,KAAK,EAAE,OAAC,CAAC,MAAM,EAAE;SACpB,CAAC;QACF,qEAAqE;QACrE,QAAQ,EAAE,OAAC,CAAC,KAAK,CAAC,OAAC,CAAC,MAAM,CAAC;YACvB,EAAE,EAAE,OAAC,CAAC,MAAM,EAAE;YACd,IAAI,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YAC3B,OAAO,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YAC9B,eAAe,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YACtC,eAAe,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YACtC,WAAW,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YAClC,QAAQ,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YAC/B,OAAO,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;SACjC,CAAC,CAAC;KACN;CACoB,CAAC"}
1
+ {"version":3,"file":"events.js","sourceRoot":"","sources":["../../src/callables/events.ts"],"names":[],"mappings":";;;AAAA,6BAAsB;AAGtB;;;GAGG;AACU,QAAA,eAAe,GAAG;IAC3B,qBAAqB,EAAE;QACnB,OAAO,EAAE,OAAC,CAAC,MAAM,CAAC;YACd,GAAG,EAAE,OAAC,CAAC,MAAM,EAAE;YACf,KAAK,EAAE,OAAC,CAAC,MAAM,EAAE;SACpB,CAAC;QACF,qEAAqE;QACrE,QAAQ,EAAE,OAAC,CAAC,KAAK,CAAC,OAAC,CAAC,MAAM,CAAC;YACvB,EAAE,EAAE,OAAC,CAAC,MAAM,EAAE;YACd,IAAI,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YAC3B,OAAO,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YAC9B,eAAe,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YACtC,eAAe,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YACtC,WAAW,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YAClC,QAAQ,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YAC/B,OAAO,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;SACjC,CAAC,CAAC;KACN;IACD,uEAAuE;IACvE,2EAA2E;IAC3E,6BAA6B,EAAE;QAC3B,OAAO,EAAE,OAAC,CAAC,MAAM,CAAC;YACd,GAAG,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;YACtB,OAAO,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;YAC1B,UAAU,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;SAChC,CAAC;QACF,QAAQ,EAAE,OAAC,CAAC,MAAM,CAAC;YACf,IAAI,EAAE,OAAC,CAAC,IAAI,CAAC,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC;SACzC,CAAC;KACL;CACoB,CAAC"}
@@ -231,6 +231,133 @@ export declare const allCallables: {
231
231
  authToken?: string | undefined;
232
232
  }>;
233
233
  };
234
+ "e2e-deleteE2EAccount": {
235
+ request: z.ZodObject<{
236
+ oId: z.ZodString;
237
+ }, "strip", z.ZodTypeAny, {
238
+ oId: string;
239
+ }, {
240
+ oId: string;
241
+ }>;
242
+ response: z.ZodObject<{
243
+ deleted: z.ZodLiteral<true>;
244
+ oId: z.ZodString;
245
+ email: z.ZodString;
246
+ steps: z.ZodRecord<z.ZodString, z.ZodUnion<[z.ZodBoolean, z.ZodString]>>;
247
+ }, "strip", z.ZodTypeAny, {
248
+ deleted: true;
249
+ email: string;
250
+ oId: string;
251
+ steps: Record<string, string | boolean>;
252
+ }, {
253
+ deleted: true;
254
+ email: string;
255
+ oId: string;
256
+ steps: Record<string, string | boolean>;
257
+ }>;
258
+ };
259
+ "e2e-deleteE2ERun": {
260
+ request: z.ZodObject<{
261
+ runId: z.ZodString;
262
+ }, "strip", z.ZodTypeAny, {
263
+ runId: string;
264
+ }, {
265
+ runId: string;
266
+ }>;
267
+ response: z.ZodObject<{
268
+ deleted: z.ZodLiteral<true>;
269
+ runId: z.ZodString;
270
+ institutes: z.ZodArray<z.ZodObject<{
271
+ oId: z.ZodString;
272
+ email: z.ZodString;
273
+ }, "strip", z.ZodTypeAny, {
274
+ email: string;
275
+ oId: string;
276
+ }, {
277
+ email: string;
278
+ oId: string;
279
+ }>, "many">;
280
+ providerContactIds: z.ZodArray<z.ZodString, "many">;
281
+ steps: z.ZodRecord<z.ZodString, z.ZodUnion<[z.ZodBoolean, z.ZodString]>>;
282
+ }, "strip", z.ZodTypeAny, {
283
+ deleted: true;
284
+ institutes: {
285
+ email: string;
286
+ oId: string;
287
+ }[];
288
+ runId: string;
289
+ steps: Record<string, string | boolean>;
290
+ providerContactIds: string[];
291
+ }, {
292
+ deleted: true;
293
+ institutes: {
294
+ email: string;
295
+ oId: string;
296
+ }[];
297
+ runId: string;
298
+ steps: Record<string, string | boolean>;
299
+ providerContactIds: string[];
300
+ }>;
301
+ };
302
+ "e2e-seedCareersHub": {
303
+ request: z.ZodObject<{
304
+ runId: z.ZodString;
305
+ password: z.ZodOptional<z.ZodString>;
306
+ activityId: z.ZodOptional<z.ZodString>;
307
+ }, "strip", z.ZodTypeAny, {
308
+ runId: string;
309
+ password?: string | undefined;
310
+ activityId?: string | undefined;
311
+ }, {
312
+ runId: string;
313
+ password?: string | undefined;
314
+ activityId?: string | undefined;
315
+ }>;
316
+ response: z.ZodObject<{
317
+ hubOId: z.ZodString;
318
+ adminUid: z.ZodString;
319
+ adminEmail: z.ZodString;
320
+ groupIds: z.ZodArray<z.ZodString, "many">;
321
+ providerContactIds: z.ZodArray<z.ZodString, "many">;
322
+ activityId: z.ZodString;
323
+ }, "strip", z.ZodTypeAny, {
324
+ providerContactIds: string[];
325
+ activityId: string;
326
+ hubOId: string;
327
+ adminUid: string;
328
+ adminEmail: string;
329
+ groupIds: string[];
330
+ }, {
331
+ providerContactIds: string[];
332
+ activityId: string;
333
+ hubOId: string;
334
+ adminUid: string;
335
+ adminEmail: string;
336
+ groupIds: string[];
337
+ }>;
338
+ };
339
+ "e2e-runEventFeedback": {
340
+ request: z.ZodObject<{
341
+ eventId: z.ZodString;
342
+ }, "strip", z.ZodTypeAny, {
343
+ eventId: string;
344
+ }, {
345
+ eventId: string;
346
+ }>;
347
+ response: z.ZodObject<{
348
+ eventId: z.ZodString;
349
+ oId: z.ZodString;
350
+ sent: z.ZodLiteral<true>;
351
+ }, "strip", z.ZodTypeAny, {
352
+ oId: string;
353
+ sent: true;
354
+ eventId: string;
355
+ }, {
356
+ oId: string;
357
+ sent: true;
358
+ eventId: string;
359
+ }>;
360
+ };
234
361
  "hubImpactReport-createHubImpactReport": {
235
362
  request: z.ZodObject<{
236
363
  oId: z.ZodOptional<z.ZodString>;
@@ -1416,6 +1543,28 @@ export declare const allCallables: {
1416
1543
  geohash?: string | undefined;
1417
1544
  }>, "many">;
1418
1545
  };
1546
+ "events-employerDeclineEvent": {
1547
+ request: z.ZodObject<{
1548
+ oId: z.ZodString;
1549
+ eventId: z.ZodString;
1550
+ attendeeId: z.ZodString;
1551
+ }, "strip", z.ZodTypeAny, {
1552
+ oId: string;
1553
+ eventId: string;
1554
+ attendeeId: string;
1555
+ }, {
1556
+ oId: string;
1557
+ eventId: string;
1558
+ attendeeId: string;
1559
+ }>;
1560
+ response: z.ZodObject<{
1561
+ code: z.ZodEnum<["declined", "notFound"]>;
1562
+ }, "strip", z.ZodTypeAny, {
1563
+ code: "declined" | "notFound";
1564
+ }, {
1565
+ code: "declined" | "notFound";
1566
+ }>;
1567
+ };
1419
1568
  "placement-propose": {
1420
1569
  request: z.ZodObject<{
1421
1570
  data: z.ZodAny;
@@ -1571,6 +1720,28 @@ export declare const callableGroups: {
1571
1720
  geohash?: string | undefined;
1572
1721
  }>, "many">;
1573
1722
  };
1723
+ "events-employerDeclineEvent": {
1724
+ request: z.ZodObject<{
1725
+ oId: z.ZodString;
1726
+ eventId: z.ZodString;
1727
+ attendeeId: z.ZodString;
1728
+ }, "strip", z.ZodTypeAny, {
1729
+ oId: string;
1730
+ eventId: string;
1731
+ attendeeId: string;
1732
+ }, {
1733
+ oId: string;
1734
+ eventId: string;
1735
+ attendeeId: string;
1736
+ }>;
1737
+ response: z.ZodObject<{
1738
+ code: z.ZodEnum<["declined", "notFound"]>;
1739
+ }, "strip", z.ZodTypeAny, {
1740
+ code: "declined" | "notFound";
1741
+ }, {
1742
+ code: "declined" | "notFound";
1743
+ }>;
1744
+ };
1574
1745
  };
1575
1746
  surveyCallables: {
1576
1747
  "surveys-createSurveyCycle": {
@@ -2755,6 +2926,133 @@ export declare const callableGroups: {
2755
2926
  authToken?: string | undefined;
2756
2927
  }>;
2757
2928
  };
2929
+ "e2e-deleteE2EAccount": {
2930
+ request: z.ZodObject<{
2931
+ oId: z.ZodString;
2932
+ }, "strip", z.ZodTypeAny, {
2933
+ oId: string;
2934
+ }, {
2935
+ oId: string;
2936
+ }>;
2937
+ response: z.ZodObject<{
2938
+ deleted: z.ZodLiteral<true>;
2939
+ oId: z.ZodString;
2940
+ email: z.ZodString;
2941
+ steps: z.ZodRecord<z.ZodString, z.ZodUnion<[z.ZodBoolean, z.ZodString]>>;
2942
+ }, "strip", z.ZodTypeAny, {
2943
+ deleted: true;
2944
+ email: string;
2945
+ oId: string;
2946
+ steps: Record<string, string | boolean>;
2947
+ }, {
2948
+ deleted: true;
2949
+ email: string;
2950
+ oId: string;
2951
+ steps: Record<string, string | boolean>;
2952
+ }>;
2953
+ };
2954
+ "e2e-deleteE2ERun": {
2955
+ request: z.ZodObject<{
2956
+ runId: z.ZodString;
2957
+ }, "strip", z.ZodTypeAny, {
2958
+ runId: string;
2959
+ }, {
2960
+ runId: string;
2961
+ }>;
2962
+ response: z.ZodObject<{
2963
+ deleted: z.ZodLiteral<true>;
2964
+ runId: z.ZodString;
2965
+ institutes: z.ZodArray<z.ZodObject<{
2966
+ oId: z.ZodString;
2967
+ email: z.ZodString;
2968
+ }, "strip", z.ZodTypeAny, {
2969
+ email: string;
2970
+ oId: string;
2971
+ }, {
2972
+ email: string;
2973
+ oId: string;
2974
+ }>, "many">;
2975
+ providerContactIds: z.ZodArray<z.ZodString, "many">;
2976
+ steps: z.ZodRecord<z.ZodString, z.ZodUnion<[z.ZodBoolean, z.ZodString]>>;
2977
+ }, "strip", z.ZodTypeAny, {
2978
+ deleted: true;
2979
+ institutes: {
2980
+ email: string;
2981
+ oId: string;
2982
+ }[];
2983
+ runId: string;
2984
+ steps: Record<string, string | boolean>;
2985
+ providerContactIds: string[];
2986
+ }, {
2987
+ deleted: true;
2988
+ institutes: {
2989
+ email: string;
2990
+ oId: string;
2991
+ }[];
2992
+ runId: string;
2993
+ steps: Record<string, string | boolean>;
2994
+ providerContactIds: string[];
2995
+ }>;
2996
+ };
2997
+ "e2e-seedCareersHub": {
2998
+ request: z.ZodObject<{
2999
+ runId: z.ZodString;
3000
+ password: z.ZodOptional<z.ZodString>;
3001
+ activityId: z.ZodOptional<z.ZodString>;
3002
+ }, "strip", z.ZodTypeAny, {
3003
+ runId: string;
3004
+ password?: string | undefined;
3005
+ activityId?: string | undefined;
3006
+ }, {
3007
+ runId: string;
3008
+ password?: string | undefined;
3009
+ activityId?: string | undefined;
3010
+ }>;
3011
+ response: z.ZodObject<{
3012
+ hubOId: z.ZodString;
3013
+ adminUid: z.ZodString;
3014
+ adminEmail: z.ZodString;
3015
+ groupIds: z.ZodArray<z.ZodString, "many">;
3016
+ providerContactIds: z.ZodArray<z.ZodString, "many">;
3017
+ activityId: z.ZodString;
3018
+ }, "strip", z.ZodTypeAny, {
3019
+ providerContactIds: string[];
3020
+ activityId: string;
3021
+ hubOId: string;
3022
+ adminUid: string;
3023
+ adminEmail: string;
3024
+ groupIds: string[];
3025
+ }, {
3026
+ providerContactIds: string[];
3027
+ activityId: string;
3028
+ hubOId: string;
3029
+ adminUid: string;
3030
+ adminEmail: string;
3031
+ groupIds: string[];
3032
+ }>;
3033
+ };
3034
+ "e2e-runEventFeedback": {
3035
+ request: z.ZodObject<{
3036
+ eventId: z.ZodString;
3037
+ }, "strip", z.ZodTypeAny, {
3038
+ eventId: string;
3039
+ }, {
3040
+ eventId: string;
3041
+ }>;
3042
+ response: z.ZodObject<{
3043
+ eventId: z.ZodString;
3044
+ oId: z.ZodString;
3045
+ sent: z.ZodLiteral<true>;
3046
+ }, "strip", z.ZodTypeAny, {
3047
+ oId: string;
3048
+ sent: true;
3049
+ eventId: string;
3050
+ }, {
3051
+ oId: string;
3052
+ sent: true;
3053
+ eventId: string;
3054
+ }>;
3055
+ };
2758
3056
  };
2759
3057
  wondeCallables: {
2760
3058
  "wonde-requestConnection": {
@@ -0,0 +1,68 @@
1
+ import { SchoolGroupInput } from "./groups";
2
+ import { ExternalEventRequest } from "./typeDefinitions";
3
+ /**
4
+ * Engagement metrics for employer offers (`externalEventRequests`) a careers hub
5
+ * passes on to its schools. The web app and the backend impact report both count
6
+ * from here, so a figure on screen and the same figure in the PDF always agree.
7
+ *
8
+ * Definitions:
9
+ * - An offer was fanned out when the dispatch stamped it: `resolvedSchools` holds
10
+ * at least one school and `numberSharedWith` is set. The migration backfilled
11
+ * `resolvedSchools` from `schools` on every document, including ones that were
12
+ * never sent, so `resolvedSchools` alone is not proof of a send; only the
13
+ * dispatch writes `numberSharedWith`.
14
+ * - The schools an offer reached are its `resolvedSchools`.
15
+ * - A view or download only counts for a school the offer reached, when the offer
16
+ * records who it reached. Without `resolvedSchools` (a school's own offer shared
17
+ * with its students, keyed by student id) every truthy key counts.
18
+ */
19
+ type EngagementInput = Pick<ExternalEventRequest, "resolvedSchools" | "viewedBy" | "downloadedBy">;
20
+ /**
21
+ * Whether an offer was actually sent out to schools by the hub dispatch.
22
+ *
23
+ * @param {object} request the offer.
24
+ * @returns {boolean} true when the offer reached at least one school.
25
+ */
26
+ export declare const isRequestFannedOut: (request: Pick<ExternalEventRequest, "resolvedSchools" | "numberSharedWith">) => boolean;
27
+ /**
28
+ * The ids that viewed or downloaded an offer, limited to schools it reached when
29
+ * that is recorded.
30
+ *
31
+ * @param {object} request the offer.
32
+ * @param {"viewedBy"|"downloadedBy"} field which engagement map to read.
33
+ * @returns {string[]} the counted ids, each once.
34
+ */
35
+ export declare const countedEngagement: (request: EngagementInput | undefined, field: "viewedBy" | "downloadedBy") => string[];
36
+ export type EmployerRequestSummary = {
37
+ /** Offers that were fanned out to schools. */
38
+ shared: number;
39
+ /** Distinct schools reached across every shared offer. */
40
+ schoolsReached: string[];
41
+ /** Distinct schools that viewed at least one shared offer they were sent. */
42
+ schoolsViewed: string[];
43
+ /** Distinct schools that downloaded at least one shared offer they were sent. */
44
+ schoolsDownloaded: string[];
45
+ /** Shared offers viewed by at least one school they reached. */
46
+ viewedByAnySchool: number;
47
+ /** Shared offers downloaded by at least one school they reached. */
48
+ downloadedByAnySchool: number;
49
+ };
50
+ /**
51
+ * Summarise a set of employer offers for hub reporting.
52
+ *
53
+ * @param {object[]} requests the offers, already narrowed to the hub and period.
54
+ * @returns {EmployerRequestSummary} the counts, per the definitions above.
55
+ */
56
+ export declare const summariseEmployerRequests: (requests: Pick<ExternalEventRequest, "resolvedSchools" | "numberSharedWith" | "viewedBy" | "downloadedBy">[]) => EmployerRequestSummary;
57
+ /**
58
+ * Whether a school may see an employer offer a hub shared. `resolvedSchools` is
59
+ * the record when present. Older offers without it fall back to the selection
60
+ * rules the school's offer list used to query on.
61
+ *
62
+ * @param {object} request the offer.
63
+ * @param {string} schoolId the school asking.
64
+ * @param {object|undefined} school the school's institute document, for its groups.
65
+ * @returns {boolean} true when the offer was sent to that school.
66
+ */
67
+ export declare const requestReachedSchool: (request: Pick<ExternalEventRequest, "oId" | "status" | "resolvedSchools" | "schoolSelectionType" | "schoolGroups" | "schools">, schoolId: string, school: SchoolGroupInput | undefined) => boolean;
68
+ export {};
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.requestReachedSchool = exports.summariseEmployerRequests = exports.countedEngagement = exports.isRequestFannedOut = void 0;
4
+ const groups_1 = require("./groups");
5
+ /**
6
+ * Whether an offer was actually sent out to schools by the hub dispatch.
7
+ *
8
+ * @param {object} request the offer.
9
+ * @returns {boolean} true when the offer reached at least one school.
10
+ */
11
+ const isRequestFannedOut = (request) => Boolean(request.resolvedSchools?.length) && typeof request.numberSharedWith === "number";
12
+ exports.isRequestFannedOut = isRequestFannedOut;
13
+ /**
14
+ * The ids that viewed or downloaded an offer, limited to schools it reached when
15
+ * that is recorded.
16
+ *
17
+ * @param {object} request the offer.
18
+ * @param {"viewedBy"|"downloadedBy"} field which engagement map to read.
19
+ * @returns {string[]} the counted ids, each once.
20
+ */
21
+ const countedEngagement = (request, field) => {
22
+ const ids = Object.entries(request?.[field] || {}).filter(([, v]) => v).map(([id]) => id);
23
+ if (!request?.resolvedSchools)
24
+ return ids;
25
+ const reached = new Set(request.resolvedSchools);
26
+ return ids.filter((id) => reached.has(id));
27
+ };
28
+ exports.countedEngagement = countedEngagement;
29
+ /**
30
+ * Summarise a set of employer offers for hub reporting.
31
+ *
32
+ * @param {object[]} requests the offers, already narrowed to the hub and period.
33
+ * @returns {EmployerRequestSummary} the counts, per the definitions above.
34
+ */
35
+ const summariseEmployerRequests = (requests) => {
36
+ const shared = requests.filter(exports.isRequestFannedOut);
37
+ const reached = new Set();
38
+ const viewed = new Set();
39
+ const downloaded = new Set();
40
+ let viewedByAnySchool = 0;
41
+ let downloadedByAnySchool = 0;
42
+ shared.forEach((r) => {
43
+ (r.resolvedSchools || []).forEach((id) => reached.add(id));
44
+ const v = (0, exports.countedEngagement)(r, "viewedBy");
45
+ const d = (0, exports.countedEngagement)(r, "downloadedBy");
46
+ v.forEach((id) => viewed.add(id));
47
+ d.forEach((id) => downloaded.add(id));
48
+ if (v.length)
49
+ viewedByAnySchool++;
50
+ if (d.length)
51
+ downloadedByAnySchool++;
52
+ });
53
+ return {
54
+ shared: shared.length,
55
+ schoolsReached: [...reached],
56
+ schoolsViewed: [...viewed],
57
+ schoolsDownloaded: [...downloaded],
58
+ viewedByAnySchool,
59
+ downloadedByAnySchool,
60
+ };
61
+ };
62
+ exports.summariseEmployerRequests = summariseEmployerRequests;
63
+ /**
64
+ * Whether a school may see an employer offer a hub shared. `resolvedSchools` is
65
+ * the record when present. Older offers without it fall back to the selection
66
+ * rules the school's offer list used to query on.
67
+ *
68
+ * @param {object} request the offer.
69
+ * @param {string} schoolId the school asking.
70
+ * @param {object|undefined} school the school's institute document, for its groups.
71
+ * @returns {boolean} true when the offer was sent to that school.
72
+ */
73
+ const requestReachedSchool = (request, schoolId, school) => {
74
+ if (request.resolvedSchools)
75
+ return request.resolvedSchools.includes(schoolId);
76
+ if (!["sentToSchools", "linkedWithEvent"].includes(request.status))
77
+ return false;
78
+ const link = school?.careersHubLinks?.[request.oId];
79
+ if (link?.status !== "approved")
80
+ return false;
81
+ if (request.schoolSelectionType === "allSchools")
82
+ return true;
83
+ if (request.schoolSelectionType === "selectGroups") {
84
+ const groups = (0, groups_1.getSchoolGroupIds)(school, request.oId);
85
+ return (request.schoolGroups || []).some((g) => groups.includes(g));
86
+ }
87
+ if (request.schoolSelectionType === "specificSchools")
88
+ return (request.schools || []).includes(schoolId);
89
+ return false;
90
+ };
91
+ exports.requestReachedSchool = requestReachedSchool;
92
+ //# sourceMappingURL=eventRequestMetrics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"eventRequestMetrics.js","sourceRoot":"","sources":["../src/eventRequestMetrics.ts"],"names":[],"mappings":";;;AAAA,qCAA6D;AAsB7D;;;;;GAKG;AACI,MAAM,kBAAkB,GAAG,CAAC,OAAyE,EAAW,EAAE,CACrH,OAAO,CAAC,OAAO,CAAC,eAAe,EAAE,MAAM,CAAC,IAAI,OAAO,OAAO,CAAC,gBAAgB,KAAK,QAAQ,CAAC;AADhF,QAAA,kBAAkB,sBAC8D;AAE7F;;;;;;;GAOG;AACI,MAAM,iBAAiB,GAAG,CAAC,OAAkC,EAAE,KAAgC,EAAY,EAAE;IAChH,MAAM,GAAG,GAAG,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC,CAAC;IAC1F,IAAI,CAAC,OAAO,EAAE,eAAe;QAAE,OAAO,GAAG,CAAC;IAC1C,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,eAAe,CAAC,CAAC;IACjD,OAAO,GAAG,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;AAC/C,CAAC,CAAC;AALW,QAAA,iBAAiB,qBAK5B;AAiBF;;;;;GAKG;AACI,MAAM,yBAAyB,GAAG,CACrC,QAAsG,EAChF,EAAE;IACxB,MAAM,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC,0BAAkB,CAAC,CAAC;IACnD,MAAM,OAAO,GAAG,IAAI,GAAG,EAAU,CAAC;IAClC,MAAM,MAAM,GAAG,IAAI,GAAG,EAAU,CAAC;IACjC,MAAM,UAAU,GAAG,IAAI,GAAG,EAAU,CAAC;IACrC,IAAI,iBAAiB,GAAG,CAAC,CAAC;IAC1B,IAAI,qBAAqB,GAAG,CAAC,CAAC;IAC9B,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE;QACjB,CAAC,CAAC,CAAC,eAAe,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;QAC3D,MAAM,CAAC,GAAG,IAAA,yBAAiB,EAAC,CAAC,EAAE,UAAU,CAAC,CAAC;QAC3C,MAAM,CAAC,GAAG,IAAA,yBAAiB,EAAC,CAAC,EAAE,cAAc,CAAC,CAAC;QAC/C,CAAC,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;QAClC,CAAC,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;QACtC,IAAI,CAAC,CAAC,MAAM;YAAE,iBAAiB,EAAE,CAAC;QAClC,IAAI,CAAC,CAAC,MAAM;YAAE,qBAAqB,EAAE,CAAC;IAC1C,CAAC,CAAC,CAAC;IACH,OAAO;QACH,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,cAAc,EAAE,CAAC,GAAG,OAAO,CAAC;QAC5B,aAAa,EAAE,CAAC,GAAG,MAAM,CAAC;QAC1B,iBAAiB,EAAE,CAAC,GAAG,UAAU,CAAC;QAClC,iBAAiB;QACjB,qBAAqB;KACxB,CAAC;AACN,CAAC,CAAC;AA1BW,QAAA,yBAAyB,6BA0BpC;AAEF;;;;;;;;;GASG;AACI,MAAM,oBAAoB,GAAG,CAChC,OAAoH,EACpH,QAAgB,EAChB,MAAkC,EAC3B,EAAE;IACT,IAAI,OAAO,CAAC,eAAe;QAAE,OAAO,OAAO,CAAC,eAAe,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAC/E,IAAI,CAAC,CAAC,eAAe,EAAE,iBAAiB,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC;QAAE,OAAO,KAAK,CAAC;IACjF,MAAM,IAAI,GAAG,MAAM,EAAE,eAAe,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACpD,IAAI,IAAI,EAAE,MAAM,KAAK,UAAU;QAAE,OAAO,KAAK,CAAC;IAC9C,IAAI,OAAO,CAAC,mBAAmB,KAAK,YAAY;QAAE,OAAO,IAAI,CAAC;IAC9D,IAAI,OAAO,CAAC,mBAAmB,KAAK,cAAc,EAAE,CAAC;QACjD,MAAM,MAAM,GAAG,IAAA,0BAAiB,EAAC,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC;QACtD,OAAO,CAAC,OAAO,CAAC,YAAY,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;IACxE,CAAC;IACD,IAAI,OAAO,CAAC,mBAAmB,KAAK,iBAAiB;QAAE,OAAO,CAAC,OAAO,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IACzG,OAAO,KAAK,CAAC;AACjB,CAAC,CAAC;AAhBW,QAAA,oBAAoB,wBAgB/B"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const eventRequestMetrics_1 = require("./eventRequestMetrics");
4
+ describe("isRequestFannedOut", () => {
5
+ it("needs both resolvedSchools and a dispatch stamp", () => {
6
+ expect((0, eventRequestMetrics_1.isRequestFannedOut)({ resolvedSchools: ["a"], numberSharedWith: 1 })).toBe(true);
7
+ expect((0, eventRequestMetrics_1.isRequestFannedOut)({ resolvedSchools: ["a"] })).toBe(false);
8
+ expect((0, eventRequestMetrics_1.isRequestFannedOut)({ resolvedSchools: [], numberSharedWith: 0 })).toBe(false);
9
+ expect((0, eventRequestMetrics_1.isRequestFannedOut)({ numberSharedWith: 3 })).toBe(false);
10
+ });
11
+ });
12
+ describe("countedEngagement", () => {
13
+ it("drops schools the offer never reached", () => {
14
+ expect((0, eventRequestMetrics_1.countedEngagement)({ resolvedSchools: ["a", "b"], viewedBy: { a: true, c: true, b: false } }, "viewedBy")).toEqual(["a"]);
15
+ });
16
+ it("counts every truthy key without resolvedSchools", () => {
17
+ expect((0, eventRequestMetrics_1.countedEngagement)({ downloadedBy: { s1: true, s2: true, s3: false } }, "downloadedBy")).toEqual(["s1", "s2"]);
18
+ });
19
+ });
20
+ describe("summariseEmployerRequests", () => {
21
+ it("only counts fanned out offers and unions their schools", () => {
22
+ const summary = (0, eventRequestMetrics_1.summariseEmployerRequests)([
23
+ { resolvedSchools: ["a", "b"], numberSharedWith: 2, viewedBy: { a: true, z: true }, downloadedBy: { z: true } },
24
+ { resolvedSchools: ["b", "c"], numberSharedWith: 1, viewedBy: { c: true }, downloadedBy: { b: true } },
25
+ // Migrated from `schools` but never sent.
26
+ { resolvedSchools: ["d"], viewedBy: { d: true } },
27
+ { viewedBy: { e: true } },
28
+ ]);
29
+ expect(summary.shared).toBe(2);
30
+ expect(summary.schoolsReached.sort()).toEqual(["a", "b", "c"]);
31
+ expect(summary.schoolsViewed.sort()).toEqual(["a", "c"]);
32
+ expect(summary.schoolsDownloaded).toEqual(["b"]);
33
+ expect(summary.viewedByAnySchool).toBe(2);
34
+ expect(summary.downloadedByAnySchool).toBe(1);
35
+ });
36
+ });
37
+ describe("requestReachedSchool", () => {
38
+ const school = { careersHubLinks: { hub: { status: "approved", groups: ["g1"] } } };
39
+ it("uses resolvedSchools when present", () => {
40
+ expect((0, eventRequestMetrics_1.requestReachedSchool)({ oId: "hub", status: "sentToSchools", resolvedSchools: ["s1"], schoolSelectionType: "allSchools" }, "s2", school)).toBe(false);
41
+ expect((0, eventRequestMetrics_1.requestReachedSchool)({ oId: "hub", status: "sentToSchools", resolvedSchools: ["s2"] }, "s2", school)).toBe(true);
42
+ });
43
+ it("falls back to the selection rules for legacy offers", () => {
44
+ expect((0, eventRequestMetrics_1.requestReachedSchool)({ oId: "hub", status: "sentToSchools", schoolSelectionType: "allSchools" }, "s2", school)).toBe(true);
45
+ expect((0, eventRequestMetrics_1.requestReachedSchool)({ oId: "hub", status: "pending", schoolSelectionType: "allSchools" }, "s2", school)).toBe(false);
46
+ expect((0, eventRequestMetrics_1.requestReachedSchool)({ oId: "hub", status: "sentToSchools", schoolSelectionType: "selectGroups", schoolGroups: ["g1"] }, "s2", school)).toBe(true);
47
+ expect((0, eventRequestMetrics_1.requestReachedSchool)({ oId: "hub", status: "sentToSchools", schoolSelectionType: "selectGroups", schoolGroups: ["g2"] }, "s2", school)).toBe(false);
48
+ expect((0, eventRequestMetrics_1.requestReachedSchool)({ oId: "hub", status: "sentToSchools", schoolSelectionType: "specificSchools", schools: ["s2"] }, "s2", school)).toBe(true);
49
+ expect((0, eventRequestMetrics_1.requestReachedSchool)({ oId: "other", status: "sentToSchools", schoolSelectionType: "allSchools" }, "s2", school)).toBe(false);
50
+ });
51
+ });
52
+ //# sourceMappingURL=eventRequestMetrics.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"eventRequestMetrics.test.js","sourceRoot":"","sources":["../src/eventRequestMetrics.test.ts"],"names":[],"mappings":"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"}
@@ -38,7 +38,15 @@ exports.JobsSlice = (0, toolkit_1.createSlice)({
38
38
  state.values[action.payload.jobId].updated = (new Date()).toISOString();
39
39
  },
40
40
  updateJob: (state, action) => {
41
- state.values[action.payload.jobId] = action.payload.data;
41
+ // Callers (e.g. the error path in useExecuteCallableJob) send a
42
+ // deliberately partial payload and write it to Firestore with a
43
+ // merge-style update() - this has to merge the same way, or a
44
+ // partial update wipes fields like viewedBy from the local copy
45
+ // even though the real document still has them.
46
+ state.values[action.payload.jobId] = {
47
+ ...state.values[action.payload.jobId],
48
+ ...action.payload.data,
49
+ };
42
50
  },
43
51
  },
44
52
  });
@@ -1 +1 @@
1
- {"version":3,"file":"jobsSlice.js","sourceRoot":"","sources":["../../../src/features/jobs/jobsSlice.ts"],"names":[],"mappings":";;;;AAAA,8CAA6C;AA+B7C,MAAM,YAAY,GAAoB;IAClC,MAAM,EAAE,EAAE;CACb,CAAC;AAGW,QAAA,SAAS,GAAG,IAAA,qBAAW,EAAC;IACjC,IAAI,EAAE,MAAM;IACZ,YAAY;IAEZ,QAAQ,EAAE;QACN,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE;YACvB,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC;QAClC,CAAC;QAED,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE;YACtB,MAAM,EAAC,GAAG,EAAE,KAAK,EAAC,GAAG,MAAM,CAAC,OAAO,CAAC;YACpC,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;YAEtB,MAAM,OAAO,GAAG,EAAC,GAAG,KAAK,CAAC,MAAM,EAAC,CAAC;YAClC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC;YAErB,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACtH,CAAC;QAED,WAAW,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE;YAC3B,OAAO,CAAC,GAAG,CAAC,WAAW,EAAE,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;YAC9C,gEAAgE;YAChE,iEAAiE;YACjE,iEAAiE;YACjE,2DAA2D;YAC3D,2DAA2D;YAC3D,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;gBAAE,OAAO;YAChD,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QAC5E,CAAC;QAED,YAAY,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE;YAC5B,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;gBAAE,OAAO;YAChD,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC;YAClE,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,OAAO,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;QAC5E,CAAC;QACD,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE;YACzB,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC;QAC7D,CAAC;KAEJ;CAEJ,CAAC,CAAC;AAEU,KAA0D,iBAAS,CAAC,OAAO,EAA1E,eAAO,eAAE,cAAM,cAAE,mBAAW,mBAAE,oBAAY,oBAAE,iBAAS,gBAAsB;AAEzF,kBAAe,iBAAS,CAAC,OAAO,CAAC"}
1
+ {"version":3,"file":"jobsSlice.js","sourceRoot":"","sources":["../../../src/features/jobs/jobsSlice.ts"],"names":[],"mappings":";;;;AAAA,8CAA6C;AA+B7C,MAAM,YAAY,GAAoB;IAClC,MAAM,EAAE,EAAE;CACb,CAAC;AAGW,QAAA,SAAS,GAAG,IAAA,qBAAW,EAAC;IACjC,IAAI,EAAE,MAAM;IACZ,YAAY;IAEZ,QAAQ,EAAE;QACN,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE;YACvB,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC;QAClC,CAAC;QAED,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE;YACtB,MAAM,EAAC,GAAG,EAAE,KAAK,EAAC,GAAG,MAAM,CAAC,OAAO,CAAC;YACpC,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;YAEtB,MAAM,OAAO,GAAG,EAAC,GAAG,KAAK,CAAC,MAAM,EAAC,CAAC;YAClC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC;YAErB,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACtH,CAAC;QAED,WAAW,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE;YAC3B,OAAO,CAAC,GAAG,CAAC,WAAW,EAAE,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;YAC9C,gEAAgE;YAChE,iEAAiE;YACjE,iEAAiE;YACjE,2DAA2D;YAC3D,2DAA2D;YAC3D,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;gBAAE,OAAO;YAChD,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QAC5E,CAAC;QAED,YAAY,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE;YAC5B,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;gBAAE,OAAO;YAChD,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC;YAClE,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,OAAO,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;QAC5E,CAAC;QACD,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE;YACzB,gEAAgE;YAChE,gEAAgE;YAChE,8DAA8D;YAC9D,gEAAgE;YAChE,gDAAgD;YAChD,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,GAAG;gBACjC,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;gBACrC,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI;aACzB,CAAC;QACN,CAAC;KAEJ;CAEJ,CAAC,CAAC;AAEU,KAA0D,iBAAS,CAAC,OAAO,EAA1E,eAAO,eAAE,cAAM,cAAE,mBAAW,mBAAE,oBAAY,oBAAE,iBAAS,gBAAsB;AAEzF,kBAAe,iBAAS,CAAC,OAAO,CAAC"}
package/lib/index.d.ts CHANGED
@@ -32,6 +32,7 @@ export * from "./productResolver";
32
32
  export * from "./pricing";
33
33
  export * from "./invoicing";
34
34
  export * from "./groups";
35
+ export * from "./eventRequestMetrics";
35
36
  export * from "./emailSections";
36
37
  export * from "./exportTransformers";
37
38
  export * from "./compassTemplate";
package/lib/index.js CHANGED
@@ -52,6 +52,7 @@ __exportStar(require("./productResolver"), exports);
52
52
  __exportStar(require("./pricing"), exports);
53
53
  __exportStar(require("./invoicing"), exports);
54
54
  __exportStar(require("./groups"), exports);
55
+ __exportStar(require("./eventRequestMetrics"), exports);
55
56
  __exportStar(require("./emailSections"), exports);
56
57
  __exportStar(require("./exportTransformers"), exports);
57
58
  __exportStar(require("./compassTemplate"), 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,8CAA4B;AAC5B,kDAAgC;AAChC,kDAAgC;AAChC,qDAAmC;AACnC,mDAAiC;AACjC,qDAAmC;AACnC,qDAAmC;AACnC,sDAAoC;AACpC,kDAAgC;AAChC,mDAAiC;AACjC,+CAA6B;AAC7B,oDAAkC;AAClC,iDAA+B;AAC/B,qDAAmC;AACnC,uDAAqC;AACrC,gDAA8B;AAC9B,4CAA0B;AAC1B,wCAAsB;AACtB,kDAAgC;AAChC,6CAA2B;AAC3B,8CAA4B;AAC5B,oDAAkC;AAClC,4CAA0B;AAC1B,8CAA4B;AAC5B,2CAAyB;AACzB,kDAAgC;AAChC,uDAAqC;AACrC,oDAAkC;AAClC,kDAAgC;AAChC,0DAAwC;AACxC,iDAA+B;AAC/B,2DAAyC;AACzC,uDAAqC;AACrC,uDAAqC;AACrC,iDAA+B;AAC/B,8CAA4B;AAC5B,qDAAmC;AACnC,0CAAwB;AACxB,kDAAgC;AAChC,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,iEAA+C;AAC/C,gEAA8C;AAC9C,2EAAyD;AACzD,6CAA2B;AAC3B,+CAA6B;AAC7B,8CAA4B;AAC5B,4CAA0B;AAC1B,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,8CAA4B;AAC5B,kDAAgC;AAChC,kDAAgC;AAChC,qDAAmC;AACnC,mDAAiC;AACjC,qDAAmC;AACnC,qDAAmC;AACnC,sDAAoC;AACpC,kDAAgC;AAChC,mDAAiC;AACjC,+CAA6B;AAC7B,oDAAkC;AAClC,iDAA+B;AAC/B,qDAAmC;AACnC,uDAAqC;AACrC,gDAA8B;AAC9B,4CAA0B;AAC1B,wCAAsB;AACtB,kDAAgC;AAChC,6CAA2B;AAC3B,8CAA4B;AAC5B,oDAAkC;AAClC,4CAA0B;AAC1B,8CAA4B;AAC5B,2CAAyB;AACzB,wDAAsC;AACtC,kDAAgC;AAChC,uDAAqC;AACrC,oDAAkC;AAClC,kDAAgC;AAChC,0DAAwC;AACxC,iDAA+B;AAC/B,2DAAyC;AACzC,uDAAqC;AACrC,uDAAqC;AACrC,iDAA+B;AAC/B,8CAA4B;AAC5B,qDAAmC;AACnC,0CAAwB;AACxB,kDAAgC;AAChC,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,iEAA+C;AAC/C,gEAA8C;AAC9C,2EAAyD;AACzD,6CAA2B;AAC3B,+CAA6B;AAC7B,8CAA4B;AAC5B,4CAA0B;AAC1B,6DAAuE;AAA9D,2IAAA,OAAO,OAAuB"}
@@ -3267,7 +3267,7 @@ export type ExternalEventAttendee = {
3267
3267
  startDate: string;
3268
3268
  endDate?: string;
3269
3269
  }[];
3270
- status: "pendingInvite" | "invited" | "requestedDates" | "providerAvailable" | "finalConfirmationSent" | "providerConfirmed" | "feedbackSent";
3270
+ status: "pendingInvite" | "invited" | "requestedDates" | "providerAvailable" | "finalConfirmationSent" | "providerConfirmed" | "feedbackSent" | "providerDeclined";
3271
3271
  emails: {
3272
3272
  [k in "invited" | "finalConfirmationSent" | "feedbackSent"]: {
3273
3273
  sent: number;
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.1123",
5
+ "version": "1.400.1125",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
8
8
  "scripts": {
@@ -10,6 +10,12 @@ import {CallableGroup} from "./types";
10
10
  * only ever sign in a reserved `e2e.*@careerthread.co.uk` user, so the UX audit
11
11
  * can walk a registered school without anyone typing a password.
12
12
  */
13
+ /** A run id as the harness derives it from E2E_RUN_ID. */
14
+ const e2eRunId = z.string().min(1).max(100).regex(/^[a-z0-9-]+$/);
15
+
16
+ /** Per-step teardown outcome: true cleared, false nothing to do, string the error. */
17
+ const teardownSteps = z.record(z.string(), z.union([z.boolean(), z.string()]));
18
+
13
19
  export const e2eCallables = {
14
20
  "e2e-redeemSignInLink": {
15
21
  request: z.object({
@@ -22,4 +28,69 @@ export const e2eCallables = {
22
28
  authToken: z.string().optional(),
23
29
  }),
24
30
  },
31
+ /**
32
+ * Admin only. Tears down one institute whose billing email is a reserved
33
+ * E2E address. Each step reports true (cleared), false (nothing to do) or
34
+ * the error message.
35
+ */
36
+ "e2e-deleteE2EAccount": {
37
+ request: z.object({oId: z.string().min(1)}),
38
+ response: z.object({
39
+ deleted: z.literal(true),
40
+ oId: z.string(),
41
+ email: z.string(),
42
+ steps: teardownSteps,
43
+ }),
44
+ },
45
+ /**
46
+ * Admin only. Tears down a whole run: every institute billed to
47
+ * `e2e.<runId>@` or `e2e.<runId>-*@`, their careers invites and hub links,
48
+ * the run's employers, provider accounts, event requests (and PDFs),
49
+ * attendees, Auth users and Brevo contacts. Institute steps are reported
50
+ * as `<oId>/<step>`.
51
+ */
52
+ "e2e-deleteE2ERun": {
53
+ request: z.object({runId: e2eRunId}),
54
+ response: z.object({
55
+ deleted: z.literal(true),
56
+ runId: z.string(),
57
+ institutes: z.array(z.object({oId: z.string(), email: z.string()})),
58
+ providerContactIds: z.array(z.string()),
59
+ steps: teardownSteps,
60
+ }),
61
+ },
62
+ /**
63
+ * Admin only. Seeds an active careers hub for a run
64
+ * (`e2e.<runId>-hub@careerthread.co.uk`) with North and South groups and
65
+ * six employers, five approved and one awaiting review.
66
+ */
67
+ "e2e-seedCareersHub": {
68
+ request: z.object({
69
+ runId: e2eRunId,
70
+ /** Defaults to the harness password, `E2e!<runId>Aa1`. */
71
+ password: z.string().min(8).max(128).optional(),
72
+ /** A public externalActivities id. Defaults to the first public employer activity. */
73
+ activityId: z.string().regex(/^[A-Za-z0-9_-]{1,128}$/).optional(),
74
+ }),
75
+ response: z.object({
76
+ hubOId: z.string(),
77
+ adminUid: z.string(),
78
+ adminEmail: z.string(),
79
+ groupIds: z.array(z.string()),
80
+ providerContactIds: z.array(z.string()),
81
+ activityId: z.string(),
82
+ }),
83
+ },
84
+ /**
85
+ * Admin only. Sends one event's post-event feedback requests now, instead
86
+ * of waiting for the daily cron. The event's institute must be an E2E account.
87
+ */
88
+ "e2e-runEventFeedback": {
89
+ request: z.object({eventId: z.string().regex(/^[A-Za-z0-9_-]{1,128}$/)}),
90
+ response: z.object({
91
+ eventId: z.string(),
92
+ oId: z.string(),
93
+ sent: z.literal(true),
94
+ }),
95
+ },
25
96
  } satisfies CallableGroup;
@@ -23,4 +23,16 @@ export const eventsCallables = {
23
23
  country: z.string().optional(),
24
24
  })),
25
25
  },
26
+ // Public: the invited attendee id in the email link is the credential,
27
+ // as for events-employerJoinEvent. Only an "invited" attendee can decline.
28
+ "events-employerDeclineEvent": {
29
+ request: z.object({
30
+ oId: z.string().min(1),
31
+ eventId: z.string().min(1),
32
+ attendeeId: z.string().min(1),
33
+ }),
34
+ response: z.object({
35
+ code: z.enum(["declined", "notFound"]),
36
+ }),
37
+ },
26
38
  } satisfies CallableGroup;
@@ -0,0 +1,53 @@
1
+ import {countedEngagement, isRequestFannedOut, requestReachedSchool, summariseEmployerRequests} from "./eventRequestMetrics";
2
+
3
+ describe("isRequestFannedOut", () => {
4
+ it("needs both resolvedSchools and a dispatch stamp", () => {
5
+ expect(isRequestFannedOut({resolvedSchools: ["a"], numberSharedWith: 1})).toBe(true);
6
+ expect(isRequestFannedOut({resolvedSchools: ["a"]})).toBe(false);
7
+ expect(isRequestFannedOut({resolvedSchools: [], numberSharedWith: 0})).toBe(false);
8
+ expect(isRequestFannedOut({numberSharedWith: 3})).toBe(false);
9
+ });
10
+ });
11
+
12
+ describe("countedEngagement", () => {
13
+ it("drops schools the offer never reached", () => {
14
+ expect(countedEngagement({resolvedSchools: ["a", "b"], viewedBy: {a: true, c: true, b: false}}, "viewedBy")).toEqual(["a"]);
15
+ });
16
+ it("counts every truthy key without resolvedSchools", () => {
17
+ expect(countedEngagement({downloadedBy: {s1: true, s2: true, s3: false}}, "downloadedBy")).toEqual(["s1", "s2"]);
18
+ });
19
+ });
20
+
21
+ describe("summariseEmployerRequests", () => {
22
+ it("only counts fanned out offers and unions their schools", () => {
23
+ const summary = summariseEmployerRequests([
24
+ {resolvedSchools: ["a", "b"], numberSharedWith: 2, viewedBy: {a: true, z: true}, downloadedBy: {z: true}},
25
+ {resolvedSchools: ["b", "c"], numberSharedWith: 1, viewedBy: {c: true}, downloadedBy: {b: true}},
26
+ // Migrated from `schools` but never sent.
27
+ {resolvedSchools: ["d"], viewedBy: {d: true}},
28
+ {viewedBy: {e: true}},
29
+ ]);
30
+ expect(summary.shared).toBe(2);
31
+ expect(summary.schoolsReached.sort()).toEqual(["a", "b", "c"]);
32
+ expect(summary.schoolsViewed.sort()).toEqual(["a", "c"]);
33
+ expect(summary.schoolsDownloaded).toEqual(["b"]);
34
+ expect(summary.viewedByAnySchool).toBe(2);
35
+ expect(summary.downloadedByAnySchool).toBe(1);
36
+ });
37
+ });
38
+
39
+ describe("requestReachedSchool", () => {
40
+ const school = {careersHubLinks: {hub: {status: "approved", groups: ["g1"]}}} as never;
41
+ it("uses resolvedSchools when present", () => {
42
+ expect(requestReachedSchool({oId: "hub", status: "sentToSchools", resolvedSchools: ["s1"], schoolSelectionType: "allSchools"}, "s2", school)).toBe(false);
43
+ expect(requestReachedSchool({oId: "hub", status: "sentToSchools", resolvedSchools: ["s2"]}, "s2", school)).toBe(true);
44
+ });
45
+ it("falls back to the selection rules for legacy offers", () => {
46
+ expect(requestReachedSchool({oId: "hub", status: "sentToSchools", schoolSelectionType: "allSchools"}, "s2", school)).toBe(true);
47
+ expect(requestReachedSchool({oId: "hub", status: "pending", schoolSelectionType: "allSchools"}, "s2", school)).toBe(false);
48
+ expect(requestReachedSchool({oId: "hub", status: "sentToSchools", schoolSelectionType: "selectGroups", schoolGroups: ["g1"]}, "s2", school)).toBe(true);
49
+ expect(requestReachedSchool({oId: "hub", status: "sentToSchools", schoolSelectionType: "selectGroups", schoolGroups: ["g2"]}, "s2", school)).toBe(false);
50
+ expect(requestReachedSchool({oId: "hub", status: "sentToSchools", schoolSelectionType: "specificSchools", schools: ["s2"]}, "s2", school)).toBe(true);
51
+ expect(requestReachedSchool({oId: "other", status: "sentToSchools", schoolSelectionType: "allSchools"}, "s2", school)).toBe(false);
52
+ });
53
+ });
@@ -0,0 +1,122 @@
1
+ import {getSchoolGroupIds, SchoolGroupInput} from "./groups";
2
+ import {ExternalEventRequest} from "./typeDefinitions";
3
+
4
+ /**
5
+ * Engagement metrics for employer offers (`externalEventRequests`) a careers hub
6
+ * passes on to its schools. The web app and the backend impact report both count
7
+ * from here, so a figure on screen and the same figure in the PDF always agree.
8
+ *
9
+ * Definitions:
10
+ * - An offer was fanned out when the dispatch stamped it: `resolvedSchools` holds
11
+ * at least one school and `numberSharedWith` is set. The migration backfilled
12
+ * `resolvedSchools` from `schools` on every document, including ones that were
13
+ * never sent, so `resolvedSchools` alone is not proof of a send; only the
14
+ * dispatch writes `numberSharedWith`.
15
+ * - The schools an offer reached are its `resolvedSchools`.
16
+ * - A view or download only counts for a school the offer reached, when the offer
17
+ * records who it reached. Without `resolvedSchools` (a school's own offer shared
18
+ * with its students, keyed by student id) every truthy key counts.
19
+ */
20
+
21
+ type EngagementInput = Pick<ExternalEventRequest, "resolvedSchools"|"viewedBy"|"downloadedBy">;
22
+
23
+ /**
24
+ * Whether an offer was actually sent out to schools by the hub dispatch.
25
+ *
26
+ * @param {object} request the offer.
27
+ * @returns {boolean} true when the offer reached at least one school.
28
+ */
29
+ export const isRequestFannedOut = (request: Pick<ExternalEventRequest, "resolvedSchools"|"numberSharedWith">): boolean =>
30
+ Boolean(request.resolvedSchools?.length) && typeof request.numberSharedWith === "number";
31
+
32
+ /**
33
+ * The ids that viewed or downloaded an offer, limited to schools it reached when
34
+ * that is recorded.
35
+ *
36
+ * @param {object} request the offer.
37
+ * @param {"viewedBy"|"downloadedBy"} field which engagement map to read.
38
+ * @returns {string[]} the counted ids, each once.
39
+ */
40
+ export const countedEngagement = (request: EngagementInput|undefined, field: "viewedBy"|"downloadedBy"): string[] => {
41
+ const ids = Object.entries(request?.[field] || {}).filter(([, v]) => v).map(([id]) => id);
42
+ if (!request?.resolvedSchools) return ids;
43
+ const reached = new Set(request.resolvedSchools);
44
+ return ids.filter((id) => reached.has(id));
45
+ };
46
+
47
+ export type EmployerRequestSummary = {
48
+ /** Offers that were fanned out to schools. */
49
+ shared: number,
50
+ /** Distinct schools reached across every shared offer. */
51
+ schoolsReached: string[],
52
+ /** Distinct schools that viewed at least one shared offer they were sent. */
53
+ schoolsViewed: string[],
54
+ /** Distinct schools that downloaded at least one shared offer they were sent. */
55
+ schoolsDownloaded: string[],
56
+ /** Shared offers viewed by at least one school they reached. */
57
+ viewedByAnySchool: number,
58
+ /** Shared offers downloaded by at least one school they reached. */
59
+ downloadedByAnySchool: number,
60
+ };
61
+
62
+ /**
63
+ * Summarise a set of employer offers for hub reporting.
64
+ *
65
+ * @param {object[]} requests the offers, already narrowed to the hub and period.
66
+ * @returns {EmployerRequestSummary} the counts, per the definitions above.
67
+ */
68
+ export const summariseEmployerRequests = (
69
+ requests: Pick<ExternalEventRequest, "resolvedSchools"|"numberSharedWith"|"viewedBy"|"downloadedBy">[]
70
+ ): EmployerRequestSummary => {
71
+ const shared = requests.filter(isRequestFannedOut);
72
+ const reached = new Set<string>();
73
+ const viewed = new Set<string>();
74
+ const downloaded = new Set<string>();
75
+ let viewedByAnySchool = 0;
76
+ let downloadedByAnySchool = 0;
77
+ shared.forEach((r) => {
78
+ (r.resolvedSchools || []).forEach((id) => reached.add(id));
79
+ const v = countedEngagement(r, "viewedBy");
80
+ const d = countedEngagement(r, "downloadedBy");
81
+ v.forEach((id) => viewed.add(id));
82
+ d.forEach((id) => downloaded.add(id));
83
+ if (v.length) viewedByAnySchool++;
84
+ if (d.length) downloadedByAnySchool++;
85
+ });
86
+ return {
87
+ shared: shared.length,
88
+ schoolsReached: [...reached],
89
+ schoolsViewed: [...viewed],
90
+ schoolsDownloaded: [...downloaded],
91
+ viewedByAnySchool,
92
+ downloadedByAnySchool,
93
+ };
94
+ };
95
+
96
+ /**
97
+ * Whether a school may see an employer offer a hub shared. `resolvedSchools` is
98
+ * the record when present. Older offers without it fall back to the selection
99
+ * rules the school's offer list used to query on.
100
+ *
101
+ * @param {object} request the offer.
102
+ * @param {string} schoolId the school asking.
103
+ * @param {object|undefined} school the school's institute document, for its groups.
104
+ * @returns {boolean} true when the offer was sent to that school.
105
+ */
106
+ export const requestReachedSchool = (
107
+ request: Pick<ExternalEventRequest, "oId"|"status"|"resolvedSchools"|"schoolSelectionType"|"schoolGroups"|"schools">,
108
+ schoolId: string,
109
+ school: SchoolGroupInput|undefined
110
+ ): boolean => {
111
+ if (request.resolvedSchools) return request.resolvedSchools.includes(schoolId);
112
+ if (!["sentToSchools", "linkedWithEvent"].includes(request.status)) return false;
113
+ const link = school?.careersHubLinks?.[request.oId];
114
+ if (link?.status !== "approved") return false;
115
+ if (request.schoolSelectionType === "allSchools") return true;
116
+ if (request.schoolSelectionType === "selectGroups") {
117
+ const groups = getSchoolGroupIds(school, request.oId);
118
+ return (request.schoolGroups || []).some((g) => groups.includes(g));
119
+ }
120
+ if (request.schoolSelectionType === "specificSchools") return (request.schools || []).includes(schoolId);
121
+ return false;
122
+ };
@@ -70,7 +70,15 @@ export const JobsSlice = createSlice({
70
70
  state.values[action.payload.jobId].updated = (new Date()).toISOString();
71
71
  },
72
72
  updateJob: (state, action) => {
73
- state.values[action.payload.jobId] = action.payload.data;
73
+ // Callers (e.g. the error path in useExecuteCallableJob) send a
74
+ // deliberately partial payload and write it to Firestore with a
75
+ // merge-style update() - this has to merge the same way, or a
76
+ // partial update wipes fields like viewedBy from the local copy
77
+ // even though the real document still has them.
78
+ state.values[action.payload.jobId] = {
79
+ ...state.values[action.payload.jobId],
80
+ ...action.payload.data,
81
+ };
74
82
  },
75
83
 
76
84
  },
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ export * from "./productResolver";
34
34
  export * from "./pricing";
35
35
  export * from "./invoicing";
36
36
  export * from "./groups";
37
+ export * from "./eventRequestMetrics";
37
38
  export * from "./emailSections";
38
39
  export * from "./exportTransformers";
39
40
  export * from "./compassTemplate";
@@ -3264,7 +3264,7 @@ export type ExternalEventAttendee = {
3264
3264
  startDate: string,
3265
3265
  endDate?: string,
3266
3266
  }[],
3267
- status: "pendingInvite"|"invited"|"requestedDates"|"providerAvailable"|"finalConfirmationSent"|"providerConfirmed"|"feedbackSent"
3267
+ status: "pendingInvite"|"invited"|"requestedDates"|"providerAvailable"|"finalConfirmationSent"|"providerConfirmed"|"feedbackSent"|"providerDeclined"
3268
3268
  emails: {
3269
3269
  [k in "invited"|"finalConfirmationSent"|"feedbackSent"]: {
3270
3270
  sent: number, // If below 3, keep sending reminders.