@devdogsuga/backstage 0.1.5 → 0.1.6

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,225 @@
1
+ import { n as isDryRun } from "./dry-run-3IxPmCtW.js";
2
+ import { r as errorMessage, t as UsageError } from "./ui-CdKo8mLw.js";
3
+ import { i as exportParams, n as EXPORT_KINDS, r as auditFilters, t as EXPORTS } from "./queries-DeEwfe2c.js";
4
+ import { t as DONE } from "./dispatch-D048O65I.js";
5
+ import { a as reportFailure, c as connect, l as dbError, o as say, r as plural, s as ProductionError, u as requireProductionKey } from "./cli-Do2nWD7W.js";
6
+ import { i as parseBound, n as findMeetingsWith, r as pickMeeting, t as describeMeeting } from "./meetings-DfU867eV.js";
7
+ import { open, rm } from "node:fs/promises";
8
+ import { execFile } from "node:child_process";
9
+ import { parseArgs, promisify } from "node:util";
10
+ /**
11
+ * Quotes a field only when it has to be: a comma, a quote, a carriage return
12
+ * or a line feed. Quoting only what needs it keeps the file readable in a
13
+ * terminal.
14
+ *
15
+ * A leading `=`, `+`, `-`, `@`, tab or carriage return gets a tab prefix:
16
+ * spreadsheets start a formula on the first four, and a member can name their
17
+ * team `=cmd|'/c calc'!A1`. The tab is invisible in a cell and neutralises it.
18
+ */
19
+ function csvField(value) {
20
+ if (value === null || value === void 0) return "";
21
+ let text;
22
+ if (typeof value === "string") text = value;
23
+ else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") text = String(value);
24
+ else if (value instanceof Date) text = csvTimestamp(value);
25
+ else text = JSON.stringify(value) ?? "";
26
+ const guarded = /^[=+\-@\t\r]/.test(text) ? `\t${text}` : text;
27
+ if (!/[",\r\n]/.test(guarded)) return guarded;
28
+ return `"${guarded.replace(/"/g, "\"\"")}"`;
29
+ }
30
+ function csvRow(values) {
31
+ return values.map(csvField).join(",") + "\r\n";
32
+ }
33
+ /**
34
+ * ISO 8601 with an explicit offset, never a bare local time: a timestamp
35
+ * without one is read in whatever zone the importer runs in.
36
+ */
37
+ function csvTimestamp(value) {
38
+ if (value === null) return "";
39
+ const date = value instanceof Date ? value : new Date(value);
40
+ return Number.isNaN(date.getTime()) ? "" : date.toISOString();
41
+ }
42
+ //#endregion
43
+ //#region src/export/commands.ts
44
+ /**
45
+ * `backstage export <stars|attendance|reflections>`: the platform's CSV
46
+ * downloads, moved here from `/console/exports`.
47
+ *
48
+ * pnpm backstage export stars --from 2026-08-17 --to 2026-12-12
49
+ * pnpm backstage export attendance --meeting 2026-09-09 --out -
50
+ *
51
+ * ## Audited, as the downloads were
52
+ *
53
+ * Each export writes a `platform.exportAudit` row BEFORE the first row is
54
+ * read, as the web routes did: an export that fails halfway still put rows
55
+ * in front of somebody. The row names the officer's platform account, found
56
+ * through the GitHub login `gh` is signed in as (the identity they linked on
57
+ * the platform), so an export nobody can attribute is refused rather than
58
+ * recorded as nobody's. The row count is filled in when the file is done.
59
+ *
60
+ * ## The file
61
+ *
62
+ * Written to `./<kind>[-<meeting>][-<from>][-<to>].csv` by default, created
63
+ * readable by its owner only (it is member PII), never over an existing file
64
+ * without `--yes`. `--out -` streams to stdout without the byte-order mark,
65
+ * for a pipe. A failure partway removes the partial file.
66
+ *
67
+ * `--from`/`--to` take Eastern days, `--to` inclusive (see `production/time.ts`).
68
+ */
69
+ function parse(argv) {
70
+ const [kind, ...rest] = argv;
71
+ if (!kind || !EXPORT_KINDS.includes(kind)) throw new UsageError(kind ? `Unknown export "${kind}". Try ${EXPORT_KINDS.join(", ")}.` : `Name the export: ${EXPORT_KINDS.join(", ")}.`);
72
+ try {
73
+ const { values } = parseArgs({
74
+ args: rest,
75
+ options: {
76
+ from: { type: "string" },
77
+ to: { type: "string" },
78
+ meeting: { type: "string" },
79
+ out: { type: "string" },
80
+ yes: { type: "boolean" },
81
+ "dry-run": { type: "boolean" }
82
+ },
83
+ allowPositionals: false,
84
+ strict: true
85
+ });
86
+ if (values.meeting && kind !== "attendance") throw new UsageError("--meeting only applies to the attendance export.");
87
+ return {
88
+ kind,
89
+ values
90
+ };
91
+ } catch (err) {
92
+ if (err instanceof UsageError) throw err;
93
+ throw new UsageError(errorMessage(err));
94
+ }
95
+ }
96
+ const run = promisify(execFile);
97
+ /** The GitHub login `gh` is signed in as. */
98
+ async function ghLogin() {
99
+ try {
100
+ const { stdout } = await run("gh", [
101
+ "api",
102
+ "user",
103
+ "--jq",
104
+ ".login"
105
+ ]);
106
+ const login = stdout.trim();
107
+ if (login) return login;
108
+ } catch {}
109
+ throw new ProductionError("Exports are audited under your GitHub login, and `gh` could not say who you are. Install it from https://cli.github.com and run `gh auth login`.");
110
+ }
111
+ const OFFICER_QUERY = `
112
+ select distinct i."user_id"::text as "userId"
113
+ from "auth"."identities" i
114
+ where i."provider" = 'github'
115
+ and lower(i."identity_data" ->> 'user_name') = lower($1)
116
+ `;
117
+ function sourceWith(sql) {
118
+ return {
119
+ officersFor: async (login) => (await sql.unsafe(OFFICER_QUERY, [login])).map((r) => String(r.userId)),
120
+ findMeetings: findMeetingsWith(sql),
121
+ audit: async (userId, kind, filters) => {
122
+ const [row] = await sql.unsafe(`insert into "platform"."exportAudit" ("userId", "kind", "filters")
123
+ values ($1, $2, $3::text::jsonb) returning "id"::text as "id"`, [
124
+ userId,
125
+ kind,
126
+ JSON.stringify(filters)
127
+ ]);
128
+ return String(row.id);
129
+ },
130
+ rows: (kind, filters) => sql.unsafe(EXPORTS[kind].sql, exportParams(filters)).cursor(500),
131
+ finish: async (auditId, rowCount) => {
132
+ await sql.unsafe(`update "platform"."exportAudit" set "rowCount" = $1 where "id" = $2::uuid`, [rowCount, auditId]);
133
+ },
134
+ close: () => sql.end({ timeout: 5 })
135
+ };
136
+ }
137
+ async function productionSource() {
138
+ return sourceWith(connect(await requireProductionKey("DB_URL")));
139
+ }
140
+ async function fileSink(path, overwrite) {
141
+ if (path === "-") return {
142
+ write: (text) => new Promise((resolve, reject) => process.stdout.write(text, (err) => err ? reject(err) : resolve())),
143
+ close: () => Promise.resolve(),
144
+ discard: () => Promise.resolve()
145
+ };
146
+ let handle;
147
+ try {
148
+ handle = await open(path, overwrite ? "w" : "wx", 384);
149
+ } catch (err) {
150
+ if (err.code === "EEXIST") throw new UsageError(`${path} already exists. Pass --yes to replace it.`);
151
+ throw new UsageError(`Could not write ${path}.`);
152
+ }
153
+ await handle.write("");
154
+ return {
155
+ write: async (text) => {
156
+ await handle.write(text);
157
+ },
158
+ close: () => handle.close(),
159
+ discard: async () => {
160
+ await handle.close().catch(() => void 0);
161
+ await rm(path, { force: true });
162
+ }
163
+ };
164
+ }
165
+ function defaultPath(kind, values, meetingSlug) {
166
+ const parts = [kind];
167
+ if (meetingSlug) parts.push(meetingSlug);
168
+ if (values.from) parts.push(values.from.slice(0, 10));
169
+ if (values.to) parts.push(values.to.slice(0, 10));
170
+ return `${parts.join("-")}.csv`;
171
+ }
172
+ async function runExport(argv, deps = {}) {
173
+ let source;
174
+ let sink;
175
+ let done = false;
176
+ try {
177
+ const { kind, values } = parse(argv);
178
+ const filters = {
179
+ from: parseBound("--from", values.from),
180
+ to: parseBound("--to", values.to)
181
+ };
182
+ if (filters.from && filters.to && filters.from >= filters.to) throw new UsageError("--from must come before --to.");
183
+ const login = await (deps.ghLogin ?? ghLogin)();
184
+ source = await (deps.source ?? productionSource)();
185
+ const officers = await source.officersFor(login);
186
+ if (officers.length !== 1) throw new ProductionError(officers.length === 0 ? `No platform account has linked the GitHub login ${login}. Link GitHub on your platform profile, then export again.` : `${plural(officers.length, "platform account")} linked the GitHub login ${login}; an export must be attributable to one.`);
187
+ let slug;
188
+ if (values.meeting) {
189
+ const meeting = pickMeeting(values.meeting, await source.findMeetings(values.meeting));
190
+ filters.meetingId = meeting.id;
191
+ slug = meeting.slug;
192
+ say(`Meeting: ${describeMeeting(meeting)}.`);
193
+ }
194
+ const path = values.out ?? defaultPath(kind, values, slug);
195
+ if (values["dry-run"] === true || isDryRun()) {
196
+ say(`Would export ${kind} to ${path === "-" ? "stdout" : path}, audited as ${login}.`);
197
+ return;
198
+ }
199
+ sink = await (deps.sink ?? fileSink)(path, values.yes === true);
200
+ const auditId = await source.audit(officers[0], kind, auditFilters(filters));
201
+ const { columns } = EXPORTS[kind];
202
+ await sink.write(csvRow(columns));
203
+ let count = 0;
204
+ for await (const page of source.rows(kind, filters)) {
205
+ await sink.write(page.map((row) => csvRow(columns.map((c) => row[c]))).join(""));
206
+ count += page.length;
207
+ }
208
+ await sink.close();
209
+ done = true;
210
+ await source.finish(auditId, count);
211
+ say(`Exported ${plural(count, "row")} to ${path === "-" ? "stdout" : path}.`, "success");
212
+ } catch (err) {
213
+ if (sink && !done) await sink.discard().catch(() => void 0);
214
+ const worded = err instanceof UsageError || err instanceof ProductionError ? err : err.code ? dbError("The export failed", err) : err;
215
+ reportFailure("export", worded);
216
+ } finally {
217
+ await source?.close().catch(() => void 0);
218
+ }
219
+ }
220
+ const handleExport = async (rest) => {
221
+ await runExport(rest);
222
+ return process.exitCode ? null : DONE;
223
+ };
224
+ //#endregion
225
+ export { handleExport };
@@ -0,0 +1,436 @@
1
+ import { n as isDryRun } from "./dry-run-3IxPmCtW.js";
2
+ import { o as unwrap, r as errorMessage, t as UsageError } from "./ui-CdKo8mLw.js";
3
+ import { t as DONE } from "./dispatch-D048O65I.js";
4
+ import { a as reportFailure, c as connect, d as withConnection, i as readInputFile, n as confirmWrite, o as say, r as plural, s as ProductionError, t as canPrompt, u as requireProductionKey } from "./cli-Do2nWD7W.js";
5
+ import { a as createAccounts, i as createAccountFor, n as accountFinder, r as accountsWith, t as parseCsv } from "./read-B-aJJewb.js";
6
+ import { n as findMeetingsWith, r as pickMeeting, t as describeMeeting } from "./meetings-DfU867eV.js";
7
+ import { text } from "@clack/prompts";
8
+ import { parseArgs } from "node:util";
9
+ //#region src/attendance/plan.ts
10
+ /**
11
+ * What an attendance import would do to one meeting, before anything is
12
+ * written.
13
+ *
14
+ * A member's own check-in (`qr`, `manual_code`) is never touched: the sheet
15
+ * can only add the people who could not check in. Imported rows carry method
16
+ * `import`, so `--replace` can find exactly the rows an earlier import wrote
17
+ * and remove the ones the corrected sheet no longer lists.
18
+ */
19
+ const IMPORT_METHOD = "import";
20
+ function planAttendance(attendees, accounts, existing, replace) {
21
+ const find = accountFinder(accounts);
22
+ const methodOf = new Map(existing.map((e) => [e.userId, e.method]));
23
+ const plan = {
24
+ record: [],
25
+ create: [],
26
+ cannotCreate: [],
27
+ checkedIn: [],
28
+ imported: [],
29
+ remove: []
30
+ };
31
+ const onSheet = /* @__PURE__ */ new Set();
32
+ for (const attendee of attendees) {
33
+ const account = find(attendee.email);
34
+ if (!account) {
35
+ (attendee.name ? plan.create : plan.cannotCreate).push(attendee);
36
+ continue;
37
+ }
38
+ if (onSheet.has(account.userId)) continue;
39
+ onSheet.add(account.userId);
40
+ const match = {
41
+ attendee,
42
+ userId: account.userId
43
+ };
44
+ const method = methodOf.get(account.userId);
45
+ if (method === void 0) plan.record.push(match);
46
+ else if (method === "import") plan.imported.push(match);
47
+ else plan.checkedIn.push({
48
+ ...match,
49
+ method
50
+ });
51
+ }
52
+ if (replace) {
53
+ const byId = new Map(accounts.map((a) => [a.userId, a]));
54
+ for (const e of existing) {
55
+ if (e.method !== "import" || onSheet.has(e.userId)) continue;
56
+ const account = byId.get(e.userId);
57
+ plan.remove.push({
58
+ userId: e.userId,
59
+ name: account?.preferredName?.trim() ?? account?.ugaEmail ?? account?.authEmail ?? e.userId
60
+ });
61
+ }
62
+ plan.remove.sort((a, b) => a.name.localeCompare(b.name));
63
+ }
64
+ return plan;
65
+ }
66
+ //#endregion
67
+ //#region src/attendance/sheet.ts
68
+ /**
69
+ * Reading a sign-in sheet: a Google or Microsoft Forms response export, or a
70
+ * spreadsheet an officer typed up. There is no fixed layout, so the columns
71
+ * are found rather than assumed:
72
+ *
73
+ * - the header is the first row (of the first ten) that names a column with
74
+ * "email" or "name" and holds no addresses itself;
75
+ * - the email column is `--email-column`, else the header mentioning "email"
76
+ * whose values are most often UGA addresses (a form can have both the
77
+ * respondent's account email and a "UGA email" question), else whichever
78
+ * column holds the most addresses;
79
+ * - the name is `--name-column`, else "First Name" + "Last Name", else the
80
+ * first header mentioning "name" that is not an email or username column.
81
+ * It is optional: it only matters when an account has to be created.
82
+ *
83
+ * A sheet with no header at all (a pasted list of addresses) is read as every
84
+ * address in every cell.
85
+ *
86
+ * Only UGA (MyID) addresses are imported: that is the address an account is
87
+ * created for and the one members sign in with. Anything else is reported.
88
+ */
89
+ const ADDRESS = /[^\s<>,;:"'()[\]]+@[^\s<>,;:"'()[\]]+\.[a-z]{2,}/i;
90
+ const UGA = /@uga\.edu$/i;
91
+ function addressIn(cell) {
92
+ const match = ADDRESS.exec(cell ?? "");
93
+ return match ? match[0].toLowerCase() : null;
94
+ }
95
+ const norm = (h) => h.trim().toLowerCase();
96
+ function findHeader(rows, options) {
97
+ const wanted = [options.emailColumn, options.nameColumn].filter((c) => !!c).map(norm);
98
+ for (let i = 0; i < Math.min(rows.length, 10); i += 1) {
99
+ const cells = rows[i].map(norm);
100
+ if (wanted.length > 0) {
101
+ if (wanted.every((w) => cells.includes(w))) return i;
102
+ continue;
103
+ }
104
+ if (cells.some((c) => c.includes("@"))) continue;
105
+ if (cells.some((c) => /e-?mail|\bname\b/.test(c))) return i;
106
+ }
107
+ if (wanted.length > 0) throw new UsageError(`No header row names ${wanted.map((w) => `"${w}"`).join(" and ")}.`);
108
+ return -1;
109
+ }
110
+ function columnNamed(header, name) {
111
+ const i = header.map(norm).indexOf(norm(name));
112
+ if (i < 0) throw new UsageError(`No column called "${name}". The columns are: ${header.map((h) => `"${h.trim()}"`).join(", ")}.`);
113
+ return i;
114
+ }
115
+ function emailColumn(header, body, options) {
116
+ if (options.emailColumn) return columnNamed(header, options.emailColumn);
117
+ const count = (i, test) => body.filter((r) => test.test(addressIn(r[i]) ?? "")).length;
118
+ const named = header.map((h, i) => ({
119
+ i,
120
+ h: norm(h)
121
+ })).filter(({ h }) => /e-?mail/.test(h)).map(({ i }) => i);
122
+ const best = (named.length > 0 ? named : header.map((_, i) => i)).map((i) => ({
123
+ i,
124
+ uga: count(i, UGA),
125
+ any: count(i, /@/)
126
+ })).sort((a, b) => b.uga - a.uga || b.any - a.any || a.i - b.i)[0];
127
+ if (!best || best.any === 0) throw new UsageError("Could not find a column of email addresses. Name it with --email-column.");
128
+ return best.i;
129
+ }
130
+ /** The name for a row, or how to build it. */
131
+ function nameReader(header, options) {
132
+ const at = (i) => (row) => row[i]?.trim() ? row[i].trim() : null;
133
+ if (options.nameColumn) {
134
+ const i = columnNamed(header, options.nameColumn);
135
+ return {
136
+ label: header[i].trim(),
137
+ read: at(i)
138
+ };
139
+ }
140
+ const cells = header.map(norm);
141
+ const first = cells.findIndex((c) => c.includes("first") && c.includes("name"));
142
+ const last = cells.findIndex((c) => c.includes("last") && c.includes("name"));
143
+ if (first >= 0 && last >= 0) return {
144
+ label: `${header[first].trim()} + ${header[last].trim()}`,
145
+ read: (row) => {
146
+ return [row[first], row[last]].map((v) => v?.trim()).filter(Boolean).join(" ") || null;
147
+ }
148
+ };
149
+ const i = cells.findIndex((c) => /\bname\b/.test(c) && !/e-?mail|user/.test(c));
150
+ return i >= 0 ? {
151
+ label: header[i].trim(),
152
+ read: at(i)
153
+ } : {
154
+ label: null,
155
+ read: () => null
156
+ };
157
+ }
158
+ function parseSheet(text, options = {}) {
159
+ const rows = parseCsv(text);
160
+ const headerAt = findHeader(rows, options);
161
+ const out = {
162
+ attendees: [],
163
+ emailColumn: null,
164
+ nameColumn: null,
165
+ notUga: [],
166
+ missingEmail: [],
167
+ duplicates: 0
168
+ };
169
+ const seen = /* @__PURE__ */ new Set();
170
+ const add = (email, name, line) => {
171
+ if (!UGA.test(email)) out.notUga.push({
172
+ email,
173
+ line
174
+ });
175
+ else if (seen.has(email)) out.duplicates += 1;
176
+ else {
177
+ seen.add(email);
178
+ out.attendees.push({
179
+ email,
180
+ name,
181
+ line
182
+ });
183
+ }
184
+ };
185
+ if (headerAt < 0) {
186
+ rows.forEach((row, i) => {
187
+ for (const cell of row) {
188
+ const email = addressIn(cell);
189
+ if (email) add(email, null, i + 1);
190
+ }
191
+ });
192
+ return out;
193
+ }
194
+ const header = rows[headerAt];
195
+ const body = rows.slice(headerAt + 1);
196
+ const emailAt = emailColumn(header, body, options);
197
+ const name = nameReader(header, options);
198
+ out.emailColumn = header[emailAt].trim() || `column ${emailAt + 1}`;
199
+ out.nameColumn = name.label;
200
+ body.forEach((row, i) => {
201
+ const line = headerAt + i + 2;
202
+ if (row.every((c) => !c.trim())) return;
203
+ const email = addressIn(row[emailAt]);
204
+ if (!email) {
205
+ out.missingEmail.push(line);
206
+ return;
207
+ }
208
+ add(email, name.read(row), line);
209
+ });
210
+ return out;
211
+ }
212
+ //#endregion
213
+ //#region src/attendance/store.ts
214
+ /**
215
+ * Production reads and writes for `import attendance`.
216
+ *
217
+ * Accounts are created before {@link applyAttendance} (Auth is a separate
218
+ * service). If the transaction then fails, they exist without profiles or
219
+ * check-ins; rerunning the import finds them by sign-in address and records
220
+ * them, so a rerun is the recovery.
221
+ */
222
+ async function readCheckIns(sql, meetingId) {
223
+ return (await sql.unsafe(`select "userId"::text as "userId", "method"::text as "method"
224
+ from "platform"."attendance" where "meetingId" = $1::uuid`, [meetingId])).map((r) => ({
225
+ userId: String(r.userId),
226
+ method: String(r.method)
227
+ }));
228
+ }
229
+ /**
230
+ * Whether production's `checkInMethod` has `import` yet. It arrives with a
231
+ * DevDogsUGA migration; an import run before that deploy would fail on the
232
+ * first insert with an enum error, so it is checked up front and worded.
233
+ */
234
+ async function requireImportMethod(sql) {
235
+ const [row] = await sql.unsafe(`select $1 = any(enum_range(null::"platform"."checkInMethod")::text[]) as "ok"`, [IMPORT_METHOD]);
236
+ if (row?.ok !== true) throw new ProductionError("Production cannot record imported check-ins yet: its checkInMethod has no 'import' value. Deploy the DevDogsUGA migration that adds it first.");
237
+ }
238
+ function applyAttendanceFor(url) {
239
+ return (w) => withConnection(url, "The import was rolled back", (sql) => sql.begin(async (tx) => {
240
+ if (w.profiles.length > 0) await tx.unsafe(`insert into "platform"."profile" ("userId", "preferredName", "ugaEmail")
241
+ select * from unnest($1::uuid[], $2::text[], $3::text[])
242
+ on conflict ("userId") do nothing`, [
243
+ w.profiles.map((p) => p.userId),
244
+ w.profiles.map((p) => p.name),
245
+ w.profiles.map((p) => p.email)
246
+ ]);
247
+ let removed = 0;
248
+ if (w.replace) removed = (await tx.unsafe(`delete from "platform"."attendance"
249
+ where "meetingId" = $1::uuid
250
+ and "method" = $2
251
+ and not ("userId" = any($3::uuid[]))`, [
252
+ w.meetingId,
253
+ IMPORT_METHOD,
254
+ w.userIds
255
+ ])).count;
256
+ return {
257
+ recorded: (await tx.unsafe(`insert into "platform"."attendance" ("meetingId", "userId", "method", "recordedAt")
258
+ select $1::uuid, u, $2::"platform"."checkInMethod", $3::timestamptz
259
+ from unnest($4::uuid[]) as u
260
+ on conflict ("meetingId", "userId") do nothing`, [
261
+ w.meetingId,
262
+ IMPORT_METHOD,
263
+ w.recordedAt.toISOString(),
264
+ w.userIds
265
+ ])).count,
266
+ removed
267
+ };
268
+ }));
269
+ }
270
+ //#endregion
271
+ //#region src/attendance/commands.ts
272
+ /**
273
+ * `backstage import attendance --meeting <day> --file <sheet.csv>`: record a
274
+ * meeting's attendance from a sign-in sheet, for the members who could not
275
+ * check in on the platform (a dead QR code, no phone, the code rotated past).
276
+ *
277
+ * The sheet is a Google or Microsoft Forms response export or a hand-made
278
+ * spreadsheet, saved as CSV; `sheet.ts` finds the email and name columns
279
+ * (or take `--email-column`/`--name-column`). One sheet is one meeting,
280
+ * named with `--meeting`.
281
+ *
282
+ * 1. read the sheet; only UGA addresses count;
283
+ * 2. find the meeting (not cancelled, not in the future) and its check-ins;
284
+ * 3. preview who is recorded, who needs an account, who checked in already,
285
+ * and with `--replace` whose earlier imported row goes; then ask;
286
+ * 4. create the missing accounts, then one transaction writes their profiles
287
+ * and the check-ins (method `import`, stamped with the meeting's start).
288
+ *
289
+ * Rerunning the same sheet records nothing new. `--replace` makes the sheet
290
+ * the whole set of imported check-ins for the meeting, for a corrected sheet;
291
+ * a member's own check-in is never removed or overwritten either way.
292
+ */
293
+ function parse(argv) {
294
+ try {
295
+ return parseArgs({
296
+ args: [...argv],
297
+ options: {
298
+ meeting: { type: "string" },
299
+ file: { type: "string" },
300
+ "email-column": { type: "string" },
301
+ "name-column": { type: "string" },
302
+ replace: { type: "boolean" },
303
+ yes: { type: "boolean" },
304
+ "dry-run": { type: "boolean" }
305
+ },
306
+ allowPositionals: false,
307
+ strict: true
308
+ }).values;
309
+ } catch (err) {
310
+ throw new UsageError(errorMessage(err));
311
+ }
312
+ }
313
+ async function productionSource() {
314
+ const sql = connect(await requireProductionKey("DB_URL"));
315
+ return {
316
+ findMeetings: findMeetingsWith(sql),
317
+ checkIns: (id) => readCheckIns(sql, id),
318
+ accounts: () => accountsWith(sql),
319
+ requireImportMethod: () => requireImportMethod(sql),
320
+ close: () => sql.end({ timeout: 5 })
321
+ };
322
+ }
323
+ const who = (a) => a.name ? `${a.name} <${a.email}>` : a.email;
324
+ function describePlan(meeting, sheet, plan, replace) {
325
+ const lines = [
326
+ `Meeting: ${describeMeeting(meeting)}${meeting.countsForCredit ? "" : " (does not count for credit)"}`,
327
+ sheet.emailColumn ? `Sheet: ${plural(sheet.attendees.length, "UGA address", "UGA addresses")} from "${sheet.emailColumn}"` + (sheet.nameColumn ? `, names from "${sheet.nameColumn}"` : ", no name column") : `Sheet: no header; ${plural(sheet.attendees.length, "UGA address", "UGA addresses")} found in its cells`,
328
+ ` ${plan.record.length} to record (have an account)`,
329
+ ` ${plan.create.length} to create an account for, then record`,
330
+ ` ${plan.checkedIn.length} checked in themselves (left as is)`,
331
+ ` ${plan.imported.length} imported before (left as is)`
332
+ ];
333
+ if (replace) lines.push(` ${plan.remove.length} imported before but not on this sheet, to remove`);
334
+ const section = (title, items) => {
335
+ if (items.length > 0) lines.push("", title, ...items.map((i) => ` ${i}`));
336
+ };
337
+ section("To record:", plan.record.map((m) => who(m.attendee)));
338
+ section("New accounts:", plan.create.map(who));
339
+ section("To remove:", plan.remove.map((r) => r.name));
340
+ section("Skipped, no account and no name on the sheet to create one with:", plan.cannotCreate.map((a) => `${a.email} (line ${a.line})`));
341
+ section("Skipped, not a UGA address:", sheet.notUga.map((n) => `${n.email} (line ${n.line})`));
342
+ if (sheet.missingEmail.length > 0) section("Skipped, no address:", [`lines ${sheet.missingEmail.join(", ")}`]);
343
+ return lines.join("\n");
344
+ }
345
+ async function runAttendance(argv, deps = {}) {
346
+ const interactive = deps.interactive ?? canPrompt();
347
+ let source;
348
+ try {
349
+ const values = parse(argv);
350
+ const dryRun = values["dry-run"] === true || isDryRun();
351
+ const replace = values.replace === true;
352
+ let named = values.meeting;
353
+ if (!named) {
354
+ if (!interactive) throw new UsageError("Name the meeting with --meeting <day|slug>, e.g. --meeting 2026-09-09.");
355
+ named = unwrap(await text({
356
+ message: "Which meeting? Its day or slug",
357
+ placeholder: "2026-09-09",
358
+ validate: (v) => v?.trim() ? void 0 : "A day or slug, please."
359
+ })).trim();
360
+ }
361
+ const sheet = parseSheet(await readInputFile(values.file, {
362
+ interactive,
363
+ message: "Path to the sign-in sheet (CSV)",
364
+ placeholder: "sign-in.csv",
365
+ read: deps.readFile
366
+ }), {
367
+ emailColumn: values["email-column"],
368
+ nameColumn: values["name-column"]
369
+ });
370
+ if (sheet.attendees.length === 0 && !replace) throw new UsageError("The sheet has no UGA addresses to record.");
371
+ if (sheet.duplicates > 0) say(`${plural(sheet.duplicates, "repeat row")} for an address already read, ignored.`, "warn");
372
+ source = await (deps.source ?? productionSource)();
373
+ const meeting = pickMeeting(named, await source.findMeetings(named));
374
+ if (meeting.cancelledAt) throw new UsageError(`${describeMeeting(meeting)} was cancelled.`);
375
+ const now = (deps.now ?? (() => /* @__PURE__ */ new Date()))();
376
+ if (meeting.startsAt > now) throw new UsageError(`${describeMeeting(meeting)} has not started yet.`);
377
+ const [checkIns, accounts] = await Promise.all([source.checkIns(meeting.id), source.accounts()]);
378
+ const plan = planAttendance(sheet.attendees, accounts, checkIns, replace);
379
+ process.stderr.write(`${describePlan(meeting, sheet, plan, replace)}\n`);
380
+ if (dryRun) {
381
+ say("Dry run: nothing written.");
382
+ return;
383
+ }
384
+ if (plan.record.length + plan.create.length + plan.remove.length === 0) {
385
+ say("Nothing to change.");
386
+ return;
387
+ }
388
+ await source.requireImportMethod();
389
+ const question = `Record ${plural(plan.record.length + plan.create.length, "check-in")} for ${meeting.slug}` + (plan.create.length > 0 ? `, creating ${plural(plan.create.length, "account")}` : "") + (replace ? `, removing ${plan.remove.length}` : "") + "?";
390
+ if (!await confirmWrite(question, {
391
+ yes: values.yes === true,
392
+ interactive
393
+ })) {
394
+ say("Nothing written.");
395
+ return;
396
+ }
397
+ let create = deps.createAccount;
398
+ if (!create && plan.create.length > 0) create = createAccountFor(await requireProductionKey("API_URL"), await requireProductionKey("SECRET_KEY"));
399
+ const { created, failed } = create ? await createAccounts(plan.create.map((a) => a.email), create) : {
400
+ created: /* @__PURE__ */ new Map(),
401
+ failed: []
402
+ };
403
+ if (failed.length > 0) say(`Could not create ${plural(failed.length, "account")}; rerun the import to record them:\n${failed.map((f) => ` ${f.email}: ${f.reason}`).join("\n")}`, "warn");
404
+ const profiles = plan.create.flatMap((a) => {
405
+ const userId = created.get(a.email);
406
+ return userId ? [{
407
+ userId,
408
+ email: a.email,
409
+ name: a.name
410
+ }] : [];
411
+ });
412
+ const { recorded, removed } = await (deps.apply ?? applyAttendanceFor(await requireProductionKey("DB_URL")))({
413
+ meetingId: meeting.id,
414
+ recordedAt: meeting.startsAt,
415
+ profiles,
416
+ userIds: [
417
+ ...plan.record,
418
+ ...plan.checkedIn,
419
+ ...plan.imported
420
+ ].map((m) => m.userId).concat(profiles.map((p) => p.userId)),
421
+ replace
422
+ });
423
+ say(`Recorded ${plural(recorded, "check-in")} for ${meeting.slug}` + (profiles.length > 0 ? ` (${plural(profiles.length, "new account")})` : "") + (replace ? `, removed ${removed}` : "") + ".", "success");
424
+ if (failed.length > 0) process.exitCode = 1;
425
+ } catch (err) {
426
+ reportFailure("import attendance", err, [ProductionError]);
427
+ } finally {
428
+ await source?.close().catch(() => void 0);
429
+ }
430
+ }
431
+ const handleAttendance = async (rest) => {
432
+ await runAttendance(rest);
433
+ return process.exitCode ? null : DONE;
434
+ };
435
+ //#endregion
436
+ export { handleAttendance };