placementt-core 1.400.1096 → 1.400.1098

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Contract for the Careers Hub impact report
4
+ * (`Placementt-Backend/functions/src/hubImpactReport.ts`).
5
+ *
6
+ * The school/trust impact report is scoped by cohort, because a school's year IS
7
+ * its cohort. A hub has no cohort, so this one is scoped by date instead, and by
8
+ * nothing else: everything in the report is derived from the hub's own links,
9
+ * employer network, events, opportunities and campaigns, so there is no filter
10
+ * the client could pass that the server should trust.
11
+ */
12
+ export declare const hubImpactReportCallables: {
13
+ "hubImpactReport-createHubImpactReport": {
14
+ request: z.ZodObject<{
15
+ /** The careers hub to report on. Omitted means the caller's own
16
+ * organisation; a different one is allowed only where the caller
17
+ * already has staff access to it. */
18
+ oId: z.ZodOptional<z.ZodString>;
19
+ /** Inclusive ISO start date. Both ends must be given, or the report
20
+ * falls back to the current academic year (1 September onwards) -
21
+ * which is the period a hub means by "this year" anyway. */
22
+ startDate: z.ZodOptional<z.ZodString>;
23
+ /** Inclusive ISO end date. */
24
+ endDate: z.ZodOptional<z.ZodString>;
25
+ }, "strip", z.ZodTypeAny, {
26
+ oId?: string | undefined;
27
+ startDate?: string | undefined;
28
+ endDate?: string | undefined;
29
+ }, {
30
+ oId?: string | undefined;
31
+ startDate?: string | undefined;
32
+ endDate?: string | undefined;
33
+ }>;
34
+ /** The Storage path of the generated PDF. The client resolves it with
35
+ * getDownloadURL, so the download is governed by the Storage rules
36
+ * rather than by a signed URL the functions runtime cannot mint. */
37
+ response: z.ZodString;
38
+ };
39
+ };
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hubImpactReportCallables = void 0;
4
+ const zod_1 = require("zod");
5
+ /**
6
+ * Contract for the Careers Hub impact report
7
+ * (`Placementt-Backend/functions/src/hubImpactReport.ts`).
8
+ *
9
+ * The school/trust impact report is scoped by cohort, because a school's year IS
10
+ * its cohort. A hub has no cohort, so this one is scoped by date instead, and by
11
+ * nothing else: everything in the report is derived from the hub's own links,
12
+ * employer network, events, opportunities and campaigns, so there is no filter
13
+ * the client could pass that the server should trust.
14
+ */
15
+ exports.hubImpactReportCallables = {
16
+ "hubImpactReport-createHubImpactReport": {
17
+ request: zod_1.z.object({
18
+ /** The careers hub to report on. Omitted means the caller's own
19
+ * organisation; a different one is allowed only where the caller
20
+ * already has staff access to it. */
21
+ oId: zod_1.z.string().optional(),
22
+ /** Inclusive ISO start date. Both ends must be given, or the report
23
+ * falls back to the current academic year (1 September onwards) -
24
+ * which is the period a hub means by "this year" anyway. */
25
+ startDate: zod_1.z.string().optional(),
26
+ /** Inclusive ISO end date. */
27
+ endDate: zod_1.z.string().optional(),
28
+ }),
29
+ /** The Storage path of the generated PDF. The client resolves it with
30
+ * getDownloadURL, so the download is governed by the Storage rules
31
+ * rather than by a signed URL the functions runtime cannot mint. */
32
+ response: zod_1.z.string(),
33
+ },
34
+ };
35
+ //# sourceMappingURL=hubImpactReport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hubImpactReport.js","sourceRoot":"","sources":["../../src/callables/hubImpactReport.ts"],"names":[],"mappings":";;;AAAA,6BAAsB;AAGtB;;;;;;;;;GASG;AACU,QAAA,wBAAwB,GAAG;IACpC,uCAAuC,EAAE;QACrC,OAAO,EAAE,OAAC,CAAC,MAAM,CAAC;YACd;;iDAEqC;YACrC,GAAG,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YAC1B;;wEAE4D;YAC5D,SAAS,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YAChC,8BAA8B;YAC9B,OAAO,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;SACjC,CAAC;QACF;;4EAEoE;QACpE,QAAQ,EAAE,OAAC,CAAC,MAAM,EAAE;KACvB;CACoB,CAAC"}
@@ -8,3 +8,4 @@ export * from "./apprenticeships";
8
8
  export * from "./he";
9
9
  export * from "./activityLogging";
10
10
  export * from "./billing";
11
+ export * from "./hubImpactReport";
@@ -24,4 +24,5 @@ __exportStar(require("./apprenticeships"), exports);
24
24
  __exportStar(require("./he"), exports);
25
25
  __exportStar(require("./activityLogging"), exports);
26
26
  __exportStar(require("./billing"), exports);
27
+ __exportStar(require("./hubImpactReport"), exports);
27
28
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/callables/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0CAAwB;AACxB,6CAA2B;AAC3B,uDAAqC;AACrC,8CAA4B;AAC5B,2CAAyB;AACzB,6CAA2B;AAC3B,oDAAkC;AAClC,uCAAqB;AACrB,oDAAkC;AAClC,4CAA0B"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/callables/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0CAAwB;AACxB,6CAA2B;AAC3B,uDAAqC;AACrC,8CAA4B;AAC5B,2CAAyB;AACzB,6CAA2B;AAC3B,oDAAkC;AAClC,uCAAqB;AACrB,oDAAkC;AAClC,4CAA0B;AAC1B,oDAAkC"}
@@ -11,6 +11,22 @@ import { z } from "zod";
11
11
  * equals the sum of the group key counts.
12
12
  */
13
13
  export declare const allCallables: {
14
+ "hubImpactReport-createHubImpactReport": {
15
+ request: z.ZodObject<{
16
+ oId: z.ZodOptional<z.ZodString>;
17
+ startDate: z.ZodOptional<z.ZodString>;
18
+ endDate: z.ZodOptional<z.ZodString>;
19
+ }, "strip", z.ZodTypeAny, {
20
+ oId?: string | undefined;
21
+ startDate?: string | undefined;
22
+ endDate?: string | undefined;
23
+ }, {
24
+ oId?: string | undefined;
25
+ startDate?: string | undefined;
26
+ endDate?: string | undefined;
27
+ }>;
28
+ response: z.ZodString;
29
+ };
14
30
  "jobRoles-getSchoolPopularity": {
15
31
  request: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
16
32
  response: z.ZodObject<{
@@ -2035,4 +2051,22 @@ export declare const callableGroups: {
2035
2051
  }>;
2036
2052
  };
2037
2053
  };
2054
+ hubImpactReportCallables: {
2055
+ "hubImpactReport-createHubImpactReport": {
2056
+ request: z.ZodObject<{
2057
+ oId: z.ZodOptional<z.ZodString>;
2058
+ startDate: z.ZodOptional<z.ZodString>;
2059
+ endDate: z.ZodOptional<z.ZodString>;
2060
+ }, "strip", z.ZodTypeAny, {
2061
+ oId?: string | undefined;
2062
+ startDate?: string | undefined;
2063
+ endDate?: string | undefined;
2064
+ }, {
2065
+ oId?: string | undefined;
2066
+ startDate?: string | undefined;
2067
+ endDate?: string | undefined;
2068
+ }>;
2069
+ response: z.ZodString;
2070
+ };
2071
+ };
2038
2072
  };
@@ -12,6 +12,7 @@ const he_1 = require("./he");
12
12
  const activityLogging_1 = require("./activityLogging");
