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..3fe716184 --- /dev/null +++ b/apps/blade/src/app/issues/[id]/page.tsx @@ -0,0 +1,125 @@ +import { notFound, redirect } from "next/navigation"; +import { z } from "zod"; + +import { auth } from "@forge/auth"; + +import { SIGN_IN_PATH } from "~/consts"; +import { api } from "~/trpc/server"; + +interface IssuePageProps { + params: Promise<{ + id: string; + }>; +} + +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; + try { + issue = await api.issues.getIssue({ id }); + } catch { + notFound(); + } + + return ( +
+
+

Issue

+

{issue.name}

+
+ +
+
+

Status

+

{issue.status}

+
+ +
+

Due Date

+

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

+
+
+ +
+

Team

+

{issue.team.name}

+
+ +
+

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

+ )} +
+
+
+ ); +} diff --git a/apps/cron/src/crons/issue-reminders.ts b/apps/cron/src/crons/issue-reminders.ts new file mode 100644 index 000000000..768189173 --- /dev/null +++ b/apps/cron/src/crons/issue-reminders.ts @@ -0,0 +1,395 @@ +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_CHANNELS = { + Teams: "Teams", + Directors: "Directors", + Design: "Design", + HackOrg: "HackOrg", +} as const; + +const ISSUE_REMINDER_DESTINATION_CHANNEL_IDS = { + Teams: "1459204271655489567", + Directors: "1463407041191088188", + HackOrg: "1461565747649187874", + Design: "1483901622558920945", +}; + +const ISSUE_REMINDER_DAYS = { + Fourteen: "Fourteen", + Seven: "Seven", + Three: "Three", + One: "One", + 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]; + +interface IssueReminderTarget { + issueId: string; + issueName: string; + teamId: string; + teamDiscordRoleId: string; + assigneeDiscordUserIds: string[]; + channel: keyof typeof ISSUE_REMINDER_CHANNELS; + day: IssueReminderDay; + overdueDays: number | null; +} + +type GroupedIssueReminders = Partial< + Record< + keyof typeof ISSUE_REMINDER_CHANNELS, + Partial> + > +>; + +const MAX_DISCORD_MESSAGE_LENGTH = 2000; +const ISSUE_REMINDER_TIMEZONE = "America/New_York"; + +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 diffDays = getIssueReminderDiffDays(date, now); + + 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 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 buildIssueReminderTarget = (issue: { + id: string; + name: string; + team: string; + date: Date | null; + teamDiscordRoleId: string; + assigneeDiscordUserIds: string[]; + channel: keyof typeof ISSUE_REMINDER_CHANNELS | null; +}): IssueReminderTarget | null => { + if (!issue.date) return null; + if (!issue.channel) return null; + const day = getIssueReminderDay(issue.date); + if (!day) return null; + if (!issue.teamDiscordRoleId) return null; + return { + issueId: issue.id, + issueName: issue.name, + teamId: issue.team, + teamDiscordRoleId: issue.teamDiscordRoleId, + assigneeDiscordUserIds: issue.assigneeDiscordUserIds, + channel: issue.channel, + day, + overdueDays: + day === ISSUE_REMINDER_DAYS.Overdue + ? Math.abs(getIssueReminderDiffDays(issue.date)) + : null, + }; +}; + +const getIssueMentionTargets = (target: IssueReminderTarget): string[] => { + if (target.assigneeDiscordUserIds.length > 0) + return target.assigneeDiscordUserIds.map((id) => `<@${id}>`); + return [`<@&${target.teamDiscordRoleId}>`]; +}; + +const groupIssueReminderTargets = ( + targets: IssueReminderTarget[], +): GroupedIssueReminders => { + const grouped: GroupedIssueReminders = {}; + for (const t of targets) { + grouped[t.channel] ??= {}; + const channelGroup = grouped[t.channel]; + if (!channelGroup) continue; + channelGroup[t.day] ??= []; + const dayGroup = channelGroup[t.day]; + if (!dayGroup) continue; + dayGroup.push(t); + } + return grouped; +}; + +const isIssueReminderTarget = ( + val: IssueReminderTarget | null, +): val is IssueReminderTarget => { + 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); + const issueTitle = sanitizeIssueReminderTitle(target.issueName); + const overdueSuffix = + target.day === ISSUE_REMINDER_DAYS.Overdue && target.overdueDays !== null + ? ` (${target.overdueDays} days)` + : ""; + return `[${issueTitle}](<${issueUrl}>)${overdueSuffix} ${mentions}`; +}; + +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 => { + return `${env.BLADE_URL.replace(/\/$/, "")}/issues/${issueId}`; +}; + +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 formatIssueReminderEmbedDescription = (content: string): string => { + return content + .replace(/^# Issue Reminders\n?/, "") + .replace(/^## (.+)$/gm, "**$1**") + .trim(); +}; + +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${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[] = []; + const sectionHeaderLength = `${sectionChunkContent}\n`.length; + + for (let index = 0; index < sectionLines.length; index++) { + const line = truncateReminderLine( + sectionLines[index] ?? "", + MAX_DISCORD_MESSAGE_LENGTH - sectionHeaderLength, + ); + 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 sendIssueReminderChunk = async ( + channel: keyof typeof ISSUE_REMINDER_CHANNELS, + channelId: string, + chunk: { content: string; targets: IssueReminderTarget[] }, +) => { + try { + 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); + } +}; + +export const issueReminders = new CronBuilder({ + name: "issue-reminders", + color: 2, +}).addCron("0 9 * * *", async () => { + 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, + issueReminderChannel: Roles.issueReminderChannel, + }) + .from(Roles) + .where(inArray(Roles.id, teamIds)); + + const roleDataByTeamId: Record< + string, + { + discordRoleId: string; + issueReminderChannel: keyof typeof ISSUE_REMINDER_CHANNELS | null; + } + > = {}; + for (const r of roles) { + roleDataByTeamId[r.id] = { + discordRoleId: r.discordRoleId, + issueReminderChannel: r.issueReminderChannel, + }; + } + const reminderTargets = issues + .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: role?.discordRoleId ?? "", + assigneeDiscordUserIds: issue.userAssignments.map( + (assignment) => assignment.user.discordUserId, + ), + channel: role?.issueReminderChannel ?? null, + }); + }) + .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 chunks = splitChannelReminderMessages(groupedChannel); + if (chunks.length === 0) continue; + + for (const chunk of chunks) { + await sendIssueReminderChunk( + channel, + ISSUE_REMINDER_DESTINATION_CHANNEL_IDS[channel], + chunk, + ); + } + } +}); diff --git a/apps/cron/src/env.ts b/apps/cron/src/env.ts index d8864e6d3..4084a7e85 100644 --- a/apps/cron/src/env.ts +++ b/apps/cron/src/env.ts @@ -9,6 +9,7 @@ export const env = createEnv({ DISCORD_WEBHOOK_REMINDERS: z.string(), DISCORD_WEBHOOK_REMINDERS_PRE: z.string(), DISCORD_WEBHOOK_REMINDERS_HACK: z.string(), + BLADE_URL: z.string().url(), }, runtimeEnvStrict: { DISCORD_BOT_TOKEN: process.env.DISCORD_BOT_TOKEN, @@ -17,6 +18,7 @@ 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, + BLADE_URL: process.env.BLADE_URL, }, skipValidation: !!process.env.CI || process.env.npm_lifecycle_event === "lint", 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(); diff --git a/packages/api/src/routers/issues.ts b/packages/api/src/routers/issues.ts index 43ac6d867..e6517a4e6 100644 --- a/packages/api/src/routers/issues.ts +++ b/packages/api/src/routers/issues.ts @@ -131,6 +131,11 @@ 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: { user: true } }, + }, }); if (!issue) throw new TRPCError({ message: `Issue not found.`, code: "NOT_FOUND" }); 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); 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), }));