From 1b25baa443add2884287fff5dfefabc8d9cd96f3 Mon Sep 17 00:00:00 2001 From: azizu06 Date: Mon, 23 Mar 2026 17:03:57 -0400 Subject: [PATCH 01/30] Add issue reminders cron job with channel mapping and due in x days grouping --- apps/cron/src/crons/issue-reminders.ts | 176 +++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 apps/cron/src/crons/issue-reminders.ts diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts new file mode 100644 index 000000000..dbf59d2d4 --- /dev/null +++ b/apps/cron/src/crons/issue-reminders.ts @@ -0,0 +1,176 @@ +import { and, inArray, isNotNull, ne } from "drizzle-orm"; + +import { db } from "@forge/db/client"; +import { Roles } from "@forge/db/schemas/auth"; +import { Issue } from "@forge/db/schemas/knight-hacks"; +import { logger } from "@forge/utils"; + +import { CronBuilder } from "../structs/CronBuilder"; + +const ISSUE_REMINDER_CHANNELS = { + Team: "Team", + Directors: "Directors", + Design: "Design", + HackOrg: "HackOrg", +} as const; + +const ISSUE_TEAM_CHANNEL_MAP: Record< + string, + keyof typeof ISSUE_REMINDER_CHANNELS +> = { + "16ced653-dafd-46bc-a6ef-8f4fba6a6b46": "Team", + "f4f544bf-7c69-43c1-b4b4-0585e73268a7": "Team", + "9fc780ed-3c84-4e9a-bd10-b5c2be51f5a8": "Team", + "b86a437b-0789-4ec4-8011-5ddde24865dc": "Directors", + "3b03b15d-4368-49e6-86c9-48c11775430b": "Design", + "110f5d0c-3299-46f6-b057-ae2ce28d4778": "HackOrg", +}; + +const ISSUE_REMINDER_DAYS = { + Fourteen: "Fourteen", + Seven: "Seven", + Three: "Three", + One: "One", + Overdue: "Overdue", +} as const; + +type IssueReminderDay = + (typeof ISSUE_REMINDER_DAYS)[keyof typeof ISSUE_REMINDER_DAYS]; + +const getIssueReminderDay = ( + date: Date, + now = new Date(), +): IssueReminderDay | null => { + const dueDate = new Date(date); + dueDate.setHours(0, 0, 0, 0); + const today = new Date(now); + today.setHours(0, 0, 0, 0); + + const diffMs = dueDate.getTime() - today.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 14) return ISSUE_REMINDER_DAYS.Fourteen; + if (diffDays === 7) return ISSUE_REMINDER_DAYS.Seven; + if (diffDays === 3) return ISSUE_REMINDER_DAYS.Three; + if (diffDays === 1) return ISSUE_REMINDER_DAYS.One; + if (diffDays < 0) return ISSUE_REMINDER_DAYS.Overdue; + return null; +}; + +const getIssueReminderChannel = ( + teamId: string, +): keyof typeof ISSUE_REMINDER_CHANNELS | null => { + return ISSUE_TEAM_CHANNEL_MAP[teamId] ?? null; +}; + +type IssueReminderTarget = { + issueId: string; + issueName: string; + teamId: string; + teamDiscordRoleId: string; + assigneeDiscordUserIds: string[]; + channel: keyof typeof ISSUE_REMINDER_CHANNELS; + day: IssueReminderDay; +}; + +const buildIssueReminderTarget = (issue: { + id: string; + name: string; + team: string; + date: Date | null; + teamDiscordRoleId: string; + assigneeDiscordUserIds: string[]; +}): IssueReminderTarget | null => { + if (!issue.date) return null; + const channel = getIssueReminderChannel(issue.team); + if (!channel) return null; + const day = getIssueReminderDay(issue.date); + if (!day) return null; + return { + issueId: issue.id, + issueName: issue.name, + teamId: issue.team, + teamDiscordRoleId: issue.teamDiscordRoleId, + assigneeDiscordUserIds: issue.assigneeDiscordUserIds, + channel, + day, + }; +}; + +const getIssueMentionTargets = (target: IssueReminderTarget): string[] => { + if (target.assigneeDiscordUserIds.length > 0) + return target.assigneeDiscordUserIds.map((id) => `<@${id}>`); + return [`<@&${target.teamDiscordRoleId}>`]; +}; + +type GroupedIssueReminders = Partial< + Record< + keyof typeof ISSUE_REMINDER_CHANNELS, + Partial> + > +>; + +const groupIssueReminderTargets = ( + targets: IssueReminderTarget[], +): GroupedIssueReminders => { + const grouped: GroupedIssueReminders = {}; + for (const t of targets) { + if (!grouped[t.channel]) grouped[t.channel] = {}; + const channelGroup = grouped[t.channel]; + if (!channelGroup) continue; + if (!channelGroup[t.day]) channelGroup[t.day] = []; + channelGroup[t.day].push(t); + } + return grouped; +}; + +const isIssueReminderTarget = ( + val: IssueReminderTarget | null, +): val is IssueReminderTarget => { + return val !== null; +}; + +export const issueReminders = new CronBuilder({ + name: "issue-reminders", + color: 2, +}).addCron("* * * * *", async () => { + const issues = await db.query.Issue.findMany({ + where: and(isNotNull(Issue.date), ne(Issue.status, "FINISHED")), + with: { + userAssignments: { + with: { + user: true, + }, + }, + }, + }); + const teamIds = [...new Set(issues.map((issue) => issue.team))]; + const roles = await db + .select({ + id: Roles.id, + discordRoleId: Roles.discordRoleId, + }) + .from(Roles) + .where(inArray(Roles.id, teamIds)); + + const roleDiscordIdByTeamId: Record = {}; + for (const r of roles) { + roleDiscordIdByTeamId[r.id] = r.discordRoleId; + } + const reminderTargets = issues + .map((issue) => + buildIssueReminderTarget({ + id: issue.id, + name: issue.name, + team: issue.team, + date: issue.date, + teamDiscordRoleId: roleDiscordIdByTeamId[issue.team] ?? "", + assigneeDiscordUserIds: issue.userAssignments.map( + (assignment) => assignment.user.discordUserId, + ), + }), + ) + .filter(isIssueReminderTarget); + + const groupedReminders = groupIssueReminderTargets(reminderTargets); +}); From 9be1ed19f560a18913d6bb366c7b9db319d3743e Mon Sep 17 00:00:00 2001 From: azizu06 Date: Mon, 23 Mar 2026 17:18:11 -0400 Subject: [PATCH 02/30] create helper for formatting channel reminder messages --- apps/cron/src/crons/issue-reminders.ts | 36 ++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index dbf59d2d4..f968c8291 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -86,6 +86,7 @@ const buildIssueReminderTarget = (issue: { if (!channel) return null; const day = getIssueReminderDay(issue.date); if (!day) return null; + if (!issue.teamDiscordRoleId) return null; return { issueId: issue.id, issueName: issue.name, @@ -130,6 +131,41 @@ const isIssueReminderTarget = ( return val !== null; }; +const ISSUE_REMINDER_DAY_LABELS: Record = { + Fourteen: "Due in 14 days", + Seven: "Due in 7 days", + Three: "Due in 3 days", + One: "Due in 1 day", + Overdue: "Overdue", +}; + +const ISSUE_REMINDER_DAY_ORDER: IssueReminderDay[] = [ + "Fourteen", + "Seven", + "Three", + "One", + "Overdue", +]; + +const formateIssueReminder = (target: IssueReminderTarget): string => { + const mentions = getIssueMentionTargets(target).join(", "); + return `-${target.issueName} ${mentions}`; +}; + +const formatChannelReminderMsg = ( + grouped: Partial>, +): string | null => { + const sections: string[] = []; + for (const day of ISSUE_REMINDER_DAY_ORDER) { + const targets = grouped[day]; + if (!targets || targets.length === 0) continue; + const lines = targets.map(formateIssueReminder).join("\n"); + sections.push(`## ${ISSUE_REMINDER_DAY_LABELS[day]}\n${lines}`); + } + if (sections.length === 0) return null; + return `# Issue Reminders\n${sections.join("\n\n")}`; +}; + export const issueReminders = new CronBuilder({ name: "issue-reminders", color: 2, From e4587ec14043b19d5817d842fb8fee1e226a258a Mon Sep 17 00:00:00 2001 From: azizu06 Date: Mon, 23 Mar 2026 17:28:36 -0400 Subject: [PATCH 03/30] add webhook envs for new issue reminder channels --- apps/cron/src/env.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/cron/src/env.ts b/apps/cron/src/env.ts index d8864e6d3..e86b606a8 100644 --- a/apps/cron/src/env.ts +++ b/apps/cron/src/env.ts @@ -9,6 +9,10 @@ export const env = createEnv({ DISCORD_WEBHOOK_REMINDERS: z.string(), DISCORD_WEBHOOK_REMINDERS_PRE: z.string(), DISCORD_WEBHOOK_REMINDERS_HACK: z.string(), + DISCORD_WEBHOOK_ISSUE_TEAM: z.string(), + DISCORD_WEBHOOK_ISSUE_DIRECTORS: z.string(), + DISCORD_WEBHOOK_ISSUE_DESIGN: z.string(), + DISCORD_WEBHOOK_ISSUE_HACKORG: z.string(), }, runtimeEnvStrict: { DISCORD_BOT_TOKEN: process.env.DISCORD_BOT_TOKEN, @@ -17,6 +21,11 @@ export const env = createEnv({ DISCORD_WEBHOOK_REMINDERS: process.env.DISCORD_WEBHOOK_REMINDERS, DISCORD_WEBHOOK_REMINDERS_PRE: process.env.DISCORD_WEBHOOK_REMINDERS_PRE, DISCORD_WEBHOOK_REMINDERS_HACK: process.env.DISCORD_WEBHOOK_REMINDERS_HACK, + DISCORD_WEBHOOK_ISSUE_TEAM: process.env.DISCORD_WEBHOOK_ISSUE_TEAM, + DISCORD_WEBHOOK_ISSUE_DIRECTORS: + process.env.DISCORD_WEBHOOK_ISSUE_DIRECTORS, + DISCORD_WEBHOOK_ISSUE_DESIGN: process.env.DISCORD_WEBHOOK_ISSUE_DESIGN, + DISCORD_WEBHOOK_ISSUE_HACKORG: process.env.DISCORD_WEBHOOK_ISSUE_HACKORG, }, skipValidation: !!process.env.CI || process.env.npm_lifecycle_event === "lint", From 43a2116720d8f72399081199c987f8ab39fef87a Mon Sep 17 00:00:00 2001 From: azizu06 Date: Mon, 23 Mar 2026 18:17:53 -0400 Subject: [PATCH 04/30] add new webhook url channels --- apps/cron/src/crons/issue-reminders.ts | 126 +++++++++++++++---------- 1 file changed, 76 insertions(+), 50 deletions(-) diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index f968c8291..a73f39607 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -1,3 +1,4 @@ +import { WebhookClient } from "discord.js"; import { and, inArray, isNotNull, ne } from "drizzle-orm"; import { db } from "@forge/db/client"; @@ -5,6 +6,7 @@ import { Roles } from "@forge/db/schemas/auth"; import { Issue } from "@forge/db/schemas/knight-hacks"; import { logger } from "@forge/utils"; +import { env } from "../env"; import { CronBuilder } from "../structs/CronBuilder"; const ISSUE_REMINDER_CHANNELS = { @@ -14,13 +16,20 @@ const ISSUE_REMINDER_CHANNELS = { HackOrg: "HackOrg", } as const; +const ISSUE_REMINDER_WEBHOOKS = { + Team: new WebhookClient({ url: env.DISCORD_WEBHOOK_ISSUE_TEAM }), + Directors: new WebhookClient({ url: env.DISCORD_WEBHOOK_ISSUE_DIRECTORS }), + Design: new WebhookClient({ url: env.DISCORD_WEBHOOK_ISSUE_DESIGN }), + HackOrg: new WebhookClient({ url: env.DISCORD_WEBHOOK_ISSUE_HACKORG }), +} as const; + const ISSUE_TEAM_CHANNEL_MAP: Record< string, keyof typeof ISSUE_REMINDER_CHANNELS > = { - "16ced653-dafd-46bc-a6ef-8f4fba6a6b46": "Team", - "f4f544bf-7c69-43c1-b4b4-0585e73268a7": "Team", - "9fc780ed-3c84-4e9a-bd10-b5c2be51f5a8": "Team", + "16ced653-dafd-46bc-a6ef-8f4fba6a6b46": "Team", // Workshop Team + "f4f544bf-7c69-43c1-b4b4-0585e73268a7": "Team", // Dev Team + "9fc780ed-3c84-4e9a-bd10-b5c2be51f5a8": "Team", // Outreach Team "b86a437b-0789-4ec4-8011-5ddde24865dc": "Directors", "3b03b15d-4368-49e6-86c9-48c11775430b": "Design", "110f5d0c-3299-46f6-b057-ae2ce28d4778": "HackOrg", @@ -34,9 +43,42 @@ const ISSUE_REMINDER_DAYS = { Overdue: "Overdue", } as const; +const ISSUE_REMINDER_DAY_LABELS: Record = { + Fourteen: "Due in 14 days", + Seven: "Due in 7 days", + Three: "Due in 3 days", + One: "Due in 1 day", + Overdue: "Overdue", +}; + +const ISSUE_REMINDER_DAY_ORDER: IssueReminderDay[] = [ + "Fourteen", + "Seven", + "Three", + "One", + "Overdue", +]; + type IssueReminderDay = (typeof ISSUE_REMINDER_DAYS)[keyof typeof ISSUE_REMINDER_DAYS]; +type IssueReminderTarget = { + issueId: string; + issueName: string; + teamId: string; + teamDiscordRoleId: string; + assigneeDiscordUserIds: string[]; + reminderChannel: keyof typeof ISSUE_REMINDER_CHANNELS; + reminderDay: IssueReminderDay; +}; + +type GroupedIssueReminders = Partial< + Record< + keyof typeof ISSUE_REMINDER_CHANNELS, + Partial> + > +>; + const getIssueReminderDay = ( date: Date, now = new Date(), @@ -63,16 +105,6 @@ const getIssueReminderChannel = ( return ISSUE_TEAM_CHANNEL_MAP[teamId] ?? null; }; -type IssueReminderTarget = { - issueId: string; - issueName: string; - teamId: string; - teamDiscordRoleId: string; - assigneeDiscordUserIds: string[]; - channel: keyof typeof ISSUE_REMINDER_CHANNELS; - day: IssueReminderDay; -}; - const buildIssueReminderTarget = (issue: { id: string; name: string; @@ -82,10 +114,10 @@ const buildIssueReminderTarget = (issue: { assigneeDiscordUserIds: string[]; }): IssueReminderTarget | null => { if (!issue.date) return null; - const channel = getIssueReminderChannel(issue.team); - if (!channel) return null; - const day = getIssueReminderDay(issue.date); - if (!day) return null; + const reminderChannel = getIssueReminderChannel(issue.team); + if (!reminderChannel) return null; + const reminderDay = getIssueReminderDay(issue.date); + if (!reminderDay) return null; if (!issue.teamDiscordRoleId) return null; return { issueId: issue.id, @@ -93,8 +125,8 @@ const buildIssueReminderTarget = (issue: { teamId: issue.team, teamDiscordRoleId: issue.teamDiscordRoleId, assigneeDiscordUserIds: issue.assigneeDiscordUserIds, - channel, - day, + reminderChannel, + reminderDay, }; }; @@ -104,23 +136,16 @@ const getIssueMentionTargets = (target: IssueReminderTarget): string[] => { return [`<@&${target.teamDiscordRoleId}>`]; }; -type GroupedIssueReminders = Partial< - Record< - keyof typeof ISSUE_REMINDER_CHANNELS, - Partial> - > ->; - const groupIssueReminderTargets = ( targets: IssueReminderTarget[], ): GroupedIssueReminders => { const grouped: GroupedIssueReminders = {}; for (const t of targets) { - if (!grouped[t.channel]) grouped[t.channel] = {}; - const channelGroup = grouped[t.channel]; + if (!grouped[t.reminderChannel]) grouped[t.reminderChannel] = {}; + const channelGroup = grouped[t.reminderChannel]; if (!channelGroup) continue; - if (!channelGroup[t.day]) channelGroup[t.day] = []; - channelGroup[t.day].push(t); + if (!channelGroup[t.reminderDay]) channelGroup[t.reminderDay] = []; + channelGroup[t.reminderDay].push(t); } return grouped; }; @@ -131,25 +156,10 @@ const isIssueReminderTarget = ( return val !== null; }; -const ISSUE_REMINDER_DAY_LABELS: Record = { - Fourteen: "Due in 14 days", - Seven: "Due in 7 days", - Three: "Due in 3 days", - One: "Due in 1 day", - Overdue: "Overdue", -}; - -const ISSUE_REMINDER_DAY_ORDER: IssueReminderDay[] = [ - "Fourteen", - "Seven", - "Three", - "One", - "Overdue", -]; - -const formateIssueReminder = (target: IssueReminderTarget): string => { +const formatIssueReminder = (target: IssueReminderTarget): string => { const mentions = getIssueMentionTargets(target).join(", "); - return `-${target.issueName} ${mentions}`; + const issueUrl = getIssueUrl(target.issueId); + return `- [${target.issueName}](${issueUrl}) ${mentions}`; }; const formatChannelReminderMsg = ( @@ -159,17 +169,21 @@ const formatChannelReminderMsg = ( for (const day of ISSUE_REMINDER_DAY_ORDER) { const targets = grouped[day]; if (!targets || targets.length === 0) continue; - const lines = targets.map(formateIssueReminder).join("\n"); + const lines = targets.map(formatIssueReminder).join("\n"); sections.push(`## ${ISSUE_REMINDER_DAY_LABELS[day]}\n${lines}`); } if (sections.length === 0) return null; return `# Issue Reminders\n${sections.join("\n\n")}`; }; +const getIssueUrl = (issueId: string): string => { + return `${env.BLADE_URL}/issues/${issueId}`; +}; + export const issueReminders = new CronBuilder({ name: "issue-reminders", color: 2, -}).addCron("* * * * *", async () => { +}).addCron("0 9 * * *", async () => { const issues = await db.query.Issue.findMany({ where: and(isNotNull(Issue.date), ne(Issue.status, "FINISHED")), with: { @@ -209,4 +223,16 @@ export const issueReminders = new CronBuilder({ .filter(isIssueReminderTarget); const groupedReminders = groupIssueReminderTargets(reminderTargets); + for (const channel of Object.keys( + ISSUE_REMINDER_CHANNELS, + ) as (keyof typeof ISSUE_REMINDER_CHANNELS)[]) { + const groupedChannel = groupedReminders[channel]; + if (!groupedChannel) continue; + const msg = formatChannelReminderMsg(groupedChannel); + if (!msg) continue; + logger.log(`Would send issue reminders to ${channel}:`); + logger.log(msg); + // Enable after real webhook URLs are configured. + // await ISSUE_REMINDER_WEBHOOKS[channel].send({ content: msg }); + } }); From cf30b4a437c3e912eaf1e3a48b60d0338092031e Mon Sep 17 00:00:00 2001 From: azizu06 Date: Mon, 23 Mar 2026 18:26:00 -0400 Subject: [PATCH 05/30] add blade to cron env --- apps/cron/src/env.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/cron/src/env.ts b/apps/cron/src/env.ts index e86b606a8..31a603ed4 100644 --- a/apps/cron/src/env.ts +++ b/apps/cron/src/env.ts @@ -9,10 +9,11 @@ export const env = createEnv({ DISCORD_WEBHOOK_REMINDERS: z.string(), DISCORD_WEBHOOK_REMINDERS_PRE: z.string(), DISCORD_WEBHOOK_REMINDERS_HACK: z.string(), - DISCORD_WEBHOOK_ISSUE_TEAM: z.string(), + DISCORD_WEBHOOK_ISSUE_TEAMS: z.string(), DISCORD_WEBHOOK_ISSUE_DIRECTORS: z.string(), DISCORD_WEBHOOK_ISSUE_DESIGN: z.string(), DISCORD_WEBHOOK_ISSUE_HACKORG: z.string(), + BLADE_URL: z.string(), }, runtimeEnvStrict: { DISCORD_BOT_TOKEN: process.env.DISCORD_BOT_TOKEN, @@ -21,11 +22,12 @@ export const env = createEnv({ DISCORD_WEBHOOK_REMINDERS: process.env.DISCORD_WEBHOOK_REMINDERS, DISCORD_WEBHOOK_REMINDERS_PRE: process.env.DISCORD_WEBHOOK_REMINDERS_PRE, DISCORD_WEBHOOK_REMINDERS_HACK: process.env.DISCORD_WEBHOOK_REMINDERS_HACK, - DISCORD_WEBHOOK_ISSUE_TEAM: process.env.DISCORD_WEBHOOK_ISSUE_TEAM, + DISCORD_WEBHOOK_ISSUE_TEAMS: process.env.DISCORD_WEBHOOK_ISSUE_TEAMS, DISCORD_WEBHOOK_ISSUE_DIRECTORS: process.env.DISCORD_WEBHOOK_ISSUE_DIRECTORS, DISCORD_WEBHOOK_ISSUE_DESIGN: process.env.DISCORD_WEBHOOK_ISSUE_DESIGN, DISCORD_WEBHOOK_ISSUE_HACKORG: process.env.DISCORD_WEBHOOK_ISSUE_HACKORG, + BLADE_URL: process.env.BLADE_URL, }, skipValidation: !!process.env.CI || process.env.npm_lifecycle_event === "lint", From 35d3b1fa95138d1186b2368aee8b821a4ef56665 Mon Sep 17 00:00:00 2001 From: azizu06 Date: Mon, 23 Mar 2026 18:26:17 -0400 Subject: [PATCH 06/30] run the issue reminder schedule --- apps/cron/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/cron/src/index.ts b/apps/cron/src/index.ts index 90a8c9275..3677a7809 100644 --- a/apps/cron/src/index.ts +++ b/apps/cron/src/index.ts @@ -1,6 +1,7 @@ import { alumniAssign } from "./crons/alumni-assign"; import { capybara, cat, duck, goat } from "./crons/animals"; import { backupFilteredDb } from "./crons/backup-filtered-db"; +import { issueReminders } from "./crons/issue-reminders"; import { leetcode } from "./crons/leetcode"; import { preReminders, reminders } from "./crons/reminder"; import { roleSync } from "./crons/role-sync"; @@ -23,3 +24,5 @@ reminders.schedule(); // hackReminders.schedule(); roleSync.schedule(); + +issueReminders.schedule(); From 98c8730c3e726a76be67d6723765bfd9c91a91bf Mon Sep 17 00:00:00 2001 From: azizu06 Date: Mon, 23 Mar 2026 18:26:45 -0400 Subject: [PATCH 07/30] include dry runs for channel msgs --- apps/cron/src/crons/issue-reminders.ts | 48 +++++++++++++------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index a73f39607..9f19cf00f 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -9,27 +9,27 @@ import { logger } from "@forge/utils"; import { env } from "../env"; import { CronBuilder } from "../structs/CronBuilder"; -const ISSUE_REMINDER_CHANNELS = { - Team: "Team", - Directors: "Directors", - Design: "Design", - HackOrg: "HackOrg", -} as const; - const ISSUE_REMINDER_WEBHOOKS = { - Team: new WebhookClient({ url: env.DISCORD_WEBHOOK_ISSUE_TEAM }), + Teams: new WebhookClient({ url: env.DISCORD_WEBHOOK_ISSUE_TEAMS }), Directors: new WebhookClient({ url: env.DISCORD_WEBHOOK_ISSUE_DIRECTORS }), Design: new WebhookClient({ url: env.DISCORD_WEBHOOK_ISSUE_DESIGN }), HackOrg: new WebhookClient({ url: env.DISCORD_WEBHOOK_ISSUE_HACKORG }), } as const; +const ISSUE_REMINDER_CHANNELS = { + Teams: "Teams", + Directors: "Directors", + Design: "Design", + HackOrg: "HackOrg", +} as const; + const ISSUE_TEAM_CHANNEL_MAP: Record< string, keyof typeof ISSUE_REMINDER_CHANNELS > = { - "16ced653-dafd-46bc-a6ef-8f4fba6a6b46": "Team", // Workshop Team - "f4f544bf-7c69-43c1-b4b4-0585e73268a7": "Team", // Dev Team - "9fc780ed-3c84-4e9a-bd10-b5c2be51f5a8": "Team", // Outreach Team + "16ced653-dafd-46bc-a6ef-8f4fba6a6b46": "Teams", + "f4f544bf-7c69-43c1-b4b4-0585e73268a7": "Teams", + "9fc780ed-3c84-4e9a-bd10-b5c2be51f5a8": "Teams", "b86a437b-0789-4ec4-8011-5ddde24865dc": "Directors", "3b03b15d-4368-49e6-86c9-48c11775430b": "Design", "110f5d0c-3299-46f6-b057-ae2ce28d4778": "HackOrg", @@ -68,8 +68,8 @@ type IssueReminderTarget = { teamId: string; teamDiscordRoleId: string; assigneeDiscordUserIds: string[]; - reminderChannel: keyof typeof ISSUE_REMINDER_CHANNELS; - reminderDay: IssueReminderDay; + channel: keyof typeof ISSUE_REMINDER_CHANNELS; + day: IssueReminderDay; }; type GroupedIssueReminders = Partial< @@ -114,10 +114,10 @@ const buildIssueReminderTarget = (issue: { assigneeDiscordUserIds: string[]; }): IssueReminderTarget | null => { if (!issue.date) return null; - const reminderChannel = getIssueReminderChannel(issue.team); - if (!reminderChannel) return null; - const reminderDay = getIssueReminderDay(issue.date); - if (!reminderDay) return null; + const channel = getIssueReminderChannel(issue.team); + if (!channel) return null; + const day = getIssueReminderDay(issue.date); + if (!day) return null; if (!issue.teamDiscordRoleId) return null; return { issueId: issue.id, @@ -125,8 +125,8 @@ const buildIssueReminderTarget = (issue: { teamId: issue.team, teamDiscordRoleId: issue.teamDiscordRoleId, assigneeDiscordUserIds: issue.assigneeDiscordUserIds, - reminderChannel, - reminderDay, + channel, + day, }; }; @@ -141,11 +141,11 @@ const groupIssueReminderTargets = ( ): GroupedIssueReminders => { const grouped: GroupedIssueReminders = {}; for (const t of targets) { - if (!grouped[t.reminderChannel]) grouped[t.reminderChannel] = {}; - const channelGroup = grouped[t.reminderChannel]; + if (!grouped[t.channel]) grouped[t.channel] = {}; + const channelGroup = grouped[t.channel]; if (!channelGroup) continue; - if (!channelGroup[t.reminderDay]) channelGroup[t.reminderDay] = []; - channelGroup[t.reminderDay].push(t); + if (!channelGroup[t.day]) channelGroup[t.day] = []; + channelGroup[t.day].push(t); } return grouped; }; @@ -230,9 +230,9 @@ export const issueReminders = new CronBuilder({ if (!groupedChannel) continue; const msg = formatChannelReminderMsg(groupedChannel); if (!msg) continue; + logger.log(`Would send issue reminders to ${channel}:`); logger.log(msg); - // Enable after real webhook URLs are configured. // await ISSUE_REMINDER_WEBHOOKS[channel].send({ content: msg }); } }); From 185149104fe16e4f0e699abef3d10462436597c0 Mon Sep 17 00:00:00 2001 From: azizu06 Date: Mon, 23 Mar 2026 18:35:23 -0400 Subject: [PATCH 08/30] extend getIssue api to inlcude related team and assignees --- packages/api/src/routers/issues.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/api/src/routers/issues.ts b/packages/api/src/routers/issues.ts index 43ac6d867..7c1305a7e 100644 --- a/packages/api/src/routers/issues.ts +++ b/packages/api/src/routers/issues.ts @@ -131,6 +131,10 @@ export const issuesRouter = { } const issue = await db.query.Issue.findFirst({ where: and(eq(Issue.id, input.id), visibilityFilter), + with: { + teamVisibility: { with: { team: true } }, + userAssignments: { with: { uset: true } }, + }, }); if (!issue) throw new TRPCError({ message: `Issue not found.`, code: "NOT_FOUND" }); From 9c82f41d991cfedf78d8a300416e9783c361392f Mon Sep 17 00:00:00 2001 From: azizu06 Date: Mon, 23 Mar 2026 18:59:32 -0400 Subject: [PATCH 09/30] add the owning team of each issue --- packages/api/src/routers/issues.ts | 3 ++- packages/db/src/schemas/relations.ts | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/api/src/routers/issues.ts b/packages/api/src/routers/issues.ts index 7c1305a7e..e6517a4e6 100644 --- a/packages/api/src/routers/issues.ts +++ b/packages/api/src/routers/issues.ts @@ -132,8 +132,9 @@ export const issuesRouter = { const issue = await db.query.Issue.findFirst({ where: and(eq(Issue.id, input.id), visibilityFilter), with: { + team: true, teamVisibility: { with: { team: true } }, - userAssignments: { with: { uset: true } }, + userAssignments: { with: { user: true } }, }, }); if (!issue) diff --git a/packages/db/src/schemas/relations.ts b/packages/db/src/schemas/relations.ts index 482898216..8ed571d62 100644 --- a/packages/db/src/schemas/relations.ts +++ b/packages/db/src/schemas/relations.ts @@ -38,7 +38,11 @@ export const PermissionRelations = relations(Permissions, ({ one }) => ({ }), })); -export const IssueRelations = relations(Issue, ({ many }) => ({ +export const IssueRelations = relations(Issue, ({ many, one }) => ({ + team: one(Roles, { + fields: [Issue.team], + references: [Roles.id], + }), teamVisibility: many(IssuesToTeamsVisibility), userAssignments: many(IssuesToUsersAssignment), })); From b6e7a0db65dcb2567b86ad584e74d18b7fec598b Mon Sep 17 00:00:00 2001 From: azizu06 Date: Mon, 23 Mar 2026 19:00:01 -0400 Subject: [PATCH 10/30] make minimal page for issue reminder hyperlink --- apps/blade/src/app/issues/[id]/page.tsx | 115 ++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 apps/blade/src/app/issues/[id]/page.tsx diff --git a/apps/blade/src/app/issues/[id]/page.tsx b/apps/blade/src/app/issues/[id]/page.tsx new file mode 100644 index 000000000..aa6ace110 --- /dev/null +++ b/apps/blade/src/app/issues/[id]/page.tsx @@ -0,0 +1,115 @@ +import { redirect } from "next/navigation"; + +import { auth } from "@forge/auth"; + +import { SIGN_IN_PATH } from "~/consts"; +import { api } from "~/trpc/server"; + +type IssuePageProps = { + params: Promise<{ + id: string; + }>; +}; + +export default async function IssuePage({ params }: IssuePageProps) { + const session = await auth(); + if (!session) redirect(SIGN_IN_PATH); + const { id } = await params; + const issue = await api.issues.getIssue({ id }); + return ( +
+
+

Issue

+

{issue.name}

+
+ +
+
+

Status

+

{issue.status}

+
+ +
+

Due Date

+

+ {issue.date + ? new Date(issue.date).toLocaleDateString() + : "No due date"} +

+
+
+ +
+

Owning Team

+

+ {issue.team?.name ?? "Unknown team"} +

+
+ +
+

Description

+

+ {issue.description} +

+
+ +
+
+

Assignees

+
+ {issue.userAssignments.length > 0 ? ( +
    + {issue.userAssignments.map((assignment) => ( +
  • + {assignment.user.name ?? assignment.user.discordUserId} +
  • + ))} +
+ ) : ( +

Unassigned

+ )} +
+
+ +
+

Visible Teams

+
+ {issue.teamVisibility.length > 0 ? ( +
    + {issue.teamVisibility.map((visibility) => ( +
  • {visibility.team.name}
  • + ))} +
+ ) : ( +

No team visibility rules

+ )} +
+
+
+ +
+

Links

+
+ {issue.links && issue.links.length > 0 ? ( + + ) : ( +

No links

+ )} +
+
+
+ ); +} From 16d03a6edfcc20963ea7f3c4a9f9663fee6e0b34 Mon Sep 17 00:00:00 2001 From: azizu06 Date: Mon, 23 Mar 2026 19:39:27 -0400 Subject: [PATCH 11/30] remove debugs --- apps/blade/src/app/issues/[id]/page.tsx | 22 +++++++++----------- apps/cron/src/crons/issue-reminders.ts | 27 ++++++++++++++----------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/apps/blade/src/app/issues/[id]/page.tsx b/apps/blade/src/app/issues/[id]/page.tsx index aa6ace110..9c7481b13 100644 --- a/apps/blade/src/app/issues/[id]/page.tsx +++ b/apps/blade/src/app/issues/[id]/page.tsx @@ -5,11 +5,11 @@ import { auth } from "@forge/auth"; import { SIGN_IN_PATH } from "~/consts"; import { api } from "~/trpc/server"; -type IssuePageProps = { +interface IssuePageProps { params: Promise<{ id: string; }>; -}; +} export default async function IssuePage({ params }: IssuePageProps) { const session = await auth(); @@ -19,19 +19,19 @@ export default async function IssuePage({ params }: IssuePageProps) { return (
-

Issue

+

Issue

{issue.name}

Status

-

{issue.status}

+

{issue.status}

Due Date

-

+

{issue.date ? new Date(issue.date).toLocaleDateString() : "No due date"} @@ -41,14 +41,12 @@ export default async function IssuePage({ params }: IssuePageProps) {

Owning Team

-

- {issue.team?.name ?? "Unknown team"} -

+

{issue.team.name}

Description

-

+

{issue.description}

@@ -56,7 +54,7 @@ export default async function IssuePage({ params }: IssuePageProps) {

Assignees

-
+
{issue.userAssignments.length > 0 ? (
    {issue.userAssignments.map((assignment) => ( @@ -73,7 +71,7 @@ export default async function IssuePage({ params }: IssuePageProps) {

    Visible Teams

    -
    +
    {issue.teamVisibility.length > 0 ? (
      {issue.teamVisibility.map((visibility) => ( @@ -89,7 +87,7 @@ export default async function IssuePage({ params }: IssuePageProps) {

      Links

      -
      +
      {issue.links && issue.links.length > 0 ? (
        {issue.links.map((link) => ( diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index 9f19cf00f..0089fadd1 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -1,4 +1,3 @@ -import { WebhookClient } from "discord.js"; import { and, inArray, isNotNull, ne } from "drizzle-orm"; import { db } from "@forge/db/client"; @@ -9,11 +8,11 @@ import { logger } from "@forge/utils"; import { env } from "../env"; import { CronBuilder } from "../structs/CronBuilder"; -const ISSUE_REMINDER_WEBHOOKS = { - Teams: new WebhookClient({ url: env.DISCORD_WEBHOOK_ISSUE_TEAMS }), - Directors: new WebhookClient({ url: env.DISCORD_WEBHOOK_ISSUE_DIRECTORS }), - Design: new WebhookClient({ url: env.DISCORD_WEBHOOK_ISSUE_DESIGN }), - HackOrg: new WebhookClient({ url: env.DISCORD_WEBHOOK_ISSUE_HACKORG }), +const _ISSUE_REMINDER_WEBHOOK_URLS = { + Teams: env.DISCORD_WEBHOOK_ISSUE_TEAMS, + Directors: env.DISCORD_WEBHOOK_ISSUE_DIRECTORS, + Design: env.DISCORD_WEBHOOK_ISSUE_DESIGN, + HackOrg: env.DISCORD_WEBHOOK_ISSUE_HACKORG, } as const; const ISSUE_REMINDER_CHANNELS = { @@ -62,7 +61,7 @@ const ISSUE_REMINDER_DAY_ORDER: IssueReminderDay[] = [ type IssueReminderDay = (typeof ISSUE_REMINDER_DAYS)[keyof typeof ISSUE_REMINDER_DAYS]; -type IssueReminderTarget = { +interface IssueReminderTarget { issueId: string; issueName: string; teamId: string; @@ -70,7 +69,7 @@ type IssueReminderTarget = { assigneeDiscordUserIds: string[]; channel: keyof typeof ISSUE_REMINDER_CHANNELS; day: IssueReminderDay; -}; +} type GroupedIssueReminders = Partial< Record< @@ -141,11 +140,13 @@ const groupIssueReminderTargets = ( ): GroupedIssueReminders => { const grouped: GroupedIssueReminders = {}; for (const t of targets) { - if (!grouped[t.channel]) grouped[t.channel] = {}; + grouped[t.channel] ??= {}; const channelGroup = grouped[t.channel]; if (!channelGroup) continue; - if (!channelGroup[t.day]) channelGroup[t.day] = []; - channelGroup[t.day].push(t); + channelGroup[t.day] ??= []; + const dayGroup = channelGroup[t.day]; + if (!dayGroup) continue; + dayGroup.push(t); } return grouped; }; @@ -233,6 +234,8 @@ export const issueReminders = new CronBuilder({ logger.log(`Would send issue reminders to ${channel}:`); logger.log(msg); - // await ISSUE_REMINDER_WEBHOOKS[channel].send({ content: msg }); + // await new WebhookClient({ + // url: _ISSUE_REMINDER_WEBHOOK_URLS[channel], + // }).send({ content: msg }); } }); From 68afef81bd93a25cd96a7a4a9c761e9a66e8993e Mon Sep 17 00:00:00 2001 From: azizu06 Date: Mon, 23 Mar 2026 23:59:11 -0400 Subject: [PATCH 12/30] remove test logs --- apps/cron/src/crons/issue-reminders.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index 0089fadd1..c8458daa5 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -8,7 +8,7 @@ import { logger } from "@forge/utils"; import { env } from "../env"; import { CronBuilder } from "../structs/CronBuilder"; -const _ISSUE_REMINDER_WEBHOOK_URLS = { +const ISSUE_REMINDER_WEBHOOK_URLS = { Teams: env.DISCORD_WEBHOOK_ISSUE_TEAMS, Directors: env.DISCORD_WEBHOOK_ISSUE_DIRECTORS, Design: env.DISCORD_WEBHOOK_ISSUE_DESIGN, @@ -178,7 +178,7 @@ const formatChannelReminderMsg = ( }; const getIssueUrl = (issueId: string): string => { - return `${env.BLADE_URL}/issues/${issueId}`; + return `${env.BLADE_URL.replace(/\/$/, "")}/issues/${issueId}`; }; export const issueReminders = new CronBuilder({ @@ -232,10 +232,8 @@ export const issueReminders = new CronBuilder({ const msg = formatChannelReminderMsg(groupedChannel); if (!msg) continue; - logger.log(`Would send issue reminders to ${channel}:`); - logger.log(msg); // await new WebhookClient({ - // url: _ISSUE_REMINDER_WEBHOOK_URLS[channel], + // url: ISSUE_REMINDER_WEBHOOK_URLS[channel], // }).send({ content: msg }); } }); From bd34e54283a5efa5804d433d0cfd2216047cd90e Mon Sep 17 00:00:00 2001 From: azizu06 Date: Tue, 24 Mar 2026 01:42:03 -0400 Subject: [PATCH 13/30] add notfound redirect if issue url param doesnt exist --- apps/blade/src/app/issues/[id]/page.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/blade/src/app/issues/[id]/page.tsx b/apps/blade/src/app/issues/[id]/page.tsx index 9c7481b13..38e8e2b6b 100644 --- a/apps/blade/src/app/issues/[id]/page.tsx +++ b/apps/blade/src/app/issues/[id]/page.tsx @@ -1,4 +1,5 @@ -import { redirect } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; +import { z } from "zod"; import { auth } from "@forge/auth"; @@ -15,7 +16,14 @@ export default async function IssuePage({ params }: IssuePageProps) { const session = await auth(); if (!session) redirect(SIGN_IN_PATH); const { id } = await params; - const issue = await api.issues.getIssue({ id }); + if (!z.string().uuid().safeParse(id).success) notFound(); + let issue; + try { + issue = await api.issues.getIssue({ id }); + } catch { + notFound(); + } + return (
        From 66f6a723429dc7abd36bd625be348827a178368c Mon Sep 17 00:00:00 2001 From: azizu06 Date: Tue, 24 Mar 2026 01:42:44 -0400 Subject: [PATCH 14/30] update env configuration to make issue webhooks optional and add reminders enabled flag --- apps/cron/src/env.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/cron/src/env.ts b/apps/cron/src/env.ts index 31a603ed4..2194df577 100644 --- a/apps/cron/src/env.ts +++ b/apps/cron/src/env.ts @@ -9,11 +9,12 @@ export const env = createEnv({ DISCORD_WEBHOOK_REMINDERS: z.string(), DISCORD_WEBHOOK_REMINDERS_PRE: z.string(), DISCORD_WEBHOOK_REMINDERS_HACK: z.string(), - DISCORD_WEBHOOK_ISSUE_TEAMS: z.string(), - DISCORD_WEBHOOK_ISSUE_DIRECTORS: z.string(), - DISCORD_WEBHOOK_ISSUE_DESIGN: z.string(), - DISCORD_WEBHOOK_ISSUE_HACKORG: z.string(), - BLADE_URL: z.string(), + DISCORD_WEBHOOK_ISSUE_TEAMS: z.string().url().optional(), + DISCORD_WEBHOOK_ISSUE_DIRECTORS: z.string().url().optional(), + DISCORD_WEBHOOK_ISSUE_DESIGN: z.string().url().optional(), + DISCORD_WEBHOOK_ISSUE_HACKORG: z.string().url().optional(), + ISSUE_REMINDERS_ENABLED: z.enum(["true", "false"]).optional(), + BLADE_URL: z.string().url(), }, runtimeEnvStrict: { DISCORD_BOT_TOKEN: process.env.DISCORD_BOT_TOKEN, @@ -27,6 +28,7 @@ export const env = createEnv({ process.env.DISCORD_WEBHOOK_ISSUE_DIRECTORS, DISCORD_WEBHOOK_ISSUE_DESIGN: process.env.DISCORD_WEBHOOK_ISSUE_DESIGN, DISCORD_WEBHOOK_ISSUE_HACKORG: process.env.DISCORD_WEBHOOK_ISSUE_HACKORG, + ISSUE_REMINDERS_ENABLED: process.env.ISSUE_REMINDERS_ENABLED, BLADE_URL: process.env.BLADE_URL, }, skipValidation: From d8ea5a5441aa8ea8310c30521fb4ed20ee514031 Mon Sep 17 00:00:00 2001 From: azizu06 Date: Tue, 24 Mar 2026 01:44:41 -0400 Subject: [PATCH 15/30] add Discord webhook integration for issue reminders and improve logging when channel doesnt exist --- apps/cron/src/crons/issue-reminders.ts | 27 +++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index c8458daa5..b69ef47ca 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -1,3 +1,4 @@ +import { WebhookClient } from "discord.js"; import { and, inArray, isNotNull, ne } from "drizzle-orm"; import { db } from "@forge/db/client"; @@ -101,7 +102,11 @@ const getIssueReminderDay = ( const getIssueReminderChannel = ( teamId: string, ): keyof typeof ISSUE_REMINDER_CHANNELS | null => { - return ISSUE_TEAM_CHANNEL_MAP[teamId] ?? null; + const channel = ISSUE_TEAM_CHANNEL_MAP[teamId] ?? null; + if (!channel) { + logger.warn(`Skipping issue reminder: no channel mapping for team ${teamId}.`); + } + return channel; }; const buildIssueReminderTarget = (issue: { @@ -160,7 +165,7 @@ const isIssueReminderTarget = ( const formatIssueReminder = (target: IssueReminderTarget): string => { const mentions = getIssueMentionTargets(target).join(", "); const issueUrl = getIssueUrl(target.issueId); - return `- [${target.issueName}](${issueUrl}) ${mentions}`; + return `- ${target.issueName}: ${issueUrl} ${mentions}`; }; const formatChannelReminderMsg = ( @@ -185,6 +190,8 @@ export const issueReminders = new CronBuilder({ name: "issue-reminders", color: 2, }).addCron("0 9 * * *", async () => { + if (env.ISSUE_REMINDERS_ENABLED !== "true") return; + const issues = await db.query.Issue.findMany({ where: and(isNotNull(Issue.date), ne(Issue.status, "FINISHED")), with: { @@ -195,6 +202,8 @@ export const issueReminders = new CronBuilder({ }, }, }); + if (issues.length === 0) return; + const teamIds = [...new Set(issues.map((issue) => issue.team))]; const roles = await db .select({ @@ -229,11 +238,19 @@ export const issueReminders = new CronBuilder({ ) as (keyof typeof ISSUE_REMINDER_CHANNELS)[]) { const groupedChannel = groupedReminders[channel]; if (!groupedChannel) continue; + + if (!ISSUE_REMINDER_WEBHOOK_URLS[channel]) { + logger.warn( + `Skipping issue reminders for ${channel}: webhook URL is not configured.`, + ); + continue; + } + const msg = formatChannelReminderMsg(groupedChannel); if (!msg) continue; - // await new WebhookClient({ - // url: ISSUE_REMINDER_WEBHOOK_URLS[channel], - // }).send({ content: msg }); + await new WebhookClient({ + url: ISSUE_REMINDER_WEBHOOK_URLS[channel], + }).send({ content: msg }); } }); From 0f310cafb08ff2e9b71b2fbd63c965cd025719da Mon Sep 17 00:00:00 2001 From: azizu06 Date: Tue, 24 Mar 2026 01:48:35 -0400 Subject: [PATCH 16/30] fix formatting --- apps/cron/src/crons/issue-reminders.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index b69ef47ca..f76ae94c7 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -104,7 +104,9 @@ const getIssueReminderChannel = ( ): keyof typeof ISSUE_REMINDER_CHANNELS | null => { const channel = ISSUE_TEAM_CHANNEL_MAP[teamId] ?? null; if (!channel) { - logger.warn(`Skipping issue reminder: no channel mapping for team ${teamId}.`); + logger.warn( + `Skipping issue reminder: no channel mapping for team ${teamId}.`, + ); } return channel; }; From 12fd90476af770abc657c20f5bdefd156442562c Mon Sep 17 00:00:00 2001 From: azizu06 Date: Tue, 24 Mar 2026 13:02:43 -0400 Subject: [PATCH 17/30] add logging message for cron when the reminder channels are disabled. --- apps/blade/src/app/issues/[id]/page.tsx | 2 +- apps/cron/src/crons/issue-reminders.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/blade/src/app/issues/[id]/page.tsx b/apps/blade/src/app/issues/[id]/page.tsx index 38e8e2b6b..a43f9c785 100644 --- a/apps/blade/src/app/issues/[id]/page.tsx +++ b/apps/blade/src/app/issues/[id]/page.tsx @@ -48,7 +48,7 @@ export default async function IssuePage({ params }: IssuePageProps) {
        -

        Owning Team

        +

        Team

        {issue.team.name}

        diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index f76ae94c7..6afd27720 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -192,7 +192,10 @@ export const issueReminders = new CronBuilder({ name: "issue-reminders", color: 2, }).addCron("0 9 * * *", async () => { - if (env.ISSUE_REMINDERS_ENABLED !== "true") return; + if (env.ISSUE_REMINDERS_ENABLED !== "true") { + logger.log("Issue reminders are disabled; skipping run."); + return; + } const issues = await db.query.Issue.findMany({ where: and(isNotNull(Issue.date), ne(Issue.status, "FINISHED")), From 8a2af3b10f9be99230ffe60ce1c3bee61c206f42 Mon Sep 17 00:00:00 2001 From: azizu06 Date: Tue, 24 Mar 2026 13:41:10 -0400 Subject: [PATCH 18/30] add page level perm check for reading issues --- apps/blade/src/app/issues/[id]/page.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/blade/src/app/issues/[id]/page.tsx b/apps/blade/src/app/issues/[id]/page.tsx index a43f9c785..3fe716184 100644 --- a/apps/blade/src/app/issues/[id]/page.tsx +++ b/apps/blade/src/app/issues/[id]/page.tsx @@ -15,6 +15,10 @@ interface IssuePageProps { export default async function IssuePage({ params }: IssuePageProps) { const session = await auth(); if (!session) redirect(SIGN_IN_PATH); + + const hasAccess = await api.roles.hasPermission({ or: ["READ_ISSUES"] }); + if (!hasAccess) notFound(); + const { id } = await params; if (!z.string().uuid().safeParse(id).success) notFound(); let issue; From 14bf6c2df0248fc9fb351da55f88b3695c76fd31 Mon Sep 17 00:00:00 2001 From: azizu06 Date: Tue, 24 Mar 2026 14:42:00 -0400 Subject: [PATCH 19/30] split long messages into chunks so it doesn't exceed Discord's web hook message length --- apps/cron/src/crons/issue-reminders.ts | 168 +++++++++++++++++++++++-- 1 file changed, 156 insertions(+), 12 deletions(-) diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index 6afd27720..e7dbc804f 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -79,16 +79,19 @@ type GroupedIssueReminders = Partial< > >; +const MAX_DISCORD_MESSAGE_LENGTH = 2000; +const ISSUE_REMINDER_SEND_ATTEMPTS = 3; +const ISSUE_REMINDER_RETRY_DELAY_MS = 500; + +const getUtcMidnightTimestamp = (date: Date): number => { + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); +}; + const getIssueReminderDay = ( date: Date, now = new Date(), ): IssueReminderDay | null => { - const dueDate = new Date(date); - dueDate.setHours(0, 0, 0, 0); - const today = new Date(now); - today.setHours(0, 0, 0, 0); - - const diffMs = dueDate.getTime() - today.getTime(); + const diffMs = getUtcMidnightTimestamp(date) - getUtcMidnightTimestamp(now); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 14) return ISSUE_REMINDER_DAYS.Fourteen; @@ -164,15 +167,26 @@ const isIssueReminderTarget = ( return val !== null; }; +const sanitizeIssueReminderTitle = (title: string): string => { + return title + .replace(/\r?\n+/g, " ") + .replace(/<@&?/g, "<@\u200b") + .replace(/ { const mentions = getIssueMentionTargets(target).join(", "); const issueUrl = getIssueUrl(target.issueId); - return `- ${target.issueName}: ${issueUrl} ${mentions}`; + const issueTitle = sanitizeIssueReminderTitle(target.issueName); + return `- ${issueTitle}: ${issueUrl} ${mentions}`; }; -const formatChannelReminderMsg = ( +const getFormattedChannelSections = ( grouped: Partial>, -): string | null => { +): string[] => { const sections: string[] = []; for (const day of ISSUE_REMINDER_DAY_ORDER) { const targets = grouped[day]; @@ -180,6 +194,13 @@ const formatChannelReminderMsg = ( const lines = targets.map(formatIssueReminder).join("\n"); sections.push(`## ${ISSUE_REMINDER_DAY_LABELS[day]}\n${lines}`); } + return sections; +}; + +const formatChannelReminderMsg = ( + grouped: Partial>, +): string | null => { + const sections = getFormattedChannelSections(grouped); if (sections.length === 0) return null; return `# Issue Reminders\n${sections.join("\n\n")}`; }; @@ -188,6 +209,124 @@ const getIssueUrl = (issueId: string): string => { return `${env.BLADE_URL.replace(/\/$/, "")}/issues/${issueId}`; }; +const getWebhookId = (url: string): string => { + const match = /\/webhooks\/([^/]+)/.exec(url); + return match?.[1] ?? "unknown"; +}; + +const getAllowedMentions = ( + targets: IssueReminderTarget[], +): { + parse: []; + users?: string[]; + roles?: string[]; +} => { + const userIds = [ + ...new Set(targets.flatMap((target) => target.assigneeDiscordUserIds)), + ]; + const roleIds = + userIds.length === 0 + ? [...new Set(targets.map((target) => target.teamDiscordRoleId))] + : []; + + return { + parse: [], + ...(userIds.length > 0 ? { users: userIds } : {}), + ...(roleIds.length > 0 ? { roles: roleIds } : {}), + }; +}; + +const splitChannelReminderMessages = ( + grouped: Partial>, +): { content: string; targets: IssueReminderTarget[] }[] => { + const chunks: { content: string; targets: IssueReminderTarget[] }[] = []; + let currentContent = "# Issue Reminders"; + let currentTargets: IssueReminderTarget[] = []; + + for (const day of ISSUE_REMINDER_DAY_ORDER) { + const targets = grouped[day]; + if (!targets || targets.length === 0) continue; + + const sectionLines = targets.map(formatIssueReminder); + const sectionContent = `## ${ISSUE_REMINDER_DAY_LABELS[day]}\n${sectionLines.join("\n")}`; + const nextContent = `${currentContent}\n\n${sectionContent}`; + + if (nextContent.length <= MAX_DISCORD_MESSAGE_LENGTH) { + currentContent = nextContent; + currentTargets.push(...targets); + continue; + } + + if (currentTargets.length > 0) { + chunks.push({ content: currentContent, targets: currentTargets }); + } + + let sectionChunkContent = `# Issue Reminders\n\n## ${ISSUE_REMINDER_DAY_LABELS[day]}`; + let sectionChunkTargets: IssueReminderTarget[] = []; + + for (let index = 0; index < sectionLines.length; index++) { + const line = sectionLines[index]; + const target = targets[index]; + if (!target) continue; + const nextSectionChunkContent = `${sectionChunkContent}\n${line}`; + if (nextSectionChunkContent.length > MAX_DISCORD_MESSAGE_LENGTH) { + if (sectionChunkTargets.length > 0) { + chunks.push({ + content: sectionChunkContent, + targets: sectionChunkTargets, + }); + } + + sectionChunkContent = `# Issue Reminders\n\n## ${ISSUE_REMINDER_DAY_LABELS[day]}\n${line}`; + sectionChunkTargets = [target]; + continue; + } + + sectionChunkContent = nextSectionChunkContent; + sectionChunkTargets.push(target); + } + + currentContent = sectionChunkContent; + currentTargets = sectionChunkTargets; + } + + if (currentTargets.length > 0) { + chunks.push({ content: currentContent, targets: currentTargets }); + } + + return chunks; +}; + +const sleep = async (ms: number) => + await new Promise((resolve) => setTimeout(resolve, ms)); + +const sendIssueReminderChunk = async ( + channel: keyof typeof ISSUE_REMINDER_CHANNELS, + webhookUrl: string, + chunk: { content: string; targets: IssueReminderTarget[] }, +) => { + const webhookId = getWebhookId(webhookUrl); + + for (let attempt = 1; attempt <= ISSUE_REMINDER_SEND_ATTEMPTS; attempt++) { + try { + await new WebhookClient({ url: webhookUrl }).send({ + content: chunk.content, + allowedMentions: getAllowedMentions(chunk.targets), + }); + return; + } catch (error) { + logger.error( + `Failed sending issue reminder chunk for ${channel} (webhook ${webhookId}) on attempt ${attempt}.`, + error, + ); + + if (attempt === ISSUE_REMINDER_SEND_ATTEMPTS) return; + + await sleep(ISSUE_REMINDER_RETRY_DELAY_MS * attempt); + } + } +}; + export const issueReminders = new CronBuilder({ name: "issue-reminders", color: 2, @@ -254,8 +393,13 @@ export const issueReminders = new CronBuilder({ const msg = formatChannelReminderMsg(groupedChannel); if (!msg) continue; - await new WebhookClient({ - url: ISSUE_REMINDER_WEBHOOK_URLS[channel], - }).send({ content: msg }); + const chunks = splitChannelReminderMessages(groupedChannel); + for (const chunk of chunks) { + await sendIssueReminderChunk( + channel, + ISSUE_REMINDER_WEBHOOK_URLS[channel], + chunk, + ); + } } }); From 01da6108c0fe1c42593ab67edf3e2349f0e2bc56 Mon Sep 17 00:00:00 2001 From: azizu06 Date: Tue, 24 Mar 2026 15:09:50 -0400 Subject: [PATCH 20/30] add additional message truncation for discord webhook --- apps/cron/src/crons/issue-reminders.ts | 138 ++++++++++++------------- 1 file changed, 64 insertions(+), 74 deletions(-) diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index e7dbc804f..27d0c04dc 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -184,25 +184,10 @@ const formatIssueReminder = (target: IssueReminderTarget): string => { return `- ${issueTitle}: ${issueUrl} ${mentions}`; }; -const getFormattedChannelSections = ( - grouped: Partial>, -): string[] => { - const sections: string[] = []; - for (const day of ISSUE_REMINDER_DAY_ORDER) { - const targets = grouped[day]; - if (!targets || targets.length === 0) continue; - const lines = targets.map(formatIssueReminder).join("\n"); - sections.push(`## ${ISSUE_REMINDER_DAY_LABELS[day]}\n${lines}`); - } - return sections; -}; - -const formatChannelReminderMsg = ( - grouped: Partial>, -): string | null => { - const sections = getFormattedChannelSections(grouped); - if (sections.length === 0) return null; - return `# Issue Reminders\n${sections.join("\n\n")}`; +const truncateReminderLine = (line: string, maxLength: number): string => { + if (line.length <= maxLength) return line; + if (maxLength <= 1) return "…"; + return `${line.slice(0, maxLength - 1)}…`; }; const getIssueUrl = (issueId: string): string => { @@ -263,9 +248,13 @@ const splitChannelReminderMessages = ( let sectionChunkContent = `# Issue Reminders\n\n## ${ISSUE_REMINDER_DAY_LABELS[day]}`; let sectionChunkTargets: IssueReminderTarget[] = []; + const sectionHeaderLength = `${sectionChunkContent}\n`.length; for (let index = 0; index < sectionLines.length; index++) { - const line = sectionLines[index]; + const line = truncateReminderLine( + sectionLines[index] ?? "", + MAX_DISCORD_MESSAGE_LENGTH - sectionHeaderLength, + ); const target = targets[index]; if (!target) continue; const nextSectionChunkContent = `${sectionChunkContent}\n${line}`; @@ -306,10 +295,11 @@ const sendIssueReminderChunk = async ( chunk: { content: string; targets: IssueReminderTarget[] }, ) => { const webhookId = getWebhookId(webhookUrl); + const webhook = new WebhookClient({ url: webhookUrl }); for (let attempt = 1; attempt <= ISSUE_REMINDER_SEND_ATTEMPTS; attempt++) { try { - await new WebhookClient({ url: webhookUrl }).send({ + await webhook.send({ content: chunk.content, allowedMentions: getAllowedMentions(chunk.targets), }); @@ -330,70 +320,70 @@ const sendIssueReminderChunk = async ( export const issueReminders = new CronBuilder({ name: "issue-reminders", color: 2, + // This addCron schedule runs in the host timezone; keep the host in UTC so it aligns with getUtcMidnightTimestamp grouping. }).addCron("0 9 * * *", async () => { if (env.ISSUE_REMINDERS_ENABLED !== "true") { logger.log("Issue reminders are disabled; skipping run."); return; } - const issues = await db.query.Issue.findMany({ - where: and(isNotNull(Issue.date), ne(Issue.status, "FINISHED")), - with: { - userAssignments: { - with: { - user: true, + const issues = await db.query.Issue.findMany({ + where: and(isNotNull(Issue.date), ne(Issue.status, "FINISHED")), + with: { + userAssignments: { + with: { + user: true, + }, }, }, - }, - }); - if (issues.length === 0) return; - - const teamIds = [...new Set(issues.map((issue) => issue.team))]; - const roles = await db - .select({ - id: Roles.id, - discordRoleId: Roles.discordRoleId, - }) - .from(Roles) - .where(inArray(Roles.id, teamIds)); - - const roleDiscordIdByTeamId: Record = {}; - for (const r of roles) { - roleDiscordIdByTeamId[r.id] = r.discordRoleId; - } - const reminderTargets = issues - .map((issue) => - buildIssueReminderTarget({ - id: issue.id, - name: issue.name, - team: issue.team, - date: issue.date, - teamDiscordRoleId: roleDiscordIdByTeamId[issue.team] ?? "", - assigneeDiscordUserIds: issue.userAssignments.map( - (assignment) => assignment.user.discordUserId, - ), - }), - ) - .filter(isIssueReminderTarget); - - const groupedReminders = groupIssueReminderTargets(reminderTargets); - for (const channel of Object.keys( - ISSUE_REMINDER_CHANNELS, - ) as (keyof typeof ISSUE_REMINDER_CHANNELS)[]) { - const groupedChannel = groupedReminders[channel]; - if (!groupedChannel) continue; - - if (!ISSUE_REMINDER_WEBHOOK_URLS[channel]) { - logger.warn( - `Skipping issue reminders for ${channel}: webhook URL is not configured.`, - ); - continue; + }); + if (issues.length === 0) return; + + const teamIds = [...new Set(issues.map((issue) => issue.team))]; + const roles = await db + .select({ + id: Roles.id, + discordRoleId: Roles.discordRoleId, + }) + .from(Roles) + .where(inArray(Roles.id, teamIds)); + + const roleDiscordIdByTeamId: Record = {}; + for (const r of roles) { + roleDiscordIdByTeamId[r.id] = r.discordRoleId; } + const reminderTargets = issues + .map((issue) => + buildIssueReminderTarget({ + id: issue.id, + name: issue.name, + team: issue.team, + date: issue.date, + teamDiscordRoleId: roleDiscordIdByTeamId[issue.team] ?? "", + assigneeDiscordUserIds: issue.userAssignments.map( + (assignment) => assignment.user.discordUserId, + ), + }), + ) + .filter(isIssueReminderTarget); + + const groupedReminders = groupIssueReminderTargets(reminderTargets); + for (const channel of Object.keys( + ISSUE_REMINDER_CHANNELS, + ) as (keyof typeof ISSUE_REMINDER_CHANNELS)[]) { + const groupedChannel = groupedReminders[channel]; + if (!groupedChannel) continue; + + if (!ISSUE_REMINDER_WEBHOOK_URLS[channel]) { + logger.warn( + `Skipping issue reminders for ${channel}: webhook URL is not configured.`, + ); + continue; + } - const msg = formatChannelReminderMsg(groupedChannel); - if (!msg) continue; + const chunks = splitChannelReminderMessages(groupedChannel); + if (chunks.length === 0) continue; - const chunks = splitChannelReminderMessages(groupedChannel); for (const chunk of chunks) { await sendIssueReminderChunk( channel, From 654b355389d8afb217374c61253318a175a69d8d Mon Sep 17 00:00:00 2001 From: azizu06 Date: Tue, 24 Mar 2026 15:17:42 -0400 Subject: [PATCH 21/30] fix format --- apps/cron/src/crons/issue-reminders.ts | 104 ++++++++++++------------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index 27d0c04dc..b1d1561e9 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -327,62 +327,62 @@ export const issueReminders = new CronBuilder({ return; } - const issues = await db.query.Issue.findMany({ - where: and(isNotNull(Issue.date), ne(Issue.status, "FINISHED")), - with: { - userAssignments: { - with: { - user: true, - }, + const issues = await db.query.Issue.findMany({ + where: and(isNotNull(Issue.date), ne(Issue.status, "FINISHED")), + with: { + userAssignments: { + with: { + user: true, }, }, - }); - if (issues.length === 0) return; - - const teamIds = [...new Set(issues.map((issue) => issue.team))]; - const roles = await db - .select({ - id: Roles.id, - discordRoleId: Roles.discordRoleId, - }) - .from(Roles) - .where(inArray(Roles.id, teamIds)); - - const roleDiscordIdByTeamId: Record = {}; - for (const r of roles) { - roleDiscordIdByTeamId[r.id] = r.discordRoleId; + }, + }); + if (issues.length === 0) return; + + const teamIds = [...new Set(issues.map((issue) => issue.team))]; + const roles = await db + .select({ + id: Roles.id, + discordRoleId: Roles.discordRoleId, + }) + .from(Roles) + .where(inArray(Roles.id, teamIds)); + + const roleDiscordIdByTeamId: Record = {}; + for (const r of roles) { + roleDiscordIdByTeamId[r.id] = r.discordRoleId; + } + const reminderTargets = issues + .map((issue) => + buildIssueReminderTarget({ + id: issue.id, + name: issue.name, + team: issue.team, + date: issue.date, + teamDiscordRoleId: roleDiscordIdByTeamId[issue.team] ?? "", + assigneeDiscordUserIds: issue.userAssignments.map( + (assignment) => assignment.user.discordUserId, + ), + }), + ) + .filter(isIssueReminderTarget); + + const groupedReminders = groupIssueReminderTargets(reminderTargets); + for (const channel of Object.keys( + ISSUE_REMINDER_CHANNELS, + ) as (keyof typeof ISSUE_REMINDER_CHANNELS)[]) { + const groupedChannel = groupedReminders[channel]; + if (!groupedChannel) continue; + + if (!ISSUE_REMINDER_WEBHOOK_URLS[channel]) { + logger.warn( + `Skipping issue reminders for ${channel}: webhook URL is not configured.`, + ); + continue; } - const reminderTargets = issues - .map((issue) => - buildIssueReminderTarget({ - id: issue.id, - name: issue.name, - team: issue.team, - date: issue.date, - teamDiscordRoleId: roleDiscordIdByTeamId[issue.team] ?? "", - assigneeDiscordUserIds: issue.userAssignments.map( - (assignment) => assignment.user.discordUserId, - ), - }), - ) - .filter(isIssueReminderTarget); - - const groupedReminders = groupIssueReminderTargets(reminderTargets); - for (const channel of Object.keys( - ISSUE_REMINDER_CHANNELS, - ) as (keyof typeof ISSUE_REMINDER_CHANNELS)[]) { - const groupedChannel = groupedReminders[channel]; - if (!groupedChannel) continue; - - if (!ISSUE_REMINDER_WEBHOOK_URLS[channel]) { - logger.warn( - `Skipping issue reminders for ${channel}: webhook URL is not configured.`, - ); - continue; - } - const chunks = splitChannelReminderMessages(groupedChannel); - if (chunks.length === 0) continue; + const chunks = splitChannelReminderMessages(groupedChannel); + if (chunks.length === 0) continue; for (const chunk of chunks) { await sendIssueReminderChunk( From afc409d48ec97bb5a73fcd0d1649071e6641f9ed Mon Sep 17 00:00:00 2001 From: azizu06 Date: Tue, 24 Mar 2026 19:50:18 -0400 Subject: [PATCH 22/30] Remove webhook retry and change issue day grouping to EST time. --- apps/cron/src/crons/issue-reminders.ts | 57 +++++++++++--------------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index b1d1561e9..694caca39 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -80,18 +80,30 @@ type GroupedIssueReminders = Partial< >; const MAX_DISCORD_MESSAGE_LENGTH = 2000; -const ISSUE_REMINDER_SEND_ATTEMPTS = 3; -const ISSUE_REMINDER_RETRY_DELAY_MS = 500; +const ISSUE_REMINDER_TIMEZONE = "America/New_York"; -const getUtcMidnightTimestamp = (date: Date): number => { - return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); +const getTimezoneMidnightTimestamp = (date: Date, timeZone: string): number => { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "numeric", + day: "numeric", + }).formatToParts(date); + + const year = Number(parts.find((part) => part.type === "year")?.value); + const month = Number(parts.find((part) => part.type === "month")?.value); + const day = Number(parts.find((part) => part.type === "day")?.value); + + return Date.UTC(year, month - 1, day); }; const getIssueReminderDay = ( date: Date, now = new Date(), ): IssueReminderDay | null => { - const diffMs = getUtcMidnightTimestamp(date) - getUtcMidnightTimestamp(now); + const diffMs = + getTimezoneMidnightTimestamp(date, ISSUE_REMINDER_TIMEZONE) - + getTimezoneMidnightTimestamp(now, ISSUE_REMINDER_TIMEZONE); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 14) return ISSUE_REMINDER_DAYS.Fourteen; @@ -194,11 +206,6 @@ const getIssueUrl = (issueId: string): string => { return `${env.BLADE_URL.replace(/\/$/, "")}/issues/${issueId}`; }; -const getWebhookId = (url: string): string => { - const match = /\/webhooks\/([^/]+)/.exec(url); - return match?.[1] ?? "unknown"; -}; - const getAllowedMentions = ( targets: IssueReminderTarget[], ): { @@ -286,41 +293,25 @@ const splitChannelReminderMessages = ( return chunks; }; -const sleep = async (ms: number) => - await new Promise((resolve) => setTimeout(resolve, ms)); - const sendIssueReminderChunk = async ( channel: keyof typeof ISSUE_REMINDER_CHANNELS, webhookUrl: string, chunk: { content: string; targets: IssueReminderTarget[] }, ) => { - const webhookId = getWebhookId(webhookUrl); const webhook = new WebhookClient({ url: webhookUrl }); - - for (let attempt = 1; attempt <= ISSUE_REMINDER_SEND_ATTEMPTS; attempt++) { - try { - await webhook.send({ - content: chunk.content, - allowedMentions: getAllowedMentions(chunk.targets), - }); - return; - } catch (error) { - logger.error( - `Failed sending issue reminder chunk for ${channel} (webhook ${webhookId}) on attempt ${attempt}.`, - error, - ); - - if (attempt === ISSUE_REMINDER_SEND_ATTEMPTS) return; - - await sleep(ISSUE_REMINDER_RETRY_DELAY_MS * attempt); - } + try { + await webhook.send({ + content: chunk.content, + allowedMentions: getAllowedMentions(chunk.targets), + }); + } catch (error) { + logger.error(`Failed sending issue reminder chunk for ${channel}.`, error); } }; export const issueReminders = new CronBuilder({ name: "issue-reminders", color: 2, - // This addCron schedule runs in the host timezone; keep the host in UTC so it aligns with getUtcMidnightTimestamp grouping. }).addCron("0 9 * * *", async () => { if (env.ISSUE_REMINDERS_ENABLED !== "true") { logger.log("Issue reminders are disabled; skipping run."); From d5946783bc557b714f8c489bffbf6ee069647b0e Mon Sep 17 00:00:00 2001 From: azizu06 Date: Tue, 24 Mar 2026 21:10:00 -0400 Subject: [PATCH 23/30] add overdue day prefix to discord msg --- apps/cron/src/crons/issue-reminders.ts | 28 +++++++++++++++++--------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index 694caca39..a9663722e 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -70,6 +70,7 @@ interface IssueReminderTarget { assigneeDiscordUserIds: string[]; channel: keyof typeof ISSUE_REMINDER_CHANNELS; day: IssueReminderDay; + overdueDays: number | null; } type GroupedIssueReminders = Partial< @@ -101,10 +102,7 @@ const getIssueReminderDay = ( date: Date, now = new Date(), ): IssueReminderDay | null => { - const diffMs = - getTimezoneMidnightTimestamp(date, ISSUE_REMINDER_TIMEZONE) - - getTimezoneMidnightTimestamp(now, ISSUE_REMINDER_TIMEZONE); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const diffDays = getIssueReminderDiffDays(date, now); if (diffDays === 14) return ISSUE_REMINDER_DAYS.Fourteen; if (diffDays === 7) return ISSUE_REMINDER_DAYS.Seven; @@ -114,6 +112,13 @@ const getIssueReminderDay = ( return null; }; +const getIssueReminderDiffDays = (date: Date, now = new Date()): number => { + const diffMs = + getTimezoneMidnightTimestamp(date, ISSUE_REMINDER_TIMEZONE) - + getTimezoneMidnightTimestamp(now, ISSUE_REMINDER_TIMEZONE); + return Math.floor(diffMs / (1000 * 60 * 60 * 24)); +}; + const getIssueReminderChannel = ( teamId: string, ): keyof typeof ISSUE_REMINDER_CHANNELS | null => { @@ -148,6 +153,10 @@ const buildIssueReminderTarget = (issue: { assigneeDiscordUserIds: issue.assigneeDiscordUserIds, channel, day, + overdueDays: + day === ISSUE_REMINDER_DAYS.Overdue + ? Math.abs(getIssueReminderDiffDays(issue.date)) + : null, }; }; @@ -193,7 +202,11 @@ const formatIssueReminder = (target: IssueReminderTarget): string => { const mentions = getIssueMentionTargets(target).join(", "); const issueUrl = getIssueUrl(target.issueId); const issueTitle = sanitizeIssueReminderTitle(target.issueName); - return `- ${issueTitle}: ${issueUrl} ${mentions}`; + const overduePrefix = + target.day === ISSUE_REMINDER_DAYS.Overdue && target.overdueDays !== null + ? `(-${target.overdueDays} days) ` + : ""; + return `- ${overduePrefix}[${issueTitle}](<${issueUrl}>) ${mentions}`; }; const truncateReminderLine = (line: string, maxLength: number): string => { @@ -313,11 +326,6 @@ export const issueReminders = new CronBuilder({ name: "issue-reminders", color: 2, }).addCron("0 9 * * *", async () => { - if (env.ISSUE_REMINDERS_ENABLED !== "true") { - logger.log("Issue reminders are disabled; skipping run."); - return; - } - const issues = await db.query.Issue.findMany({ where: and(isNotNull(Issue.date), ne(Issue.status, "FINISHED")), with: { From 772ebbca3f4807434ac7e7344e540bacff447dcc Mon Sep 17 00:00:00 2001 From: azizu06 Date: Tue, 24 Mar 2026 21:26:30 -0400 Subject: [PATCH 24/30] change overdue format --- apps/cron/src/crons/issue-reminders.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index a9663722e..79ee25ae5 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -204,7 +204,7 @@ const formatIssueReminder = (target: IssueReminderTarget): string => { const issueTitle = sanitizeIssueReminderTitle(target.issueName); const overduePrefix = target.day === ISSUE_REMINDER_DAYS.Overdue && target.overdueDays !== null - ? `(-${target.overdueDays} days) ` + ? `(${target.overdueDays} days) ` : ""; return `- ${overduePrefix}[${issueTitle}](<${issueUrl}>) ${mentions}`; }; From 539cd3eb24c6112b78afa3200844733c23bf20ed Mon Sep 17 00:00:00 2001 From: azizu06 Date: Tue, 24 Mar 2026 21:29:09 -0400 Subject: [PATCH 25/30] again format --- apps/cron/src/crons/issue-reminders.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index 79ee25ae5..3daf5f393 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -202,11 +202,11 @@ const formatIssueReminder = (target: IssueReminderTarget): string => { const mentions = getIssueMentionTargets(target).join(", "); const issueUrl = getIssueUrl(target.issueId); const issueTitle = sanitizeIssueReminderTitle(target.issueName); - const overduePrefix = + const overdueSuffix = target.day === ISSUE_REMINDER_DAYS.Overdue && target.overdueDays !== null - ? `(${target.overdueDays} days) ` + ? ` (${target.overdueDays} days)` : ""; - return `- ${overduePrefix}[${issueTitle}](<${issueUrl}>) ${mentions}`; + return `- [${issueTitle}](<${issueUrl}>)${overdueSuffix} ${mentions}`; }; const truncateReminderLine = (line: string, maxLength: number): string => { From 641ad9f8be0e0a92056f4c79bdfd45c635b59b4b Mon Sep 17 00:00:00 2001 From: azizu06 Date: Tue, 24 Mar 2026 21:36:11 -0400 Subject: [PATCH 26/30] fix spacing --- apps/cron/src/crons/issue-reminders.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index 3daf5f393..b88c00455 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -254,7 +254,7 @@ const splitChannelReminderMessages = ( const sectionLines = targets.map(formatIssueReminder); const sectionContent = `## ${ISSUE_REMINDER_DAY_LABELS[day]}\n${sectionLines.join("\n")}`; - const nextContent = `${currentContent}\n\n${sectionContent}`; + const nextContent = `${currentContent}\n${sectionContent}`; if (nextContent.length <= MAX_DISCORD_MESSAGE_LENGTH) { currentContent = nextContent; From ad710c24b407d7d85a85092c558f2279130e5582 Mon Sep 17 00:00:00 2001 From: azizu06 Date: Wed, 25 Mar 2026 17:42:50 -0400 Subject: [PATCH 27/30] make a list non bulleted --- apps/cron/src/crons/issue-reminders.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index b88c00455..334f22b57 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -206,7 +206,7 @@ const formatIssueReminder = (target: IssueReminderTarget): string => { target.day === ISSUE_REMINDER_DAYS.Overdue && target.overdueDays !== null ? ` (${target.overdueDays} days)` : ""; - return `- [${issueTitle}](<${issueUrl}>)${overdueSuffix} ${mentions}`; + return `[${issueTitle}](<${issueUrl}>)${overdueSuffix} ${mentions}`; }; const truncateReminderLine = (line: string, maxLength: number): string => { From 2672534d41667758a26600966906a0ab505a19ac Mon Sep 17 00:00:00 2001 From: azizu06 Date: Thu, 26 Mar 2026 12:32:59 -0400 Subject: [PATCH 28/30] remove not needed webhook vars --- apps/cron/src/env.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/apps/cron/src/env.ts b/apps/cron/src/env.ts index 2194df577..4084a7e85 100644 --- a/apps/cron/src/env.ts +++ b/apps/cron/src/env.ts @@ -9,11 +9,6 @@ export const env = createEnv({ DISCORD_WEBHOOK_REMINDERS: z.string(), DISCORD_WEBHOOK_REMINDERS_PRE: z.string(), DISCORD_WEBHOOK_REMINDERS_HACK: z.string(), - DISCORD_WEBHOOK_ISSUE_TEAMS: z.string().url().optional(), - DISCORD_WEBHOOK_ISSUE_DIRECTORS: z.string().url().optional(), - DISCORD_WEBHOOK_ISSUE_DESIGN: z.string().url().optional(), - DISCORD_WEBHOOK_ISSUE_HACKORG: z.string().url().optional(), - ISSUE_REMINDERS_ENABLED: z.enum(["true", "false"]).optional(), BLADE_URL: z.string().url(), }, runtimeEnvStrict: { @@ -23,12 +18,6 @@ export const env = createEnv({ DISCORD_WEBHOOK_REMINDERS: process.env.DISCORD_WEBHOOK_REMINDERS, DISCORD_WEBHOOK_REMINDERS_PRE: process.env.DISCORD_WEBHOOK_REMINDERS_PRE, DISCORD_WEBHOOK_REMINDERS_HACK: process.env.DISCORD_WEBHOOK_REMINDERS_HACK, - DISCORD_WEBHOOK_ISSUE_TEAMS: process.env.DISCORD_WEBHOOK_ISSUE_TEAMS, - DISCORD_WEBHOOK_ISSUE_DIRECTORS: - process.env.DISCORD_WEBHOOK_ISSUE_DIRECTORS, - DISCORD_WEBHOOK_ISSUE_DESIGN: process.env.DISCORD_WEBHOOK_ISSUE_DESIGN, - DISCORD_WEBHOOK_ISSUE_HACKORG: process.env.DISCORD_WEBHOOK_ISSUE_HACKORG, - ISSUE_REMINDERS_ENABLED: process.env.ISSUE_REMINDERS_ENABLED, BLADE_URL: process.env.BLADE_URL, }, skipValidation: From a7c77b49ba4471ff95198fb0f662d5a62601b72d Mon Sep 17 00:00:00 2001 From: azizu06 Date: Thu, 26 Mar 2026 12:36:33 -0400 Subject: [PATCH 29/30] Remove hard-coded team channel map and use the new channels enum col on the Roles table instead --- packages/db/src/schemas/auth.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/db/src/schemas/auth.ts b/packages/db/src/schemas/auth.ts index 5e44f84ad..d84af45ac 100644 --- a/packages/db/src/schemas/auth.ts +++ b/packages/db/src/schemas/auth.ts @@ -1,8 +1,15 @@ -import { pgTableCreator, primaryKey } from "drizzle-orm/pg-core"; +import { pgEnum, pgTableCreator, primaryKey } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; const createTable = pgTableCreator((name) => `auth_${name}`); +export const IssueReminderChannelEnum = pgEnum("issue_reminder_channel", [ + "Teams", + "Directors", + "Design", + "HackOrg", +]); + export const User = createTable("user", (t) => ({ id: t.uuid().notNull().primaryKey().defaultRandom(), discordUserId: t.varchar({ length: 255 }).notNull(), @@ -37,6 +44,7 @@ export const Roles = createTable("roles", (t) => ({ name: t.varchar().notNull().default(""), discordRoleId: t.varchar().unique().notNull(), permissions: t.varchar().notNull(), + issueReminderChannel: IssueReminderChannelEnum(), })); export const InsertRolesSchema = createInsertSchema(Roles); From d62c46509806c157b6d4c061a08b9b03a663558b Mon Sep 17 00:00:00 2001 From: azizu06 Date: Thu, 26 Mar 2026 12:41:37 -0400 Subject: [PATCH 30/30] remove creation of webhook and just send as embed to that channel. --- apps/cron/src/crons/issue-reminders.ts | 107 +++++++++++++------------ 1 file changed, 54 insertions(+), 53 deletions(-) diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts index 334f22b57..768189173 100644 --- a/apps/cron/src/crons/issue-reminders.ts +++ b/apps/cron/src/crons/issue-reminders.ts @@ -1,21 +1,15 @@ -import { WebhookClient } from "discord.js"; +import { Routes } from "discord-api-types/v10"; import { and, inArray, isNotNull, ne } from "drizzle-orm"; import { db } from "@forge/db/client"; import { Roles } from "@forge/db/schemas/auth"; import { Issue } from "@forge/db/schemas/knight-hacks"; import { logger } from "@forge/utils"; +import { api } from "@forge/utils/discord"; import { env } from "../env"; import { CronBuilder } from "../structs/CronBuilder"; -const ISSUE_REMINDER_WEBHOOK_URLS = { - Teams: env.DISCORD_WEBHOOK_ISSUE_TEAMS, - Directors: env.DISCORD_WEBHOOK_ISSUE_DIRECTORS, - Design: env.DISCORD_WEBHOOK_ISSUE_DESIGN, - HackOrg: env.DISCORD_WEBHOOK_ISSUE_HACKORG, -} as const; - const ISSUE_REMINDER_CHANNELS = { Teams: "Teams", Directors: "Directors", @@ -23,16 +17,11 @@ const ISSUE_REMINDER_CHANNELS = { HackOrg: "HackOrg", } as const; -const ISSUE_TEAM_CHANNEL_MAP: Record< - string, - keyof typeof ISSUE_REMINDER_CHANNELS -> = { - "16ced653-dafd-46bc-a6ef-8f4fba6a6b46": "Teams", - "f4f544bf-7c69-43c1-b4b4-0585e73268a7": "Teams", - "9fc780ed-3c84-4e9a-bd10-b5c2be51f5a8": "Teams", - "b86a437b-0789-4ec4-8011-5ddde24865dc": "Directors", - "3b03b15d-4368-49e6-86c9-48c11775430b": "Design", - "110f5d0c-3299-46f6-b057-ae2ce28d4778": "HackOrg", +const ISSUE_REMINDER_DESTINATION_CHANNEL_IDS = { + Teams: "1459204271655489567", + Directors: "1463407041191088188", + HackOrg: "1461565747649187874", + Design: "1483901622558920945", }; const ISSUE_REMINDER_DAYS = { @@ -119,18 +108,6 @@ const getIssueReminderDiffDays = (date: Date, now = new Date()): number => { return Math.floor(diffMs / (1000 * 60 * 60 * 24)); }; -const getIssueReminderChannel = ( - teamId: string, -): keyof typeof ISSUE_REMINDER_CHANNELS | null => { - const channel = ISSUE_TEAM_CHANNEL_MAP[teamId] ?? null; - if (!channel) { - logger.warn( - `Skipping issue reminder: no channel mapping for team ${teamId}.`, - ); - } - return channel; -}; - const buildIssueReminderTarget = (issue: { id: string; name: string; @@ -138,10 +115,10 @@ const buildIssueReminderTarget = (issue: { date: Date | null; teamDiscordRoleId: string; assigneeDiscordUserIds: string[]; + channel: keyof typeof ISSUE_REMINDER_CHANNELS | null; }): IssueReminderTarget | null => { if (!issue.date) return null; - const channel = getIssueReminderChannel(issue.team); - if (!channel) return null; + if (!issue.channel) return null; const day = getIssueReminderDay(issue.date); if (!day) return null; if (!issue.teamDiscordRoleId) return null; @@ -151,7 +128,7 @@ const buildIssueReminderTarget = (issue: { teamId: issue.team, teamDiscordRoleId: issue.teamDiscordRoleId, assigneeDiscordUserIds: issue.assigneeDiscordUserIds, - channel, + channel: issue.channel, day, overdueDays: day === ISSUE_REMINDER_DAYS.Overdue @@ -241,6 +218,13 @@ const getAllowedMentions = ( }; }; +const formatIssueReminderEmbedDescription = (content: string): string => { + return content + .replace(/^# Issue Reminders\n?/, "") + .replace(/^## (.+)$/gm, "**$1**") + .trim(); +}; + const splitChannelReminderMessages = ( grouped: Partial>, ): { content: string; targets: IssueReminderTarget[] }[] => { @@ -308,14 +292,21 @@ const splitChannelReminderMessages = ( const sendIssueReminderChunk = async ( channel: keyof typeof ISSUE_REMINDER_CHANNELS, - webhookUrl: string, + channelId: string, chunk: { content: string; targets: IssueReminderTarget[] }, ) => { - const webhook = new WebhookClient({ url: webhookUrl }); try { - await webhook.send({ - content: chunk.content, - allowedMentions: getAllowedMentions(chunk.targets), + await api.post(Routes.channelMessages(channelId), { + body: { + embeds: [ + { + title: "Issue Reminders", + description: formatIssueReminderEmbedDescription(chunk.content), + color: 0xcca4f4, + }, + ], + allowed_mentions: getAllowedMentions(chunk.targets), + }, }); } catch (error) { logger.error(`Failed sending issue reminder chunk for ${channel}.`, error); @@ -343,27 +334,44 @@ export const issueReminders = new CronBuilder({ .select({ id: Roles.id, discordRoleId: Roles.discordRoleId, + issueReminderChannel: Roles.issueReminderChannel, }) .from(Roles) .where(inArray(Roles.id, teamIds)); - const roleDiscordIdByTeamId: Record = {}; + const roleDataByTeamId: Record< + string, + { + discordRoleId: string; + issueReminderChannel: keyof typeof ISSUE_REMINDER_CHANNELS | null; + } + > = {}; for (const r of roles) { - roleDiscordIdByTeamId[r.id] = r.discordRoleId; + roleDataByTeamId[r.id] = { + discordRoleId: r.discordRoleId, + issueReminderChannel: r.issueReminderChannel, + }; } const reminderTargets = issues - .map((issue) => - buildIssueReminderTarget({ + .map((issue) => { + const role = roleDataByTeamId[issue.team]; + if (!role?.issueReminderChannel) { + logger.warn( + `Skipping issue reminder: no issue reminder channel configured for team ${issue.team}.`, + ); + } + return buildIssueReminderTarget({ id: issue.id, name: issue.name, team: issue.team, date: issue.date, - teamDiscordRoleId: roleDiscordIdByTeamId[issue.team] ?? "", + teamDiscordRoleId: role?.discordRoleId ?? "", assigneeDiscordUserIds: issue.userAssignments.map( (assignment) => assignment.user.discordUserId, ), - }), - ) + channel: role?.issueReminderChannel ?? null, + }); + }) .filter(isIssueReminderTarget); const groupedReminders = groupIssueReminderTargets(reminderTargets); @@ -373,20 +381,13 @@ export const issueReminders = new CronBuilder({ const groupedChannel = groupedReminders[channel]; if (!groupedChannel) continue; - if (!ISSUE_REMINDER_WEBHOOK_URLS[channel]) { - logger.warn( - `Skipping issue reminders for ${channel}: webhook URL is not configured.`, - ); - continue; - } - const chunks = splitChannelReminderMessages(groupedChannel); if (chunks.length === 0) continue; for (const chunk of chunks) { await sendIssueReminderChunk( channel, - ISSUE_REMINDER_WEBHOOK_URLS[channel], + ISSUE_REMINDER_DESTINATION_CHANNEL_IDS[channel], chunk, ); }