13
13
  const billing_1 = require("./billing");
14
14
  const jobRoles_1 = require("./jobRoles");
15
+ const hubImpactReport_1 = require("./hubImpactReport");
15
16
  /**
16
17
  * The single source of truth for every typed callable. Add a feature group's
17
18
  * spread here and its callables are automatically covered by:
@@ -35,7 +36,8 @@ exports.allCallables = {
35
36
  ...activityLogging_1.activityLoggingCallables,
36
37
  ...billing_1.billingCallables,
37
38
  ...jobRoles_1.jobRolesCallables,
39
+ ...hubImpactReport_1.hubImpactReportCallables,
38
40
  };
39
41
  /** The per-group maps, exported so tests can assert no key collisions. */
40
- exports.callableGroups = { destinationsConfigCallables: destinationsConfig_1.destinationsConfigCallables, placementCallables: placement_1.placementCallables, eventsCallables: events_1.eventsCallables, surveyCallables: surveys_1.surveyCallables, checksCallables: checks_1.checksCallables, benchmarksCallables: benchmarks_1.benchmarksCallables, apprenticeshipsCallables: apprenticeships_1.apprenticeshipsCallables, heCallables: he_1.heCallables, activityLoggingCallables: activityLogging_1.activityLoggingCallables, billingCallables: billing_1.billingCallables, jobRolesCallables: jobRoles_1.jobRolesCallables };
42
+ exports.callableGroups = { destinationsConfigCallables: destinationsConfig_1.destinationsConfigCallables, placementCallables: placement_1.placementCallables, eventsCallables: events_1.eventsCallables, surveyCallables: surveys_1.surveyCallables, checksCallables: checks_1.checksCallables, benchmarksCallables: benchmarks_1.benchmarksCallables, apprenticeshipsCallables: apprenticeships_1.apprenticeshipsCallables, heCallables: he_1.heCallables, activityLoggingCallables: activityLogging_1.activityLoggingCallables, billingCallables: billing_1.billingCallables, jobRolesCallables: jobRoles_1.jobRolesCallables, hubImpactReportCallables: hubImpactReport_1.hubImpactReportCallables };
41
43
  //# sourceMappingURL=registry.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/callables/registry.ts"],"names":[],"mappings":";;;AAEA,6DAAiE;AACjE,2CAA+C;AAC/C,qCAAyC;AACzC,uCAA0C;AAC1C,qCAAyC;AACzC,6CAAiD;AACjD,uDAA2D;AAC3D,6BAAiC;AACjC,uDAA2D;AAC3D,uCAA2C;AAC3C,yCAA6C;AAE7C;;;;;;;;;;GAUG;AACU,QAAA,YAAY,GAAG;IACxB,GAAG,gDAA2B;IAC9B,GAAG,8BAAkB;IACrB,GAAG,wBAAe;IAClB,GAAG,yBAAe;IAClB,GAAG,wBAAe;IAClB,GAAG,gCAAmB;IACtB,GAAG,0CAAwB;IAC3B,GAAG,gBAAW;IACd,GAAG,0CAAwB;IAC3B,GAAG,0BAAgB;IACnB,GAAG,4BAAiB;CACC,CAAC;AAiB1B,0EAA0E;AAC7D,QAAA,cAAc,GAAG,EAAC,2BAA2B,EAA3B,gDAA2B,EAAE,kBAAkB,EAAlB,8BAAkB,EAAE,eAAe,EAAf,wBAAe,EAAE,eAAe,EAAf,yBAAe,EAAE,eAAe,EAAf,wBAAe,EAAE,mBAAmB,EAAnB,gCAAmB,EAAE,wBAAwB,EAAxB,0CAAwB,EAAE,WAAW,EAAX,gBAAW,EAAE,wBAAwB,EAAxB,0CAAwB,EAAE,gBAAgB,EAAhB,0BAAgB,EAAE,iBAAiB,EAAjB,4BAAiB,EAAC,CAAC"}
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/callables/registry.ts"],"names":[],"mappings":";;;AAEA,6DAAiE;AACjE,2CAA+C;AAC/C,qCAAyC;AACzC,uCAA0C;AAC1C,qCAAyC;AACzC,6CAAiD;AACjD,uDAA2D;AAC3D,6BAAiC;AACjC,uDAA2D;AAC3D,uCAA2C;AAC3C,yCAA6C;AAC7C,uDAA2D;AAE3D;;;;;;;;;;GAUG;AACU,QAAA,YAAY,GAAG;IACxB,GAAG,gDAA2B;IAC9B,GAAG,8BAAkB;IACrB,GAAG,wBAAe;IAClB,GAAG,yBAAe;IAClB,GAAG,wBAAe;IAClB,GAAG,gCAAmB;IACtB,GAAG,0CAAwB;IAC3B,GAAG,gBAAW;IACd,GAAG,0CAAwB;IAC3B,GAAG,0BAAgB;IACnB,GAAG,4BAAiB;IACpB,GAAG,0CAAwB;CACN,CAAC;AAiB1B,0EAA0E;AAC7D,QAAA,cAAc,GAAG,EAAC,2BAA2B,EAA3B,gDAA2B,EAAE,kBAAkB,EAAlB,8BAAkB,EAAE,eAAe,EAAf,wBAAe,EAAE,eAAe,EAAf,yBAAe,EAAE,eAAe,EAAf,wBAAe,EAAE,mBAAmB,EAAnB,gCAAmB,EAAE,wBAAwB,EAAxB,0CAAwB,EAAE,WAAW,EAAX,gBAAW,EAAE,wBAAwB,EAAxB,0CAAwB,EAAE,gBAAgB,EAAhB,0BAAgB,EAAE,iBAAiB,EAAjB,4BAAiB,EAAE,wBAAwB,EAAxB,0CAAwB,EAAC,CAAC"}
@@ -1,20 +1,22 @@
1
1
  /**
2
- * globalSearch — one query box, several Algolia indexes.
2
+ * globalSearch — one query box, several indexes.
3
3
  *
4
4
  * The navbar search sends whatever the user typed here. Every index that the
5
5
  * signed-in institute can see is queried in a single round trip, and the answer
6
6
  * comes back keyed by index so the UI can render a section per index.
7
7
  *
8
- * Adding an index is meant to be a single entry in GLOBAL_SEARCH_INDEXES: name
9
- * the Algolia index, say when an institute can see it, restrict it to that
10
- * institute's records, and describe how one hit becomes a row.
8
+ * Adding an index is meant to be a single entry in GLOBAL_SEARCH_INDEXES. Most
9
+ * are Algolia-backed: name the index, say when an institute can see it, restrict
10
+ * it to that institute's records, and describe how one hit becomes a row. A
11
+ * small collection the caller already holds can instead be matched in memory
12
+ * with `local`, as a hub's schools are.
11
13
  */
12
14
  import { InstituteData, UserData } from "../../typeDefinitions";
13
15
  /** How many results a section shows before the user drills into one index. */
14
16
  export declare const GLOBAL_SEARCH_OVERVIEW_HITS = 5;
15
17
  /** How many results one index shows per page once the user has drilled in. */
16
18
  export declare const GLOBAL_SEARCH_INDEX_HITS = 10;
17
- export type GlobalSearchIndexId = "placements" | "employers" | "students";
19
+ export type GlobalSearchIndexId = "placements" | "employers" | "students" | "schools";
18
20
  /** One row, flattened out of whatever the underlying index stores. */
