route + query updates for "assignees" update

This commit is contained in:
Oliver Bryan
2026-01-16 22:44:15 +00:00
parent 6ffb05eb3b
commit 2842d21ed5
4 changed files with 86 additions and 28 deletions

View File

@@ -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 { aliasedTable, and, eq, inArray, sql } from "drizzle-orm";
import { db } from "../client"; import { db } from "../client";
@@ -8,7 +8,7 @@ export async function createIssue(
description: string, description: string,
creatorId: number, creatorId: number,
sprintId?: number, sprintId?: number,
assigneeId?: number, assigneeIds?: number[],
status?: string, status?: string,
) { ) {
// prevents two issues with the same unique number // prevents two issues with the same unique number
@@ -22,7 +22,6 @@ export async function createIssue(
const nextNumber = (lastIssue?.max || 0) + 1; const nextNumber = (lastIssue?.max || 0) + 1;
// 2. create new issue
const [newIssue] = await tx const [newIssue] = await tx
.insert(Issue) .insert(Issue)
.values({ .values({
@@ -32,11 +31,23 @@ export async function createIssue(
number: nextNumber, number: nextNumber,
creatorId, creatorId,
sprintId, sprintId,
assigneeId,
...(status && { status }), ...(status && { status }),
}) })
.returning(); .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; return newIssue;
}); });
} }
@@ -51,13 +62,27 @@ export async function updateIssue(
title?: string; title?: string;
description?: string; description?: string;
sprintId?: number | null; sprintId?: number | null;
assigneeId?: number | null;
status?: string; status?: string;
}, },
) { ) {
return await db.update(Issue).set(updates).where(eq(Issue.id, id)).returning(); 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() { export async function getIssues() {
return await db.select().from(Issue); return await db.select().from(Issue);
} }
@@ -119,18 +144,41 @@ export async function replaceIssueStatus(organisationId: number, oldStatus: stri
return { updated: result.rowCount ?? 0 }; return { updated: result.rowCount ?? 0 };
} }
export async function getIssuesWithUsersByProject(projectId: number) { export async function getIssuesWithUsersByProject(projectId: number): Promise<IssueResponse[]> {
const Creator = aliasedTable(User, "Creator"); const Creator = aliasedTable(User, "Creator");
const Assignee = aliasedTable(User, "Assignee");
return await db const issuesWithCreators = await db
.select({ .select({
Issue: Issue, Issue: Issue,
Creator: Creator, Creator: Creator,
Assignee: Assignee,
}) })
.from(Issue) .from(Issue)
.where(eq(Issue.projectId, projectId)) .where(eq(Issue.projectId, projectId))
.innerJoin(Creator, eq(Issue.creatorId, Creator.id)) .innerJoin(Creator, eq(Issue.creatorId, Creator.id));
.leftJoin(Assignee, eq(Issue.assigneeId, Assignee.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<number, UserRecord[]>();
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) || [],
}));
} }

View File

@@ -7,7 +7,7 @@ export default async function issueCreate(req: AuthedRequest) {
const parsed = await parseJsonBody(req, IssueCreateRequestSchema); const parsed = await parseJsonBody(req, IssueCreateRequestSchema);
if ("error" in parsed) return parsed.error; 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); const project = await getProjectByID(projectId);
if (!project) { if (!project) {
@@ -20,7 +20,7 @@ export default async function issueCreate(req: AuthedRequest) {
description, description,
req.userId, req.userId,
sprintId ?? undefined, sprintId ?? undefined,
assigneeId ?? undefined, assigneeIds,
status, status,
); );

View File

@@ -1,32 +1,43 @@
import { IssueUpdateRequestSchema } from "@sprint/shared"; import { type IssueRecord, IssueUpdateRequestSchema } from "@sprint/shared";
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { updateIssue } from "../../db/queries"; import { getIssueByID, setIssueAssignees, updateIssue } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
export default async function issueUpdate(req: BunRequest) { export default async function issueUpdate(req: BunRequest) {
const parsed = await parseJsonBody(req, IssueUpdateRequestSchema); const parsed = await parseJsonBody(req, IssueUpdateRequestSchema);
if ("error" in parsed) return parsed.error; 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 // check that at least one field is being updated
if ( if (
title === undefined && title === undefined &&
description === undefined && description === undefined &&
status === undefined && status === undefined &&
assigneeId === undefined && assigneeIds === undefined &&
sprintId === undefined sprintId === undefined
) { ) {
return errorResponse("no updates provided", "NO_UPDATES", 400); return errorResponse("no updates provided", "NO_UPDATES", 400);
} }
const issue = await updateIssue(id, { const hasIssueFieldUpdates =
title, title !== undefined || description !== undefined || status !== undefined || sprintId !== undefined;
description,
sprintId, let issue: IssueRecord | undefined;
assigneeId, if (hasIssueFieldUpdates) {
status, [issue] = await updateIssue(id, {
}); title,
description,
sprintId,
status,
});
} else {
issue = await getIssueByID(id);
}
if (assigneeIds !== undefined) {
await setIssueAssignees(id, assigneeIds ?? []);
}
return Response.json(issue); return Response.json(issue);
} }

View File

@@ -67,7 +67,7 @@ export const IssueCreateRequestSchema = z.object({
title: z.string().min(1, "title is required").max(ISSUE_TITLE_MAX_LENGTH), title: z.string().min(1, "title is required").max(ISSUE_TITLE_MAX_LENGTH),
description: z.string().max(ISSUE_DESCRIPTION_MAX_LENGTH).default(""), description: z.string().max(ISSUE_DESCRIPTION_MAX_LENGTH).default(""),
status: z.string().max(ISSUE_STATUS_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()).optional(),
sprintId: z.number().int().positive().nullable().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(), title: z.string().min(1).max(ISSUE_TITLE_MAX_LENGTH).optional(),
description: z.string().max(ISSUE_DESCRIPTION_MAX_LENGTH).optional(), description: z.string().max(ISSUE_DESCRIPTION_MAX_LENGTH).optional(),
status: z.string().max(ISSUE_STATUS_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(), sprintId: z.number().int().positive().nullable().optional(),
}); });
@@ -328,14 +328,13 @@ export const IssueRecordSchema = z.object({
description: z.string(), description: z.string(),
status: z.string(), status: z.string(),
creatorId: z.number(), creatorId: z.number(),
assigneeId: z.number().nullable(),
sprintId: z.number().nullable(), sprintId: z.number().nullable(),
}); });
export const IssueResponseSchema = z.object({ export const IssueResponseSchema = z.object({
Issue: IssueRecordSchema, Issue: IssueRecordSchema,
Creator: UserResponseSchema, Creator: UserResponseSchema,
Assignee: UserResponseSchema.nullable(), Assignees: z.array(UserResponseSchema),
}); });
export type IssueResponseType = z.infer<typeof IssueResponseSchema>; export type IssueResponseType = z.infer<typeof IssueResponseSchema>;