backend routes with zod schemas

This commit is contained in:
Oliver Bryan
2026-01-13 15:32:31 +00:00
parent e2cbe6bab3
commit 1ab5c80691
31 changed files with 345 additions and 609 deletions

View File

@@ -1,45 +1,32 @@
import { LoginRequestSchema } from "@issue/shared";
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { buildAuthCookie, generateToken, verifyPassword } from "../../auth/utils"; import { buildAuthCookie, generateToken, verifyPassword } from "../../auth/utils";
import { createSession, getUserByUsername } from "../../db/queries"; import { createSession, getUserByUsername } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.trim().length > 0;
export default async function login(req: BunRequest) { export default async function login(req: BunRequest) {
if (req.method !== "POST") { if (req.method !== "POST") {
return new Response("method not allowed", { status: 405 }); return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
} }
let body: unknown; const parsed = await parseJsonBody(req, LoginRequestSchema);
try { if ("error" in parsed) return parsed.error;
body = await req.json();
} catch {
return new Response("invalid JSON", { status: 400 });
}
if (!body || typeof body !== "object") { const { username, password } = parsed.data;
return new Response("invalid request body", { status: 400 });
}
const { username, password } = body as Record<string, unknown>;
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
return new Response("username and password are required", { status: 400 });
}
const user = await getUserByUsername(username); const user = await getUserByUsername(username);
if (!user) { if (!user) {
return new Response("invalid credentials", { status: 401 }); return errorResponse("invalid credentials", "INVALID_CREDENTIALS", 401);
} }
const ok = await verifyPassword(password, user.passwordHash); const ok = await verifyPassword(password, user.passwordHash);
if (!ok) { if (!ok) {
return new Response("invalid credentials", { status: 401 }); return errorResponse("invalid credentials", "INVALID_CREDENTIALS", 401);
} }
const session = await createSession(user.id); const session = await createSession(user.id);
if (!session) { if (!session) {
return new Response("failed to create session", { status: 500 }); return errorResponse("failed to create session", "SESSION_ERROR", 500);
} }
const token = generateToken(session.id, user.id); const token = generateToken(session.id, user.id);

View File

@@ -1,62 +1,33 @@
import { RegisterRequestSchema } from "@issue/shared";
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { buildAuthCookie, generateToken, hashPassword } from "../../auth/utils"; import { buildAuthCookie, generateToken, hashPassword } from "../../auth/utils";
import { createSession, createUser, getUserByUsername } from "../../db/queries"; import { createSession, createUser, getUserByUsername } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.trim().length > 0;
export default async function register(req: BunRequest) { export default async function register(req: BunRequest) {
if (req.method !== "POST") { if (req.method !== "POST") {
return new Response("method not allowed", { status: 405 }); return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
} }
let body: unknown; const parsed = await parseJsonBody(req, RegisterRequestSchema);
try { if ("error" in parsed) return parsed.error;
body = await req.json();
} catch {
return new Response("invalid JSON", { status: 400 });
}
if (!body || typeof body !== "object") { const { name, username, password, avatarURL } = parsed.data;
return new Response("invalid request body", { status: 400 });
}
const { name, username, password, avatarURL } = body as Record<string, unknown>;
if (!isNonEmptyString(name) || !isNonEmptyString(username) || !isNonEmptyString(password)) {
return new Response("name, username, and password are required", { status: 400 });
}
if (username.length < 1 || username.length > 32) {
return new Response("username must be 1-32 characters", { status: 400 });
}
if (password.length < 8) {
return new Response("password must be at least 8 characters", { status: 400 });
}
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumber = /[0-9]/.test(password);
if (!hasUpperCase || !hasLowerCase || !hasNumber) {
return new Response("password must contain uppercase, lowercase, and numbers", { status: 400 });
}
const existing = await getUserByUsername(username); const existing = await getUserByUsername(username);
if (existing) { if (existing) {
return new Response("username already taken", { status: 400 }); return errorResponse("username already taken", "USERNAME_TAKEN", 400);
} }
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
const user = await createUser(name, username, passwordHash, avatarURL as string | undefined); const user = await createUser(name, username, passwordHash, avatarURL);
if (!user) { if (!user) {
return new Response("failed to create user", { status: 500 }); return errorResponse("failed to create user", "USER_CREATE_ERROR", 500);
} }
const session = await createSession(user.id); const session = await createSession(user.id);
if (!session) { if (!session) {
return new Response("failed to create session", { status: 500 }); return errorResponse("failed to create session", "SESSION_ERROR", 500);
} }
const token = generateToken(session.id, user.id); const token = generateToken(session.id, user.id);

View File

@@ -1,35 +1,28 @@
import { IssueCreateRequestSchema } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { createIssue, getProjectByID, getProjectByKey } from "../../db/queries"; import { createIssue, getProjectByID } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
// /issue/create?projectId=1&title=Testing&description=Description&status=TO%20DO
// OR
// /issue/create?projectKey=projectKey&title=Testing&description=Description&status=TO%20DO
export default async function issueCreate(req: AuthedRequest) { export default async function issueCreate(req: AuthedRequest) {
const url = new URL(req.url); const parsed = await parseJsonBody(req, IssueCreateRequestSchema);
const projectId = url.searchParams.get("projectId"); if ("error" in parsed) return parsed.error;
const projectKey = url.searchParams.get("projectKey");
let project = null; const { projectId, title, description = "", status, assigneeId, sprintId } = parsed.data;
if (projectId) {
project = await getProjectByID(Number(projectId)); const project = await getProjectByID(projectId);
} else if (projectKey) {
project = await getProjectByKey(projectKey);
} else {
return new Response("missing project key or project id", { status: 400 });
}
if (!project) { if (!project) {
return new Response(`project not found: provided ${projectId ?? projectKey}`, { status: 404 }); return errorResponse(`project not found: ${projectId}`, "PROJECT_NOT_FOUND", 404);
} }
const title = url.searchParams.get("title") || "Untitled Issue"; const issue = await createIssue(
const description = url.searchParams.get("description") || ""; project.id,
const sprintIdParam = url.searchParams.get("sprintId"); title,
const sprintId = sprintIdParam ? Number(sprintIdParam) : undefined; description,
const assigneeIdParam = url.searchParams.get("assigneeId"); req.userId,
const assigneeId = assigneeIdParam ? Number(assigneeIdParam) : undefined; sprintId ?? undefined,
const status = url.searchParams.get("status") || undefined; assigneeId ?? undefined,
status,
const issue = await createIssue(project.id, title, description, req.userId, sprintId, assigneeId, status); );
return Response.json(issue); return Response.json(issue);
} }

View File

@@ -1,18 +1,18 @@
import { IssueDeleteRequestSchema } from "@issue/shared";
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { deleteIssue } from "../../db/queries"; import { deleteIssue } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
// /issue/delete?id=1
export default async function issueDelete(req: BunRequest) { export default async function issueDelete(req: BunRequest) {
const url = new URL(req.url); const parsed = await parseJsonBody(req, IssueDeleteRequestSchema);
const id = url.searchParams.get("id"); if ("error" in parsed) return parsed.error;
if (!id) {
return new Response("missing issue id", { status: 400 });
}
const result = await deleteIssue(Number(id)); const { id } = parsed.data;
const result = await deleteIssue(id);
if (result.rowCount === 0) { if (result.rowCount === 0) {
return new Response(`no issue with id ${id} found`, { status: 404 }); return errorResponse(`no issue with id ${id} found`, "ISSUE_NOT_FOUND", 404);
} }
return new Response(`issue with id ${id} deleted`, { status: 200 }); return Response.json({ success: true });
} }

View File

@@ -1,41 +1,26 @@
import { IssueUpdateRequestSchema } from "@issue/shared";
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { updateIssue } from "../../db/queries"; import { updateIssue } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
// /issue/update?id=1&title=Testing&description=Description&assigneeId=2&status=IN%20PROGRESS
// assigneeId can be "null" to unassign
export default async function issueUpdate(req: BunRequest) { export default async function issueUpdate(req: BunRequest) {
const url = new URL(req.url); const parsed = await parseJsonBody(req, IssueUpdateRequestSchema);
const id = url.searchParams.get("id"); if ("error" in parsed) return parsed.error;
if (!id) {
return new Response("missing issue id", { status: 400 }); const { id, title, description, status, assigneeId, sprintId } = parsed.data;
// check that at least one field is being updated
if (
title === undefined &&
description === undefined &&
status === undefined &&
assigneeId === undefined &&
sprintId === undefined
) {
return errorResponse("no updates provided", "NO_UPDATES", 400);
} }
const title = url.searchParams.get("title") || undefined; const issue = await updateIssue(id, {
const description = url.searchParams.get("description") || undefined;
const sprintIdParam = url.searchParams.get("sprintId");
const assigneeIdParam = url.searchParams.get("assigneeId");
const status = url.searchParams.get("status") || undefined;
// parse sprintId: "null" means unassign, number means assign, undefined means no change
let sprintId: number | null | undefined;
if (sprintIdParam === "null") {
sprintId = null;
} else if (sprintIdParam) {
sprintId = Number(sprintIdParam);
}
// same for assigneeId
let assigneeId: number | null | undefined;
if (assigneeIdParam === "null") {
assigneeId = null;
} else if (assigneeIdParam) {
assigneeId = Number(assigneeIdParam);
}
if (!title && !description && sprintId === undefined && assigneeId === undefined && !status) {
return new Response("no updates provided", { status: 400 });
}
const issue = await updateIssue(Number(id), {
title, title,
description, description,
sprintId, sprintId,

View File

@@ -1,14 +1,20 @@
import { IssuesByProjectQuerySchema } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { getIssuesWithUsersByProject, getProjectByID } from "../../db/queries"; import { getIssuesWithUsersByProject, getProjectByID } from "../../db/queries";
import { errorResponse, parseQueryParams } from "../../validation";
export default async function issuesByProject(req: AuthedRequest) { export default async function issuesByProject(req: AuthedRequest) {
const url = new URL(req.url); const url = new URL(req.url);
const projectId = url.searchParams.get("projectId"); const parsed = parseQueryParams(url, IssuesByProjectQuerySchema);
if ("error" in parsed) return parsed.error;
const project = await getProjectByID(Number(projectId)); const { projectId } = parsed.data;
const project = await getProjectByID(projectId);
if (!project) { if (!project) {
return new Response(`project not found: provided ${projectId}`, { status: 404 }); return errorResponse(`project not found: ${projectId}`, "PROJECT_NOT_FOUND", 404);
} }
const issues = await getIssuesWithUsersByProject(project.id); const issues = await getIssuesWithUsersByProject(project.id);
return Response.json(issues); return Response.json(issues);

View File

@@ -1,38 +1,21 @@
import { IssuesReplaceStatusRequestSchema } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { getOrganisationMemberRole, replaceIssueStatus } from "../../db/queries"; import { getOrganisationMemberRole, replaceIssueStatus } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
// /issues/replace-status?organisationId=1&oldStatus=TO%20DO&newStatus=IN%20PROGRESS
export default async function issuesReplaceStatus(req: AuthedRequest) { export default async function issuesReplaceStatus(req: AuthedRequest) {
const url = new URL(req.url); const parsed = await parseJsonBody(req, IssuesReplaceStatusRequestSchema);
const organisationIdParam = url.searchParams.get("organisationId"); if ("error" in parsed) return parsed.error;
const oldStatus = url.searchParams.get("oldStatus");
const newStatus = url.searchParams.get("newStatus");
if (!organisationIdParam) { const { organisationId, oldStatus, newStatus } = parsed.data;
return new Response("missing organisationId", { status: 400 });
}
if (!oldStatus) {
return new Response("missing oldStatus", { status: 400 });
}
if (!newStatus) {
return new Response("missing newStatus", { status: 400 });
}
const organisationId = Number(organisationIdParam);
if (!Number.isInteger(organisationId)) {
return new Response("organisationId must be an integer", { status: 400 });
}
// check if user is admin or owner of the organisation
const membership = await getOrganisationMemberRole(organisationId, req.userId); const membership = await getOrganisationMemberRole(organisationId, req.userId);
if (!membership) { if (!membership) {
return new Response("not a member of this organisation", { status: 403 }); return errorResponse("not a member of this organisation", "NOT_MEMBER", 403);
} }
if (membership.role !== "owner" && membership.role !== "admin") { if (membership.role !== "owner" && membership.role !== "admin") {
return new Response("only admins and owners can replace statuses", { status: 403 }); return errorResponse("only admins and owners can replace statuses", "PERMISSION_DENIED", 403);
} }
const result = await replaceIssueStatus(organisationId, oldStatus, newStatus); const result = await replaceIssueStatus(organisationId, oldStatus, newStatus);

View File

@@ -1,28 +1,18 @@
import { IssuesStatusCountQuerySchema } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { getIssueStatusCountByOrganisation, getOrganisationMemberRole } from "../../db/queries"; import { getIssueStatusCountByOrganisation, getOrganisationMemberRole } from "../../db/queries";
import { errorResponse, parseQueryParams } from "../../validation";
// /issues/status-count?organisationId=1&status=TODO
export default async function issuesStatusCount(req: AuthedRequest) { export default async function issuesStatusCount(req: AuthedRequest) {
const url = new URL(req.url); const url = new URL(req.url);
const organisationIdParam = url.searchParams.get("organisationId"); const parsed = parseQueryParams(url, IssuesStatusCountQuerySchema);
const status = url.searchParams.get("status"); if ("error" in parsed) return parsed.error;
if (!organisationIdParam) { const { organisationId, status } = parsed.data;
return new Response("missing organisationId", { status: 400 });
}
if (!status) {
return new Response("missing status", { status: 400 });
}
const organisationId = Number(organisationIdParam);
if (!Number.isInteger(organisationId)) {
return new Response("organisationId must be an integer", { status: 400 });
}
const membership = await getOrganisationMemberRole(organisationId, req.userId); const membership = await getOrganisationMemberRole(organisationId, req.userId);
if (!membership) { if (!membership) {
return new Response("not a member of this organisation", { status: 403 }); return errorResponse("not a member of this organisation", "NOT_MEMBER", 403);
} }
const result = await getIssueStatusCountByOrganisation(organisationId, status); const result = await getIssueStatusCountByOrganisation(organisationId, status);

View File

@@ -1,3 +1,4 @@
import { OrgAddMemberRequestSchema } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { import {
createOrganisationMember, createOrganisationMember,
@@ -5,53 +6,39 @@ import {
getOrganisationMemberRole, getOrganisationMemberRole,
getUserById, getUserById,
} from "../../db/queries"; } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
// /organisation/add-member?organisationId=1&userId=2&role=member
export default async function organisationAddMember(req: AuthedRequest) { export default async function organisationAddMember(req: AuthedRequest) {
const url = new URL(req.url); const parsed = await parseJsonBody(req, OrgAddMemberRequestSchema);
const organisationId = url.searchParams.get("organisationId"); if ("error" in parsed) return parsed.error;
const userId = url.searchParams.get("userId");
const role = url.searchParams.get("role") || "member";
if (!organisationId || !userId) { const { organisationId, userId, role } = parsed.data;
return new Response(
`missing parameters: ${!organisationId ? "organisationId " : ""}${!userId ? "userId" : ""}`,
{ status: 400 },
);
}
const orgIdNumber = Number(organisationId); const organisation = await getOrganisationById(organisationId);
const userIdNumber = Number(userId);
if (!Number.isInteger(orgIdNumber) || !Number.isInteger(userIdNumber)) {
return new Response("organisationId and userId must be integers", { status: 400 });
}
const organisation = await getOrganisationById(orgIdNumber);
if (!organisation) { if (!organisation) {
return new Response(`organisation with id ${organisationId} not found`, { status: 404 }); return errorResponse(`organisation with id ${organisationId} not found`, "ORG_NOT_FOUND", 404);
} }
const user = await getUserById(userIdNumber); const user = await getUserById(userId);
if (!user) { if (!user) {
return new Response(`user with id ${userId} not found`, { status: 404 }); return errorResponse(`user with id ${userId} not found`, "USER_NOT_FOUND", 404);
} }
const existingMember = await getOrganisationMemberRole(orgIdNumber, userIdNumber); const existingMember = await getOrganisationMemberRole(organisationId, userId);
if (existingMember) { if (existingMember) {
return new Response("User is already a member of this organisation", { status: 409 }); return errorResponse("user is already a member of this organisation", "ALREADY_MEMBER", 409);
} }
const requesterMember = await getOrganisationMemberRole(orgIdNumber, req.userId); const requesterMember = await getOrganisationMemberRole(organisationId, req.userId);
if (!requesterMember) { if (!requesterMember) {
return new Response("You are not a member of this organisation", { status: 403 }); return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
} }
if (requesterMember.role !== "owner" && requesterMember.role !== "admin") { if (requesterMember.role !== "owner" && requesterMember.role !== "admin") {
return new Response("Only owners and admins can add members", { status: 403 }); return errorResponse("only owners and admins can add members", "PERMISSION_DENIED", 403);
} }
const member = await createOrganisationMember(orgIdNumber, userIdNumber, role); const member = await createOrganisationMember(organisationId, userId, role);
return Response.json(member); return Response.json(member);
} }

View File

@@ -1,23 +1,18 @@
import { OrgByIdQuerySchema } from "@issue/shared";
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { getOrganisationById } from "../../db/queries"; import { getOrganisationById } from "../../db/queries";
import { errorResponse, parseQueryParams } from "../../validation";
// /organisation/by-id?id=1
export default async function organisationById(req: BunRequest) { export default async function organisationById(req: BunRequest) {
const url = new URL(req.url); const url = new URL(req.url);
const id = url.searchParams.get("id"); const parsed = parseQueryParams(url, OrgByIdQuerySchema);
if ("error" in parsed) return parsed.error;
if (!id) { const { id } = parsed.data;
return new Response("organisation id is required", { status: 400 });
}
const organisationId = Number(id); const organisation = await getOrganisationById(id);
if (!Number.isInteger(organisationId)) {
return new Response("organisation id must be an integer", { status: 400 });
}
const organisation = await getOrganisationById(organisationId);
if (!organisation) { if (!organisation) {
return new Response(`organisation with id ${id} not found`, { status: 404 }); return errorResponse(`organisation with id ${id} not found`, "ORG_NOT_FOUND", 404);
} }
return Response.json(organisation); return Response.json(organisation);

View File

@@ -1,31 +1,8 @@
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { getOrganisationsByUserId, getUserById } from "../../db/queries"; import { getOrganisationsByUserId } from "../../db/queries";
// /organisations/by-user?userId=1
export default async function organisationsByUser(req: AuthedRequest) { export default async function organisationsByUser(req: AuthedRequest) {
const url = new URL(req.url); const organisations = await getOrganisationsByUserId(req.userId);
const userId = url.searchParams.get("userId");
if (!userId) {
return new Response("userId is required", { status: 400 });
}
const userIdNumber = Number(userId);
if (!Number.isInteger(userIdNumber)) {
return new Response("userId must be an integer", { status: 400 });
}
// Users can only view their own organisations
if (req.userId !== userIdNumber) {
return new Response("Access denied: you can only view your own organisations", { status: 403 });
}
const user = await getUserById(userIdNumber);
if (!user) {
return new Response(`user with id ${userId} not found`, { status: 404 });
}
const organisations = await getOrganisationsByUserId(user.id);
return Response.json(organisations); return Response.json(organisations);
} }

View File

@@ -1,37 +1,20 @@
import { OrgCreateRequestSchema } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { createOrganisationWithOwner, getOrganisationBySlug } from "../../db/queries"; import { createOrganisationWithOwner, getOrganisationBySlug } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
// /organisation/create?name=Org%20Name&slug=org-name&userId=1&description=Optional%20description
export default async function organisationCreate(req: AuthedRequest) { export default async function organisationCreate(req: AuthedRequest) {
const url = new URL(req.url); const parsed = await parseJsonBody(req, OrgCreateRequestSchema);
const name = url.searchParams.get("name"); if ("error" in parsed) return parsed.error;
const slug = url.searchParams.get("slug");
const userId = url.searchParams.get("userId");
const description = url.searchParams.get("description") || undefined;
if (!name || !slug || !userId) { const { name, slug, description } = parsed.data;
return new Response(
`missing parameters: ${!name ? "name " : ""}${!slug ? "slug " : ""}${!userId ? "userId" : ""}`,
{ status: 400 },
);
}
const userIdNumber = Number(userId);
if (!Number.isInteger(userIdNumber)) {
return new Response("userId must be an integer", { status: 400 });
}
// users can only create organisations for themselves (userId cannot be spoofed)
if (req.userId !== userIdNumber) {
return new Response("access denied: you can only create organisations for yourself", { status: 403 });
}
const existingOrganisation = await getOrganisationBySlug(slug); const existingOrganisation = await getOrganisationBySlug(slug);
if (existingOrganisation) { if (existingOrganisation) {
return new Response(`organisation with slug "${slug}" already exists`, { status: 409 }); return errorResponse(`organisation with slug "${slug}" already exists`, "SLUG_TAKEN", 409);
} }
const organisation = await createOrganisationWithOwner(name, slug, userIdNumber, description); const organisation = await createOrganisationWithOwner(name, slug, req.userId, description);
return Response.json(organisation); return Response.json(organisation);
} }

View File

@@ -1,26 +1,20 @@
import { OrgDeleteRequestSchema } from "@issue/shared";
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { deleteOrganisation, getOrganisationById } from "../../db/queries"; import { deleteOrganisation, getOrganisationById } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
// /organisation/delete?id=1
export default async function organisationDelete(req: BunRequest) { export default async function organisationDelete(req: BunRequest) {
const url = new URL(req.url); const parsed = await parseJsonBody(req, OrgDeleteRequestSchema);
const id = url.searchParams.get("id"); if ("error" in parsed) return parsed.error;
if (!id) { const { id } = parsed.data;
return new Response("organisation id is required", { status: 400 });
}
const organisationId = Number(id); const organisation = await getOrganisationById(id);
if (!Number.isInteger(organisationId)) {
return new Response("organisation id must be an integer", { status: 400 });
}
const organisation = await getOrganisationById(organisationId);
if (!organisation) { if (!organisation) {
return new Response(`organisation with id ${id} not found`, { status: 404 }); return errorResponse(`organisation with id ${id} not found`, "ORG_NOT_FOUND", 404);
} }
await deleteOrganisation(organisationId); await deleteOrganisation(id);
return Response.json({ success: true }); return Response.json({ success: true });
} }

View File

@@ -1,26 +1,21 @@
import { OrgMembersQuerySchema } from "@issue/shared";
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { getOrganisationById, getOrganisationMembers } from "../../db/queries"; import { getOrganisationById, getOrganisationMembers } from "../../db/queries";
import { errorResponse, parseQueryParams } from "../../validation";
// /organisation/members?organisationId=1
export default async function organisationMembers(req: BunRequest) { export default async function organisationMembers(req: BunRequest) {
const url = new URL(req.url); const url = new URL(req.url);
const organisationId = url.searchParams.get("organisationId"); const parsed = parseQueryParams(url, OrgMembersQuerySchema);
if ("error" in parsed) return parsed.error;
if (!organisationId) { const { organisationId } = parsed.data;
return new Response("organisationId is required", { status: 400 });
}
const orgIdNumber = Number(organisationId); const organisation = await getOrganisationById(organisationId);
if (!Number.isInteger(orgIdNumber)) {
return new Response("organisationId must be an integer", { status: 400 });
}
const organisation = await getOrganisationById(orgIdNumber);
if (!organisation) { if (!organisation) {
return new Response(`organisation with id ${organisationId} not found`, { status: 404 }); return errorResponse(`organisation with id ${organisationId} not found`, "ORG_NOT_FOUND", 404);
} }
const members = await getOrganisationMembers(orgIdNumber); const members = await getOrganisationMembers(organisationId);
return Response.json(members); return Response.json(members);
} }

View File

@@ -1,50 +1,38 @@
import { OrgRemoveMemberRequestSchema } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { getOrganisationById, getOrganisationMemberRole, removeOrganisationMember } from "../../db/queries"; import { getOrganisationById, getOrganisationMemberRole, removeOrganisationMember } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
// /organisation/remove-member?organisationId=1&userId=2
export default async function organisationRemoveMember(req: AuthedRequest) { export default async function organisationRemoveMember(req: AuthedRequest) {
const url = new URL(req.url); const parsed = await parseJsonBody(req, OrgRemoveMemberRequestSchema);
const organisationId = url.searchParams.get("organisationId"); if ("error" in parsed) return parsed.error;
const userId = url.searchParams.get("userId");
if (!organisationId || !userId) { const { organisationId, userId } = parsed.data;
return new Response(
`missing parameters: ${!organisationId ? "organisationId " : ""}${!userId ? "userId" : ""}`,
{ status: 400 },
);
}
const orgIdNumber = Number(organisationId); const organisation = await getOrganisationById(organisationId);
const userIdNumber = Number(userId);
if (!Number.isInteger(orgIdNumber) || !Number.isInteger(userIdNumber)) {
return new Response("organisationId and userId must be integers", { status: 400 });
}
const organisation = await getOrganisationById(orgIdNumber);
if (!organisation) { if (!organisation) {
return new Response(`organisation with id ${organisationId} not found`, { status: 404 }); return errorResponse(`organisation with id ${organisationId} not found`, "ORG_NOT_FOUND", 404);
} }
const memberToRemove = await getOrganisationMemberRole(orgIdNumber, userIdNumber); const memberToRemove = await getOrganisationMemberRole(organisationId, userId);
if (!memberToRemove) { if (!memberToRemove) {
return new Response("User is not a member of this organisation", { status: 404 }); return errorResponse("user is not a member of this organisation", "NOT_MEMBER", 404);
} }
if (memberToRemove.role === "owner") { if (memberToRemove.role === "owner") {
return new Response("Cannot remove the organisation owner", { status: 403 }); return errorResponse("cannot remove the organisation owner", "CANNOT_REMOVE_OWNER", 403);
} }
const requesterMember = await getOrganisationMemberRole(orgIdNumber, req.userId); const requesterMember = await getOrganisationMemberRole(organisationId, req.userId);
if (!requesterMember) { if (!requesterMember) {
return new Response("You are not a member of this organisation", { status: 403 }); return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
} }
if (requesterMember.role !== "owner" && requesterMember.role !== "admin") { if (requesterMember.role !== "owner" && requesterMember.role !== "admin") {
return new Response("Only owners and admins can remove members", { status: 403 }); return errorResponse("only owners and admins can remove members", "PERMISSION_DENIED", 403);
} }
await removeOrganisationMember(orgIdNumber, userIdNumber); await removeOrganisationMember(organisationId, userId);
return Response.json({ success: true }); return Response.json({ success: true });
} }

View File

@@ -1,3 +1,4 @@
import { OrgUpdateMemberRoleRequestSchema } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { import {
getOrganisationById, getOrganisationById,
@@ -5,56 +6,43 @@ import {
getUserById, getUserById,
updateOrganisationMemberRole, updateOrganisationMemberRole,
} from "../../db/queries"; } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
// /organisation/update-member-role?organisationId=1&userId=2&role=admin
export default async function organisationUpdateMemberRole(req: AuthedRequest) { export default async function organisationUpdateMemberRole(req: AuthedRequest) {
const url = new URL(req.url); const parsed = await parseJsonBody(req, OrgUpdateMemberRoleRequestSchema);
const organisationId = url.searchParams.get("organisationId"); if ("error" in parsed) return parsed.error;
const userId = url.searchParams.get("userId");
const role = url.searchParams.get("role"); const { organisationId, userId, role } = parsed.data;
if (!role || !["admin", "member"].includes(role)) {
return new Response("Invalid role: must be either 'admin' or 'member'", { status: 400 }); const organisation = await getOrganisationById(organisationId);
if (!organisation) {
return errorResponse(`organisation with id ${organisationId} not found`, "ORG_NOT_FOUND", 404);
} }
if (!organisationId || !userId || !role) { const user = await getUserById(userId);
return new Response( if (!user) {
`missing parameters: ${!organisationId ? "organisationId " : ""}${!userId ? "userId " : ""}${!role ? "role" : ""}`, return errorResponse(`user with id ${userId} not found`, "USER_NOT_FOUND", 404);
{ status: 400 }, }
const requesterMember = await getOrganisationMemberRole(organisationId, req.userId);
if (!requesterMember) {
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
}
let member = await getOrganisationMemberRole(organisationId, userId);
if (!member) {
return errorResponse(
`user with id ${userId} is not a member of this organisation`,
"NOT_MEMBER",
404,
); );
} }
const orgIdNumber = Number(organisationId);
const userIdNumber = Number(userId);
if (!Number.isInteger(orgIdNumber) || !Number.isInteger(userIdNumber)) {
return new Response("organisationId and userId must be integers", { status: 400 });
}
const organisation = await getOrganisationById(orgIdNumber);
if (!organisation) {
return new Response(`organisation with id ${organisationId} not found`, { status: 404 });
}
const user = await getUserById(userIdNumber);
if (!user) {
return new Response(`user with id ${userId} not found`, { status: 404 });
}
const requesterMember = await getOrganisationMemberRole(orgIdNumber, req.userId);
if (!requesterMember) {
return new Response("You are not a member of this organisation", { status: 403 });
}
let member = await getOrganisationMemberRole(orgIdNumber, userIdNumber);
if (!member) {
return new Response(`User with id ${userId} is not a member of this organisation`, { status: 404 });
}
if (requesterMember.role !== "owner" && requesterMember.role !== "admin") { if (requesterMember.role !== "owner" && requesterMember.role !== "admin") {
return new Response("Only owners and admins can update member roles", { status: 403 }); return errorResponse("only owners and admins can update member roles", "PERMISSION_DENIED", 403);
} }
member = await updateOrganisationMemberRole(orgIdNumber, userIdNumber, role); member = await updateOrganisationMemberRole(organisationId, userId, role);
return Response.json(member); return Response.json(member);
} }

View File

@@ -1,62 +1,28 @@
import { ISSUE_STATUS_MAX_LENGTH } from "@issue/shared"; import { OrgUpdateRequestSchema } from "@issue/shared";
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { getOrganisationById, updateOrganisation } from "../../db/queries"; import { getOrganisationById, updateOrganisation } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
// /organisation/update?id=1&name=New%20Name&description=New%20Description&slug=new-slug
export default async function organisationUpdate(req: BunRequest) { export default async function organisationUpdate(req: BunRequest) {
const url = new URL(req.url); const parsed = await parseJsonBody(req, OrgUpdateRequestSchema);
const id = url.searchParams.get("id"); if ("error" in parsed) return parsed.error;
const name = url.searchParams.get("name") || undefined;
const description = url.searchParams.get("description") || undefined;
const slug = url.searchParams.get("slug") || undefined;
const statusesParam = url.searchParams.get("statuses");
let statuses: Record<string, string> | undefined; const { id, name, description, slug, statuses } = parsed.data;
if (statusesParam) {
try {
const parsed = JSON.parse(statusesParam);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return new Response("statuses must be an object", { status: 400 });
}
const entries = Object.entries(parsed);
if (entries.length === 0) {
return new Response("statuses must have at least one status", { status: 400 });
}
if (!entries.every(([key, value]) => typeof key === "string" && typeof value === "string")) {
return new Response("statuses values must be strings", { status: 400 });
}
if (entries.some(([key]) => key.length > ISSUE_STATUS_MAX_LENGTH)) {
return new Response(`status must be <= ${ISSUE_STATUS_MAX_LENGTH} characters`, {
status: 400,
});
}
statuses = parsed;
} catch {
return new Response("invalid statuses format (must be JSON object)", { status: 400 });
}
}
if (!id) { const existingOrganisation = await getOrganisationById(id);
return new Response("organisation id is required", { status: 400 });
}
const organisationId = Number(id);
if (!Number.isInteger(organisationId)) {
return new Response("organisation id must be an integer", { status: 400 });
}
const existingOrganisation = await getOrganisationById(organisationId);
if (!existingOrganisation) { if (!existingOrganisation) {
return new Response(`organisation with id ${id} does not exist`, { status: 404 }); return errorResponse(`organisation with id ${id} does not exist`, "ORG_NOT_FOUND", 404);
} }
if (!name && !description && !slug && !statuses) { if (!name && !description && !slug && !statuses) {
return new Response("at least one of name, description, slug, or statuses must be provided", { return errorResponse(
status: 400, "at least one of name, description, slug, or statuses must be provided",
}); "NO_UPDATES",
400,
);
} }
const organisation = await updateOrganisation(organisationId, { const organisation = await updateOrganisation(id, {
name, name,
description, description,
slug, slug,

View File

@@ -1,23 +1,18 @@
import { ProjectByCreatorQuerySchema } from "@issue/shared";
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { getProjectsByCreatorID, getUserById } from "../../db/queries"; import { getProjectsByCreatorID, getUserById } from "../../db/queries";
import { errorResponse, parseQueryParams } from "../../validation";
// /projects/by-creator?creatorId=1
export default async function projectsByCreator(req: BunRequest) { export default async function projectsByCreator(req: BunRequest) {
const url = new URL(req.url); const url = new URL(req.url);
const creatorId = url.searchParams.get("creatorId"); const parsed = parseQueryParams(url, ProjectByCreatorQuerySchema);
if ("error" in parsed) return parsed.error;
if (!creatorId) { const { creatorId } = parsed.data;
return new Response("creatorId is required", { status: 400 });
}
const creatorIdNumber = Number(creatorId); const creator = await getUserById(creatorId);
if (!Number.isInteger(creatorIdNumber)) {
return new Response("creatorId must be an integer", { status: 400 });
}
const creator = await getUserById(creatorIdNumber);
if (!creator) { if (!creator) {
return new Response(`user with id ${creatorId} not found`, { status: 404 }); return errorResponse(`user with id ${creatorId} not found`, "USER_NOT_FOUND", 404);
} }
const projects = await getProjectsByCreatorID(creator.id); const projects = await getProjectsByCreatorID(creator.id);

View File

@@ -1,33 +1,27 @@
import { ProjectByOrgQuerySchema } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { getOrganisationById, getOrganisationsByUserId, getProjectsByOrganisationId } from "../../db/queries"; import { getOrganisationById, getOrganisationsByUserId, getProjectsByOrganisationId } from "../../db/queries";
import { errorResponse, parseQueryParams } from "../../validation";
// /projects/by-organisation?organisationId=1
export default async function projectsByOrganisation(req: AuthedRequest) { export default async function projectsByOrganisation(req: AuthedRequest) {
const url = new URL(req.url); const url = new URL(req.url);
const organisationId = url.searchParams.get("organisationId"); const parsed = parseQueryParams(url, ProjectByOrgQuerySchema);
if ("error" in parsed) return parsed.error;
if (!organisationId) { const { organisationId } = parsed.data;
return new Response("organisationId is required", { status: 400 });
}
const orgIdNumber = Number(organisationId); const organisation = await getOrganisationById(organisationId);
if (!Number.isInteger(orgIdNumber)) {
return new Response("organisationId must be an integer", { status: 400 });
}
const organisation = await getOrganisationById(orgIdNumber);
if (!organisation) { if (!organisation) {
return new Response(`organisation with id ${organisationId} not found`, { status: 404 }); return errorResponse(`organisation with id ${organisationId} not found`, "ORG_NOT_FOUND", 404);
} }
// Check if user has access to this organisation
const userOrganisations = await getOrganisationsByUserId(req.userId); const userOrganisations = await getOrganisationsByUserId(req.userId);
const hasAccess = userOrganisations.some((item) => item.Organisation.id === orgIdNumber); const hasAccess = userOrganisations.some((item) => item.Organisation.id === organisationId);
if (!hasAccess) { if (!hasAccess) {
return new Response("Access denied: you are not a member of this organisation", { status: 403 }); return errorResponse("access denied: you are not a member of this organisation", "NOT_MEMBER", 403);
} }
const projects = await getProjectsByOrganisationId(orgIdNumber); const projects = await getProjectsByOrganisationId(organisationId);
return Response.json(projects); return Response.json(projects);
} }

View File

@@ -1,33 +1,25 @@
import type { BunRequest } from "bun"; import { ProjectCreateRequestSchema } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware";
import { createProject, getProjectByKey, getUserById } from "../../db/queries"; import { createProject, getProjectByKey, getUserById } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
// /project/create?key=KEY&name=Testing&creatorId=1&organisationId=1 export default async function projectCreate(req: AuthedRequest) {
export default async function projectCreate(req: BunRequest) { const parsed = await parseJsonBody(req, ProjectCreateRequestSchema);
const url = new URL(req.url); if ("error" in parsed) return parsed.error;
const key = url.searchParams.get("key");
const name = url.searchParams.get("name");
const creatorId = url.searchParams.get("creatorId");
const organisationId = url.searchParams.get("organisationId");
if (!key || !name || !creatorId || !organisationId) { const { key, name, organisationId } = parsed.data;
return new Response(
`missing parameters: ${!key ? "key " : ""}${!name ? "name " : ""}${!creatorId ? "creatorId " : ""}${!organisationId ? "organisationId" : ""}`,
{ status: 400 },
);
}
// check if project with key already exists in the organisation
const existingProject = await getProjectByKey(key); const existingProject = await getProjectByKey(key);
if (existingProject?.organisationId === parseInt(organisationId, 10)) { if (existingProject?.organisationId === organisationId) {
return new Response(`project with key ${key} already exists`, { status: 400 }); return errorResponse(`project with key ${key} already exists in this organisation`, "KEY_TAKEN", 400);
} }
const creator = await getUserById(parseInt(creatorId, 10)); const creator = await getUserById(req.userId);
if (!creator) { if (!creator) {
return new Response(`creator with id ${creatorId} not found`, { status: 404 }); return errorResponse(`creator not found`, "CREATOR_NOT_FOUND", 404);
} }
const project = await createProject(key, name, creator.id, parseInt(organisationId, 10)); const project = await createProject(key, name, creator.id, organisationId);
return Response.json(project); return Response.json(project);
} }

View File

@@ -1,21 +1,20 @@
import { ProjectDeleteRequestSchema } from "@issue/shared";
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { deleteProject, getProjectByID } from "../../db/queries"; import { deleteProject, getProjectByID } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
// /project/delete?id=1
export default async function projectDelete(req: BunRequest) { export default async function projectDelete(req: BunRequest) {
const url = new URL(req.url); const parsed = await parseJsonBody(req, ProjectDeleteRequestSchema);
const id = url.searchParams.get("id"); if ("error" in parsed) return parsed.error;
if (!id) { const { id } = parsed.data;
return new Response(`project id is required`, { status: 400 });
}
const existingProject = await getProjectByID(Number(id)); const existingProject = await getProjectByID(id);
if (!existingProject) { if (!existingProject) {
return new Response(`project with id ${id} does not exist`, { status: 404 }); return errorResponse(`project with id ${id} does not exist`, "PROJECT_NOT_FOUND", 404);
} }
await deleteProject(Number(id)); await deleteProject(id);
return new Response(`project with id ${id} deleted successfully`, { status: 200 }); return Response.json({ success: true });
} }

View File

@@ -1,45 +1,46 @@
import { ProjectUpdateRequestSchema } from "@issue/shared";
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { getProjectByID, getProjectByKey, getUserById, updateProject } from "../../db/queries"; import { getProjectByID, getProjectByKey, getUserById, updateProject } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
// /project/update?id=1&key=NEW&name=new%20name&creatorId=1&organisationId=1
export default async function projectUpdate(req: BunRequest) { export default async function projectUpdate(req: BunRequest) {
const url = new URL(req.url); const parsed = await parseJsonBody(req, ProjectUpdateRequestSchema);
const id = url.searchParams.get("id"); if ("error" in parsed) return parsed.error;
const key = url.searchParams.get("key") || undefined;
const name = url.searchParams.get("name") || undefined;
const creatorId = url.searchParams.get("creatorId") || undefined;
const organisationId = url.searchParams.get("organisationId") || undefined;
if (!id) { const { id, key, name, creatorId, organisationId } = parsed.data;
return new Response(`project id is required`, { status: 400 });
}
const existingProject = await getProjectByID(Number(id)); const existingProject = await getProjectByID(id);
if (!existingProject) { if (!existingProject) {
return new Response(`project with id ${id} does not exist`, { status: 404 }); return errorResponse(`project with id ${id} does not exist`, "PROJECT_NOT_FOUND", 404);
} }
if (!key && !name && !creatorId && !organisationId) { if (!key && !name && !creatorId && !organisationId) {
return new Response(`at least one of key, name, creatorId, or organisationId must be provided`, { return errorResponse(
status: 400, "at least one of key, name, creatorId, or organisationId must be provided",
}); "NO_UPDATES",
400,
);
} }
const projectWithKey = key ? await getProjectByKey(key) : null; if (key) {
if (projectWithKey && projectWithKey.id !== Number(id)) { const projectWithKey = await getProjectByKey(key);
return new Response(`a project with key "${key}" already exists`, { status: 400 }); if (projectWithKey && projectWithKey.id !== id) {
return errorResponse(`a project with key "${key}" already exists`, "KEY_TAKEN", 400);
}
} }
const newCreator = creatorId ? await getUserById(Number(creatorId)) : null; if (creatorId) {
if (creatorId && !newCreator) { const newCreator = await getUserById(creatorId);
return new Response(`user with id ${creatorId} does not exist`, { status: 400 }); if (!newCreator) {
return errorResponse(`user with id ${creatorId} does not exist`, "USER_NOT_FOUND", 400);
}
} }
const project = await updateProject(Number(id), { const project = await updateProject(id, {
key, key,
name, name,
creatorId: newCreator?.id, creatorId,
organisationId: organisationId ? Number(organisationId) : undefined, organisationId,
}); });
return Response.json(project); return Response.json(project);

View File

@@ -1,23 +1,18 @@
import { ProjectByIdQuerySchema } from "@issue/shared";
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { getProjectWithCreatorByID } from "../../db/queries"; import { getProjectWithCreatorByID } from "../../db/queries";
import { errorResponse, parseQueryParams } from "../../validation";
// /project/with-creator?id=1
export default async function projectWithCreatorByID(req: BunRequest) { export default async function projectWithCreatorByID(req: BunRequest) {
const url = new URL(req.url); const url = new URL(req.url);
const id = url.searchParams.get("id"); const parsed = parseQueryParams(url, ProjectByIdQuerySchema);
if ("error" in parsed) return parsed.error;
if (!id) { const { id } = parsed.data;
return new Response("project id is required", { status: 400 });
}
const projectId = Number(id); const projectWithCreator = await getProjectWithCreatorByID(id);
if (!Number.isInteger(projectId)) {
return new Response("project id must be an integer", { status: 400 });
}
const projectWithCreator = await getProjectWithCreatorByID(projectId);
if (!projectWithCreator || !projectWithCreator.Project) { if (!projectWithCreator || !projectWithCreator.Project) {
return new Response(`project with id ${id} not found`, { status: 404 }); return errorResponse(`project with id ${id} not found`, "PROJECT_NOT_FOUND", 404);
} }
return Response.json(projectWithCreator); return Response.json(projectWithCreator);

View File

@@ -1,63 +1,29 @@
import { SprintCreateRequestSchema } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { createSprint, getOrganisationMemberRole, getProjectByID } from "../../db/queries"; import { createSprint, getOrganisationMemberRole, getProjectByID } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
// /sprint/create?projectId=1&name=Sprint%201&startDate=2025-01-01T00:00:00.000Z&endDate=2025-01-14T23:59:00.000Z
export default async function sprintCreate(req: AuthedRequest) { export default async function sprintCreate(req: AuthedRequest) {
const url = new URL(req.url); const parsed = await parseJsonBody(req, SprintCreateRequestSchema);
const projectId = url.searchParams.get("projectId"); if ("error" in parsed) return parsed.error;
const name = url.searchParams.get("name");
const color = url.searchParams.get("color") || undefined;
const startDateParam = url.searchParams.get("startDate");
const endDateParam = url.searchParams.get("endDate");
if (!projectId || !name || !startDateParam || !endDateParam) { const { projectId, name, color, startDate, endDate } = parsed.data;
return new Response(
`missing parameters: ${!projectId ? "projectId " : ""}${!name ? "name " : ""}${
!startDateParam ? "startDate " : ""
}${!endDateParam ? "endDate" : ""}`,
{ status: 400 },
);
}
const projectIdNumber = Number(projectId); const project = await getProjectByID(projectId);
if (!Number.isInteger(projectIdNumber)) {
return new Response("projectId must be an integer", { status: 400 });
}
const project = await getProjectByID(projectIdNumber);
if (!project) { if (!project) {
return new Response(`project not found: provided ${projectId}`, { status: 404 }); return errorResponse(`project not found: ${projectId}`, "PROJECT_NOT_FOUND", 404);
} }
const membership = await getOrganisationMemberRole(project.organisationId, req.userId); const membership = await getOrganisationMemberRole(project.organisationId, req.userId);
if (!membership) { if (!membership) {
return new Response("not a member of this organisation", { status: 403 }); return errorResponse("not a member of this organisation", "NOT_MEMBER", 403);
} }
if (membership.role !== "owner" && membership.role !== "admin") { if (membership.role !== "owner" && membership.role !== "admin") {
return new Response("only owners and admins can create sprints", { status: 403 }); return errorResponse("only owners and admins can create sprints", "PERMISSION_DENIED", 403);
} }
const trimmedName = name.trim(); const sprint = await createSprint(project.id, name, color, new Date(startDate), new Date(endDate));
if (trimmedName === "") {
return new Response("name cannot be empty", { status: 400 });
}
const startDate = new Date(startDateParam);
if (Number.isNaN(startDate.valueOf())) {
return new Response("startDate must be a valid date", { status: 400 });
}
const endDate = new Date(endDateParam);
if (Number.isNaN(endDate.valueOf())) {
return new Response("endDate must be a valid date", { status: 400 });
}
if (startDate > endDate) {
return new Response("endDate must be after startDate", { status: 400 });
}
const sprint = await createSprint(project.id, trimmedName, color, startDate, endDate);
return Response.json(sprint); return Response.json(sprint);
} }

View File

@@ -1,28 +1,23 @@
import { SprintsByProjectQuerySchema } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { getOrganisationMemberRole, getProjectByID, getSprintsByProject } from "../../db/queries"; import { getOrganisationMemberRole, getProjectByID, getSprintsByProject } from "../../db/queries";
import { errorResponse, parseQueryParams } from "../../validation";
// /sprints/by-project?projectId=1
export default async function sprintsByProject(req: AuthedRequest) { export default async function sprintsByProject(req: AuthedRequest) {
const url = new URL(req.url); const url = new URL(req.url);
const projectId = url.searchParams.get("projectId"); const parsed = parseQueryParams(url, SprintsByProjectQuerySchema);
if ("error" in parsed) return parsed.error;
if (!projectId) { const { projectId } = parsed.data;
return new Response("missing projectId", { status: 400 });
}
const projectIdNumber = Number(projectId); const project = await getProjectByID(projectId);
if (!Number.isInteger(projectIdNumber)) {
return new Response("projectId must be an integer", { status: 400 });
}
const project = await getProjectByID(projectIdNumber);
if (!project) { if (!project) {
return new Response(`project not found: provided ${projectId}`, { status: 404 }); return errorResponse(`project not found: ${projectId}`, "PROJECT_NOT_FOUND", 404);
} }
const membership = await getOrganisationMemberRole(project.organisationId, req.userId); const membership = await getOrganisationMemberRole(project.organisationId, req.userId);
if (!membership) { if (!membership) {
return new Response("not a member of this organisation", { status: 403 }); return errorResponse("not a member of this organisation", "NOT_MEMBER", 403);
} }
const sprints = await getSprintsByProject(project.id); const sprints = await getSprintsByProject(project.id);

View File

@@ -1,21 +1,20 @@
import { calculateBreakTimeMs, calculateWorkTimeMs } from "@issue/shared"; import { calculateBreakTimeMs, calculateWorkTimeMs, TimerEndRequestSchema } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { endTimedSession, getActiveTimedSession } from "../../db/queries"; import { endTimedSession, getActiveTimedSession } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
// POST /timer/end
export default async function timerEnd(req: AuthedRequest) { export default async function timerEnd(req: AuthedRequest) {
const url = new URL(req.url); const parsed = await parseJsonBody(req, TimerEndRequestSchema);
const issueId = url.searchParams.get("issueId"); if ("error" in parsed) return parsed.error;
if (!issueId || Number.isNaN(Number(issueId))) {
return new Response("missing issue id", { status: 400 }); const { issueId } = parsed.data;
}
const activeSession = await getActiveTimedSession(req.userId, Number(issueId)); const activeSession = await getActiveTimedSession(req.userId, issueId);
if (!activeSession) { if (!activeSession) {
return new Response("no active timer", { status: 400 }); return errorResponse("no active timer", "NO_ACTIVE_TIMER", 400);
} }
// already ended - return existing without modification
if (activeSession.endedAt) { if (activeSession.endedAt) {
return Response.json({ return Response.json({
...activeSession, ...activeSession,
@@ -27,7 +26,7 @@ export default async function timerEnd(req: AuthedRequest) {
const ended = await endTimedSession(activeSession.id, activeSession.timestamps); const ended = await endTimedSession(activeSession.id, activeSession.timestamps);
if (!ended) { if (!ended) {
return new Response("failed to end timer", { status: 500 }); return errorResponse("failed to end timer", "END_FAILED", 500);
} }
return Response.json({ return Response.json({

View File

@@ -1,17 +1,18 @@
import { calculateBreakTimeMs, calculateWorkTimeMs } from "@issue/shared"; import { calculateBreakTimeMs, calculateWorkTimeMs, TimerGetQuerySchema } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { getInactiveTimedSessions } from "../../db/queries"; import { getInactiveTimedSessions } from "../../db/queries";
import { parseQueryParams } from "../../validation";
// GET /timer?issueId=123
export default async function timerGetInactive(req: AuthedRequest) { export default async function timerGetInactive(req: AuthedRequest) {
const url = new URL(req.url); const url = new URL(req.url);
const issueId = url.searchParams.get("issueId"); const parsed = parseQueryParams(url, TimerGetQuerySchema);
if (!issueId || Number.isNaN(Number(issueId))) { if ("error" in parsed) return parsed.error;
return new Response("missing issue id", { status: 400 });
}
const sessions = await getInactiveTimedSessions(Number(issueId));
if (!sessions[0] || !sessions) { const { issueId } = parsed.data;
const sessions = await getInactiveTimedSessions(issueId);
if (!sessions || sessions.length === 0) {
return Response.json(null); return Response.json(null);
} }

View File

@@ -1,15 +1,21 @@
import { calculateBreakTimeMs, calculateWorkTimeMs, isTimerRunning } from "@issue/shared"; import {
calculateBreakTimeMs,
calculateWorkTimeMs,
isTimerRunning,
TimerGetQuerySchema,
} from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { getActiveTimedSession } from "../../db/queries"; import { getActiveTimedSession } from "../../db/queries";
import { parseQueryParams } from "../../validation";
// GET /timer?issueId=123
export default async function timerGet(req: AuthedRequest) { export default async function timerGet(req: AuthedRequest) {
const url = new URL(req.url); const url = new URL(req.url);
const issueId = url.searchParams.get("issueId"); const parsed = parseQueryParams(url, TimerGetQuerySchema);
if (!issueId || Number.isNaN(Number(issueId))) { if ("error" in parsed) return parsed.error;
return new Response("missing issue id", { status: 400 });
} const { issueId } = parsed.data;
const activeSession = await getActiveTimedSession(req.userId, Number(issueId));
const activeSession = await getActiveTimedSession(req.userId, issueId);
if (!activeSession) { if (!activeSession) {
return Response.json(null); return Response.json(null);

View File

@@ -1,20 +1,23 @@
import { calculateBreakTimeMs, calculateWorkTimeMs, isTimerRunning } from "@issue/shared"; import {
calculateBreakTimeMs,
calculateWorkTimeMs,
isTimerRunning,
TimerToggleRequestSchema,
} from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { appendTimestamp, createTimedSession, getActiveTimedSession } from "../../db/queries"; import { appendTimestamp, createTimedSession, getActiveTimedSession } from "../../db/queries";
import { parseJsonBody } from "../../validation";
// POST /timer/toggle?issueId=123
export default async function timerToggle(req: AuthedRequest) { export default async function timerToggle(req: AuthedRequest) {
const url = new URL(req.url); const parsed = await parseJsonBody(req, TimerToggleRequestSchema);
const issueId = url.searchParams.get("issueId"); if ("error" in parsed) return parsed.error;
if (!issueId || Number.isNaN(Number(issueId))) {
return new Response("missing issue id", { status: 400 });
}
const activeSession = await getActiveTimedSession(req.userId, Number(issueId)); const { issueId } = parsed.data;
const activeSession = await getActiveTimedSession(req.userId, issueId);
if (!activeSession) { if (!activeSession) {
// no active session, create new one with first timestamp const newSession = await createTimedSession(req.userId, issueId);
const newSession = await createTimedSession(req.userId, Number(issueId));
return Response.json({ return Response.json({
...newSession, ...newSession,
workTimeMs: 0, workTimeMs: 0,
@@ -23,10 +26,9 @@ export default async function timerToggle(req: AuthedRequest) {
}); });
} }
// active session exists, append timestamp (toggle)
const updated = await appendTimestamp(activeSession.id, activeSession.timestamps); const updated = await appendTimestamp(activeSession.id, activeSession.timestamps);
if (!updated) { if (!updated) {
return new Response("failed to update timer", { status: 500 }); return Response.json({ error: "failed to update timer", code: "UPDATE_FAILED" }, { status: 500 });
} }
const running = isTimerRunning(updated.timestamps); const running = isTimerRunning(updated.timestamps);

View File

@@ -1,18 +1,18 @@
import { UserByUsernameQuerySchema } from "@issue/shared";
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { getUserByUsername } from "../../db/queries"; import { getUserByUsername } from "../../db/queries";
import { errorResponse, parseQueryParams } from "../../validation";
// /user/by-username?username=someusername
export default async function userByUsername(req: BunRequest) { export default async function userByUsername(req: BunRequest) {
const url = new URL(req.url); const url = new URL(req.url);
const username = url.searchParams.get("username"); const parsed = parseQueryParams(url, UserByUsernameQuerySchema);
if ("error" in parsed) return parsed.error;
if (!username) { const { username } = parsed.data;
return new Response("username is required", { status: 400 });
}
const user = await getUserByUsername(username); const user = await getUserByUsername(username);
if (!user) { if (!user) {
return new Response(`User with username '${username}' not found`, { status: 404 }); return errorResponse(`user with username '${username}' not found`, "USER_NOT_FOUND", 404);
} }
return Response.json(user); return Response.json(user);

View File

@@ -1,25 +1,28 @@
import type { UserRecord } from "@issue/shared"; import { UserUpdateRequestSchema } from "@issue/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { hashPassword } from "../../auth/utils"; import { hashPassword } from "../../auth/utils";
import { getUserById } from "../../db/queries"; import { getUserById } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
// /user/update?id=1&name=NewName&password=NewPassword&avatarURL=...
export default async function update(req: AuthedRequest) { export default async function update(req: AuthedRequest) {
const url = new URL(req.url); const parsed = await parseJsonBody(req, UserUpdateRequestSchema);
const id = url.searchParams.get("id"); if ("error" in parsed) return parsed.error;
if (!id) {
return new Response("id is required", { status: 400 });
}
const user = await getUserById(Number(id)); const { name, password, avatarURL } = parsed.data;
const user = await getUserById(req.userId);
if (!user) { if (!user) {
return new Response("user not found", { status: 404 }); return errorResponse("user not found", "USER_NOT_FOUND", 404);
}
if (!name && !password && avatarURL === undefined) {
return errorResponse(
"at least one of name, password, or avatarURL must be provided",
"NO_UPDATES",
400,
);
} }
const name = url.searchParams.get("name") || undefined;
const password = url.searchParams.get("password") || undefined;
const avatarURL =
url.searchParams.get("avatarURL") === "null" ? null : url.searchParams.get("avatarURL") || undefined;
let passwordHash: string | undefined; let passwordHash: string | undefined;
if (password !== undefined) { if (password !== undefined) {
passwordHash = await hashPassword(password); passwordHash = await hashPassword(password);
@@ -29,8 +32,8 @@ export default async function update(req: AuthedRequest) {
const updatedUser = await updateById(user.id, { name, passwordHash, avatarURL }); const updatedUser = await updateById(user.id, { name, passwordHash, avatarURL });
if (!updatedUser) { if (!updatedUser) {
return new Response("failed to update user", { status: 500 }); return errorResponse("failed to update user", "UPDATE_FAILED", 500);
} }
return Response.json(updatedUser as UserRecord); return Response.json(updatedUser);
} }