19
21
  export type GlobalSearchHit = {
20
22
  /** The Algolia objectID, which is the Firestore document id. */
@@ -54,15 +56,22 @@ export type GlobalSearchIndexDefinition = {
54
56
  id: GlobalSearchIndexId;
55
57
  label: string;
56
58
  labelSingular: string;
57
- /** The Algolia index name — the unsorted, relevance-ordered replica. */
58
- indexName: string;
59
59
  /** Whether this institute has the product that owns these records. */
60
60
  available: (institute?: InstituteData, user?: UserData) => boolean;
61
+ } & ({
62
+ /** The Algolia index name — the unsorted, relevance-ordered replica. */
63
+ indexName: string;
61
64
  /** Restricts the index to records this user is allowed to see. */
62
65
  filters: (user: UserData, institute?: InstituteData) => string;
63
66
  /** Turns one raw hit into a row. */
64
67
  toHit: (hit: any) => GlobalSearchHit;
65
- };
68
+ } | {
69
+ /**
70
+ * Searched in memory, from records the caller already holds, instead of
71
+ * through Algolia. Returns every match, ranked; `globalSearch` pages it.
72
+ */
73
+ local: (params: GlobalSearchParams) => GlobalSearchHit[];
74
+ });
66
75
  /**
67
76
  * The oIds whose employer databases this institute may read.
68
77
  *
@@ -79,6 +88,21 @@ export declare const employerSearchScopes: (user: UserData, institute?: Institut
79
88
  oId: string;
80
89
  linked: boolean;
81
90
  }[];
91
+ /**
92
+ * A careers hub's linked schools, matched in memory.
93
+ *
94
+ * There is no Algolia index for institutes, and a hub links to tens of schools
95
+ * rather than thousands, so the records Institutes.tsx already holds on
96
+ * OrganisationContext (`trustOrHubSchools`, scoped to what this user may see)
97
+ * are searched directly. Matches on the school's name and email domain, and on
98
+ * its admin's name and email where the caller attached the admin.
99
+ *
100
+ * @param {UserData} user the signed-in hub user.
101
+ * @param {GlobalSearchParams["schools"]} schools the hub's linked schools, keyed by oId.
102
+ * @param {string} query whatever the user typed.
103
+ * @return {GlobalSearchHit[]} every matching school, alphabetically.
104
+ */
105
+ export declare const searchLinkedSchools: (user: UserData, schools: GlobalSearchParams["schools"], query: string) => GlobalSearchHit[];
82
106
  /**
83
107
  * The indexes the navbar search covers.
84
108
  *
@@ -97,6 +121,15 @@ export declare const availableGlobalSearchIndexes: (institute?: InstituteData, u
97
121
  export type GlobalSearchParams = {
98
122
  user: UserData;
99
123
  institute?: InstituteData;
124
+ /**
125
+ * A hub's linked schools, as Institutes.tsx holds them on OrganisationContext.
126
+ * Searched in memory by the "schools" index; nothing else reads it.
127
+ */
128
+ schools?: {
129
+ [oId: string]: InstituteData & {
130
+ adminUser?: UserData;
131
+ };
132
+ };
100
133
  /** Whatever the user typed. Blank returns nothing rather than everything. */
101
134
  query: string;
102
135
  /** Restrict to specific indexes — how the drill-down asks for one index. */
@@ -108,6 +141,9 @@ export type GlobalSearchParams = {
108
141
  /**
109
142
  * Search every index this institute can see, in one round trip.
110
143
  *
144
+ * Algolia-backed indexes go out together in one multipleQueries batch; local
145
+ * ones are matched from the records passed in and paged here to the same shape.
146
+ *
111
147
  * Indexes with no matches are still returned, with `nbHits: 0` and no hits, so
112
148
  * a drill-down can say "no results" rather than render nothing. The overview UI
113
149
  * is expected to skip empty sections itself.
@@ -115,7 +151,7 @@ export type GlobalSearchParams = {
115
151
  * @param {GlobalSearchParams} params the search.
116
152
  * @return {Promise<GlobalSearchResults>} results keyed by index id.
117
153
  */
118
- export declare const globalSearch: ({ user, institute, query, indexIds, hitsPerPage, page, }: GlobalSearchParams) => Promise<GlobalSearchResults>;
154
+ export declare const globalSearch: (params: GlobalSearchParams) => Promise<GlobalSearchResults>;
119
155
  /**
120
156
  * The total across every section — what the header count is drawn from.
121
157
  *
@@ -1,20 +1,22 @@
1
1
  "use strict";
2
2
  /**
3
- * globalSearch — one query box, several Algolia indexes.
3
+ * globalSearch — one query box, several indexes.
4
4
  *
5
5
  * The navbar search sends whatever the user typed here. Every index that the
6
6
  * signed-in institute can see is queried in a single round trip, and the answer
7
7
  * comes back keyed by index so the UI can render a section per index.
8
8
  *
9
- * Adding an index is meant to be a single entry in GLOBAL_SEARCH_INDEXES: name
10
- * the Algolia index, say when an institute can see it, restrict it to that
11
- * institute's records, and describe how one hit becomes a row.
9
+ * Adding an index is meant to be a single entry in GLOBAL_SEARCH_INDEXES. Most
10
+ * are Algolia-backed: name the index, say when an institute can see it, restrict
11
+ * it to that institute's records, and describe how one hit becomes a row. A
12
+ * small collection the caller already holds can instead be matched in memory
13
+ * with `local`, as a hub's schools are.
12
14
  */
13
15
  var __importDefault = (this && this.__importDefault) || function (mod) {
14
16
  return (mod && mod.__esModule) ? mod : { "default": mod };
15
17
  };
16
18
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.nonEmptyGlobalSearchSections = exports.totalGlobalSearchHits = exports.globalSearch = exports.availableGlobalSearchIndexes = exports.GLOBAL_SEARCH_INDEXES = exports.employerSearchScopes = exports.GLOBAL_SEARCH_INDEX_HITS = exports.GLOBAL_SEARCH_OVERVIEW_HITS = void 0;
19
+ exports.nonEmptyGlobalSearchSections = exports.totalGlobalSearchHits = exports.globalSearch = exports.availableGlobalSearchIndexes = exports.GLOBAL_SEARCH_INDEXES = exports.searchLinkedSchools = exports.employerSearchScopes = exports.GLOBAL_SEARCH_INDEX_HITS = exports.GLOBAL_SEARCH_OVERVIEW_HITS = void 0;
18
20
  const algoliasearch_1 = __importDefault(require("algoliasearch"));
19
21
  const util_1 = require("../../firebase/util");
20
22
  const algoliaConfig_1 = require("../../algoliaConfig");
@@ -25,6 +27,11 @@ exports.GLOBAL_SEARCH_OVERVIEW_HITS = 5;
25
27
  exports.GLOBAL_SEARCH_INDEX_HITS = 10;
26
28
  const fullName = (forename, surname) => [forename, surname].filter(Boolean).join(" ").trim();
27
29
  const joinParts = (...parts) => parts.filter(Boolean).join(" · ") || undefined;
30
+ /** Every whitespace-separated term of the query appears somewhere in the text. */
31
+ const matchesEveryTerm = (text, query) => {
32
+ const haystack = text.toLowerCase();
33
+ return query.toLowerCase().split(/\s+/).filter(Boolean).every((term) => haystack.includes(term));
34
+ };
28
35
  /** Packages that run their own students, placements and cohorts. */
29
36
  const isSchoolPackage = (institute) => institute?.package === "institutes-one" || institute?.package === "institutes-two";
30
37
  /** Packages that keep an employer database of their own. */
@@ -54,6 +61,40 @@ const employerSearchScopes = (user, institute) => {
54
61
  ];
55
62
  };
