@cyc-seattle/platform 1.37.1 → 1.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,7 @@ app owns. The apps that run on it live in their own repos, such as
11
11
  | [`bootstrap`](stacks/bootstrap) | Service accounts and project IAM, the Pulumi state buckets and KMS keys, the org's GitHub repos and settings, and Firebase on the project | A human with project Owner and `gh` `admin:org`. Never CI. |
12
12
  | [`infra`](stacks/infra) | The VPC, the cluster subnet and its Cloud NAT on a static address, the private services peering, the prod and dev Cloud SQL instances, the DNS zones, Artifact Registry and its cleanup policy, the GKE Autopilot cluster, the shared Gateway, its address, certificates, and dev's DNS records, the `dev` and `prod` namespaces with their quotas and network policies, dev's RBAC, the prod Job alerts, the log exclusions, the public website's Firebase Hosting site, and each environment's `platform/outputs.json` | `deploy-runner`, from Actions after approval. A deployer, with `mise run deploy`, when breaking glass. |
13
13
  | [`smoke`](stacks/smoke) | One `WebService` in dev, at `smoke.dev.cycsail.team`, for the integration tests | `deploy-dev`, from Actions for each queued PR. A deployer, with `PULUMI_STACK=dev mise run deploy`, when breaking glass. |
14
- | [`backend`](stacks/backend) | Directus, with the schema apply from `schemas/`, its roles, policies, and permission rules, the sync machine users, its OIDC client, and MCP, and the clubspot-sync and gsuite-sync CronJobs, with dev's Clubspot and Workspace fakes | `deploy-dev`, from Actions for each queued PR, and `deploy-runner` on a push to `main`, after `infra`. |
14
+ | [`backend`](stacks/backend) | Directus, with the schema apply from `schemas/`, its roles, policies, and permission rules, the sync and website build machine users, its OIDC client, and MCP, and the clubspot-sync and gsuite-sync CronJobs, with dev's Clubspot and Workspace fakes | `deploy-dev`, from Actions for each queued PR, and `deploy-runner` on a push to `main`, after `infra`. |
15
15
  | [`authentik`](stacks/authentik) | Authentik, the sign-in at `login.<domain>`: its server and worker, database, secrets, and A record, dev's Mailpit, and its own configuration: flows, stages, policies, the Google source, the `staff` group, and the Directus OIDC and portal proxy applications | `deploy-dev`, from Actions for each queued PR, and `deploy-runner` on a push to `main`, after `backend`. |
16
16
 
17
17
  ## @cyc-seattle/platform
