diff --git a/packages/api/src/routers/issues.ts b/packages/api/src/routers/issues.ts index 8304f3cdb..43ac6d867 100644 --- a/packages/api/src/routers/issues.ts +++ b/packages/api/src/routers/issues.ts @@ -7,10 +7,12 @@ import { and, eq, exists, inArray, sql } from "@forge/db"; import { db } from "@forge/db/client"; import { Permissions } from "@forge/db/schemas/auth"; import { + InsertTemplateSchema, Issue, IssueSchema, IssuesToTeamsVisibility, IssuesToUsersAssignment, + Template, } from "@forge/db/schemas/knight-hacks"; import { permissions } from "@forge/utils"; @@ -30,6 +32,23 @@ async function requireIssue(id: string, label = "Issue") { return issue; } +const baseTemplateSubIssueSchema = z.object({ + title: z.string().min(1, "Title is required"), + description: z.string().optional(), + team: z.string().optional(), + assignee: z.string().optional(), + dateMs: z.number().int().optional(), +}); + +export type TemplateSubIssue = z.infer & { + children?: TemplateSubIssue[]; +}; + +const templateSubIssueSchema: z.ZodType = + baseTemplateSubIssueSchema.extend({ + children: z.lazy(() => z.array(templateSubIssueSchema)).optional(), + }); + export const issuesRouter = { createIssue: permProcedure .input(CreateIssueInputSchema.omit({ creator: true })) @@ -275,4 +294,108 @@ export const issuesRouter = { return { success: true }; }), + createTemplate: permProcedure + .input( + InsertTemplateSchema.extend({ + name: z.string().min(1, "A template name is required"), // excludes empty strings + body: z.array(templateSubIssueSchema), + }), + ) + .mutation(async ({ ctx, input }) => { + permissions.controlPerms.or(["EDIT_ISSUE_TEMPLATES"], ctx); + + const [newTemplate] = await db.insert(Template).values(input).returning(); + + if (newTemplate === undefined) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `There was an error creating the template: ${input.name}`, + }); + } + + return newTemplate; + }), + updateTemplate: permProcedure + .input( + InsertTemplateSchema.omit({ + createdAt: true, + updatedAt: true, + }) + .partial() // makes all future fields optional + .extend({ + id: z.string().uuid(), // forces ID to not be an optional field + name: z.string().min(1, "A template name is required").optional(), // excludes empty strings + body: z.array(templateSubIssueSchema).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + permissions.controlPerms.or(["EDIT_ISSUE_TEMPLATES"], ctx); + + // We want to separate id from all optional fields + const { id, ...updateData } = input; + + // this is true only if all fields of updateData are undefined + const hasUpdates = Object.values(updateData).some( + // value is unknown because it seems like ts can't resolve that value might be undefined + // and gives a type error thinking it can never overlap with undefined + (value: unknown) => value !== undefined, + ); + + if (!hasUpdates) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You must provide at least one field to update.", + }); + } + + const [updatedTemplate] = await db + .update(Template) + .set(updateData) + .where(eq(Template.id, id)) + .returning(); + + if (updatedTemplate === undefined) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Template with ID ${id} was not found`, + }); + } + + return updatedTemplate; + }), + deleteTemplate: permProcedure + .input( + z.object({ + id: z.string().uuid(), + }), + ) + .mutation(async ({ ctx, input }) => { + permissions.controlPerms.or(["EDIT_ISSUE_TEMPLATES"], ctx); + + const [deleted] = await db + .delete(Template) + .where(eq(Template.id, input.id)) + .returning(); + + if (!deleted) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Template with ID ${input.id} was not found`, + }); + } + + return { deletedId: deleted.id }; + }), + getTemplates: permProcedure.query(async ({ ctx }) => { + permissions.controlPerms.or( + ["READ_ISSUE_TEMPLATES", "EDIT_ISSUE_TEMPLATES"], + ctx, + ); + + const templates = await db.query.Template.findMany({ + orderBy: (templates, { desc }) => [desc(templates.createdAt)], + }); + + return templates; + }), } satisfies TRPCRouterRecord; diff --git a/packages/consts/src/permissions.ts b/packages/consts/src/permissions.ts index 306d8cf05..70d201dcc 100644 --- a/packages/consts/src/permissions.ts +++ b/packages/consts/src/permissions.ts @@ -115,6 +115,16 @@ export const PERMISSION_DATA: Record = { name: "Edit Issues", desc: "Allows creating, editing, or deleting issues.", }, + EDIT_ISSUE_TEMPLATES: { + idx: 22, + name: "Edit Issue Templates", + desc: "Allows creating, editing, or deleting templates.", + }, + READ_ISSUE_TEMPLATES: { + idx: 23, + name: "Read Issue Templates", + desc: "Grants access to issue templates.", + }, } as const satisfies Record; export const PERMISSIONS = Object.fromEntries( diff --git a/packages/db/src/schemas/knight-hacks.ts b/packages/db/src/schemas/knight-hacks.ts index 10332769f..434c4727e 100644 --- a/packages/db/src/schemas/knight-hacks.ts +++ b/packages/db/src/schemas/knight-hacks.ts @@ -615,3 +615,17 @@ export const IssuesToUsersAssignment = createTable( pk: primaryKey({ columns: [table.issueId, table.userId] }), }), ); + +export const Template = createTable("template", (t) => ({ + id: t.uuid().notNull().primaryKey().defaultRandom(), + name: t.text().notNull(), + body: t.jsonb().notNull(), + createdAt: t.timestamp().defaultNow().notNull(), + updatedAt: t + .timestamp() + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), +})); + +export const InsertTemplateSchema = createInsertSchema(Template);