56
63
  exports.employerSearchScopes = employerSearchScopes;
64
+ /**
65
+ * A careers hub's linked schools, matched in memory.
66
+ *
67
+ * There is no Algolia index for institutes, and a hub links to tens of schools
68
+ * rather than thousands, so the records Institutes.tsx already holds on
69
+ * OrganisationContext (`trustOrHubSchools`, scoped to what this user may see)
70
+ * are searched directly. Matches on the school's name and email domain, and on
71
+ * its admin's name and email where the caller attached the admin.
72
+ *
73
+ * @param {UserData} user the signed-in hub user.
74
+ * @param {GlobalSearchParams["schools"]} schools the hub's linked schools, keyed by oId.
75
+ * @param {string} query whatever the user typed.
76
+ * @return {GlobalSearchHit[]} every matching school, alphabetically.
77
+ */
78
+ const searchLinkedSchools = (user, schools = {}, query) => Object.entries(schools)
79
+ .filter(([, school]) => matchesEveryTerm([
80
+ school.name,
81
+ school.emailDomain,
82
+ fullName(school.adminUser?.details?.forename, school.adminUser?.details?.surname),
83
+ school.adminUser?.email,
84
+ ].filter(Boolean).join(" "), query))
85
+ .sort(([, a], [, b]) => (a.name || "").localeCompare(b.name || ""))
86
+ .map(([oId, school]) => {
87
+ const status = school.careersHubLinks?.[user.oId]?.status;
88
+ return {
89
+ id: oId,
90
+ title: school.name || "Unnamed school",
91
+ subtitle: joinParts(fullName(school.adminUser?.details?.forename, school.adminUser?.details?.surname) || undefined, school.adminUser?.email),
92
+ meta: status && (0, util_1.capitaliseWords)(status),
93
+ path: `/institutes/schools/viewSchool/${oId}`,
94
+ raw: school,
95
+ };
96
+ });
97
+ exports.searchLinkedSchools = searchLinkedSchools;
57
98
  /**
58
99
  * The indexes the navbar search covers.
59
100
  *
@@ -116,6 +157,15 @@ exports.GLOBAL_SEARCH_INDEXES = [
116
157
  raw: hit,
117
158
  }),
118
159
  },
160
+ {
161
+ id: "schools",
162
+ label: "Schools",
163
+ labelSingular: "school",
164
+ // Hubs only for now. A trust's schools open a different page, so they
165
+ // are a separate entry with its own path once wanted.
166
+ available: (institute) => institute?.package === "careersHub",
167
+ local: ({ user, schools, query }) => (0, exports.searchLinkedSchools)(user, schools, query),
168
+ },
119
169
  ];
120
170
  /**
121
171
  * The indexes this institute can search, in the order they should be shown.
@@ -126,9 +176,13 @@ exports.GLOBAL_SEARCH_INDEXES = [
126
176
  */
127
177
  const availableGlobalSearchIndexes = (institute, user) => exports.GLOBAL_SEARCH_INDEXES.filter((index) => index.available(institute, user));
128
178
  exports.availableGlobalSearchIndexes = availableGlobalSearchIndexes;
179
+ const isAlgoliaBacked = (index) => "indexName" in index;
129
180
  /**
130
181
  * Search every index this institute can see, in one round trip.
131
182
  *
183
+ * Algolia-backed indexes go out together in one multipleQueries batch; local
184
+ * ones are matched from the records passed in and paged here to the same shape.
185
+ *
132
186
  * Indexes with no matches are still returned, with `nbHits: 0` and no hits, so
133
187
  * a drill-down can say "no results" rather than render nothing. The overview UI
134
188
  * is expected to skip empty sections itself.
@@ -136,35 +190,44 @@ exports.availableGlobalSearchIndexes = availableGlobalSearchIndexes;
136
190
  * @param {GlobalSearchParams} params the search.
137
191
  * @return {Promise<GlobalSearchResults>} results keyed by index id.
138
192
  */