@@ -32,7 +32,10 @@ export function scheduleActivities(ctx) {
32
32
  const span = start !== null && end !== null ? end - start : 0;
33
33
  const closedFor = closed.get(activity.date);
34
34
  if (closedFor !== undefined && activity.kind !== "travel") {
35
- ctx.warn("closed_date", `${offering.name} has an activity on ${activity.date}, a closed day: ${closedFor}`, activity.key, { version_id: activity.version_id, date: activity.date });
35
+ ctx.warn("closed_date", `${offering.name}'s ${activity.kind} falls on ${closedFor}, a closed day`, activity.key, {
36
+ version_id: activity.version_id,
37
+ date: activity.date,
38
+ });
36
39
  }
37
40
  const usesBoats = activity.kind !== "travel" && activity.uses_boats;
38
41
  result.push({
@@ -10,6 +10,22 @@ function sum(values) {
10
10
  total += v;
11
11
  return total;
12
12
  }
13
+ /**
14
+ * `n` of something, in words: "9 Sailqubes", "1 Opti". Only a one-word name is made plural, and not one that already
15
+ * ends in "s", such as RIBs; any other name is used as given, since adding an "s" to "Laser Radial" reads wrong.
16
+ */
17
+ function countOf(n, name) {
18
+ const plural = n !== 1 && /^\S+$/.test(name) && !/s$/i.test(name);
19
+ return `${n} ${plural ? `${name}s` : name}`;
20
+ }
21
+ /** Names as a sentence lists them: `a`, `a and b`, or `a, b, and c`. */
22
+ function listOf(names) {
23
+ return names.length <= 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}`;
24
+ }
25
+ /** The offerings a block holds, by name, so a warning about it says whose it is. */
26
+ function offeringsOf(block) {
27
+ return listOf([...new Set(block.members.map((a) => a.offering.name))]);
28
+ }
13
29
  function groupBy(rows, key) {
14
30
  const groups = new Map();
15
31
  for (const row of rows)
@@ -71,7 +87,7 @@ export class Staffing {
71
87
  continue;
72
88
  }
73
89
  if (row.date !== a.date) {
74
- this.ctx.warn("block_date_mismatch", `${a.offering.name}'s activity on ${a.date} is in ${row.name}, which is on ${row.date}`, a.stored.key, { version_id: a.stored.version_id, date: a.date });
90
+ this.ctx.warn("block_date_mismatch", `${a.offering.name}'s ${a.kind} is in ${row.name}, which is on ${row.date}`, a.stored.key, { version_id: a.stored.version_id, date: a.date });
75
91
  }
76
92
  members.set(id, [...(members.get(id) ?? []), a]);
77
93
  }
@@ -114,7 +130,7 @@ export class Staffing {
114
130
  const block = a.stored.block_id == null ? undefined : blocks.get(a.stored.block_id);
115
131
  if (block !== undefined && sum(block.staff.map((s) => s.count)) > 0)
116
132
  continue;
117
- this.ctx.warn("unallocated_activity", `${a.offering.name}'s ${a.kind} on ${a.date} has no staff`, a.stored.key, {
133
+ this.ctx.warn("unallocated_activity", `${a.offering.name}'s ${a.kind} has no staff`, a.stored.key, {
118
134
  version_id: a.stored.version_id,
119
135
  date: a.date,
120
136
  });
@@ -146,7 +162,7 @@ export class Staffing {
146
162
  if (sailors <= 0)
147
163
  return;
148
164
  const scope = { version_id: block.row.version_id, date: block.row.date };
149
- const where = `${block.row.date}, ${block.row.name}`;
165
+ const where = offeringsOf(block);
150
166
  let load = 0;
151
167
  let counted = 0;
152
168
  const required = [];
@@ -172,7 +188,7 @@ export class Staffing {
172
188
  return required.some((r) => this.qualifiedFor(held, level, r)) ? s.count : 0;
173
189
  }));
174
190
  if (load > qualified + 1e-9) {
175
- ctx.warn("over_capacity", `${where}: ${counted} sailors, but its staff can take ${Math.floor((qualified * counted) / load + 1e-9)}`, block.row.key, scope);
191
+ ctx.warn("over_capacity", `${where}: ${counted} sailors, but the staff can take ${Math.floor((qualified * counted) / load + 1e-9)}`, block.row.key, scope);
176
192
  }
177
193
  }
178
194
  if (!block.members.some((a) => a.uses_boats))
@@ -188,7 +204,7 @@ export class Staffing {
188
204
  seats += (b.count + (b.sailor_owned ?? 0)) * perBoat;
189
205
  }
190
206
  if (sailors > seats) {
191
- ctx.warn("over_capacity", `${where}: ${sailors} sailors, but its boats can take ${seats}`, block.row.key, scope);
207
+ ctx.warn("over_capacity", `${where}: ${sailors} sailors, but the boats can take ${seats}`, block.row.key, scope);
192
208
  }
193
209
  }
194
210
  /**
@@ -201,6 +217,7 @@ export class Staffing {
201
217
  const classes = new Map(ctx.data.boat_classes.map((c) => [c.id, c]));
202
218
  const fleet = new Map(ctx.data.plan_boat_rates.map((r) => [r.boat_class_id, r.quantity]));
203
219
  const resources = new Map(ctx.data.plan_resources.map((r) => [r.key, r]));
220
+ const organization = ctx.data.organization.name;
204
221
  for (const [date, list] of dates) {
205
222
  const check = (code, what, have, used) => {
206
223
  if (have == null)
@@ -209,9 +226,12 @@ export class Staffing {
209
226
  const peak = mostAtOnce(out);
210
227
  if (peak.total <= have)
211
228
  return;
212
- const names = peak.at.map(({ block, n }) => `${block.row.name} ${n}`).join(", ");
229
+ const whose = peak.at.length === 1
230
+ ? `, for ${offeringsOf(peak.at[0].block)}`
231
+ : `: ${listOf(peak.at.map(({ block, n }) => `${n} for ${offeringsOf(block)}`))}`;
232
+ const message = `${countOf(peak.total, what)} out, but ${organization} has ${have}${whose}`;
213
233
  for (const version of new Set(peak.at.map(({ block }) => block.row.version_id))) {
214
- ctx.warn(code, `${date}: ${peak.total} ${what} out, but the organization has ${have} (${names})`, what, {
234
+ ctx.warn(code, message, what, {
215
235
  version_id: version,
216
236
  date: date,
217
237
  });
@@ -258,7 +278,7 @@ export class Staffing {
258
278
  const total = result.get(key) ?? { hours: 0, dates: 0 };
259
279
  result.set(key, { hours: total.hours + hours, dates: total.dates + 1 });
260
280
  for (const version of new Set(list.map((b) => b.row.version_id))) {
261
- this.ctx.warn("position_double_booked", `${names.get(key) ?? key} coaches ${clash[0].row.name} and ${clash[1].row.name} at the same time on ${date}` +
281
+ this.ctx.warn("position_double_booked", `${names.get(key) ?? key} coaches ${clash[0].row.name} and ${clash[1].row.name} at the same time` +
262
282
  (hours > 0 ? `, so another coach is needed for ${round2(hours)} hours` : ""), key, { version_id: version, date });
263
283
  }
264
284
  }
@@ -72,6 +72,25 @@ export interface Schema {
72
72
  sync_tasks: SyncTasks[];
73
73
  version_set_members: VersionSetMembers[];
74
74
  version_sets: VersionSets[];
75
+ website_categories: WebsiteCategories[];
76
+ website_categories_faq_topics: WebsiteCategoriesFaqTopics[];
77
+ website_categories_files: WebsiteCategoriesFiles[];
78
+ website_categories_profiles: WebsiteCategoriesProfiles[];
79
+ website_categories_programs: WebsiteCategoriesPrograms[];
80
+ website_faq_topics: WebsiteFaqTopics[];
81
+ website_faqs: WebsiteFaqs[];
82
+ website_page_sections: WebsitePageSections[];
83
+ website_pages: WebsitePages[];
84
+ website_pages_faq_topics: WebsitePagesFaqTopics[];
85
+ website_policies: WebsitePolicies[];
86
+ website_policy_versions: WebsitePolicyVersions[];
87
+ website_profiles: WebsiteProfiles[];
88
+ website_programs: WebsitePrograms[];
89
+ website_programs_catalog_programs: WebsiteProgramsCatalogPrograms[];
90
+ website_programs_files: WebsiteProgramsFiles[];
91
+ website_settings: WebsiteSettings;
92
+ website_sponsor_levels: WebsiteSponsorLevels[];
93
+ website_sponsors: WebsiteSponsors[];
75
94
  }
76
95
  /** The chart of accounts that plans, results, and actuals book to. */
77
96
  export interface Accounts {
@@ -1107,3 +1126,224 @@ export interface VersionSets {
1107
1126
  /** The organization's P&L. A draft's is worked out again a moment after each change to it. A saved one's is what it showed when it was saved, worked out a moment after. One with none means working it out failed, and the logs say why. An admin can fill in a saved one's. */
1108
1127
  results: (string | PlanResults)[];
1109
1128
  }
1129
+ /** Program categories, such as Summer Sailing Camps, each with a page at /program-category/ and its slug. */
1130
+ export interface WebsiteCategories {
1131
+ id: string;
1132
+ /** The category's address: /program-category/clinics for clinics. */
1133
+ slug: string;
1134
+ title: string;
1135
+ summary: string | null;
1136
+ /** Open sends a program's button to Clubspot. Coming soon sends it to the newsletter. */
1137
+ status: "open" | "closed" | "coming_soon";
1138
+ sort: number | null;
1139
+ image: string | null;
1140
+ banner: string | null;
1141
+ /** Markdown. */
1142
+ top: string | null;
1143
+ /** Markdown. */
1144
+ bottom: string | null;
1145
+ /** A quote from a family. Leave it empty for none. */
1146
+ testimonial: string | null;
1147
+ /** In the order the page shows them. */
1148
+ programs: (string | WebsiteCategoriesPrograms)[];
1149
+ faq_topics: (string | WebsiteCategoriesFaqTopics)[];
1150
+ profiles: (string | WebsiteCategoriesProfiles)[];
1151
+ /** Photos shown on the category's page, in order. */
1152
+ gallery: (string | WebsiteCategoriesFiles)[];
1153
+ }
1154
+ export interface WebsiteCategoriesFaqTopics {
1155
+ id: string;
1156
+ category_id: string | WebsiteCategories | null;
1157
+ topic_id: string | WebsiteFaqTopics | null;
1158
+ sort: number | null;
1159
+ }
1160
+ export interface WebsiteCategoriesFiles {
1161
+ id: string;
1162
+ category_id: string | WebsiteCategories | null;
1163
+ file_id: string | null;
1164
+ sort: number | null;
1165
+ }
1166
+ export interface WebsiteCategoriesProfiles {
1167
+ id: string;
1168
+ category_id: string | WebsiteCategories | null;
1169
+ profile_id: string | WebsiteProfiles | null;
1170
+ sort: number | null;
1171
+ }
1172
+ export interface WebsiteCategoriesPrograms {
1173
+ id: string;
1174
+ category_id: string | WebsiteCategories | null;
1175
+ program_id: string | WebsitePrograms | null;
1176
+ sort: number | null;
1177
+ }
1178
+ /** Sets of questions and answers, which pages and program categories show. */
1179
+ export interface WebsiteFaqTopics {
1180
+ id: string;
1181
+ /** Such as Summer Camp Other Questions. */
1182
+ title: string;
1183
+ /** The heading a page shows above it, such as Registration Questions. */
1184
+ heading: string | null;
1185
+ /** In the order the site shows them. */
1186
+ questions: (string | WebsiteFaqs)[];
1187
+ }
1188
+ /** One question and its answer. */
1189
+ export interface WebsiteFaqs {
1190
+ id: string;
1191
+ topic_id: string | WebsiteFaqTopics;
1192
+ sort: number | null;
1193
+ question: string;
1194
+ /** Markdown. */
1195
+ answer: string | null;
1196
+ }
1197
+ export interface WebsitePageSections {
1198
+ id: string;
1199
+ page_id: string | WebsitePages;
1200
+ sort: number | null;
1201
+ title: string;
1202
+ image: string | null;
1203
+ /** Markdown. */
1204
+ body: string | null;
1205
+ action_label: string | null;
1206
+ /** A path on the site, such as /donate, or a full web address. */
1207
+ action_url: string | null;
1208
+ }
1209
+ /** The site's fixed pages, such as Donate and Contact Us. A page's slug is its address. */
1210
+ export interface WebsitePages {
1211
+ id: string;
1212
+ /** The page's address: /donate for donate. The home page is home. */
1213
+ slug: string;
1214
+ title: string;
1215
+ /** One or two sentences, for search engines and link previews. */
1216
+ description: string | null;
1217
+ banner: string | null;
1218
+ image: string | null;
1219
+ /** Markdown. */
1220
+ body: string | null;
1221
+ action_label: string | null;
1222
+ /** A path on the site, such as /donate, or a full web address. */
1223
+ action_url: string | null;
1224
+ /** Blocks of text below the body, each with its own picture and button. */
1225
+ sections: (string | WebsitePageSections)[];
1226
+ faq_topics: (string | WebsitePagesFaqTopics)[];
1227
+ }
1228
+ export interface WebsitePagesFaqTopics {
1229
+ id: string;
1230
+ page_id: string | WebsitePages | null;
1231
+ topic_id: string | WebsiteFaqTopics | null;
1232
+ sort: number | null;
1233
+ }
1234
+ /** Policies, each at /policy/ and its slug. The site shows the latest version that has taken effect. */
1235
+ export interface WebsitePolicies {
1236
+ id: string;
1237
+ /** The policy's address: /policy/sick-policy for sick-policy. */
1238
+ slug: string;
1239
+ title: string;
1240
+ sort: number | null;
1241
+ in_footer: boolean;
1242
+ versions: (string | WebsitePolicyVersions)[];
1243
+ }
1244
+ export interface WebsitePolicyVersions {
1245
+ id: string;
1246
+ policy_id: string | WebsitePolicies;
1247
+ title: string;
1248
+ effective: string;
1249
+ /** Markdown. */
1250
+ body: string | null;
1251
+ }
1252
+ /** The crew the site shows. Not linked to People, so a public bio can never show private details. */
1253
+ export interface WebsiteProfiles {
1254
+ id: string;
1255
+ name: string;
1256
+ /** Such as Sailing Director. */
1257
+ title: string | null;
1258
+ sort: number | null;
1259
+ /** Markdown. */
1260
+ bio: string | null;
1261
+ photo: string | null;
1262
+ /** The lists on Our Crew this person is in. */
1263
+ groups: ("staff" | "officers" | "coach" | "instructor")[] | null;
1264
+ }
1265
+ /** A program's page, at /program/ and its slug, and its card on each category's page. */
1266
+ export interface WebsitePrograms {
1267
+ id: string;
1268
+ /** The program's address: /program/opti-race-team for opti-race-team. */
1269
+ slug: string;
1270
+ title: string;
1271
+ /** A hidden program has no page and no card. */
1272
+ status: "published" | "hidden";
1273
+ sort: number | null;
1274
+ /** Such as the ages, under the title. */
1275
+ subtitle: string | null;
1276
+ /** The text on the program's card. */
1277
+ summary: string | null;
1278
+ card_image: string | null;
1279
+ card_button_label: string | null;
1280
+ /** Where the card's button goes: the program's page, or Clubspot. */
1281
+ card_link: string | null;
1282
+ banner: string | null;
1283
+ /** Markdown. */
1284
+ body: string | null;
1285
+ registration_label: string | null;
1286
+ /** The program's Clubspot sign-up page. */
1287
+ registration_url: string | null;
1288
+ /** Photos shown on the program's page, in order. */
1289
+ gallery: (string | WebsiteProgramsFiles)[];
1290
+ /** The programs in the catalog this page is about, for their dates and prices later. Optional. */
1291
+ catalog_programs: (string | WebsiteProgramsCatalogPrograms)[];
1292
+ }
1293
+ export interface WebsiteProgramsCatalogPrograms {
1294
+ id: string;
1295
+ website_program_id: string | WebsitePrograms | null;
1296
+ catalog_program_id: string | Programs | null;
1297
+ sort: number | null;
1298
+ }
1299
+ export interface WebsiteProgramsFiles {
1300
+ id: string;
1301
+ program_id: string | WebsitePrograms | null;
1302
+ file_id: string | null;
1303
+ sort: number | null;
1304
+ }
1305
+ /** What every page of the website shows: the menu, the footer, and the contact details. */
1306
+ export interface WebsiteSettings {
1307
+ id: string;
1308
+ /** The center's name, as the site shows it. */
1309
+ name: string;
1310
+ /** One or two sentences about the center, for search engines and link previews. */
1311
+ description: string | null;
1312
+ email: string | null;
1313
+ phone: string | null;
1314
+ /** One line per line of the address. */
1315
+ address: string | null;
1316
+ /** A Google Maps link to the center. */
1317
+ map_url: string | null;
1318
+ /** The center's federal tax ID, which donors need. */
1319
+ ein: string | null;
1320
+ /** Shown in the footer, in this order. */
1321
+ social: unknown;
1322
+ /** Markdown. A banner across the top of every page. Leave it empty for none. */
1323
+ announcement: string | null;
1324
+ logo: string | null;
1325
+ /** The round logo. */
1326
+ badge: string | null;
1327
+ /** The top menu: each heading and the links under it, in order. */
1328
+ menu: unknown;
1329
+ }
1330
+ /** Sponsorship levels, on the Sponsorships page. */
1331
+ export interface WebsiteSponsorLevels {
1332
+ id: string;
1333
+ name: string;
1334
+ /** In cents: 12500 is $125.00. */
1335
+ amount_cents: number;
1336
+ sort: number | null;
1337
+ /** Markdown. */
1338
+ benefits: string | null;
1339
+ sponsors: (string | WebsiteSponsors)[];
1340
+ }
1341
+ /** Sponsors, whose logos the Sponsorships page shows. */
1342
+ export interface WebsiteSponsors {
1343
+ id: string;
1344
+ name: string;
1345
+ url: string | null;
1346
+ logo: string | null;
1347
+ level_id: string | WebsiteSponsorLevels | null;
1348
+ sort: number | null;
1349
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyc-seattle/platform",
3
- "version": "1.37.1",
3
+ "version": "1.38.1",
4
4
  "description": "Pulumi components for CYC's shared Google Cloud platform: WebService, the platform outputs reader, and the stacks that build the platform",
5
5
  "author": "CYC Community Sailing Center",
6
6
  "license": "Apache-2.0",