From 2842d21ed5a4f3c211d266c89c461aee956bcc0b Mon Sep 17 00:00:00 2001 From: Oliver Bryan <04oliverbryan@gmail.com> Date: Fri, 16 Jan 2026 22:44:15 +0000 Subject: [PATCH] route + query updates for "assignees" update --- packages/backend/src/db/queries/issues.ts | 70 +++++++++++++++++---- packages/backend/src/routes/issue/create.ts | 4 +- packages/backend/src/routes/issue/update.ts | 33 ++++++---- packages/shared/src/api-schemas.ts | 7 +-- 4 files changed, 86 insertions(+), 28 deletions(-) diff --git a/packages/backend/src/db/queries/issues.ts b/packages/backend/src/db/queries/issues.ts index fc68991..decc043 100644 --- a/packages/backend/src/db/queries/issues.ts +++ b/packages/backend/src/db/queries/issues.ts @@ -1,4 +1,4 @@ -import { Issue, User } from "@sprint/shared"; +import { Issue, IssueAssignee, type IssueResponse, User, type UserRecord } from "@sprint/shared"; import { aliasedTable, and, eq, inArray, sql } from "drizzle-orm"; import { db } from "../client"; @@ -8,7 +8,7 @@ export async function createIssue( description: string, creatorId: number, sprintId?: number, - assigneeId?: number, + assigneeIds?: number[], status?: string, ) { // prevents two issues with the same unique number @@ -22,7 +22,6 @@ export async function createIssue( const nextNumber = (lastIssue?.max || 0) + 1; - // 2. create new issue const [newIssue] = await tx .insert(Issue) .values({ @@ -32,11 +31,23 @@ export async function createIssue( number: nextNumber, creatorId, sprintId, - assigneeId, ...(status && { status }), }) .returning(); + if (!newIssue) { + throw new Error("failed to create issue"); + } + + if (assigneeIds && assigneeIds.length > 0) { + await tx.insert(IssueAssignee).values( + assigneeIds.map((userId) => ({ + issueId: newIssue.id, + userId, + })), + ); + } + return newIssue; }); } @@ -51,13 +62,27 @@ export async function updateIssue( title?: string; description?: string; sprintId?: number | null; - assigneeId?: number | null; status?: string; }, ) { return await db.update(Issue).set(updates).where(eq(Issue.id, id)).returning(); } +export async function setIssueAssignees(issueId: number, userIds: number[]) { + return await db.transaction(async (tx) => { + await tx.delete(IssueAssignee).where(eq(IssueAssignee.issueId, issueId)); + + if (userIds.length > 0) { + await tx.insert(IssueAssignee).values( + userIds.map((userId) => ({ + issueId, + userId, + })), + ); + } + }); +} + export async function getIssues() { return await db.select().from(Issue); } @@ -119,18 +144,41 @@ export async function replaceIssueStatus(organisationId: number, oldStatus: stri return { updated: result.rowCount ?? 0 }; } -export async function getIssuesWithUsersByProject(projectId: number) { +export async function getIssuesWithUsersByProject(projectId: number): Promise { const Creator = aliasedTable(User, "Creator"); - const Assignee = aliasedTable(User, "Assignee"); - return await db + const issuesWithCreators = await db .select({ Issue: Issue, Creator: Creator, - Assignee: Assignee, }) .from(Issue) .where(eq(Issue.projectId, projectId)) - .innerJoin(Creator, eq(Issue.creatorId, Creator.id)) - .leftJoin(Assignee, eq(Issue.assigneeId, Assignee.id)); + .innerJoin(Creator, eq(Issue.creatorId, Creator.id)); + + const issueIds = issuesWithCreators.map((i) => i.Issue.id); + const assigneesData = + issueIds.length > 0 + ? await db + .select({ + issueId: IssueAssignee.issueId, + User: User, + }) + .from(IssueAssignee) + .innerJoin(User, eq(IssueAssignee.userId, User.id)) + .where(inArray(IssueAssignee.issueId, issueIds)) + : []; + + const assigneesByIssue = new Map(); + for (const a of assigneesData) { + const existing = assigneesByIssue.get(a.issueId) || []; + existing.push(a.User); + assigneesByIssue.set(a.issueId, existing); + } + + return issuesWithCreators.map((row) => ({ + Issue: row.Issue, + Creator: row.Creator, + Assignees: assigneesByIssue.get(row.Issue.id) || [], + })); } diff --git a/packages/backend/src/routes/issue/create.ts b/packages/backend/src/routes/issue/create.ts index edbcb02..3cab076 100644 --- a/packages/backend/src/routes/issue/create.ts +++ b/packages/backend/src/routes/issue/create.ts @@ -7,7 +7,7 @@ export default async function issueCreate(req: AuthedRequest) { const parsed = await parseJsonBody(req, IssueCreateRequestSchema); if ("error" in parsed) return parsed.error; - const { projectId, title, description = "", status, assigneeId, sprintId } = parsed.data; + const { projectId, title, description = "", status, assigneeIds, sprintId } = parsed.data; const project = await getProjectByID(projectId); if (!project) { @@ -20,7 +20,7 @@ export default async function issueCreate(req: AuthedRequest) { description, req.userId, sprintId ?? undefined, - assigneeId ?? undefined, + assigneeIds, status, ); diff --git a/packages/backend/src/routes/issue/update.ts b/packages/backend/src/routes/issue/update.ts index 80bb06b..21e9b21 100644 --- a/packages/backend/src/routes/issue/update.ts +++ b/packages/backend/src/routes/issue/update.ts @@ -1,32 +1,43 @@ -import { IssueUpdateRequestSchema } from "@sprint/shared"; +import { type IssueRecord, IssueUpdateRequestSchema } from "@sprint/shared"; import type { BunRequest } from "bun"; -import { updateIssue } from "../../db/queries"; +import { getIssueByID, setIssueAssignees, updateIssue } from "../../db/queries"; import { errorResponse, parseJsonBody } from "../../validation"; export default async function issueUpdate(req: BunRequest) { const parsed = await parseJsonBody(req, IssueUpdateRequestSchema); if ("error" in parsed) return parsed.error; - const { id, title, description, status, assigneeId, sprintId } = parsed.data; + const { id, title, description, status, assigneeIds, sprintId } = parsed.data; // check that at least one field is being updated if ( title === undefined && description === undefined && status === undefined && - assigneeId === undefined && + assigneeIds === undefined && sprintId === undefined ) { return errorResponse("no updates provided", "NO_UPDATES", 400); } - const issue = await updateIssue(id, { - title, - description, - sprintId, - assigneeId, - status, - }); + const hasIssueFieldUpdates = + title !== undefined || description !== undefined || status !== undefined || sprintId !== undefined; + + let issue: IssueRecord | undefined; + if (hasIssueFieldUpdates) { + [issue] = await updateIssue(id, { + title, + description, + sprintId, + status, + }); + } else { + issue = await getIssueByID(id); + } + + if (assigneeIds !== undefined) { + await setIssueAssignees(id, assigneeIds ?? []); + } return Response.json(issue); } diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index a00af52..5505f75 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -67,7 +67,7 @@ export const IssueCreateRequestSchema = z.object({ title: z.string().min(1, "title is required").max(ISSUE_TITLE_MAX_LENGTH), description: z.string().max(ISSUE_DESCRIPTION_MAX_LENGTH).default(""), status: z.string().max(ISSUE_STATUS_MAX_LENGTH).optional(), - assigneeId: z.number().int().positive().nullable().optional(), + assigneeIds: z.array(z.number().int().positive()).optional(), sprintId: z.number().int().positive().nullable().optional(), }); @@ -78,7 +78,7 @@ export const IssueUpdateRequestSchema = z.object({ title: z.string().min(1).max(ISSUE_TITLE_MAX_LENGTH).optional(), description: z.string().max(ISSUE_DESCRIPTION_MAX_LENGTH).optional(), status: z.string().max(ISSUE_STATUS_MAX_LENGTH).optional(), - assigneeId: z.number().int().positive().nullable().optional(), + assigneeIds: z.array(z.number().int().positive()).nullable().optional(), sprintId: z.number().int().positive().nullable().optional(), }); @@ -328,14 +328,13 @@ export const IssueRecordSchema = z.object({ description: z.string(), status: z.string(), creatorId: z.number(), - assigneeId: z.number().nullable(), sprintId: z.number().nullable(), }); export const IssueResponseSchema = z.object({ Issue: IssueRecordSchema, Creator: UserResponseSchema, - Assignee: UserResponseSchema.nullable(), + Assignees: z.array(UserResponseSchema), }); export type IssueResponseType = z.infer;