From 8e81670581f71ff556b5a75296752ec0647d81c2 Mon Sep 17 00:00:00 2001 From: DGoel1602 Date: Sun, 8 Mar 2026 16:12:58 -0400 Subject: [PATCH 01/15] feat: add full issue schema and relations for it --- packages/consts/src/index.ts | 1 + packages/consts/src/issue.ts | 6 ++ packages/db/src/schemas/knight-hacks.ts | 108 +++++++++++++++++++++++- 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 packages/consts/src/issue.ts diff --git a/packages/consts/src/index.ts b/packages/consts/src/index.ts index 8b20ceec0..623f4b48b 100644 --- a/packages/consts/src/index.ts +++ b/packages/consts/src/index.ts @@ -8,3 +8,4 @@ export * as GUILD from "./guild"; export * as MINIO from "./minio"; export * as PERMISSIONS from "./permissions"; export * as TEAM from "./team"; +export * as ISSUE from "./issue"; diff --git a/packages/consts/src/issue.ts b/packages/consts/src/issue.ts new file mode 100644 index 000000000..4ace767b5 --- /dev/null +++ b/packages/consts/src/issue.ts @@ -0,0 +1,6 @@ +export const ISSUE_STATUS = [ + "BACKLOG", + "PLANNING", + "IN_PROGRESS", + "FINISHED", +] as const; diff --git a/packages/db/src/schemas/knight-hacks.ts b/packages/db/src/schemas/knight-hacks.ts index bcb310505..6eaedcff7 100644 --- a/packages/db/src/schemas/knight-hacks.ts +++ b/packages/db/src/schemas/knight-hacks.ts @@ -1,5 +1,6 @@ import { relations } from "drizzle-orm"; import { + foreignKey, pgEnum, pgTableCreator, primaryKey, @@ -8,7 +9,7 @@ import { import { createInsertSchema } from "drizzle-zod"; import z from "zod"; -import { EVENTS, FORMS } from "@forge/consts"; +import { EVENTS, FORMS, ISSUE } from "@forge/consts"; import { Roles, User } from "./auth"; @@ -26,6 +27,7 @@ export const hackathonApplicationStateEnum = pgEnum( "hackathon_application_state", FORMS.HACKATHON_APPLICATION_STATES, ); +export const issueStatus = pgEnum("issue_status", ISSUE.ISSUE_STATUS); export const Hackathon = createTable("hackathon", (t) => ({ id: t.uuid().notNull().primaryKey().defaultRandom(), @@ -547,3 +549,107 @@ export const TrpcFormConnection = createTable("trpc_form_connection", (t) => ({ })); export const TrpcFormConnectionSchema = createInsertSchema(TrpcFormConnection); + +export const Issue = createTable( + "issue", + (t) => ({ + id: t.uuid().notNull().primaryKey().defaultRandom(), + status: issueStatus().notNull(), + name: t.text().notNull(), + description: t.text().notNull(), + links: t.text().array(), + event: t.uuid().references(() => Event.id), + date: t.timestamp(), + team: t + .uuid() + .notNull() + .references(() => Roles.id), + creator: t + .uuid() + .notNull() + .references(() => User.id), + parent: t.uuid(), + }), + (table) => ({ + parentReference: foreignKey({ + columns: [table.parent], + foreignColumns: [table.id], + name: "issue_parent_fk", + }), + }), +); + +export const IssuesToTeamsVisibility = createTable( + "issues_to_teams_visibility", + (t) => ({ + issueId: t + .uuid("issue_id") + .notNull() + .references(() => Issue.id), + teamId: t + .uuid("team_id") + .notNull() + .references(() => Roles.id), + }), + (table) => ({ + pk: primaryKey({ columns: [table.issueId, table.teamId] }), + }), +); + +export const issuesToTeamsVisibilityRelations = relations( + IssuesToTeamsVisibility, + ({ one }) => ({ + issue: one(Issue, { + fields: [IssuesToTeamsVisibility.issueId], + references: [Issue.id], + }), + team: one(Roles, { + fields: [IssuesToTeamsVisibility.teamId], + references: [Roles.id], + }), + }), +); + +export const IssuesToUsersAssignment = createTable( + "issues_to_users_assignment", + (t) => ({ + issueId: t + .uuid("issue_id") + .notNull() + .references(() => Issue.id), + userId: t + .uuid("user_id") + .notNull() + .references(() => User.id), + }), + (table) => ({ + pk: primaryKey({ columns: [table.issueId, table.userId] }), + }), +); + +export const issuesToUsersAssignmentRelations = relations( + IssuesToUsersAssignment, + ({ one }) => ({ + issue: one(Issue, { + fields: [IssuesToUsersAssignment.issueId], + references: [Issue.id], + }), + user: one(User, { + fields: [IssuesToUsersAssignment.userId], + references: [User.id], + }), + }), +); + +export const issueRelations = relations(Issue, ({ many }) => ({ + teamVisibility: many(IssuesToTeamsVisibility), + userAssignments: many(IssuesToUsersAssignment), +})); + +export const rolesRelations = relations(Roles, ({ many }) => ({ + visibleIssues: many(IssuesToTeamsVisibility), +})); + +export const usersRelations = relations(User, ({ many }) => ({ + assignedIssues: many(IssuesToUsersAssignment), +})); From 7a1662b4b17df7d180b558c17ae9474b1116e8eb Mon Sep 17 00:00:00 2001 From: Schevis Date: Mon, 9 Mar 2026 22:56:32 -0400 Subject: [PATCH 02/15] feat: add issues router with CRUD and sub-issue support Implements createIssue, getAllIssues, updateIssue, createSubIssue, and deleteIssue procedures with team visibility and user assignment junction handling, and adds READ_ISSUES/EDIT_ISSUES permissions to gate access. --- packages/api/src/root.ts | 3 + packages/api/src/routers/issues.ts | 245 +++++++++++++++++++++++++++++ packages/consts/src/permissions.ts | 10 ++ 3 files changed, 258 insertions(+) create mode 100644 packages/api/src/routers/issues.ts diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 9a654347c..679f33cf7 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -12,6 +12,7 @@ import { hackathonRouter } from "./routers/hackathon"; import { hackerMutationRouter } from "./routers/hackers/mutations"; import { hackerPaginationRouter } from "./routers/hackers/pagination"; import { hackerQueryRouter } from "./routers/hackers/queries"; +import { issuesRouter } from "./routers/issues"; import { judgeRouter } from "./routers/judge"; import { memberRouter } from "./routers/member"; import { miscRouter } from "./routers/misc"; @@ -45,6 +46,7 @@ export const appRouter = createTRPCRouter<{ companies: typeof companiesRouter; forms: typeof formsRouter; roles: typeof rolesRouter; + issues: typeof issuesRouter; }>({ misc: miscRouter, auth: authRouter, @@ -68,6 +70,7 @@ export const appRouter = createTRPCRouter<{ companies: companiesRouter, forms: formsRouter, roles: rolesRouter, + issues: issuesRouter, }); // export type definition of API diff --git a/packages/api/src/routers/issues.ts b/packages/api/src/routers/issues.ts new file mode 100644 index 000000000..e2a2c7e3c --- /dev/null +++ b/packages/api/src/routers/issues.ts @@ -0,0 +1,245 @@ +import type { TRPCRouterRecord } from "@trpc/server"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +import { ISSUE } from "@forge/consts"; +import { and, eq, inArray, sql } from "@forge/db"; +import { db } from "@forge/db/client"; +import { + Issue, + IssuesToTeamsVisibility, + IssuesToUsersAssignment, +} from "@forge/db/schemas/knight-hacks"; +import { permissions } from "@forge/utils"; + +import { permProcedure } from "../trpc"; + +const createIssueInput = z.object({ + name: z.string().min(1), + description: z.string().min(1), + status: z.enum(ISSUE.ISSUE_STATUS), + date: z.date().nullable().optional(), + event: z.string().uuid().nullable().optional(), + links: z.array(z.string().url()).nullable().optional(), + team: z.string().uuid(), + assigneeIds: z.array(z.string().uuid()).optional(), + teamVisibilityIds: z.array(z.string().uuid()).optional(), +}); + +async function requireIssue(id: string, label = "Issue") { + const issue = await db.query.Issue.findFirst({ + where: (t, { eq }) => eq(t.id, id), + }); + if (!issue) + throw new TRPCError({ message: `${label} not found.`, code: "NOT_FOUND" }); + return issue; +} + +async function insertJunctions( + issueId: string, + teamVisibilityIds?: string[], + assigneeIds?: string[], +) { + if (teamVisibilityIds?.length) { + await db + .insert(IssuesToTeamsVisibility) + .values(teamVisibilityIds.map((teamId) => ({ issueId, teamId }))); + } + if (assigneeIds?.length) { + await db + .insert(IssuesToUsersAssignment) + .values(assigneeIds.map((userId) => ({ issueId, userId }))); + } +} + +export const issuesRouter = { + createIssue: permProcedure + .input(createIssueInput) + .mutation(async ({ ctx, input }) => { + permissions.controlPerms.or(["EDIT_ISSUES"], ctx); + + const [issue] = await db + .insert(Issue) + .values({ + name: input.name, + description: input.description, + status: input.status, + date: input.date ?? null, + event: input.event ?? null, + links: input.links ?? null, + team: input.team, + creator: ctx.session.user.id, + }) + .returning(); + + if (!issue) + throw new TRPCError({ + message: "Failed to create issue.", + code: "INTERNAL_SERVER_ERROR", + }); + + await insertJunctions(issue.id, input.teamVisibilityIds, input.assigneeIds); + return issue; + }), + + getAllIssues: permProcedure + .input( + z + .object({ + dateFrom: z.date().optional(), + dateTo: z.date().optional(), + assigneeIds: z.array(z.string().uuid()).optional(), + creatorId: z.string().uuid().optional(), + teamId: z.string().uuid().optional(), + status: z.enum(ISSUE.ISSUE_STATUS).optional(), + parentId: z.string().uuid().nullable().optional(), + }) + .optional(), + ) + .query(async ({ ctx, input }) => { + permissions.controlPerms.or(["READ_ISSUES"], ctx); + + const filters: ReturnType[] = []; + + if (input?.creatorId) filters.push(eq(Issue.creator, input.creatorId)); + if (input?.teamId) filters.push(eq(Issue.team, input.teamId)); + if (input?.status) filters.push(eq(Issue.status, input.status)); + if (input?.dateFrom) filters.push(sql`${Issue.date} >= ${input.dateFrom}`); + if (input?.dateTo) filters.push(sql`${Issue.date} <= ${input.dateTo}`); + if (input?.parentId !== undefined) { + filters.push( + input.parentId === null + ? sql`${Issue.parent} IS NULL` + : eq(Issue.parent, input.parentId), + ); + } + + if (input?.assigneeIds?.length) { + const rows = await db + .select({ issueId: IssuesToUsersAssignment.issueId }) + .from(IssuesToUsersAssignment) + .where(inArray(IssuesToUsersAssignment.userId, input.assigneeIds)); + const ids = rows.map((r) => r.issueId); + if (ids.length === 0) return []; + filters.push(inArray(Issue.id, ids)); + } + + return db.query.Issue.findMany({ + where: and(...filters), + with: { + teamVisibility: { with: { team: true } }, + userAssignments: { with: { user: true } }, + }, + }); + }), + + updateIssue: permProcedure + .input( + z.object({ + id: z.string().uuid(), + name: z.string().min(1).optional(), + description: z.string().min(1).optional(), + status: z.enum(ISSUE.ISSUE_STATUS).optional(), + date: z.date().nullable().optional(), + event: z.string().uuid().nullable().optional(), + links: z.array(z.string().url()).nullable().optional(), + team: z.string().uuid().optional(), + assigneeIds: z.array(z.string().uuid()).optional(), + teamVisibilityIds: z.array(z.string().uuid()).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + permissions.controlPerms.or(["EDIT_ISSUES"], ctx); + await requireIssue(input.id); + + const { id, assigneeIds, teamVisibilityIds, ...fields } = input; + const updateData = Object.fromEntries( + (Object.entries(fields) as [string, unknown][]).filter( + ([, v]) => v !== undefined, + ), + ); + + if (Object.keys(updateData).length > 0) { + await db.update(Issue).set(updateData).where(eq(Issue.id, id)); + } + + if (teamVisibilityIds !== undefined) { + await db + .delete(IssuesToTeamsVisibility) + .where(eq(IssuesToTeamsVisibility.issueId, id)); + if (teamVisibilityIds.length > 0) { + await db + .insert(IssuesToTeamsVisibility) + .values(teamVisibilityIds.map((teamId) => ({ issueId: id, teamId }))); + } + } + + if (assigneeIds !== undefined) { + await db + .delete(IssuesToUsersAssignment) + .where(eq(IssuesToUsersAssignment.issueId, id)); + if (assigneeIds.length > 0) { + await db + .insert(IssuesToUsersAssignment) + .values(assigneeIds.map((userId) => ({ issueId: id, userId }))); + } + } + + return db.query.Issue.findFirst({ + where: (t, { eq }) => eq(t.id, id), + with: { + teamVisibility: { with: { team: true } }, + userAssignments: { with: { user: true } }, + }, + }); + }), + + createSubIssue: permProcedure + .input(createIssueInput.extend({ parentId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + permissions.controlPerms.or(["EDIT_ISSUES"], ctx); + await requireIssue(input.parentId, "Parent issue"); + + const [issue] = await db + .insert(Issue) + .values({ + name: input.name, + description: input.description, + status: input.status, + date: input.date ?? null, + event: input.event ?? null, + links: input.links ?? null, + team: input.team, + creator: ctx.session.user.id, + parent: input.parentId, + }) + .returning(); + + if (!issue) + throw new TRPCError({ + message: "Failed to create sub-issue.", + code: "INTERNAL_SERVER_ERROR", + }); + + await insertJunctions(issue.id, input.teamVisibilityIds, input.assigneeIds); + return issue; + }), + + deleteIssue: permProcedure + .input(z.object({ id: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + permissions.controlPerms.or(["EDIT_ISSUES"], ctx); + await requireIssue(input.id); + + await db + .delete(IssuesToUsersAssignment) + .where(eq(IssuesToUsersAssignment.issueId, input.id)); + await db + .delete(IssuesToTeamsVisibility) + .where(eq(IssuesToTeamsVisibility.issueId, input.id)); + await db.update(Issue).set({ parent: null }).where(eq(Issue.parent, input.id)); + await db.delete(Issue).where(eq(Issue.id, input.id)); + + return { success: true }; + }), +} satisfies TRPCRouterRecord; diff --git a/packages/consts/src/permissions.ts b/packages/consts/src/permissions.ts index 747dc7f0c..306d8cf05 100644 --- a/packages/consts/src/permissions.ts +++ b/packages/consts/src/permissions.ts @@ -105,6 +105,16 @@ export const PERMISSION_DATA: Record = { name: "Configure Roles", desc: "Allows creating, editing, or deleting roles.", }, + READ_ISSUES: { + idx: 20, + name: "Read Issues", + desc: "Grants access to view issues on the calendar.", + }, + EDIT_ISSUES: { + idx: 21, + name: "Edit Issues", + desc: "Allows creating, editing, or deleting issues.", + }, } as const satisfies Record; export const PERMISSIONS = Object.fromEntries( From c06569ea2e031f83a964b47ca4417ade163b1076 Mon Sep 17 00:00:00 2001 From: Schevis Date: Mon, 9 Mar 2026 22:59:17 -0400 Subject: [PATCH 03/15] refactor: format code for better readability in issues router --- packages/api/src/routers/issues.ts | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/api/src/routers/issues.ts b/packages/api/src/routers/issues.ts index e2a2c7e3c..5aaf336bf 100644 --- a/packages/api/src/routers/issues.ts +++ b/packages/api/src/routers/issues.ts @@ -78,7 +78,11 @@ export const issuesRouter = { code: "INTERNAL_SERVER_ERROR", }); - await insertJunctions(issue.id, input.teamVisibilityIds, input.assigneeIds); + await insertJunctions( + issue.id, + input.teamVisibilityIds, + input.assigneeIds, + ); return issue; }), @@ -104,7 +108,8 @@ export const issuesRouter = { if (input?.creatorId) filters.push(eq(Issue.creator, input.creatorId)); if (input?.teamId) filters.push(eq(Issue.team, input.teamId)); if (input?.status) filters.push(eq(Issue.status, input.status)); - if (input?.dateFrom) filters.push(sql`${Issue.date} >= ${input.dateFrom}`); + if (input?.dateFrom) + filters.push(sql`${Issue.date} >= ${input.dateFrom}`); if (input?.dateTo) filters.push(sql`${Issue.date} <= ${input.dateTo}`); if (input?.parentId !== undefined) { filters.push( @@ -170,7 +175,9 @@ export const issuesRouter = { if (teamVisibilityIds.length > 0) { await db .insert(IssuesToTeamsVisibility) - .values(teamVisibilityIds.map((teamId) => ({ issueId: id, teamId }))); + .values( + teamVisibilityIds.map((teamId) => ({ issueId: id, teamId })), + ); } } @@ -221,7 +228,11 @@ export const issuesRouter = { code: "INTERNAL_SERVER_ERROR", }); - await insertJunctions(issue.id, input.teamVisibilityIds, input.assigneeIds); + await insertJunctions( + issue.id, + input.teamVisibilityIds, + input.assigneeIds, + ); return issue; }), @@ -237,7 +248,10 @@ export const issuesRouter = { await db .delete(IssuesToTeamsVisibility) .where(eq(IssuesToTeamsVisibility.issueId, input.id)); - await db.update(Issue).set({ parent: null }).where(eq(Issue.parent, input.id)); + await db + .update(Issue) + .set({ parent: null }) + .where(eq(Issue.parent, input.id)); await db.delete(Issue).where(eq(Issue.id, input.id)); return { success: true }; From 62cedc4ec02806428f6db0f293ab476ce85bce5d Mon Sep 17 00:00:00 2001 From: DGoel1602 Date: Mon, 9 Mar 2026 23:40:04 -0400 Subject: [PATCH 04/15] chore: move all db relations to a seperate file to avoid circular dependencies --- packages/db/src/client.ts | 6 +- packages/db/src/schemas/auth.ts | 38 ----------- packages/db/src/schemas/knight-hacks.ts | 48 +------------ packages/db/src/schemas/relations.ts | 89 +++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 86 deletions(-) create mode 100644 packages/db/src/schemas/relations.ts diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 59e1c466b..9a3e5ad52 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -5,6 +5,7 @@ import Pool from "pg-pool"; import { env } from "./env"; import * as authSchema from "./schemas/auth"; import * as knightHacksSchema from "./schemas/knight-hacks"; +import * as relations from "./schemas/relations"; const pool = new Pool({ connectionString: env.DATABASE_URL, @@ -12,11 +13,12 @@ const pool = new Pool({ type AuthSchema = typeof authSchema; type KnightHacksSchema = typeof knightHacksSchema; +type RelationsSchema = typeof relations; -type DatabaseSchema = AuthSchema & KnightHacksSchema; +type DatabaseSchema = AuthSchema & KnightHacksSchema & RelationsSchema; export const db: NodePgDatabase = drizzle({ client: pool, - schema: { ...authSchema, ...knightHacksSchema }, + schema: { ...authSchema, ...knightHacksSchema, ...relations }, casing: "snake_case", }); diff --git a/packages/db/src/schemas/auth.ts b/packages/db/src/schemas/auth.ts index 0226af891..5e44f84ad 100644 --- a/packages/db/src/schemas/auth.ts +++ b/packages/db/src/schemas/auth.ts @@ -1,9 +1,6 @@ -import { relations } from "drizzle-orm"; import { pgTableCreator, primaryKey } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; -import { Member } from "./knight-hacks"; - const createTable = pgTableCreator((name) => `auth_${name}`); export const User = createTable("user", (t) => ({ @@ -44,33 +41,6 @@ export const Roles = createTable("roles", (t) => ({ export const InsertRolesSchema = createInsertSchema(Roles); -export const UserRelations = relations(User, ({ many, one }) => ({ - accounts: many(Account), - member: one(Member), - permissions: many(Permissions, { - relationName: "userPermissionRel", - }), -})); - -export const RoleRelations = relations(Roles, ({ many }) => ({ - permissions: many(Permissions, { - relationName: "rolePermissionRel", - }), -})); - -export const PermissionRelations = relations(Permissions, ({ one }) => ({ - role: one(Roles, { - fields: [Permissions.roleId], - references: [Roles.id], - relationName: "rolePermissionRel", - }), - user: one(User, { - fields: [Permissions.userId], - references: [User.id], - relationName: "userPermissionRel", - }), -})); - export const Account = createTable( "account", (t) => ({ @@ -102,10 +72,6 @@ export const Account = createTable( }), ); -export const AccountRelations = relations(Account, ({ one }) => ({ - user: one(User, { fields: [Account.userId], references: [User.id] }), -})); - export const Session = createTable("session", (t) => ({ id: t.text().notNull().primaryKey(), sessionToken: t.varchar({ length: 255 }).notNull(), @@ -126,10 +92,6 @@ export const Session = createTable("session", (t) => ({ .notNull(), })); -export const SessionRelations = relations(Session, ({ one }) => ({ - user: one(User, { fields: [Session.userId], references: [User.id] }), -})); - export const JudgeSession = createTable("judge_session", (t) => ({ sessionToken: t.varchar({ length: 255 }).notNull().primaryKey(), roomName: t.text().notNull(), diff --git a/packages/db/src/schemas/knight-hacks.ts b/packages/db/src/schemas/knight-hacks.ts index 6eaedcff7..820bbf23e 100644 --- a/packages/db/src/schemas/knight-hacks.ts +++ b/packages/db/src/schemas/knight-hacks.ts @@ -1,4 +1,3 @@ -import { relations } from "drizzle-orm"; import { foreignKey, pgEnum, @@ -140,10 +139,6 @@ export type SelectHacker = typeof Hacker.$inferSelect; export type InsertMember = typeof Member.$inferInsert; export type SelectMember = typeof Member.$inferSelect; -export const MemberRelations = relations(Member, ({ one }) => ({ - user: one(User, { fields: [Member.userId], references: [User.id] }), -})); - export const InsertMemberSchema = createInsertSchema(Member); export const InsertHackerSchema = createInsertSchema(Hacker); @@ -579,6 +574,8 @@ export const Issue = createTable( }), ); +export const IssueSchema = createInsertSchema(Issue); + export const IssuesToTeamsVisibility = createTable( "issues_to_teams_visibility", (t) => ({ @@ -596,20 +593,6 @@ export const IssuesToTeamsVisibility = createTable( }), ); -export const issuesToTeamsVisibilityRelations = relations( - IssuesToTeamsVisibility, - ({ one }) => ({ - issue: one(Issue, { - fields: [IssuesToTeamsVisibility.issueId], - references: [Issue.id], - }), - team: one(Roles, { - fields: [IssuesToTeamsVisibility.teamId], - references: [Roles.id], - }), - }), -); - export const IssuesToUsersAssignment = createTable( "issues_to_users_assignment", (t) => ({ @@ -626,30 +609,3 @@ export const IssuesToUsersAssignment = createTable( pk: primaryKey({ columns: [table.issueId, table.userId] }), }), ); - -export const issuesToUsersAssignmentRelations = relations( - IssuesToUsersAssignment, - ({ one }) => ({ - issue: one(Issue, { - fields: [IssuesToUsersAssignment.issueId], - references: [Issue.id], - }), - user: one(User, { - fields: [IssuesToUsersAssignment.userId], - references: [User.id], - }), - }), -); - -export const issueRelations = relations(Issue, ({ many }) => ({ - teamVisibility: many(IssuesToTeamsVisibility), - userAssignments: many(IssuesToUsersAssignment), -})); - -export const rolesRelations = relations(Roles, ({ many }) => ({ - visibleIssues: many(IssuesToTeamsVisibility), -})); - -export const usersRelations = relations(User, ({ many }) => ({ - assignedIssues: many(IssuesToUsersAssignment), -})); diff --git a/packages/db/src/schemas/relations.ts b/packages/db/src/schemas/relations.ts new file mode 100644 index 000000000..d805a16c3 --- /dev/null +++ b/packages/db/src/schemas/relations.ts @@ -0,0 +1,89 @@ +import { relations } from "drizzle-orm"; + +import { Account, Permissions, Roles, Session, User } from "./auth"; +import { + Issue, + IssuesToTeamsVisibility, + IssuesToUsersAssignment, + Member, +} from "./knight-hacks"; + +export const UserRelations = relations(User, ({ many, one }) => ({ + accounts: many(Account), + member: one(Member), + permissions: many(Permissions, { + relationName: "userPermissionRel", + }), +})); + +export const RoleRelations = relations(Roles, ({ many }) => ({ + permissions: many(Permissions, { + relationName: "rolePermissionRel", + }), +})); + +export const PermissionRelations = relations(Permissions, ({ one }) => ({ + role: one(Roles, { + fields: [Permissions.roleId], + references: [Roles.id], + relationName: "rolePermissionRel", + }), + user: one(User, { + fields: [Permissions.userId], + references: [User.id], + relationName: "userPermissionRel", + }), +})); + +export const AccountRelations = relations(Account, ({ one }) => ({ + user: one(User, { fields: [Account.userId], references: [User.id] }), +})); + +export const MemberRelations = relations(Member, ({ one }) => ({ + user: one(User, { fields: [Member.userId], references: [User.id] }), +})); + +export const SessionRelations = relations(Session, ({ one }) => ({ + user: one(User, { fields: [Session.userId], references: [User.id] }), +})); + +export const issuesToTeamsVisibilityRelations = relations( + IssuesToTeamsVisibility, + ({ one }) => ({ + issue: one(Issue, { + fields: [IssuesToTeamsVisibility.issueId], + references: [Issue.id], + }), + team: one(Roles, { + fields: [IssuesToTeamsVisibility.teamId], + references: [Roles.id], + }), + }), +); + +export const issuesToUsersAssignmentRelations = relations( + IssuesToUsersAssignment, + ({ one }) => ({ + issue: one(Issue, { + fields: [IssuesToUsersAssignment.issueId], + references: [Issue.id], + }), + user: one(User, { + fields: [IssuesToUsersAssignment.userId], + references: [User.id], + }), + }), +); + +export const issueRelations = relations(Issue, ({ many }) => ({ + teamVisibility: many(IssuesToTeamsVisibility), + userAssignments: many(IssuesToUsersAssignment), +})); + +export const rolesRelations = relations(Roles, ({ many }) => ({ + visibleIssues: many(IssuesToTeamsVisibility), +})); + +export const usersRelations = relations(User, ({ many }) => ({ + assignedIssues: many(IssuesToUsersAssignment), +})); From 1e7447e05667c3398e7a52a9c411093a93d5236b Mon Sep 17 00:00:00 2001 From: DGoel1602 Date: Mon, 9 Mar 2026 23:58:44 -0400 Subject: [PATCH 05/15] feat: limit visibility of all issues, infer schema and remove sub issue creation --- packages/api/src/routers/issues.ts | 83 +++++++++--------------------- 1 file changed, 23 insertions(+), 60 deletions(-) diff --git a/packages/api/src/routers/issues.ts b/packages/api/src/routers/issues.ts index 5aaf336bf..15f0b4fc1 100644 --- a/packages/api/src/routers/issues.ts +++ b/packages/api/src/routers/issues.ts @@ -3,10 +3,12 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { ISSUE } from "@forge/consts"; -import { and, eq, inArray, sql } from "@forge/db"; +import { and, eq, exists, inArray, sql } from "@forge/db"; import { db } from "@forge/db/client"; +import { Permissions } from "@forge/db/schemas/auth"; import { Issue, + IssueSchema, IssuesToTeamsVisibility, IssuesToUsersAssignment, } from "@forge/db/schemas/knight-hacks"; @@ -14,14 +16,7 @@ import { permissions } from "@forge/utils"; import { permProcedure } from "../trpc"; -const createIssueInput = z.object({ - name: z.string().min(1), - description: z.string().min(1), - status: z.enum(ISSUE.ISSUE_STATUS), - date: z.date().nullable().optional(), - event: z.string().uuid().nullable().optional(), - links: z.array(z.string().url()).nullable().optional(), - team: z.string().uuid(), +const CreateIssueInputSchema = IssueSchema.extend({ assigneeIds: z.array(z.string().uuid()).optional(), teamVisibilityIds: z.array(z.string().uuid()).optional(), }); @@ -54,20 +49,15 @@ async function insertJunctions( export const issuesRouter = { createIssue: permProcedure - .input(createIssueInput) + .input(CreateIssueInputSchema) .mutation(async ({ ctx, input }) => { permissions.controlPerms.or(["EDIT_ISSUES"], ctx); + const { teamVisibilityIds, assigneeIds, ...rest } = input; const [issue] = await db .insert(Issue) .values({ - name: input.name, - description: input.description, - status: input.status, - date: input.date ?? null, - event: input.event ?? null, - links: input.links ?? null, - team: input.team, + ...rest, creator: ctx.session.user.id, }) .returning(); @@ -78,11 +68,7 @@ export const issuesRouter = { code: "INTERNAL_SERVER_ERROR", }); - await insertJunctions( - issue.id, - input.teamVisibilityIds, - input.assigneeIds, - ); + await insertJunctions(issue.id, teamVisibilityIds, assigneeIds); return issue; }), @@ -129,13 +115,26 @@ export const issuesRouter = { filters.push(inArray(Issue.id, ids)); } - return db.query.Issue.findMany({ - where: and(...filters), + const userRoles = (await db.query.Permissions.findMany({ + where: eq(Permissions.userId, ctx.session.user.id), + })).map(p => p.roleId); + const issues = await db.query.Issue.findMany({ + where: and(...filters, exists( + db.select() + .from(IssuesToTeamsVisibility) + .where( + and( + eq(IssuesToTeamsVisibility.issueId, Issue.id), + inArray(IssuesToTeamsVisibility.teamId, userRoles) + ) + ) + )), with: { teamVisibility: { with: { team: true } }, userAssignments: { with: { user: true } }, }, }); + return issues; }), updateIssue: permProcedure @@ -200,42 +199,6 @@ export const issuesRouter = { }, }); }), - - createSubIssue: permProcedure - .input(createIssueInput.extend({ parentId: z.string().uuid() })) - .mutation(async ({ ctx, input }) => { - permissions.controlPerms.or(["EDIT_ISSUES"], ctx); - await requireIssue(input.parentId, "Parent issue"); - - const [issue] = await db - .insert(Issue) - .values({ - name: input.name, - description: input.description, - status: input.status, - date: input.date ?? null, - event: input.event ?? null, - links: input.links ?? null, - team: input.team, - creator: ctx.session.user.id, - parent: input.parentId, - }) - .returning(); - - if (!issue) - throw new TRPCError({ - message: "Failed to create sub-issue.", - code: "INTERNAL_SERVER_ERROR", - }); - - await insertJunctions( - issue.id, - input.teamVisibilityIds, - input.assigneeIds, - ); - return issue; - }), - deleteIssue: permProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { From 2284c06891629a5d7e3f4a86f1d9609f2e6a469e Mon Sep 17 00:00:00 2001 From: DGoel1602 Date: Tue, 10 Mar 2026 00:03:20 -0400 Subject: [PATCH 06/15] feat: add getting a single issue --- packages/api/src/routers/issues.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/api/src/routers/issues.ts b/packages/api/src/routers/issues.ts index 15f0b4fc1..8fbc2763d 100644 --- a/packages/api/src/routers/issues.ts +++ b/packages/api/src/routers/issues.ts @@ -72,6 +72,36 @@ export const issuesRouter = { return issue; }), + getIssue: permProcedure + .input( + z + .object({ + id: z.string() + }) + ) + .query(async ({ ctx, input }) => { + permissions.controlPerms.or(["READ_ISSUES"], ctx); + const userRoles = (await db.query.Permissions.findMany({ + where: eq(Permissions.userId, ctx.session.user.id), + })).map(p => p.roleId); + const issue = await db.query.Issue.findFirst({ + where: and(eq(Issue.id, input.id), exists( + db.select() + .from(IssuesToTeamsVisibility) + .where( + and( + eq(IssuesToTeamsVisibility.issueId, Issue.id), + inArray(IssuesToTeamsVisibility.teamId, userRoles) + ) + ) + )), + }); + if (!issue) + throw new TRPCError({ message: `Issue not found.`, code: "NOT_FOUND" }); + return issue; + }), + + getAllIssues: permProcedure .input( z From 354fc9fdc7a7e295a528aa8769a5dc3f572368f5 Mon Sep 17 00:00:00 2001 From: DGoel1602 Date: Tue, 10 Mar 2026 00:03:47 -0400 Subject: [PATCH 07/15] chore: format --- packages/api/src/routers/issues.ts | 84 +++++++++++++++++------------- 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/packages/api/src/routers/issues.ts b/packages/api/src/routers/issues.ts index 8fbc2763d..d05b79d7d 100644 --- a/packages/api/src/routers/issues.ts +++ b/packages/api/src/routers/issues.ts @@ -74,34 +74,38 @@ export const issuesRouter = { getIssue: permProcedure .input( - z - .object({ - id: z.string() - }) + z.object({ + id: z.string(), + }), ) .query(async ({ ctx, input }) => { permissions.controlPerms.or(["READ_ISSUES"], ctx); - const userRoles = (await db.query.Permissions.findMany({ - where: eq(Permissions.userId, ctx.session.user.id), - })).map(p => p.roleId); - const issue = await db.query.Issue.findFirst({ - where: and(eq(Issue.id, input.id), exists( - db.select() - .from(IssuesToTeamsVisibility) - .where( - and( - eq(IssuesToTeamsVisibility.issueId, Issue.id), - inArray(IssuesToTeamsVisibility.teamId, userRoles) - ) - ) - )), - }); - if (!issue) - throw new TRPCError({ message: `Issue not found.`, code: "NOT_FOUND" }); - return issue; + const userRoles = ( + await db.query.Permissions.findMany({ + where: eq(Permissions.userId, ctx.session.user.id), + }) + ).map((p) => p.roleId); + const issue = await db.query.Issue.findFirst({ + where: and( + eq(Issue.id, input.id), + exists( + db + .select() + .from(IssuesToTeamsVisibility) + .where( + and( + eq(IssuesToTeamsVisibility.issueId, Issue.id), + inArray(IssuesToTeamsVisibility.teamId, userRoles), + ), + ), + ), + ), + }); + if (!issue) + throw new TRPCError({ message: `Issue not found.`, code: "NOT_FOUND" }); + return issue; }), - getAllIssues: permProcedure .input( z @@ -145,26 +149,32 @@ export const issuesRouter = { filters.push(inArray(Issue.id, ids)); } - const userRoles = (await db.query.Permissions.findMany({ - where: eq(Permissions.userId, ctx.session.user.id), - })).map(p => p.roleId); + const userRoles = ( + await db.query.Permissions.findMany({ + where: eq(Permissions.userId, ctx.session.user.id), + }) + ).map((p) => p.roleId); const issues = await db.query.Issue.findMany({ - where: and(...filters, exists( - db.select() - .from(IssuesToTeamsVisibility) - .where( - and( - eq(IssuesToTeamsVisibility.issueId, Issue.id), - inArray(IssuesToTeamsVisibility.teamId, userRoles) - ) - ) - )), + where: and( + ...filters, + exists( + db + .select() + .from(IssuesToTeamsVisibility) + .where( + and( + eq(IssuesToTeamsVisibility.issueId, Issue.id), + inArray(IssuesToTeamsVisibility.teamId, userRoles), + ), + ), + ), + ), with: { teamVisibility: { with: { team: true } }, userAssignments: { with: { user: true } }, }, }); - return issues; + return issues; }), updateIssue: permProcedure From b09ba0bb40398f5e5a6637b9e1bbb11e7cec1c2b Mon Sep 17 00:00:00 2001 From: DGoel1602 Date: Tue, 10 Mar 2026 00:09:12 -0400 Subject: [PATCH 08/15] chore: more efficient assignee filtering --- packages/api/src/routers/issues.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/api/src/routers/issues.ts b/packages/api/src/routers/issues.ts index d05b79d7d..ba49f4e71 100644 --- a/packages/api/src/routers/issues.ts +++ b/packages/api/src/routers/issues.ts @@ -140,13 +140,19 @@ export const issuesRouter = { } if (input?.assigneeIds?.length) { - const rows = await db - .select({ issueId: IssuesToUsersAssignment.issueId }) - .from(IssuesToUsersAssignment) - .where(inArray(IssuesToUsersAssignment.userId, input.assigneeIds)); - const ids = rows.map((r) => r.issueId); - if (ids.length === 0) return []; - filters.push(inArray(Issue.id, ids)); + filters.push( + exists( + db + .select() + .from(IssuesToUsersAssignment) + .where( + and( + eq(IssuesToUsersAssignment.issueId, Issue.id), + inArray(IssuesToUsersAssignment.userId, input.assigneeIds), + ), + ), + ), + ); } const userRoles = ( From 10333fb869c7a05b7f44d42a605e0576bb1c06c6 Mon Sep 17 00:00:00 2001 From: DGoel1602 Date: Tue, 10 Mar 2026 00:22:37 -0400 Subject: [PATCH 09/15] fix: better visibility filtering with officer by passes --- packages/api/src/routers/issues.ts | 86 +++++++++++++++++------------- 1 file changed, 48 insertions(+), 38 deletions(-) diff --git a/packages/api/src/routers/issues.ts b/packages/api/src/routers/issues.ts index ba49f4e71..7b5acea01 100644 --- a/packages/api/src/routers/issues.ts +++ b/packages/api/src/routers/issues.ts @@ -80,26 +80,31 @@ export const issuesRouter = { ) .query(async ({ ctx, input }) => { permissions.controlPerms.or(["READ_ISSUES"], ctx); - const userRoles = ( - await db.query.Permissions.findMany({ - where: eq(Permissions.userId, ctx.session.user.id), - }) - ).map((p) => p.roleId); - const issue = await db.query.Issue.findFirst({ - where: and( - eq(Issue.id, input.id), - exists( - db - .select() - .from(IssuesToTeamsVisibility) - .where( - and( - eq(IssuesToTeamsVisibility.issueId, Issue.id), - inArray(IssuesToTeamsVisibility.teamId, userRoles), - ), + + let visibilityFilter; + + if (ctx.session.permissions.IS_OFFICER) { + visibilityFilter = sql`TRUE`; + } else { + const userRoles = ( + await db.query.Permissions.findMany({ + where: eq(Permissions.userId, ctx.session.user.id), + }) + ).map((p) => p.roleId); + visibilityFilter = exists( + db + .select() + .from(IssuesToTeamsVisibility) + .where( + and( + eq(IssuesToTeamsVisibility.issueId, Issue.id), + inArray(IssuesToTeamsVisibility.teamId, userRoles), ), - ), - ), + ), + ); + } + const issue = await db.query.Issue.findFirst({ + where: and(eq(Issue.id, input.id), visibilityFilter), }); if (!issue) throw new TRPCError({ message: `Issue not found.`, code: "NOT_FOUND" }); @@ -139,6 +144,29 @@ export const issuesRouter = { ); } + let visibilityFilter; + + if (ctx.session.permissions.IS_OFFICER) { + visibilityFilter = sql`TRUE`; + } else { + const userRoles = ( + await db.query.Permissions.findMany({ + where: eq(Permissions.userId, ctx.session.user.id), + }) + ).map((p) => p.roleId); + visibilityFilter = exists( + db + .select() + .from(IssuesToTeamsVisibility) + .where( + and( + eq(IssuesToTeamsVisibility.issueId, Issue.id), + inArray(IssuesToTeamsVisibility.teamId, userRoles), + ), + ), + ); + } + if (input?.assigneeIds?.length) { filters.push( exists( @@ -155,26 +183,8 @@ export const issuesRouter = { ); } - const userRoles = ( - await db.query.Permissions.findMany({ - where: eq(Permissions.userId, ctx.session.user.id), - }) - ).map((p) => p.roleId); const issues = await db.query.Issue.findMany({ - where: and( - ...filters, - exists( - db - .select() - .from(IssuesToTeamsVisibility) - .where( - and( - eq(IssuesToTeamsVisibility.issueId, Issue.id), - inArray(IssuesToTeamsVisibility.teamId, userRoles), - ), - ), - ), - ), + where: and(...filters, visibilityFilter), with: { teamVisibility: { with: { team: true } }, userAssignments: { with: { user: true } }, From b1ea9e7c0e51305f6cec24d1b32bae07db9472fe Mon Sep 17 00:00:00 2001 From: Schevis Date: Tue, 10 Mar 2026 01:27:46 -0400 Subject: [PATCH 10/15] fix: guard against empty inArray crash when user has no role assignments --- packages/api/src/routers/issues.ts | 50 +++++++++++++++++------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/api/src/routers/issues.ts b/packages/api/src/routers/issues.ts index 7b5acea01..16fb13439 100644 --- a/packages/api/src/routers/issues.ts +++ b/packages/api/src/routers/issues.ts @@ -91,17 +91,20 @@ export const issuesRouter = { where: eq(Permissions.userId, ctx.session.user.id), }) ).map((p) => p.roleId); - visibilityFilter = exists( - db - .select() - .from(IssuesToTeamsVisibility) - .where( - and( - eq(IssuesToTeamsVisibility.issueId, Issue.id), - inArray(IssuesToTeamsVisibility.teamId, userRoles), - ), - ), - ); + visibilityFilter = + userRoles.length === 0 + ? sql`FALSE` + : exists( + db + .select() + .from(IssuesToTeamsVisibility) + .where( + and( + eq(IssuesToTeamsVisibility.issueId, Issue.id), + inArray(IssuesToTeamsVisibility.teamId, userRoles), + ), + ), + ); } const issue = await db.query.Issue.findFirst({ where: and(eq(Issue.id, input.id), visibilityFilter), @@ -154,17 +157,20 @@ export const issuesRouter = { where: eq(Permissions.userId, ctx.session.user.id), }) ).map((p) => p.roleId); - visibilityFilter = exists( - db - .select() - .from(IssuesToTeamsVisibility) - .where( - and( - eq(IssuesToTeamsVisibility.issueId, Issue.id), - inArray(IssuesToTeamsVisibility.teamId, userRoles), - ), - ), - ); + visibilityFilter = + userRoles.length === 0 + ? sql`FALSE` + : exists( + db + .select() + .from(IssuesToTeamsVisibility) + .where( + and( + eq(IssuesToTeamsVisibility.issueId, Issue.id), + inArray(IssuesToTeamsVisibility.teamId, userRoles), + ), + ), + ); } if (input?.assigneeIds?.length) { From 620704601de95fcbaab5911ae6cff9049faa1a5f Mon Sep 17 00:00:00 2001 From: DGoel1602 Date: Tue, 10 Mar 2026 01:23:39 -0400 Subject: [PATCH 11/15] fix: relation shadowing --- packages/db/src/schemas/relations.ts | 29 ++++++++++++---------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/db/src/schemas/relations.ts b/packages/db/src/schemas/relations.ts index d805a16c3..482898216 100644 --- a/packages/db/src/schemas/relations.ts +++ b/packages/db/src/schemas/relations.ts @@ -10,16 +10,19 @@ import { export const UserRelations = relations(User, ({ many, one }) => ({ accounts: many(Account), + sessions: many(Session), member: one(Member), permissions: many(Permissions, { relationName: "userPermissionRel", }), + assignedIssues: many(IssuesToUsersAssignment), })); export const RoleRelations = relations(Roles, ({ many }) => ({ permissions: many(Permissions, { relationName: "rolePermissionRel", }), + visibleIssues: many(IssuesToTeamsVisibility), })); export const PermissionRelations = relations(Permissions, ({ one }) => ({ @@ -35,16 +38,9 @@ export const PermissionRelations = relations(Permissions, ({ one }) => ({ }), })); -export const AccountRelations = relations(Account, ({ one }) => ({ - user: one(User, { fields: [Account.userId], references: [User.id] }), -})); - -export const MemberRelations = relations(Member, ({ one }) => ({ - user: one(User, { fields: [Member.userId], references: [User.id] }), -})); - -export const SessionRelations = relations(Session, ({ one }) => ({ - user: one(User, { fields: [Session.userId], references: [User.id] }), +export const IssueRelations = relations(Issue, ({ many }) => ({ + teamVisibility: many(IssuesToTeamsVisibility), + userAssignments: many(IssuesToUsersAssignment), })); export const issuesToTeamsVisibilityRelations = relations( @@ -75,15 +71,14 @@ export const issuesToUsersAssignmentRelations = relations( }), ); -export const issueRelations = relations(Issue, ({ many }) => ({ - teamVisibility: many(IssuesToTeamsVisibility), - userAssignments: many(IssuesToUsersAssignment), +export const AccountRelations = relations(Account, ({ one }) => ({ + user: one(User, { fields: [Account.userId], references: [User.id] }), })); -export const rolesRelations = relations(Roles, ({ many }) => ({ - visibleIssues: many(IssuesToTeamsVisibility), +export const MemberRelations = relations(Member, ({ one }) => ({ + user: one(User, { fields: [Member.userId], references: [User.id] }), })); -export const usersRelations = relations(User, ({ many }) => ({ - assignedIssues: many(IssuesToUsersAssignment), +export const SessionRelations = relations(Session, ({ one }) => ({ + user: one(User, { fields: [Session.userId], references: [User.id] }), })); From f96272aff886fbd6951e26d4b32ed689cd00791c Mon Sep 17 00:00:00 2001 From: DGoel1602 Date: Tue, 10 Mar 2026 02:08:02 -0400 Subject: [PATCH 12/15] fix: coderabbit review --- packages/api/src/routers/issues.ts | 70 +++++++++++++------------ packages/db/src/schemas/knight-hacks.ts | 14 +++-- 2 files changed, 47 insertions(+), 37 deletions(-) diff --git a/packages/api/src/routers/issues.ts b/packages/api/src/routers/issues.ts index 16fb13439..f712d2f7e 100644 --- a/packages/api/src/routers/issues.ts +++ b/packages/api/src/routers/issues.ts @@ -30,46 +30,50 @@ async function requireIssue(id: string, label = "Issue") { return issue; } -async function insertJunctions( - issueId: string, - teamVisibilityIds?: string[], - assigneeIds?: string[], -) { - if (teamVisibilityIds?.length) { - await db - .insert(IssuesToTeamsVisibility) - .values(teamVisibilityIds.map((teamId) => ({ issueId, teamId }))); - } - if (assigneeIds?.length) { - await db - .insert(IssuesToUsersAssignment) - .values(assigneeIds.map((userId) => ({ issueId, userId }))); - } -} - export const issuesRouter = { createIssue: permProcedure - .input(CreateIssueInputSchema) + .input(CreateIssueInputSchema.omit({ creator: true })) .mutation(async ({ ctx, input }) => { permissions.controlPerms.or(["EDIT_ISSUES"], ctx); - const { teamVisibilityIds, assigneeIds, ...rest } = input; - const [issue] = await db - .insert(Issue) - .values({ - ...rest, - creator: ctx.session.user.id, - }) - .returning(); + return await db.transaction(async (tx) => { + const { teamVisibilityIds, assigneeIds, ...rest } = input; - if (!issue) - throw new TRPCError({ - message: "Failed to create issue.", - code: "INTERNAL_SERVER_ERROR", - }); + const [issue] = await tx + .insert(Issue) + .values({ + ...rest, + creator: ctx.session.user.id, + }) + .returning(); - await insertJunctions(issue.id, teamVisibilityIds, assigneeIds); - return issue; + if (!issue) { + throw new TRPCError({ + message: "Failed to create issue.", + code: "INTERNAL_SERVER_ERROR", + }); + } + + if (teamVisibilityIds?.length) { + await db.insert(IssuesToTeamsVisibility).values( + teamVisibilityIds.map((teamId) => ({ + issueId: issue.id, + teamId, + })), + ); + } + + if (assigneeIds?.length) { + await db.insert(IssuesToUsersAssignment).values( + assigneeIds.map((userId) => ({ + issueId: issue.id, + userId, + })), + ); + } + + return issue; + }); }), getIssue: permProcedure diff --git a/packages/db/src/schemas/knight-hacks.ts b/packages/db/src/schemas/knight-hacks.ts index 820bbf23e..5e21c4a01 100644 --- a/packages/db/src/schemas/knight-hacks.ts +++ b/packages/db/src/schemas/knight-hacks.ts @@ -1,5 +1,6 @@ import { foreignKey, + index, pgEnum, pgTableCreator, primaryKey, @@ -571,6 +572,11 @@ export const Issue = createTable( foreignColumns: [table.id], name: "issue_parent_fk", }), + teamIdx: index("issue_team_idx").on(table.team), + creatorIdx: index("issue_creator_idx").on(table.creator), + statusIdx: index("issue_status_idx").on(table.status), + dateIdx: index("issue_date_idx").on(table.date), + parentIdx: index("issue_parent_idx").on(table.parent), }), ); @@ -582,11 +588,11 @@ export const IssuesToTeamsVisibility = createTable( issueId: t .uuid("issue_id") .notNull() - .references(() => Issue.id), + .references(() => Issue.id, { onDelete: "cascade" }), teamId: t .uuid("team_id") .notNull() - .references(() => Roles.id), + .references(() => Roles.id, { onDelete: "cascade" }), }), (table) => ({ pk: primaryKey({ columns: [table.issueId, table.teamId] }), @@ -599,11 +605,11 @@ export const IssuesToUsersAssignment = createTable( issueId: t .uuid("issue_id") .notNull() - .references(() => Issue.id), + .references(() => Issue.id, { onDelete: "cascade" }), userId: t .uuid("user_id") .notNull() - .references(() => User.id), + .references(() => User.id, { onDelete: "cascade" }), }), (table) => ({ pk: primaryKey({ columns: [table.issueId, table.userId] }), From fb251e4048bb904f0be581d9795354a63788eaf4 Mon Sep 17 00:00:00 2001 From: DGoel1602 Date: Tue, 10 Mar 2026 10:39:40 -0400 Subject: [PATCH 13/15] chore: more conventional cascades --- packages/api/src/routers/issues.ts | 10 ---------- packages/db/src/schemas/knight-hacks.ts | 4 ++-- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/api/src/routers/issues.ts b/packages/api/src/routers/issues.ts index f712d2f7e..8304f3cdb 100644 --- a/packages/api/src/routers/issues.ts +++ b/packages/api/src/routers/issues.ts @@ -271,16 +271,6 @@ export const issuesRouter = { permissions.controlPerms.or(["EDIT_ISSUES"], ctx); await requireIssue(input.id); - await db - .delete(IssuesToUsersAssignment) - .where(eq(IssuesToUsersAssignment.issueId, input.id)); - await db - .delete(IssuesToTeamsVisibility) - .where(eq(IssuesToTeamsVisibility.issueId, input.id)); - await db - .update(Issue) - .set({ parent: null }) - .where(eq(Issue.parent, input.id)); await db.delete(Issue).where(eq(Issue.id, input.id)); return { success: true }; diff --git a/packages/db/src/schemas/knight-hacks.ts b/packages/db/src/schemas/knight-hacks.ts index 5e21c4a01..37b766598 100644 --- a/packages/db/src/schemas/knight-hacks.ts +++ b/packages/db/src/schemas/knight-hacks.ts @@ -554,7 +554,7 @@ export const Issue = createTable( name: t.text().notNull(), description: t.text().notNull(), links: t.text().array(), - event: t.uuid().references(() => Event.id), + event: t.uuid().references(() => Event.id, { onDelete: "set null" }), date: t.timestamp(), team: t .uuid() @@ -571,7 +571,7 @@ export const Issue = createTable( columns: [table.parent], foreignColumns: [table.id], name: "issue_parent_fk", - }), + }).onDelete("set null"), teamIdx: index("issue_team_idx").on(table.team), creatorIdx: index("issue_creator_idx").on(table.creator), statusIdx: index("issue_status_idx").on(table.status), From b15d5a549dffeeecc21cf06ff56fe64426e07be2 Mon Sep 17 00:00:00 2001 From: Schevis Date: Tue, 10 Mar 2026 13:19:32 -0400 Subject: [PATCH 14/15] fix: add onDelete cascade to team and creator foreign keys in issues table --- packages/db/src/schemas/knight-hacks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/db/src/schemas/knight-hacks.ts b/packages/db/src/schemas/knight-hacks.ts index 37b766598..dc2a90745 100644 --- a/packages/db/src/schemas/knight-hacks.ts +++ b/packages/db/src/schemas/knight-hacks.ts @@ -559,11 +559,11 @@ export const Issue = createTable( team: t .uuid() .notNull() - .references(() => Roles.id), + .references(() => Roles.id, { onDelete: "cascade" }), creator: t .uuid() .notNull() - .references(() => User.id), + .references(() => User.id, { onDelete: "cascade" }), parent: t.uuid(), }), (table) => ({ From d44c9d719da66cf4db9ea4157881ceecbe8cbf06 Mon Sep 17 00:00:00 2001 From: Schevis Date: Tue, 10 Mar 2026 13:38:08 -0400 Subject: [PATCH 15/15] fix: change onDelete behavior for team and creator foreign keys in Issue table to restrict --- packages/db/src/schemas/knight-hacks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/db/src/schemas/knight-hacks.ts b/packages/db/src/schemas/knight-hacks.ts index dc2a90745..10332769f 100644 --- a/packages/db/src/schemas/knight-hacks.ts +++ b/packages/db/src/schemas/knight-hacks.ts @@ -559,11 +559,11 @@ export const Issue = createTable( team: t .uuid() .notNull() - .references(() => Roles.id, { onDelete: "cascade" }), + .references(() => Roles.id, { onDelete: "restrict" }), creator: t .uuid() .notNull() - .references(() => User.id, { onDelete: "cascade" }), + .references(() => User.id, { onDelete: "restrict" }), parent: t.uuid(), }), (table) => ({