139
- const globalSearch = async ({ user, institute, query, indexIds, hitsPerPage = exports.GLOBAL_SEARCH_OVERVIEW_HITS, page = 0, }) => {
193
+ const globalSearch = async (params) => {
194
+ const { user, institute, query, indexIds, hitsPerPage = exports.GLOBAL_SEARCH_OVERVIEW_HITS, page = 0 } = params;
140
195
  const trimmed = query?.trim();
141
- if (!trimmed || !user?.algoliaKey)
196
+ if (!trimmed || !user)
142
197
  return {};
143
198
  const indexes = (0, exports.availableGlobalSearchIndexes)(institute, user)
144
199
  .filter((index) => !indexIds || indexIds.includes(index.id));
145
200
  if (!indexes.length)
146
201
  return {};
147
- const client = (0, algoliasearch_1.default)(algoliaConfig_1.ALGOLIA_APP_ID, user.algoliaKey);
148
- const { results } = await client.multipleQueries(indexes.map((index) => ({
149
- indexName: index.indexName,
150
- query: trimmed,
151
- params: {
152
- filters: index.filters(user, institute),
153
- hitsPerPage,
154
- page,
155
- },
156
- })));
157
- return Object.fromEntries(indexes.map((index, i) => {
158
- const result = results[i];
159
- return [index.id, {
160
- id: index.id,
161
- label: index.label,
162
- labelSingular: index.labelSingular,
163
- nbHits: result?.nbHits ?? 0,
164
- page: result?.page ?? page,
165
- nbPages: result?.nbPages ?? 0,
166
- hits: (result?.hits ?? []).map(index.toHit),
167
- }];
202
+ const algoliaIndexes = indexes.filter(isAlgoliaBacked);
203
+ const algoliaResults = algoliaIndexes.length && user.algoliaKey ?
204
+ (await (0, algoliasearch_1.default)(algoliaConfig_1.ALGOLIA_APP_ID, user.algoliaKey).multipleQueries(algoliaIndexes.map((index) => ({
205
+ indexName: index.indexName,
206
+ query: trimmed,
207
+ params: {
208
+ filters: index.filters(user, institute),
209
+ hitsPerPage,
210
+ page,
211
+ },
212
+ })))).results :
213
+ [];
214
+ const section = (index, hits, nbHits, nbPages, at) => ({
215
+ id: index.id,
216
+ label: index.label,
217
+ labelSingular: index.labelSingular,
218
+ nbHits,
219
+ page: at,
220
+ nbPages,
221
+ hits,
222
+ });
223
+ return Object.fromEntries(indexes.map((index) => {
224
+ if (!isAlgoliaBacked(index)) {
225
+ const matches = index.local({ ...params, query: trimmed });
226
+ const start = page * hitsPerPage;
227
+ return [index.id, section(index, matches.slice(start, start + hitsPerPage), matches.length, Math.ceil(matches.length / hitsPerPage), page)];
228
+ }
229
+ const result = algoliaResults[algoliaIndexes.indexOf(index)];
230
+ return [index.id, section(index, (result?.hits ?? []).map(index.toHit), result?.nbHits ?? 0, result?.nbPages ?? 0, result?.page ?? page)];
168
231
  }));
169
232
  };
170
233
  exports.globalSearch = globalSearch;
@@ -1 +1 @@
1
- {"version":3,"file":"globalSearch.js","sourceRoot":"","sources":["../../../src/features/search/globalSearch.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"globalSearch.js","sourceRoot":"","sources":["../../../src/features/search/globalSearch.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;GAYG;;;;;;AAEH,kEAA0C;AAE1C,8CAAgF;AAChF,uDAAmD;AACnD,2DAAsD;AAEtD,8EAA8E;AACjE,QAAA,2BAA2B,GAAG,CAAC,CAAC;AAE7C,8EAA8E;AACjE,QAAA,wBAAwB,GAAG,EAAE,CAAC;AA2D3C,MAAM,QAAQ,GAAG,CAAC,QAAiB,EAAE,OAAgB,EAAE,EAAE,CACrD,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;AAEzD,MAAM,SAAS,GAAG,CAAC,GAAG,KAAiC,EAAE,EAAE,CACvD,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,SAAS,CAAC;AAEnD,kFAAkF;AAClF,MAAM,gBAAgB,GAAG,CAAC,IAAY,EAAE,KAAa,EAAE,EAAE;IACrD,MAAM,QAAQ,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;IACpC,OAAO,KAAK,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;AACrG,CAAC,CAAC;AAEF,oEAAoE;AACpE,MAAM,eAAe,GAAG,CAAC,SAAyB,EAAE,EAAE,CAClD,SAAS,EAAE,OAAO,KAAK,gBAAgB,IAAI,SAAS,EAAE,OAAO,KAAK,gBAAgB,CAAC;AAEvF,4DAA4D;AAC5D,MAAM,mBAAmB,GAAG,CAAC,SAAyB,EAAE,EAAE,CACtD,eAAe,CAAC,SAAS,CAAC,IAAI,SAAS,EAAE,OAAO,KAAK,YAAY,CAAC;AAEtE;;;;;;;;;;;GAWG;AACI,MAAM,oBAAoB,GAAG,CAAC,IAAc,EAAE,SAAyB,EAAoC,EAAE;IAChH,MAAM,QAAQ,GAAG,CAAC,KAA0C,EAAE,EAAE,CAC5D,MAAM,CAAC,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC;SACtB,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,UAAU,CAAC;SACjD,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC;IAE7B,MAAM,MAAM,GAAG,CAAC,GAAG,QAAQ,CAAC,SAAS,EAAE,UAAU,CAAC,EAAE,GAAG,QAAQ,CAAC,SAAS,EAAE,eAAe,CAAC,CAAC;SACvF,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,KAAK,IAAI,CAAC,GAAG,CAAC,CAAC;IAEvC,OAAO;QACH,EAAC,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAC;QAC9B,0EAA0E;QAC1E,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,EAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAC,CAAC,CAAC;KACrE,CAAC;AACN,CAAC,CAAC;AAdW,QAAA,oBAAoB,wBAc/B;AAEF;;;;;;;;;;;;;GAaG;AACI,MAAM,mBAAmB,GAAG,CAAC,IAAc,EAAE,UAAyC,EAAE,EAAE,KAAa,EAAqB,EAAE,CACjI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC;KAClB,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC,gBAAgB,CAAC;IACrC,MAAM,CAAC,IAAI;IACX,MAAM,CAAC,WAAW;IAClB,QAAQ,CAAC,MAAM,CAAC,SAAS,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,CAAC,SAAS,EAAE,OAAO,EAAE,OAAO,CAAC;IACjF,MAAM,CAAC,SAAS,EAAE,KAAK;CAC1B,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,CAAC;KACnC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC;KAClE,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,EAAE,EAAE;IACnB,MAAM,MAAM,GAAG,MAAM,CAAC,eAAe,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAC1D,OAAO;QACH,EAAE,EAAE,GAAG;QACP,KAAK,EAAE,MAAM,CAAC,IAAI,IAAI,gBAAgB;QACtC,QAAQ,EAAE,SAAS,CACf,QAAQ,CAAC,MAAM,CAAC,SAAS,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,CAAC,SAAS,EAAE,OAAO,EAAE,OAAO,CAAC,IAAI,SAAS,EAC9F,MAAM,CAAC,SAAS,EAAE,KAAK,CAC1B;QACD,IAAI,EAAE,MAAM,IAAI,IAAA,sBAAe,EAAC,MAAM,CAAC;QACvC,IAAI,EAAE,kCAAkC,GAAG,EAAE;QAC7C,GAAG,EAAE,MAAM;KACd,CAAC;AACN,CAAC,CAAC,CAAC;AAtBE,QAAA,mBAAmB,uBAsBrB;AAEX;;;;;GAKG;AACU,QAAA,qBAAqB,GAAkC;IAChE;QACI,EAAE,EAAE,YAAY;QAChB,KAAK,EAAE,YAAY;QACnB,aAAa,EAAE,WAAW;QAC1B,SAAS,EAAE,YAAY;QACvB,SAAS,EAAE,CAAC,SAAS,EAAE,EAAE,CAAC,IAAA,iCAAe,EAAC,SAAS,CAAC,CAAC,GAAG;QACxD,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,IAAA,iCAA0B,EAAC,IAAI,CAAC,GAAG,CAAC,kBAAkB;QAChF,KAAK,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;YACb,EAAE,EAAE,GAAG,CAAC,QAAQ;YAChB,KAAK,EAAE,GAAG,CAAC,KAAK,IAAI,GAAG,CAAC,IAAI,IAAI,oBAAoB;YACpD,QAAQ,EAAE,SAAS,CACf,GAAG,CAAC,KAAK,IAAI,GAAG,CAAC,IAAI,EACrB,QAAQ,CAAC,GAAG,CAAC,eAAe,EAAE,GAAG,CAAC,cAAc,CAAC,IAAI,SAAS,EAC9D,GAAG,CAAC,QAAQ,CACf;YACD,IAAI,EAAE,GAAG,CAAC,UAAU;YACpB,IAAI,EAAE,kCAAkC,GAAG,CAAC,QAAQ,EAAE;YACtD,GAAG,EAAE,GAAG;SACX,CAAC;KACL;IACD;QACI,EAAE,EAAE,WAAW;QACf,KAAK,EAAE,WAAW;QAClB,aAAa,EAAE,UAAU;QACzB,SAAS,EAAE,kBAAkB;QAC7B,SAAS,EAAE,CAAC,SAAS,EAAE,EAAE,CAAC,OAAO,CAAC,mBAAmB,CAAC,SAAS,CAAC,IAAI,IAAA,iCAAe,EAAC,SAAS,CAAC,CAAC,OAAO,CAAC;QACvG,4EAA4E;QAC5E,yEAAyE;QACzE,uEAAuE;QACvE,OAAO,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,EAAE,CAAC,IAAI,IAAA,4BAAoB,EAAC,IAAI,EAAE,SAAS,CAAC;aAClE,GAAG,CAAC,CAAC,EAAC,GAAG,EAAE,MAAM,EAAC,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC;YAC7B,YAAY,GAAG,4BAA4B,GAAG,mBAAmB,CAAC,CAAC;YACnE,WAAW,GAAG,cAAc,CAAC,CAAC;aACjC,IAAI,CAAC,MAAM,CAAC,GAAG;QACpB,KAAK,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;YACb,EAAE,EAAE,GAAG,CAAC,QAAQ;YAChB,KAAK,EAAE,GAAG,CAAC,IAAI,IAAI,QAAQ,CAAC,GAAG,CAAC,eAAe,EAAE,GAAG,CAAC,cAAc,CAAC,IAAI,kBAAkB;YAC1F,QAAQ,EAAE,SAAS,CACf,QAAQ,CAAC,GAAG,CAAC,eAAe,EAAE,GAAG,CAAC,cAAc,CAAC,IAAI,SAAS,EAC9D,GAAG,CAAC,aAAa,CACpB;YACD,IAAI,EAAE,GAAG,CAAC,MAAM;YAChB,IAAI,EAAE,mCAAmC,GAAG,CAAC,QAAQ,EAAE;YACvD,GAAG,EAAE,GAAG;SACX,CAAC;KACL;IACD;QACI,EAAE,EAAE,UAAU;QACd,KAAK,EAAE,UAAU;QACjB,aAAa,EAAE,SAAS;QACxB,SAAS,EAAE,OAAO;QAClB,SAAS,EAAE,CAAC,SAAS,EAAE,EAAE,CAAC,eAAe,CAAC,SAAS,CAAC;QACpD,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,IAAA,iCAA0B,EAAC,IAAI,CAAC,GAAG,CAAC,wBAAwB;QACtF,KAAK,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;YACb,EAAE,EAAE,GAAG,CAAC,QAAQ;YAChB,KAAK,EAAE,QAAQ,CAAC,GAAG,CAAC,OAAO,EAAE,QAAQ,EAAE,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,IAAI,GAAG,CAAC,KAAK,IAAI,iBAAiB;YAC9F,QAAQ,EAAE,GAAG,CAAC,KAAK;YACnB,IAAI,EAAE,GAAG,CAAC,OAAO,EAAE,IAAI,IAAI,GAAG,CAAC,OAAO,EAAE,IAAI;YAC5C,IAAI,EAAE,qBAAqB,GAAG,CAAC,QAAQ,EAAE;YACzC,GAAG,EAAE,GAAG;SACX,CAAC;KACL;IACD;QACI,EAAE,EAAE,SAAS;QACb,KAAK,EAAE,SAAS;QAChB,aAAa,EAAE,QAAQ;QACvB,sEAAsE;QACtE,sDAAsD;QACtD,SAAS,EAAE,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,YAAY;QAC7D,KAAK,EAAE,CAAC,EAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAC,EAAE,EAAE,CAAC,IAAA,2BAAmB,EAAC,IAAI,EAAE,OAAO,EAAE,KAAK,CAAC;KAC/E;CACJ,CAAC;AAEF;;;;;;GAMG;AACI,MAAM,4BAA4B,GAAG,CAAC,SAAyB,EAAE,IAAe,EAAiC,EAAE,CACtH,6BAAqB,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,SAAS,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC;AADjE,QAAA,4BAA4B,gCACqC;AAqB9E,MAAM,eAAe,GAAG,CAAC,KAAkC,EAA+B,EAAE,CAAC,WAAW,IAAI,KAAK,CAAC;AAElH;;;;;;;;;;;;GAYG;AACI,MAAM,YAAY,GAAG,KAAK,EAAE,MAA0B,EAAgC,EAAE;IAC3F,MAAM,EAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,QAAQ,EAAE,WAAW,GAAG,mCAA2B,EAAE,IAAI,GAAG,CAAC,EAAC,GAAG,MAAM,CAAC;IACvG,MAAM,OAAO,GAAG,KAAK,EAAE,IAAI,EAAE,CAAC;IAC9B,IAAI,CAAC,OAAO,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,CAAC;IAEjC,MAAM,OAAO,GAAG,IAAA,oCAA4B,EAAC,SAAS,EAAE,IAAI,CAAC;SACxD,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,QAAQ,IAAI,QAAQ,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC;IAEjE,IAAI,CAAC,OAAO,CAAC,MAAM;QAAE,OAAO,EAAE,CAAC;IAE/B,MAAM,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC;IACvD,MAAM,cAAc,GAAG,cAAc,CAAC,MAAM,IAAI,IAAI,CAAC,UAAU,CAAC,CAAC;QAC7D,CAAC,MAAM,IAAA,uBAAa,EAAC,8BAAc,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC,eAAe,CAAC,cAAc,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;YACjG,SAAS,EAAE,KAAK,CAAC,SAAS;YAC1B,KAAK,EAAE,OAAO;YACd,MAAM,EAAE;gBACJ,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,SAAS,CAAC;gBACvC,WAAW;gBACX,IAAI;aACP;SACJ,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;QACf,EAAE,CAAC;IAEP,MAAM,OAAO,GAAG,CAAC,KAAkC,EAAE,IAAuB,EAAE,MAAc,EAAE,OAAe,EAAE,EAAU,EAA4B,EAAE,CAAC,CAAC;QACrJ,EAAE,EAAE,KAAK,CAAC,EAAE;QACZ,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,aAAa,EAAE,KAAK,CAAC,aAAa;QAClC,MAAM;QACN,IAAI,EAAE,EAAE;QACR,OAAO;QACP,IAAI;KACP,CAAC,CAAC;IAEH,OAAO,MAAM,CAAC,WAAW,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE;QAC5C,IAAI,CAAC,eAAe,CAAC,KAAK,CAAC,EAAE,CAAC;YAC1B,MAAM,OAAO,GAAG,KAAK,CAAC,KAAK,CAAC,EAAC,GAAG,MAAM,EAAE,KAAK,EAAE,OAAO,EAAC,CAAC,CAAC;YACzD,MAAM,KAAK,GAAG,IAAI,GAAG,WAAW,CAAC;YACjC,OAAO,CAAC,KAAK,CAAC,EAAE,EAAE,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,GAAG,WAAW,CAAC,EAAE,OAAO,CAAC,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,GAAG,WAAW,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;QAChJ,CAAC;QACD,MAAM,MAAM,GAAG,cAAc,CAAC,cAAc,CAAC,OAAO,CAAC,KAAK,CAAC,CAA+E,CAAC;QAC3I,OAAO,CAAC,KAAK,CAAC,EAAE,EAAE,OAAO,CAAC,KAAK,EAAE,CAAC,MAAM,EAAE,IAAI,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,MAAM,IAAI,CAAC,EAAE,MAAM,EAAE,OAAO,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC;IAC9I,CAAC,CAAC,CAAwB,CAAC;AAC/B,CAAC,CAAC;AA1CW,QAAA,YAAY,gBA0CvB;AAEF;;;;;GAKG;AACI,MAAM,qBAAqB,GAAG,CAAC,OAA4B,EAAU,EAAE,CAC1E,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,CAAC,KAAK,GAAG,CAAC,KAAK,EAAE,MAAM,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AADxE,QAAA,qBAAqB,yBACmD;AAErF;;;;;GAKG;AACI,MAAM,4BAA4B,GAAG,CAAC,OAA4B,EAA8B,EAAE,CACrG,6BAAqB;KAChB,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;KACjC,MAAM,CAAC,CAAC,OAAO,EAAuC,EAAE,CAAC,OAAO,CAAC,OAAO,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC;AAH7F,QAAA,4BAA4B,gCAGiE"}
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.1096",
5
+ "version": "1.400.1098",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
8
8
  "scripts": {
@@ -0,0 +1,33 @@
1
+ import {z} from "zod";
2
+ import {CallableGroup} from "./types";
3
+
4
+ /**
5
+ * Contract for the Careers Hub impact report
6
+ * (`Placementt-Backend/functions/src/hubImpactReport.ts`).
7
+ *
8
+ * The school/trust impact report is scoped by cohort, because a school's year IS
9
+ * its cohort. A hub has no cohort, so this one is scoped by date instead, and by
10
+ * nothing else: everything in the report is derived from the hub's own links,
11
+ * employer network, events, opportunities and campaigns, so there is no filter
12
+ * the client could pass that the server should trust.
13
+ */
14
+ export const hubImpactReportCallables = {
15
+ "hubImpactReport-createHubImpactReport": {
16
+ request: z.object({
17
+ /** The careers hub to report on. Omitted means the caller's own
18
+ * organisation; a different one is allowed only where the caller
19
+ * already has staff access to it. */
20
+ oId: z.string().optional(),
21
+ /** Inclusive ISO start date. Both ends must be given, or the report
22
+ * falls back to the current academic year (1 September onwards) -
23
+ * which is the period a hub means by "this year" anyway. */
24
+ startDate: z.string().optional(),
25
+ /** Inclusive ISO end date. */
26
+ endDate: z.string().optional(),
27
+ }),
28
+ /** The Storage path of the generated PDF. The client resolves it with
29
+ * getDownloadURL, so the download is governed by the Storage rules
30
+ * rather than by a signed URL the functions runtime cannot mint. */
31
+ response: z.string(),
32
+ },
33
+ } satisfies CallableGroup;
@@ -8,3 +8,4 @@ export * from "./apprenticeships";
8
8
  export * from "./he";
9
9
  export * from "./activityLogging";
10
10
  export * from "./billing";
11
+ export * from "./hubImpactReport";
@@ -11,6 +11,7 @@ import {heCallables} from "./he";
11
11
  import {activityLoggingCallables} from "./activityLogging";
12
12
  import {billingCallables} from "./billing";
13
13
  import {jobRolesCallables} from "./jobRoles";
14
+ import {hubImpactReportCallables} from "./hubImpactReport";
14
15
 
15
16
  /**
16
17
  * The single source of truth for every typed callable. Add a feature group's
@@ -35,6 +36,7 @@ export const allCallables = {
35
36
  ...activityLoggingCallables,
36
37
  ...billingCallables,
37
38
  ...jobRolesCallables,
39
+ ...hubImpactReportCallables,
38
40
  } satisfies CallableGroup;
39
41
 
40
42
  /** Every registered callable name. */
@@ -53,4 +55,4 @@ export type CallableContracts = {
53
55
  };
54
56
 
55
57
  /** The per-group maps, exported so tests can assert no key collisions. */
56
- export const callableGroups = {destinationsConfigCallables, placementCallables, eventsCallables, surveyCallables, checksCallables, benchmarksCallables, apprenticeshipsCallables, heCallables, activityLoggingCallables, billingCallables, jobRolesCallables};
58
+ export const callableGroups = {destinationsConfigCallables, placementCallables, eventsCallables, surveyCallables, checksCallables, benchmarksCallables, apprenticeshipsCallables, heCallables, activityLoggingCallables, billingCallables, jobRolesCallables, hubImpactReportCallables};
@@ -1,18 +1,20 @@
1
1
  /**
2
- * globalSearch — one query box, several Algolia indexes.
2
+ * globalSearch — one query box, several indexes.
3
3
  *
4
4
  * The navbar search sends whatever the user typed here. Every index that the
5
5
  * signed-in institute can see is queried in a single round trip, and the answer
6
6
  * comes back keyed by index so the UI can render a section per index.
7
7
  *
8
- * Adding an index is meant to be a single entry in GLOBAL_SEARCH_INDEXES: name
9
- * the Algolia index, say when an institute can see it, restrict it to that
10
- * institute's records, and describe how one hit becomes a row.
8
+ * Adding an index is meant to be a single entry in GLOBAL_SEARCH_INDEXES. Most
9
+ * are Algolia-backed: name the index, say when an institute can see it, restrict
10
+ * it to that institute's records, and describe how one hit becomes a row. A
11
+ * small collection the caller already holds can instead be matched in memory
12
+ * with `local`, as a hub's schools are.
11
13
  */
12
14
 
13
15
  import algoliasearch from "algoliasearch";
14
16
  import {InstituteData, UserData} from "../../typeDefinitions";
15
- import {quoteAlgoliaFilterIfNeeded} from "../../firebase/util";
17
+ import {capitaliseWords, quoteAlgoliaFilterIfNeeded} from "../../firebase/util";
16
18
  import {ALGOLIA_APP_ID} from "../../algoliaConfig";
17
19
  import {resolveProducts} from "../../productResolver";
18
20
 
@@ -22,7 +24,7 @@ export const GLOBAL_SEARCH_OVERVIEW_HITS = 5;
22
24
  /** How many results one index shows per page once the user has drilled in. */
23
25
  export const GLOBAL_SEARCH_INDEX_HITS = 10;
24
26
 
25
- export type GlobalSearchIndexId = "placements"|"employers"|"students";
27
+ export type GlobalSearchIndexId = "placements"|"employers"|"students"|"schools";
26
28
 
27
29
  /** One row, flattened out of whatever the underlying index stores. */
28
30
  export type GlobalSearchHit = {
@@ -62,15 +64,22 @@ export type GlobalSearchIndexDefinition = {
62
64
  id: GlobalSearchIndexId,
63
65
  label: string,
64
66
  labelSingular: string,
65
- /** The Algolia index name — the unsorted, relevance-ordered replica. */
66
- indexName: string,
67
67
  /** Whether this institute has the product that owns these records. */
68
68
  available: (institute?: InstituteData, user?: UserData) => boolean,
69
+ } & ({
70
+ /** The Algolia index name — the unsorted, relevance-ordered replica. */
71
+ indexName: string,
69
72
  /** Restricts the index to records this user is allowed to see. */
70
73
  filters: (user: UserData, institute?: InstituteData) => string,
71
74
  /** Turns one raw hit into a row. */
72
75
  toHit: (hit: any) => GlobalSearchHit,
73
- };
76
+ } | {
77
+ /**
78
+ * Searched in memory, from records the caller already holds, instead of
79
+ * through Algolia. Returns every match, ranked; `globalSearch` pages it.
80
+ */
81
+ local: (params: GlobalSearchParams) => GlobalSearchHit[],
82
+ });
74
83
 
75
84
  const fullName = (forename?: string, surname?: string) =>
76
85
  [forename, surname].filter(Boolean).join(" ").trim();
@@ -78,6 +87,12 @@ const fullName = (forename?: string, surname?: string) =>
78
87
  const joinParts = (...parts: (string|undefined|false)[]) =>
79
88
  parts.filter(Boolean).join(" · ") || undefined;
80
89
 
90
+ /** Every whitespace-separated term of the query appears somewhere in the text. */
91
+ const matchesEveryTerm = (text: string, query: string) => {
92
+ const haystack = text.toLowerCase();
93
+ return query.toLowerCase().split(/\s+/).filter(Boolean).every((term) => haystack.includes(term));
94
+ };
95
+
81
96
  /** Packages that run their own students, placements and cohorts. */
82
97
  const isSchoolPackage = (institute?: InstituteData) =>
83
98
  institute?.package === "institutes-one" || institute?.package === "institutes-two";
@@ -114,6 +129,44 @@ export const employerSearchScopes = (user: UserData, institute?: InstituteData):
114
129
  ];
115
130
  };
116
131
 
132
+ /**
133
+ * A careers hub's linked schools, matched in memory.
134
+ *
135
+ * There is no Algolia index for institutes, and a hub links to tens of schools
136
+ * rather than thousands, so the records Institutes.tsx already holds on
137
+ * OrganisationContext (`trustOrHubSchools`, scoped to what this user may see)
138
+ * are searched directly. Matches on the school's name and email domain, and on
139
+ * its admin's name and email where the caller attached the admin.
140
+ *
141
+ * @param {UserData} user the signed-in hub user.
142
+ * @param {GlobalSearchParams["schools"]} schools the hub's linked schools, keyed by oId.
143
+ * @param {string} query whatever the user typed.
144
+ * @return {GlobalSearchHit[]} every matching school, alphabetically.
145
+ */
146
+ export const searchLinkedSchools = (user: UserData, schools: GlobalSearchParams["schools"] = {}, query: string): GlobalSearchHit[] =>
147
+ Object.entries(schools)
148
+ .filter(([, school]) => matchesEveryTerm([
149
+ school.name,
150
+ school.emailDomain,
151
+ fullName(school.adminUser?.details?.forename, school.adminUser?.details?.surname),
152
+ school.adminUser?.email,
153
+ ].filter(Boolean).join(" "), query))
154
+ .sort(([, a], [, b]) => (a.name || "").localeCompare(b.name || ""))
155
+ .map(([oId, school]) => {
156
+ const status = school.careersHubLinks?.[user.oId]?.status;
157
+ return {
158
+ id: oId,
159
+ title: school.name || "Unnamed school",
160
+ subtitle: joinParts(
161
+ fullName(school.adminUser?.details?.forename, school.adminUser?.details?.surname) || undefined,
162
+ school.adminUser?.email,
163
+ ),
164
+ meta: status && capitaliseWords(status),
165
+ path: `/institutes/schools/viewSchool/${oId}`,
166
+ raw: school,
167
+ };
168
+ });
169
+
117
170
  /**
118
171
  * The indexes the navbar search covers.
119
172
  *
@@ -183,6 +236,15 @@ export const GLOBAL_SEARCH_INDEXES: GlobalSearchIndexDefinition[] = [
183
236
  raw: hit,
184
237
  }),
185
238
  },
239
+ {
240
+ id: "schools",
241
+ label: "Schools",
242
+ labelSingular: "school",
243
+ // Hubs only for now. A trust's schools open a different page, so they
244
+ // are a separate entry with its own path once wanted.
245
+ available: (institute) => institute?.package === "careersHub",
246
+ local: ({user, schools, query}) => searchLinkedSchools(user, schools, query),
247
+ },
186
248
  ];
187
249
 
188
250
  /**
@@ -198,6 +260,11 @@ export const availableGlobalSearchIndexes = (institute?: InstituteData, user?: U
198
260
  export type GlobalSearchParams = {
199
261
  user: UserData,
200
262
  institute?: InstituteData,
263
+ /**
264
+ * A hub's linked schools, as Institutes.tsx holds them on OrganisationContext.
265
+ * Searched in memory by the "schools" index; nothing else reads it.
266
+ */
267
+ schools?: {[oId: string]: InstituteData&{adminUser?: UserData}},
201
268
  /** Whatever the user typed. Blank returns nothing rather than everything. */
202
269
  query: string,
203
270
  /** Restrict to specific indexes — how the drill-down asks for one index. */
@@ -207,9 +274,16 @@ export type GlobalSearchParams = {
207
274
  page?: number,
208
275
  };
209
276
 
277
+ type AlgoliaBackedIndex = Extract<GlobalSearchIndexDefinition, {indexName: string}>;
278
+
279
+ const isAlgoliaBacked = (index: GlobalSearchIndexDefinition): index is AlgoliaBackedIndex => "indexName" in index;
280
+
210
281
  /**
211
282
  * Search every index this institute can see, in one round trip.
212
283
  *
284
+ * Algolia-backed indexes go out together in one multipleQueries batch; local
285
+ * ones are matched from the records passed in and paged here to the same shape.
286
+ *
213
287
  * Indexes with no matches are still returned, with `nbHits: 0` and no hits, so
214
288
  * a drill-down can say "no results" rather than render nothing. The overview UI
215
289
  * is expected to skip empty sections itself.
@@ -217,45 +291,47 @@ export type GlobalSearchParams = {
217
291
  * @param {GlobalSearchParams} params the search.
218
292
  * @return {Promise<GlobalSearchResults>} results keyed by index id.
219
293
  */
220
- export const globalSearch = async ({
221
- user,
222
- institute,
223
- query,
224
- indexIds,
225
- hitsPerPage = GLOBAL_SEARCH_OVERVIEW_HITS,
226
- page = 0,
227
- }: GlobalSearchParams): Promise<GlobalSearchResults> => {
294
+ export const globalSearch = async (params: GlobalSearchParams): Promise<GlobalSearchResults> => {
295
+ const {user, institute, query, indexIds, hitsPerPage = GLOBAL_SEARCH_OVERVIEW_HITS, page = 0} = params;
228
296
  const trimmed = query?.trim();
229
- if (!trimmed || !user?.algoliaKey) return {};
297
+ if (!trimmed || !user) return {};
230
298
 
231
299
  const indexes = availableGlobalSearchIndexes(institute, user)
232
300
  .filter((index) => !indexIds || indexIds.includes(index.id));
233
301
 
234
302
  if (!indexes.length) return {};
235
303
 
236
- const client = algoliasearch(ALGOLIA_APP_ID, user.algoliaKey);
304
+ const algoliaIndexes = indexes.filter(isAlgoliaBacked);
305
+ const algoliaResults = algoliaIndexes.length && user.algoliaKey ?
306
+ (await algoliasearch(ALGOLIA_APP_ID, user.algoliaKey).multipleQueries(algoliaIndexes.map((index) => ({
307
+ indexName: index.indexName,
308
+ query: trimmed,
309
+ params: {
310
+ filters: index.filters(user, institute),
311
+ hitsPerPage,
312
+ page,
313
+ },
314
+ })))).results :
315
+ [];
237
316
 
238
- const {results} = await client.multipleQueries(indexes.map((index) => ({
239
- indexName: index.indexName,
240
- query: trimmed,
241
- params: {
242
- filters: index.filters(user, institute),
243
- hitsPerPage,
244
- page,
245
- },
246
- })));
317
+ const section = (index: GlobalSearchIndexDefinition, hits: GlobalSearchHit[], nbHits: number, nbPages: number, at: number): GlobalSearchIndexResults => ({
318
+ id: index.id,
319
+ label: index.label,
320
+ labelSingular: index.labelSingular,
321
+ nbHits,
322
+ page: at,
323
+ nbPages,
324
+ hits,
325
+ });
247
326
 
248
- return Object.fromEntries(indexes.map((index, i) => {
249
- const result = results[i] as {hits?: any[], nbHits?: number, page?: number, nbPages?: number}|undefined;
250
- return [index.id, {
251
- id: index.id,
252
- label: index.label,
253
- labelSingular: index.labelSingular,
254
- nbHits: result?.nbHits ?? 0,
255
- page: result?.page ?? page,
256
- nbPages: result?.nbPages ?? 0,
257
- hits: (result?.hits ?? []).map(index.toHit),
258
- } as GlobalSearchIndexResults];
327
+ return Object.fromEntries(indexes.map((index) => {
328
+ if (!isAlgoliaBacked(index)) {
329
+ const matches = index.local({...params, query: trimmed});
330
+ const start = page * hitsPerPage;
331
+ return [index.id, section(index, matches.slice(start, start + hitsPerPage), matches.length, Math.ceil(matches.length / hitsPerPage), page)];
332
+ }
333
+ const result = algoliaResults[algoliaIndexes.indexOf(index)] as {hits?: any[], nbHits?: number, page?: number, nbPages?: number}|undefined;
334
+ return [index.id, section(index, (result?.hits ?? []).map(index.toHit), result?.nbHits ?? 0, result?.nbPages ?? 0, result?.page ?? page)];
259
335
  })) as GlobalSearchResults;
260
336
  };
261
337