Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions packages/api/src/routers/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<typeof baseTemplateSubIssueSchema> & {
children?: TemplateSubIssue[];
};

const templateSubIssueSchema: z.ZodType<TemplateSubIssue> =
baseTemplateSubIssueSchema.extend({
children: z.lazy(() => z.array(templateSubIssueSchema)).optional(),
});

export const issuesRouter = {
createIssue: permProcedure
.input(CreateIssueInputSchema.omit({ creator: true }))
Expand Down Expand Up @@ -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;
10 changes: 10 additions & 0 deletions packages/consts/src/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,16 @@ export const PERMISSION_DATA: Record<string, PermissionDataObj> = {
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<string, PermissionDataObj>;

export const PERMISSIONS = Object.fromEntries(
Expand Down
14 changes: 14 additions & 0 deletions packages/db/src/schemas/knight-hacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Loading