From 9e828f4db1bf4abec5dfee0cb81b25628629d249 Mon Sep 17 00:00:00 2001 From: Flaryiest Date: Fri, 17 Jul 2026 13:35:56 -0600 Subject: [PATCH 1/5] Remove unused checkMemberRoles import to fix lint baseline --- src/interaction-handlers/VerifyHandler.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/interaction-handlers/VerifyHandler.ts b/src/interaction-handlers/VerifyHandler.ts index 90eebcb2..f345d7b7 100644 --- a/src/interaction-handlers/VerifyHandler.ts +++ b/src/interaction-handlers/VerifyHandler.ts @@ -4,7 +4,6 @@ import { OtherAttendeesDoc, VerificationDoc, } from "@/types/db/verification"; -import { checkMemberRoles } from "@/util/discord"; import { getGuildDocRef, getHackathonDocRef } from "@/util/nwplus-firestore"; import { ApplyOptions } from "@sapphire/decorators"; From 31ee1310a937503cd0891caf291af3bad3ac4ac4 Mon Sep 17 00:00:00 2001 From: Flaryiest Date: Fri, 17 Jul 2026 13:35:56 -0600 Subject: [PATCH 2/5] Remove CSV schedule ingestion now that Notion owns the schedule --- Dockerfile | 5 +- package-lock.json | 7 -- package.json | 1 - src/commands/UploadSchedule.ts | 170 ------------------------- src/util/schedule-csv.ts | 221 --------------------------------- 5 files changed, 1 insertion(+), 403 deletions(-) delete mode 100644 src/commands/UploadSchedule.ts delete mode 100644 src/util/schedule-csv.ts diff --git a/Dockerfile b/Dockerfile index 89805507..3e09d09a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,4 @@ ENV NAME=factotum ENV NODE_ENV=production -# Shift schedule CSV times are parsed as server-local time -ENV TZ=America/Vancouver - -CMD ["npm", "start"] \ No newline at end of file +CMD ["npm", "start"] \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 576f9802..ed68944b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "@sapphire/discord.js-utilities": "^7.3.3", "@sapphire/framework": "^5.3.6", "@sapphire/utilities": "^3.18.2", - "csv-parse": "^7.0.0", "discord.js": "^14.20.0", "dotenv": "^16.5.0", "firebase-admin": "^13.4.0" @@ -2330,12 +2329,6 @@ "node": ">= 8" } }, - "node_modules/csv-parse": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.0.tgz", - "integrity": "sha512-CSssqPAK5us09FhMI9juM0jnqXUJP+rtWeIfivTYBLNH/8rnxkQlZvoRemF6MAyfNov9XU8mN2wwF/pP68sxTA==", - "license": "MIT" - }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", diff --git a/package.json b/package.json index 4a1ac903..ac2e433b 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "@sapphire/discord.js-utilities": "^7.3.3", "@sapphire/framework": "^5.3.6", "@sapphire/utilities": "^3.18.2", - "csv-parse": "^7.0.0", "discord.js": "^14.20.0", "dotenv": "^16.5.0", "firebase-admin": "^13.4.0" diff --git a/src/commands/UploadSchedule.ts b/src/commands/UploadSchedule.ts deleted file mode 100644 index 6606b4c4..00000000 --- a/src/commands/UploadSchedule.ts +++ /dev/null @@ -1,170 +0,0 @@ -import BaseCommand from "@/classes/BaseCommand"; -import { idHints } from "@/constants/id-hints"; -import { ShiftDoc } from "@/types/db/shift-schedule"; -import { getGuildDocRef, logToAdminLog } from "@/util/nwplus-firestore"; -import { ParsedShift, parseScheduleCsv, RowError } from "@/util/schedule-csv"; - -import { ApplyOptions } from "@sapphire/decorators"; -import { Command, CommandOptionsRunTypeEnum } from "@sapphire/framework"; -import { - AttachmentBuilder, - EmbedBuilder, - MessageFlags, - SlashCommandBuilder, -} from "discord.js"; -import { FieldValue, Timestamp } from "firebase-admin/firestore"; - -// Firestore caps a write batch at 500 operations. -const BATCH_LIMIT = 500; - -const MAX_FILE_SIZE_BYTES = 1024 * 1024; // 1 MB -const DOWNLOAD_TIMEOUT_MS = 10_000; - -const toShiftDoc = (shift: ParsedShift): ShiftDoc => ({ - startTime: Timestamp.fromDate(shift.startTime), - durationMinutes: shift.durationMinutes, - location: shift.location, - description: shift.description, - organizerEmails: shift.organizerEmails, - shiftLeadEmails: shift.shiftLeadEmails, - organizerIds: [], - shiftLeadIds: [], - ...(shift.channelId && { channelId: shift.channelId }), - ...(shift.link && { link: shift.link }), - pingSent: false, - completed: false, -}); - -const formatErrors = (errors: RowError[]): string => - errors - .map((error) => `Row ${error.row}: ${error.messages.join("; ")}`) - .join("\n"); - -@ApplyOptions({ - name: "upload-schedule", - description: "Upload a CSV schedule (replaces the existing one).", - runIn: CommandOptionsRunTypeEnum.GuildText, - preconditions: ["AdminRoleOnly"], -}) -class UploadSchedule extends BaseCommand { - protected override buildCommand(builder: SlashCommandBuilder) { - return builder.addAttachmentOption((option) => - option - .setName("file") - .setDescription("Schedule CSV file") - .setRequired(true), - ); - } - - protected override setCommandOptions() { - return { - idHints: [idHints.uploadSchedule], - }; - } - - public override async chatInputRun( - interaction: Command.ChatInputCommandInteraction, - ) { - const guild = interaction.guild!; - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - const file = interaction.options.getAttachment("file", true); - if (!file.name.toLowerCase().endsWith(".csv")) { - return interaction.editReply({ content: "Please upload a `.csv` file." }); - } - if (file.size > MAX_FILE_SIZE_BYTES) { - return interaction.editReply({ - content: `File is too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Please upload a CSV under 1 MB.`, - }); - } - - let text: string; - try { - const response = await fetch(file.url, { - signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), - }); - if (!response.ok) { - return interaction.editReply({ - content: `Failed to download the uploaded file (HTTP ${response.status}). Please try again.`, - }); - } - text = await response.text(); - } catch { - return interaction.editReply({ - content: - "Failed to download the uploaded file (network error or timeout). Please try again.", - }); - } - - const { shifts, errors } = parseScheduleCsv(text); - - if (errors.length > 0) { - const report = formatErrors(errors); - const intro = `Could not upload schedule — ${errors.length} row(s) had errors and nothing was saved.`; - if (report.length <= 1800) { - return interaction.editReply({ - content: `${intro}\n\`\`\`\n${report}\n\`\`\``, - }); - } - return interaction.editReply({ - content: intro, - files: [ - new AttachmentBuilder(Buffer.from(report, "utf8"), { - name: "schedule-errors.txt", - }), - ], - }); - } - - if (shifts.length === 0) { - return interaction.editReply({ content: "No shifts found in the file." }); - } - - // Replace the existing schedule atomically: deletes, writes, and metadata - // go in a single batch so a failure leaves the old schedule intact. - const scheduleDocRef = getGuildDocRef(guild.id) - .collection("command-data") - .doc("shift-schedule"); - const shiftsCollection = scheduleDocRef.collection("shifts"); - const { firestore } = shiftsCollection; - - const existing = await shiftsCollection.listDocuments(); - if (existing.length + shifts.length + 1 > BATCH_LIMIT) { - return interaction.editReply({ - content: `Schedule is too large to replace atomically (existing + new shifts must be under ${BATCH_LIMIT - 1}). Nothing was saved.`, - }); - } - - const batch = firestore.batch(); - for (const ref of existing) batch.delete(ref); - for (const shift of shifts) - batch.set(shiftsCollection.doc(), toShiftDoc(shift)); - batch.set( - scheduleDocRef, - { active: true, lastUpdated: FieldValue.serverTimestamp() }, - { merge: true }, - ); - await batch.commit(); - - await logToAdminLog( - guild, - `Shift schedule uploaded by <@${interaction.user.id}>: ${shifts.length} shift(s).`, - ); - - const startTimes = shifts.map((shift) => shift.startTime.getTime()); - const earliest = new Date(Math.min(...startTimes)); - const latest = new Date(Math.max(...startTimes)); - - const embed = new EmbedBuilder().setTitle("Schedule uploaded").addFields( - { name: "Shifts loaded", value: `${shifts.length}` }, - { - name: "Date range", - value: `${earliest.toLocaleString()} → ${latest.toLocaleString()}`, - }, - ); - - return interaction.editReply({ embeds: [embed] }); - } -} - -export default UploadSchedule; diff --git a/src/util/schedule-csv.ts b/src/util/schedule-csv.ts deleted file mode 100644 index abd243a5..00000000 --- a/src/util/schedule-csv.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { parse } from "csv-parse/sync"; - -export interface ParsedShift { - // server-local; becomes a Timestamp on write - startTime: Date; - durationMinutes: number; - location: string; - description: string; - organizerEmails: string[]; - shiftLeadEmails: string[]; - channelId?: string; - link?: string; -} - -export interface RowError { - // spreadsheet row (header is row 1) - row: number; - messages: string[]; -} - -export interface ParseResult { - shifts: ParsedShift[]; - errors: RowError[]; -} - -const REQUIRED_COLUMNS = [ - "organizer_emails", - "start_time", - "location", - "shift_leads", - "duration_minutes", - "description", -] as const; -const OPTIONAL_COLUMNS = ["channel_id", "link"] as const; -const ALL_COLUMNS = [...REQUIRED_COLUMNS, ...OPTIONAL_COLUMNS]; -type ColumnName = (typeof ALL_COLUMNS)[number]; - -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const START_TIME_REGEX = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/; - -// split a cell into trimmed, lowercased emails -const parseEmailList = (value: string): string[] => - value - .split(",") - .map((email) => email.trim().toLowerCase()) - .filter((email) => email.length > 0); - -// parse "YYYY-MM-DD HH:MM" (server-local); null if invalid or not a real date -const parseStartTime = (value: string): Date | null => { - const match = START_TIME_REGEX.exec(value.trim()); - if (!match) return null; - const [, year, month, day, hour, minute] = match.map(Number); - const date = new Date(year, month - 1, day, hour, minute); - // reject rolled-over dates like Feb 31 - if ( - date.getFullYear() !== year || - date.getMonth() !== month - 1 || - date.getDate() !== day || - date.getHours() !== hour || - date.getMinutes() !== minute - ) { - return null; - } - return date; -}; - -// validate one row's fields -> a shift, or the problems found -const validateRow = ( - get: (column: ColumnName) => string, -): { shift?: ParsedShift; messages: string[] } => { - const messages: string[] = []; - - const organizerEmails = parseEmailList(get("organizer_emails")); - if (organizerEmails.length === 0) { - messages.push("organizer_emails is required (at least one email)."); - } else { - const invalid = organizerEmails.filter((email) => !EMAIL_REGEX.test(email)); - if (invalid.length > 0) { - messages.push(`Invalid organizer email(s): ${invalid.join(", ")}`); - } - } - - const shiftLeadEmails = parseEmailList(get("shift_leads")); - const invalidLeads = shiftLeadEmails.filter( - (email) => !EMAIL_REGEX.test(email), - ); - if (invalidLeads.length > 0) { - messages.push(`Invalid shift lead email(s): ${invalidLeads.join(", ")}`); - } - - const startTime = parseStartTime(get("start_time")); - if (!startTime) { - messages.push( - 'start_time must be a valid date in "YYYY-MM-DD HH:MM" format.', - ); - } - - const durationRaw = get("duration_minutes"); - const durationMinutes = Number(durationRaw); - if ( - durationRaw === "" || - !Number.isInteger(durationMinutes) || - durationMinutes <= 0 - ) { - messages.push("duration_minutes must be a positive integer."); - } - - // captured but not validated — degraded display at worst, mapping/send handles the rest - const location = get("location"); - const description = get("description"); - const channelId = get("channel_id"); - const link = get("link"); - - if (!startTime || messages.length > 0) return { messages }; - - return { - messages, - shift: { - startTime, - durationMinutes, - location, - description, - organizerEmails, - shiftLeadEmails, - ...(channelId !== "" && { channelId }), - ...(link !== "" && { link }), - }, - }; -}; - -// parse + validate the schedule CSV; collects every error per row, no side effects -export function parseScheduleCsv(content: string): ParseResult { - let rows: string[][]; - try { - rows = parse(content, { - skip_empty_lines: true, - trim: true, - bom: true, - relax_column_count: true, - }) as string[][]; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - shifts: [], - errors: [ - { row: 1, messages: [`Could not parse file as CSV: ${message}`] }, - ], - }; - } - - if (rows.length === 0) { - return { - shifts: [], - errors: [{ row: 1, messages: ["File is empty."] }], - }; - } - - const header = rows[0].map((column) => column.trim().toLowerCase()); - const missingColumns = REQUIRED_COLUMNS.filter( - (column) => !header.includes(column), - ); - if (missingColumns.length > 0) { - return { - shifts: [], - errors: [ - { - row: 1, - messages: [ - `Missing required column(s): ${missingColumns.join(", ")}`, - ], - }, - ], - }; - } - - const columnIndex = Object.fromEntries( - ALL_COLUMNS.map((column) => [column, header.indexOf(column)]), - ) as Record; - const cell = (row: string[], column: ColumnName): string => { - const index = columnIndex[column]; - return index >= 0 ? (row[index] ?? "").trim() : ""; - }; - - const shifts: ParsedShift[] = []; - const errors: RowError[] = []; - const seenKeys = new Map(); - - for (let i = 1; i < rows.length; i++) { - const rowNumber = i + 1; // header is row 1 - const { shift, messages } = validateRow((column) => cell(rows[i], column)); - - if (!shift) { - errors.push({ row: rowNumber, messages }); - continue; - } - - // rows identical in every field are duplicates, and fail the upload - const duplicateKey = [ - shift.startTime.getTime(), - shift.location.toLowerCase(), - [...shift.organizerEmails].sort().join(","), - [...shift.shiftLeadEmails].sort().join(","), - shift.durationMinutes, - shift.description, - shift.channelId ?? "", - shift.link ?? "", - ].join("|"); - const firstSeenRow = seenKeys.get(duplicateKey); - if (firstSeenRow !== undefined) { - errors.push({ - row: rowNumber, - messages: [`Duplicate of row ${firstSeenRow}.`], - }); - continue; - } - seenKeys.set(duplicateKey, rowNumber); - shifts.push(shift); - } - - return { shifts, errors }; -} From 443e50d938fa40dc3e955c9ff2135e008562f1de Mon Sep 17 00:00:00 2001 From: Flaryiest Date: Fri, 17 Jul 2026 13:36:07 -0600 Subject: [PATCH 3/5] Add Notion-synced shift schema, organizer mappings, and Firestore helpers --- src/types/db/organizer-mapping.ts | 14 ++++++++ src/types/db/shift-schedule.ts | 59 +++++++++++++++++++------------ src/util/discord.ts | 4 +++ src/util/nwplus-firestore.ts | 16 +++++++++ src/util/organizer-mappings.ts | 42 ++++++++++++++++++++++ 5 files changed, 113 insertions(+), 22 deletions(-) create mode 100644 src/types/db/organizer-mapping.ts create mode 100644 src/util/organizer-mappings.ts diff --git a/src/types/db/organizer-mapping.ts b/src/types/db/organizer-mapping.ts new file mode 100644 index 00000000..78c8b426 --- /dev/null +++ b/src/types/db/organizer-mapping.ts @@ -0,0 +1,14 @@ +import { Timestamp } from "firebase-admin/firestore"; + +/** + * An email→Discord link at organizerMappings/{normalizedEmail}, written by + * /link-email. The doc ID is the normalized (trimmed, lowercased) email, so + * lookups are direct doc reads. Global rather than guild-scoped because + * Notion Person fields usually carry personal emails, which stay the same + * across hackathons. + */ +export interface OrganizerMappingDoc { + email: string; + discordUserId: string; + linkedAt: Timestamp; +} diff --git a/src/types/db/shift-schedule.ts b/src/types/db/shift-schedule.ts index 2428f4a3..0a1bbdc0 100644 --- a/src/types/db/shift-schedule.ts +++ b/src/types/db/shift-schedule.ts @@ -1,27 +1,42 @@ import { Timestamp } from "firebase-admin/firestore"; -// Metadata -export interface ShiftScheduleDoc { - active: boolean; - lastUpdated: Timestamp; - /** Reserved for later PRs — default channel for reminder pings. */ - defaultChannelId?: string; -} - -export interface ShiftDoc { +/** + * A shift at hackathons/{hackathonId}/shifts/{notionPageId}, written by the + * Notion→Firestore sync (Functions-new). Notion is the only source of truth + * for the schedule, so Factotum treats these documents as read-only. + */ +export interface OrganizerShiftDoc { + notionPageId: string; + title: string; + description?: string; + location?: string; + notionUrl?: string; startTime: Timestamp; - durationMinutes: number; - location: string; - description: string; + endTime: Timestamp; organizerEmails: string[]; - shiftLeadEmails: string[]; - // Populated in a later PR once emails are resolved to Discord IDs - organizerIds: string[]; - shiftLeadIds: string[]; - // Optional per-shift channel override for the reminder ping - channelId?: string; - // Optional link shown in the reminder - link?: string; - pingSent: boolean; - completed: boolean; + leadOrganizerEmails: string[]; + updatedAt: Timestamp; +} + +/** + * Reminder state at hackathons/{hackathonId}/reminders/{shiftId}, owned by + * Factotum. Kept in a separate collection so Notion sync upserts can never + * reset whether a reminder was already sent. A missing doc means "not sent". + */ +export interface ShiftReminderDoc { + reminderSent: boolean; + sentAt?: Timestamp; + lastAttemptAt?: Timestamp; + lastError?: string; +} + +/** + * Optional per-guild reminder settings at command-data/shift-schedule. + * Both fields are manual escape hatches, set directly in Firestore. + */ +export interface ShiftReminderConfigDoc { + /** Overrides the #shift-reminders channel-name convention. */ + reminderChannelId?: string; + /** Stops all reminder pings for the guild without shutting the bot down. */ + paused?: boolean; } diff --git a/src/util/discord.ts b/src/util/discord.ts index fe9a304e..37f48df3 100644 --- a/src/util/discord.ts +++ b/src/util/discord.ts @@ -5,6 +5,10 @@ import { GuildMemberRoleManager, } from "discord.js"; +/** Truncates text to `max` chars, adding an ellipsis when it overflows */ +export const truncate = (value: string, max: number): string => + value.length > max ? `${value.slice(0, max - 1)}…` : value; + /** Checks if the provided member has any of the given roles */ export const checkMemberRoles = ( member: GuildMember | APIInteractionGuildMember, diff --git a/src/util/nwplus-firestore.ts b/src/util/nwplus-firestore.ts index 6e29fe91..e2d99975 100644 --- a/src/util/nwplus-firestore.ts +++ b/src/util/nwplus-firestore.ts @@ -16,6 +16,22 @@ export const getHackathonDocRef = (hackathonName: string) => { return db.collection("Hackathons").doc(hackathonName); }; +// The lowercase `hackathons` collection holds Notion-synced automation data +// (written by Functions-new), distinct from `Hackathons` above which holds +// application data. Its doc IDs follow the same naming as GuildDoc.hackathonName +// (e.g. "cmd-f2026") — that field is how a guild finds its shifts. +export const getHackathonShiftsRef = (hackathonId: string) => { + return db.collection("hackathons").doc(hackathonId).collection("shifts"); +}; + +export const getShiftRemindersRef = (hackathonId: string) => { + return db.collection("hackathons").doc(hackathonId).collection("reminders"); +}; + +export const getOrganizerMappingDocRef = (normalizedEmail: string) => { + return db.collection("organizerMappings").doc(normalizedEmail); +}; + export const logToAdminLog = async (guild: Guild, message: string) => { const guildDocRef = getGuildDocRef(guild.id); const guildDocData = (await guildDocRef.get()).data() as GuildDoc; diff --git a/src/util/organizer-mappings.ts b/src/util/organizer-mappings.ts new file mode 100644 index 00000000..067cb158 --- /dev/null +++ b/src/util/organizer-mappings.ts @@ -0,0 +1,42 @@ +import { OrganizerMappingDoc } from "@/types/db/organizer-mapping"; +import { db } from "@/util/firestore"; +import { getOrganizerMappingDocRef } from "@/util/nwplus-firestore"; + +/** Normalization used for organizerMappings doc IDs and all email comparisons */ +export const normalizeEmail = (email: string): string => + email.trim().toLowerCase(); + +/** + * Whether a normalized email can be an organizerMappings doc ID — Firestore + * rejects path separators and oversized IDs, and Notion organizer fields are + * free text, so junk like "n/a" must not crash a whole shift's resolution. + */ +export const isMappableEmail = (email: string): boolean => + email.length > 0 && email.length <= 320 && !email.includes("/"); + +/** + * Resolves emails to Discord user IDs via organizerMappings. Emails without a + * mapping (or that can't be looked up) stay unresolved — callers decide + * whether to log or surface them. + */ +export const resolveOrganizerIds = async ( + emails: string[], +): Promise<{ ids: string[]; unresolvedEmails: string[] }> => { + const normalized = [...new Set(emails.map(normalizeEmail))].filter(Boolean); + const mappable = normalized.filter(isMappableEmail); + const unresolvedEmails = normalized.filter( + (email) => !isMappableEmail(email), + ); + if (mappable.length === 0) return { ids: [], unresolvedEmails }; + + // Mapping doc IDs are the normalized emails themselves, so this is a single + // batched read — no queries needed. + const snapshots = await db.getAll(...mappable.map(getOrganizerMappingDocRef)); + const ids = new Set(); + snapshots.forEach((snapshot, index) => { + const mapping = snapshot.data() as OrganizerMappingDoc | undefined; + if (mapping?.discordUserId) ids.add(mapping.discordUserId); + else unresolvedEmails.push(mappable[index]); + }); + return { ids: [...ids], unresolvedEmails }; +}; From 1143d1c96b46517e734ceb5db7787ecb66f92ce2 Mon Sep 17 00:00:00 2001 From: Flaryiest Date: Fri, 17 Jul 2026 13:36:07 -0600 Subject: [PATCH 4/5] Add /link-email command for shift reminder mentions --- src/commands/LinkEmail.ts | 111 +++++++++++++++++++++++++ src/constants/id-hints.ts | 4 +- src/preconditions/OrganizerRoleOnly.ts | 58 +++++++++++++ 3 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 src/commands/LinkEmail.ts create mode 100644 src/preconditions/OrganizerRoleOnly.ts diff --git a/src/commands/LinkEmail.ts b/src/commands/LinkEmail.ts new file mode 100644 index 00000000..1d6ef1b7 --- /dev/null +++ b/src/commands/LinkEmail.ts @@ -0,0 +1,111 @@ +import BaseCommand from "@/classes/BaseCommand"; +import { idHints } from "@/constants/id-hints"; +import { GuildDoc } from "@/types/db/guild"; +import { OrganizerMappingDoc } from "@/types/db/organizer-mapping"; +import { checkMemberRoles } from "@/util/discord"; +import { + getGuildDocRef, + getOrganizerMappingDocRef, +} from "@/util/nwplus-firestore"; +import { normalizeEmail } from "@/util/organizer-mappings"; + +import { ApplyOptions } from "@sapphire/decorators"; +import { Command, CommandOptionsRunTypeEnum } from "@sapphire/framework"; +import { MessageFlags, SlashCommandBuilder } from "discord.js"; +import { Timestamp } from "firebase-admin/firestore"; + +// Also excludes '/' and caps length: the email becomes a Firestore doc ID, +// which rejects path separators and IDs over 1500 bytes. +const EMAIL_REGEX = /^[^\s@/]+@[^\s@/]+\.[^\s@/]+$/; +const MAX_EMAIL_LENGTH = 320; + +/** + * Links an email from the Notion shift schedule to a Discord account so shift + * reminders can mention the right person. Everything is ephemeral — mappings + * pair members with (often personal) emails, which must never be shown + * publicly. + */ +@ApplyOptions({ + name: "link-email", + description: + "Link the email on your Notion shifts to your Discord account for shift reminders.", + runIn: CommandOptionsRunTypeEnum.GuildText, + preconditions: ["OrganizerRoleOnly"], +}) +class LinkEmail extends BaseCommand { + protected override buildCommand(builder: SlashCommandBuilder) { + return builder + .addStringOption((option) => + option + .setName("email") + .setDescription( + "The email on your shifts in Notion (personal emails are fine)", + ) + .setRequired(true), + ) + .addUserOption((option) => + option + .setName("user") + .setDescription( + "Link the email to this member instead (admins and staff only)", + ), + ); + } + + protected override setCommandOptions() { + return { + idHints: [idHints.linkEmail], + }; + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction, + ) { + const email = normalizeEmail(interaction.options.getString("email", true)); + const targetUser = interaction.options.getUser("user", false); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + if (!EMAIL_REGEX.test(email) || email.length > MAX_EMAIL_LENGTH) { + return interaction.editReply( + "That doesn't look like a valid email address.", + ); + } + + const guildDocData = ( + await getGuildDocRef(interaction.guildId!).get() + ).data() as GuildDoc; + const isAdminOrStaff = checkMemberRoles(interaction.member!, [ + guildDocData.roleIds.admin, + guildDocData.roleIds.staff, + ]); + if (targetUser && !isAdminOrStaff) { + return interaction.editReply( + "Only admins and staff can link an email for someone else.", + ); + } + const target = targetUser ?? interaction.user; + + const mappingDocRef = getOrganizerMappingDocRef(email); + const existing = (await mappingDocRef.get()).data() as + | OrganizerMappingDoc + | undefined; + // Don't reveal whose it is — that would leak another member's email link. + if (existing && existing.discordUserId !== target.id && !isAdminOrStaff) { + return interaction.editReply( + "That email is already linked to a different Discord account. Ask an admin to re-link it.", + ); + } + + await mappingDocRef.set({ + email, + discordUserId: target.id, + linkedAt: Timestamp.now(), + } satisfies OrganizerMappingDoc); + + return interaction.editReply( + `Linked ${email} to ${target}. Shift reminders will now mention ${targetUser ? "them" : "you"}.`, + ); + } +} + +export default LinkEmail; diff --git a/src/constants/id-hints.ts b/src/constants/id-hints.ts index 428e38b2..eae11235 100644 --- a/src/constants/id-hints.ts +++ b/src/constants/id-hints.ts @@ -14,7 +14,7 @@ const devIdHints = { startTickets: "1402483611156086924", startTrivia: "1386423290645712967", startVerification: "1385475309876412540", - uploadSchedule: "", + linkEmail: "1527733283444756661", }; const prodIdHints = { @@ -28,7 +28,7 @@ const prodIdHints = { startTickets: "1404733926160728084", startTrivia: "1404734018066448448", startVerification: "1404734012332965969", - uploadSchedule: "", + linkEmail: "", }; export const idHints = diff --git a/src/preconditions/OrganizerRoleOnly.ts b/src/preconditions/OrganizerRoleOnly.ts new file mode 100644 index 00000000..5486cb03 --- /dev/null +++ b/src/preconditions/OrganizerRoleOnly.ts @@ -0,0 +1,58 @@ +import { GuildDoc } from "@/types/db/guild"; +import { VerificationDoc } from "@/types/db/verification"; +import { checkMemberRoles } from "@/util/discord"; +import { getGuildDocRef } from "@/util/nwplus-firestore"; + +import { Precondition } from "@sapphire/framework"; +import { ChatInputCommandInteraction } from "discord.js"; + +/** + * A precondition that requires the user to have the organizer, admin, or staff + * role to run the command. Also enforces that the command is run in a server. + * The organizer role comes from the verification config, so before + * /start-verification is run only admins and staff pass. + */ +class OrganizerRoleOnlyPrecondition extends Precondition { + public override async chatInputRun( + interaction: ChatInputCommandInteraction, + ): Precondition.AsyncResult { + if (!interaction.guildId) { + return this.error({ message: "This command must be run in a server!" }); + } + const guildDocRef = getGuildDocRef(interaction.guildId); + const guildDoc = await guildDocRef.get(); + const data = guildDoc.data() as GuildDoc; + if (!guildDoc.exists || !data?.setupComplete) { + return this.error({ + message: + "This server is not setup yet. Run /init-bot to setup the server.", + }); + } + + const allowedRoles = [data.roleIds.admin, data.roleIds.staff]; + const verificationDoc = await guildDocRef + .collection("command-data") + .doc("verification") + .get(); + const organizerRole = ( + verificationDoc.data() as VerificationDoc | undefined + )?.roleIds?.organizer; + if (organizerRole) allowedRoles.push(organizerRole); + + if (!checkMemberRoles(interaction.member!, allowedRoles)) { + return this.error({ + message: "Only organizers, admins, and staff can use this command!", + }); + } + + return this.ok(); + } +} + +declare module "@sapphire/framework" { + interface Preconditions { + OrganizerRoleOnly: never; + } +} + +export default OrganizerRoleOnlyPrecondition; From 0335aac4381a3b03606a25bfa57e8a97ef8bf5cb Mon Sep 17 00:00:00 2001 From: Flaryiest Date: Fri, 17 Jul 2026 13:36:07 -0600 Subject: [PATCH 5/5] Add shift reminder scheduler reading Notion-synced shifts --- src/index.ts | 3 + src/services/shift-reminder-scheduler.ts | 456 +++++++++++++++++++++++ 2 files changed, 459 insertions(+) create mode 100644 src/services/shift-reminder-scheduler.ts diff --git a/src/index.ts b/src/index.ts index e3061c97..2b778a38 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { LogLevel, SapphireClient } from "@sapphire/framework"; import { GatewayIntentBits } from "discord.js"; import "dotenv/config"; +import { shiftReminderScheduler } from "./services/shift-reminder-scheduler"; import { GuildDoc } from "./types/db/guild"; import { PronounsDoc } from "./types/db/pronouns"; import { TicketDoc } from "./types/db/ticket"; @@ -93,6 +94,8 @@ const initializeBot = async () => { console.log("Finished processing all guild documents"); + shiftReminderScheduler.start(client); + client.on("guildMemberAdd", async (member) => { const guildDocRef = await getGuildDocRef(member.guild.id).get(); const guildDocData = guildDocRef.data() as GuildDoc; diff --git a/src/services/shift-reminder-scheduler.ts b/src/services/shift-reminder-scheduler.ts new file mode 100644 index 00000000..c20f0658 --- /dev/null +++ b/src/services/shift-reminder-scheduler.ts @@ -0,0 +1,456 @@ +import { GuildDoc } from "@/types/db/guild"; +import { + OrganizerShiftDoc, + ShiftReminderConfigDoc, + ShiftReminderDoc, +} from "@/types/db/shift-schedule"; +import { truncate } from "@/util/discord"; +import { + getFactotumBaseDocRef, + getGuildDocRef, + getHackathonShiftsRef, + getShiftRemindersRef, + logToAdminLog, +} from "@/util/nwplus-firestore"; +import { resolveOrganizerIds } from "@/util/organizer-mappings"; + +import { SapphireClient } from "@sapphire/framework"; +import { EmbedBuilder, Guild, PermissionFlagsBits } from "discord.js"; +import { Timestamp } from "firebase-admin/firestore"; + +const PING_LEAD_MS = 10 * 60_000; +const SCAN_INTERVAL_MS = 5 * 60_000; +// Timers are only ever set up to SCAN_HORIZON_MS ahead, so the 32-bit setTimeout +// ceiling (~24.8 days) is structurally impossible; shifts further out are simply +// picked up by a later sweep. The horizon deliberately exceeds +// SCAN_INTERVAL_MS + PING_LEAD_MS so no shift can slip between consecutive +// sweeps (the extra 60 s covers sweep jitter). +const SCAN_HORIZON_MS = SCAN_INTERVAL_MS + PING_LEAD_MS + 60_000; +// Sweeps ignore shifts that started longer ago than this: old enough that a +// miss alert is pure noise, and it keeps the sweep from re-reading a whole +// event's history forever. +const SCAN_LOOKBACK_MS = 7 * 24 * 60 * 60_000; +// Misses older than this are recorded silently instead of alerting — a first +// sync of a schedule that includes past days shouldn't flood the admin log +// with one alert per already-ended shift. +const MISS_ALERT_MAX_AGE_MS = 24 * 60 * 60_000; + +const formatPeople = (ids: string[], emails: string[]): string => { + const parts = [...ids.map((id) => `<@${id}>`), ...emails]; + return parts.length > 0 ? parts.join(", ") : "—"; +}; + +const formatDuration = (startMs: number, endMs: number): string => { + const totalMinutes = Math.max(1, Math.round((endMs - startMs) / 60_000)); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + const minutesText = `${minutes} minute${minutes === 1 ? "" : "s"}`; + if (hours === 0) return minutesText; + const hoursText = `${hours} hour${hours === 1 ? "" : "s"}`; + return minutes === 0 ? hoursText : `${hoursText} ${minutesText}`; +}; + +const getReminderConfig = async ( + guildId: string, +): Promise => { + const configSnap = await getGuildDocRef(guildId) + .collection("command-data") + .doc("shift-schedule") + .get(); + return configSnap.data() as ShiftReminderConfigDoc | undefined; +}; + +// Resolves both organizer groups of a shift against organizerMappings. +const resolveShiftPeople = async (shift: OrganizerShiftDoc) => { + const [organizers, leads] = await Promise.all([ + resolveOrganizerIds(shift.organizerEmails ?? []), + resolveOrganizerIds(shift.leadOrganizerEmails ?? []), + ]); + return { + leads, + mentionIds: [...new Set([...organizers.ids, ...leads.ids])], + unresolvedEmails: [ + ...new Set([...organizers.unresolvedEmails, ...leads.unresolvedEmails]), + ], + }; +}; + +/** + * Sends a ping to a shift's organizers 10 minutes before it starts. Shifts are + * read from hackathons/{GuildDoc.hackathonName}/shifts, which the Notion sync + * owns — this service never writes them. Its own state (whether a reminder was + * sent) lives in the parallel reminders collection. + */ +class ShiftReminderScheduler { + private client!: SapphireClient; + private started = false; + private readonly timers = new Map< + string, + { timeout: NodeJS.Timeout; pingAtMs: number } + >(); + private readonly inFlight = new Set(); + + public start(client: SapphireClient): void { + if (this.started) return; + this.started = true; + this.client = client; + // The boot sweep IS restart recovery: it re-schedules imminent shifts and + // catches up anything whose window passed while the bot was down. + void this.scanAll(); + setInterval(() => void this.scanAll(), SCAN_INTERVAL_MS); + } + + private async scanAll(): Promise { + let guildDocs; + try { + guildDocs = await getFactotumBaseDocRef().collection("guilds").get(); + } catch (error) { + console.error("Shift reminder sweep failed to list guilds:", error); + return; + } + const results = await Promise.allSettled( + guildDocs.docs.map((doc) => this.scanGuild(doc.id)), + ); + results.forEach((result, index) => { + if (result.status === "rejected") { + console.error( + `Shift reminder sweep failed for guild ${guildDocs.docs[index].id}:`, + result.reason, + ); + } + }); + } + + private async scanGuild(guildId: string): Promise { + const guildDocData = (await getGuildDocRef(guildId).get()).data() as + | GuildDoc + | undefined; + // hackathonName doubles as the hackathons/{id} doc ID the sync writes to. + if (!guildDocData?.setupComplete || !guildDocData.hackathonName) return; + const hackathonId = guildDocData.hackathonName; + + const config = await getReminderConfig(guildId); + if (config?.paused) { + this.cancelGuildTimers(guildId); + return; + } + + const guild = await this.fetchGuild(guildId); + if (!guild) return; + + const now = Date.now(); + const [shiftsSnap, remindersSnap] = await Promise.all([ + getHackathonShiftsRef(hackathonId) + .where("startTime", ">", Timestamp.fromMillis(now - SCAN_LOOKBACK_MS)) + .where("startTime", "<=", Timestamp.fromMillis(now + SCAN_HORIZON_MS)) + .get(), + getShiftRemindersRef(hackathonId).get(), + ]); + const reminders = new Map( + remindersSnap.docs.map((doc) => [doc.id, doc.data() as ShiftReminderDoc]), + ); + + // Timers only ever cover shifts starting within the horizon, so a pending + // shift missing from this sweep was deleted in Notion (or pushed far out) + // — either way the pending reminder must stop; a later sweep reschedules + // pushed-out shifts when they come back into range. + const shiftIds = new Set(shiftsSnap.docs.map((doc) => doc.id)); + const prefix = `${guildId}:`; + for (const [key, entry] of this.timers) { + if (key.startsWith(prefix) && !shiftIds.has(key.slice(prefix.length))) { + clearTimeout(entry.timeout); + this.timers.delete(key); + } + } + + for (const doc of shiftsSnap.docs) { + // Isolate each shift: a throwing update/fire/admin-log must not abort the + // rest of the sweep — later (=further-out) shifts would silently miss + // their window for up to a full scan interval. + try { + const shift = doc.data() as OrganizerShiftDoc; + if (reminders.get(doc.id)?.reminderSent) continue; + + const startMs = shift.startTime.toMillis(); + // A shift can never end before it starts — guard against swapped or + // wrong-day times from the sync so a future shift isn't misfiled as + // already over. + const endMs = Math.max(shift.endTime.toMillis(), startMs); + const pingAtMs = startMs - PING_LEAD_MS; + const startUnix = Math.floor(startMs / 1000); + + if (now >= startMs) { + // The ping window passed without a successful send (bot down, no + // usable channel, or the shift was added too late) — pinging + // mid-shift would be noise. Record the miss FIRST so a failing + // admin-log send can't retrigger this branch every sweep, then + // alert once for recent misses. + const ended = now >= endMs; + await this.markReminder( + hackathonId, + doc.id, + ended + ? "Shift ended before a reminder was sent" + : "Reminder window passed before a reminder could be sent", + ); + if (now - startMs <= MISS_ALERT_MAX_AGE_MS) { + const { mentionIds, unresolvedEmails } = + await resolveShiftPeople(shift); + await logToAdminLog( + guild, + `Missed shift reminder (shift already ${ended ? "ended" : "started"}): ${truncate(shift.title, 200)} at — organizers: ${truncate(formatPeople(mentionIds, unresolvedEmails), 1000)}`, + ); + } + continue; + } + + const key = `${guildId}:${doc.id}`; + if (pingAtMs > now) { + const existing = this.timers.get(key); + // Start time changed in Notion since this timer was set — reschedule + // at the new time (covers moves in both directions). + if (existing && existing.pingAtMs !== pingAtMs) { + clearTimeout(existing.timeout); + this.timers.delete(key); + } + if (!this.timers.has(key)) { + this.timers.set(key, { + pingAtMs, + timeout: setTimeout(() => { + void this.fire(guildId, hackathonId, doc.id).catch((error) => + console.error(`Shift reminder ${key} failed:`, error), + ); + }, pingAtMs - now), + }); + } + } else { + await this.fire(guildId, hackathonId, doc.id); + } + } catch (error) { + console.error( + `Shift reminder sweep failed for guild ${guildId} shift ${doc.id}:`, + error, + ); + } + } + } + + private async fire( + guildId: string, + hackathonId: string, + shiftId: string, + ): Promise { + const key = `${guildId}:${shiftId}`; + const entry = this.timers.get(key); + if (entry !== undefined) clearTimeout(entry.timeout); + this.timers.delete(key); + + if (this.inFlight.has(key)) return; + this.inFlight.add(key); + try { + const shiftRef = getHackathonShiftsRef(hackathonId).doc(shiftId); + const reminderRef = getShiftRemindersRef(hackathonId).doc(shiftId); + const [shiftSnap, reminderSnap] = await Promise.all([ + shiftRef.get(), + reminderRef.get(), + ]); + // Stale timer: the shift was deleted in Notion. + if (!shiftSnap.exists) return; + const shift = shiftSnap.data() as OrganizerShiftDoc; + const reminder = reminderSnap.data() as ShiftReminderDoc | undefined; + if (reminder?.reminderSent) return; + + const startMs = shift.startTime.toMillis(); + const endMs = shift.endTime.toMillis(); + const pingAtMs = startMs - PING_LEAD_MS; + const now = Date.now(); + // Moved to a later start since this was scheduled — a sweep reschedules. + if (pingAtMs - now > 60_000) return; + // Moved to an earlier start that already passed — the sweep records it + // as missed rather than pinging mid-shift. + if (now >= startMs) return; + + const guild = await this.fetchGuild(guildId); + if (!guild) return; + const config = await getReminderConfig(guildId); + if (config?.paused) return; + + // Always resolve people from the freshly-read doc, so organizer changes + // in Notion up until the ping are honoured. + const { leads, mentionIds, unresolvedEmails } = + await resolveShiftPeople(shift); + + const late = now > pingAtMs + 60_000; + const mentionText = mentionIds.map((id) => `<@${id}>`).join(" "); + const content = truncate( + `${mentionText ? `${mentionText}\n\n` : ""}🔔 **Reminder: Your shift starts in 10 minutes**${late ? " *(late reminder)*" : ""}`, + 2000, + ); + + const startUnix = Math.floor(startMs / 1000); + // Notion titles/descriptions are uncapped, but Discord's builders throw + // over their hard caps (title 256, field value 1024) — and a throw here + // loses the ping. Truncate: degraded display at worst. Unresolved emails + // deliberately never appear here — this message is public, and Notion + // emails are often personal; they go to the admin log instead. + const embed = new EmbedBuilder() + .setTitle(truncate(`Shift reminder: ${shift.title}`, 256)) + .addFields( + { name: "Starts", value: ` ()` }, + { name: "Duration", value: formatDuration(startMs, endMs) }, + { name: "Location", value: truncate(shift.location || "—", 1024) }, + { + name: "Shift lead(s)", + value: truncate(formatPeople(leads.ids, []), 1024), + }, + { + name: "Description", + value: truncate(shift.description || "—", 1024), + }, + ); + if (shift.notionUrl?.startsWith("https://")) + embed.setURL(shift.notionUrl); + + // Plain-text stand-in for channels where the embed can't be sent (most + // commonly a denied Embed Links permission) — the mentions and shift + // essentials still get through. + const fallbackContent = truncate( + `${content}\n${truncate(shift.title, 200)} — ${truncate(shift.location || "location TBD", 200)} — (${formatDuration(startMs, endMs)})`, + 2000, + ); + + const candidateIds = [ + ...new Set( + [ + config?.reminderChannelId, + guild.channels.cache.find( + (channel) => + channel.name === "shift-reminders" && channel.isTextBased(), + )?.id, + ].filter((id): id is string => Boolean(id)), + ), + ]; + + let sent = false; + let lastSendError: unknown; + for (const channelId of candidateIds) { + let channel; + try { + channel = await guild.channels.fetch(channelId); + } catch { + continue; + } + if (!channel?.isTextBased()) continue; + const me = guild.members.me; + if ( + !me || + !channel + .permissionsFor(me) + .has([ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + ]) + ) { + continue; + } + try { + await channel.send({ content, embeds: [embed] }); + sent = true; + break; + } catch (error) { + try { + await channel.send({ content: fallbackContent }); + sent = true; + break; + } catch (fallbackError) { + lastSendError = fallbackError; + console.error( + `Shift reminder ${key} send failed in channel ${channelId}:`, + error, + fallbackError, + ); + } + } + } + + // Mark-after-send is deliberate: the worst case is a rare duplicate ping + // if the bot crashes between send and write — never a silently lost + // reminder. Plain set (no merge) so a lastError from an earlier failed + // attempt doesn't linger after a successful retry. + if (sent) { + await reminderRef.set({ + reminderSent: true, + sentAt: Timestamp.now(), + lastAttemptAt: Timestamp.now(), + } satisfies ShiftReminderDoc); + if (unresolvedEmails.length > 0) { + await logToAdminLog( + guild, + `Shift reminder for ${truncate(shift.title, 200)} at sent, but these emails have no /link-email mapping and could not be mentioned: ${truncate(unresolvedEmails.join(", "), 1000)}`, + ); + } + } else { + // Leave reminderSent false so the next sweep retries — a transient + // Discord failure shouldn't permanently eat the ping. Retries are + // naturally bounded to the 10-minute window: once the shift starts, + // the sweep records the miss and stops. + const failureReason = lastSendError + ? `Send failed: ${truncate( + lastSendError instanceof Error + ? lastSendError.message + : String(lastSendError), + 300, + )}` + : "No usable reminder channel (checked the configured reminderChannelId and #shift-reminders)"; + await reminderRef.set({ + reminderSent: false, + lastAttemptAt: Timestamp.now(), + lastError: failureReason, + } satisfies ShiftReminderDoc); + await logToAdminLog( + guild, + `Shift reminder for ${truncate(shift.title, 200)} at could not be sent — ${failureReason}. Check the reminderChannelId config / #shift-reminders channel and the bot's permissions there. Intended mentions: ${truncate(formatPeople(mentionIds, unresolvedEmails), 800)}`, + ); + } + } finally { + this.inFlight.delete(key); + } + } + + // Terminal no-send states also set reminderSent — it doubles as "stop + // processing this shift", so a miss alerts once instead of every sweep. + private async markReminder( + hackathonId: string, + shiftId: string, + lastError: string, + ): Promise { + await getShiftRemindersRef(hackathonId) + .doc(shiftId) + .set({ + reminderSent: true, + lastAttemptAt: Timestamp.now(), + lastError, + } satisfies ShiftReminderDoc); + } + + private cancelGuildTimers(guildId: string): void { + const prefix = `${guildId}:`; + for (const [key, entry] of this.timers) { + if (key.startsWith(prefix)) { + clearTimeout(entry.timeout); + this.timers.delete(key); + } + } + } + + // Bot kicked from the guild → skip quietly. + private async fetchGuild(guildId: string): Promise { + try { + return await this.client.guilds.fetch(guildId); + } catch { + return null; + } + } +} + +export const shiftReminderScheduler = new ShiftReminderScheduler();