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..8304f3cdb --- /dev/null +++ b/packages/api/src/routers/issues.ts @@ -0,0 +1,278 @@ +import type { TRPCRouterRecord } from "@trpc/server"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +import { ISSUE } from "@forge/consts"; +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"; +import { permissions } from "@forge/utils"; + +import { permProcedure } from "../trpc"; + +const CreateIssueInputSchema = IssueSchema.extend({ + 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; +} + +export const issuesRouter = { + createIssue: permProcedure + .input(CreateIssueInputSchema.omit({ creator: true })) + .mutation(async ({ ctx, input }) => { + permissions.controlPerms.or(["EDIT_ISSUES"], ctx); + + return await db.transaction(async (tx) => { + const { teamVisibilityIds, assigneeIds, ...rest } = input; + + const [issue] = await tx + .insert(Issue) + .values({ + ...rest, + creator: ctx.session.user.id, + }) + .returning(); + + 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 + .input( + z.object({ + id: z.string(), + }), + ) + .query(async ({ ctx, input }) => { + permissions.controlPerms.or(["READ_ISSUES"], ctx); + + 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 = + 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), + }); + if (!issue) + throw new TRPCError({ message: `Issue not found.`, code: "NOT_FOUND" }); + 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), + ); + } + + 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 = + 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) { + filters.push( + exists( + db + .select() + .from(IssuesToUsersAssignment) + .where( + and( + eq(IssuesToUsersAssignment.issueId, Issue.id), + inArray(IssuesToUsersAssignment.userId, input.assigneeIds), + ), + ), + ), + ); + } + + const issues = await db.query.Issue.findMany({ + where: and(...filters, visibilityFilter), + with: { + teamVisibility: { with: { team: true } }, + userAssignments: { with: { user: true } }, + }, + }); + return issues; + }), + + 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 } }, + }, + }); + }), + 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(Issue).where(eq(Issue.id, input.id)); + + return { success: true }; + }), +} satisfies TRPCRouterRecord; 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/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( 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 bcb310505..10332769f 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, + index, 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(), @@ -138,10 +140,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); @@ -547,3 +545,73 @@ 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, { onDelete: "set null" }), + date: t.timestamp(), + team: t + .uuid() + .notNull() + .references(() => Roles.id, { onDelete: "restrict" }), + creator: t + .uuid() + .notNull() + .references(() => User.id, { onDelete: "restrict" }), + parent: t.uuid(), + }), + (table) => ({ + parentReference: foreignKey({ + 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), + dateIdx: index("issue_date_idx").on(table.date), + parentIdx: index("issue_parent_idx").on(table.parent), + }), +); + +export const IssueSchema = createInsertSchema(Issue); + +export const IssuesToTeamsVisibility = createTable( + "issues_to_teams_visibility", + (t) => ({ + issueId: t + .uuid("issue_id") + .notNull() + .references(() => Issue.id, { onDelete: "cascade" }), + teamId: t + .uuid("team_id") + .notNull() + .references(() => Roles.id, { onDelete: "cascade" }), + }), + (table) => ({ + pk: primaryKey({ columns: [table.issueId, table.teamId] }), + }), +); + +export const IssuesToUsersAssignment = createTable( + "issues_to_users_assignment", + (t) => ({ + issueId: t + .uuid("issue_id") + .notNull() + .references(() => Issue.id, { onDelete: "cascade" }), + userId: t + .uuid("user_id") + .notNull() + .references(() => User.id, { onDelete: "cascade" }), + }), + (table) => ({ + pk: primaryKey({ columns: [table.issueId, table.userId] }), + }), +); diff --git a/packages/db/src/schemas/relations.ts b/packages/db/src/schemas/relations.ts new file mode 100644 index 000000000..482898216 --- /dev/null +++ b/packages/db/src/schemas/relations.ts @@ -0,0 +1,84 @@ +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), + 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 }) => ({ + role: one(Roles, { + fields: [Permissions.roleId], + references: [Roles.id], + relationName: "rolePermissionRel", + }), + user: one(User, { + fields: [Permissions.userId], + references: [User.id], + relationName: "userPermissionRel", + }), +})); + +export const IssueRelations = relations(Issue, ({ many }) => ({ + teamVisibility: many(IssuesToTeamsVisibility), + userAssignments: many(IssuesToUsersAssignment), +})); + +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 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] }), +}));