diff --git a/packages/backend/src/routes/auth/login.ts b/packages/backend/src/routes/auth/login.ts index cd2971c..3965b31 100644 --- a/packages/backend/src/routes/auth/login.ts +++ b/packages/backend/src/routes/auth/login.ts @@ -1,45 +1,32 @@ +import { LoginRequestSchema } from "@issue/shared"; import type { BunRequest } from "bun"; import { buildAuthCookie, generateToken, verifyPassword } from "../../auth/utils"; import { createSession, getUserByUsername } from "../../db/queries"; - -const isNonEmptyString = (value: unknown): value is string => - typeof value === "string" && value.trim().length > 0; +import { errorResponse, parseJsonBody } from "../../validation"; export default async function login(req: BunRequest) { if (req.method !== "POST") { - return new Response("method not allowed", { status: 405 }); + return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405); } - let body: unknown; - try { - body = await req.json(); - } catch { - return new Response("invalid JSON", { status: 400 }); - } + const parsed = await parseJsonBody(req, LoginRequestSchema); + if ("error" in parsed) return parsed.error; - if (!body || typeof body !== "object") { - return new Response("invalid request body", { status: 400 }); - } - - const { username, password } = body as Record; - - if (!isNonEmptyString(username) || !isNonEmptyString(password)) { - return new Response("username and password are required", { status: 400 }); - } + const { username, password } = parsed.data; const user = await getUserByUsername(username); if (!user) { - return new Response("invalid credentials", { status: 401 }); + return errorResponse("invalid credentials", "INVALID_CREDENTIALS", 401); } const ok = await verifyPassword(password, user.passwordHash); if (!ok) { - return new Response("invalid credentials", { status: 401 }); + return errorResponse("invalid credentials", "INVALID_CREDENTIALS", 401); } const session = await createSession(user.id); 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); diff --git a/packages/backend/src/routes/auth/register.ts b/packages/backend/src/routes/auth/register.ts index b8c4f2a..af2891a 100644 --- a/packages/backend/src/routes/auth/register.ts +++ b/packages/backend/src/routes/auth/register.ts @@ -1,62 +1,33 @@ +import { RegisterRequestSchema } from "@issue/shared"; import type { BunRequest } from "bun"; import { buildAuthCookie, generateToken, hashPassword } from "../../auth/utils"; import { createSession, createUser, getUserByUsername } from "../../db/queries"; - -const isNonEmptyString = (value: unknown): value is string => - typeof value === "string" && value.trim().length > 0; +import { errorResponse, parseJsonBody } from "../../validation"; export default async function register(req: BunRequest) { if (req.method !== "POST") { - return new Response("method not allowed", { status: 405 }); + return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405); } - let body: unknown; - try { - body = await req.json(); - } catch { - return new Response("invalid JSON", { status: 400 }); - } + const parsed = await parseJsonBody(req, RegisterRequestSchema); + if ("error" in parsed) return parsed.error; - if (!body || typeof body !== "object") { - return new Response("invalid request body", { status: 400 }); - } - - const { name, username, password, avatarURL } = body as Record; - - 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 { name, username, password, avatarURL } = parsed.data; const existing = await getUserByUsername(username); if (existing) { - return new Response("username already taken", { status: 400 }); + return errorResponse("username already taken", "USERNAME_TAKEN", 400); } 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) { - 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); 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); diff --git a/packages/backend/src/routes/issue/create.ts b/packages/backend/src/routes/issue/create.ts index dadc370..c758873 100644 --- a/packages/backend/src/routes/issue/create.ts +++ b/packages/backend/src/routes/issue/create.ts @@ -1,35 +1,28 @@ +import { IssueCreateRequestSchema } from "@issue/shared"; 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) { - const url = new URL(req.url); - const projectId = url.searchParams.get("projectId"); - const projectKey = url.searchParams.get("projectKey"); + const parsed = await parseJsonBody(req, IssueCreateRequestSchema); + if ("error" in parsed) return parsed.error; - let project = null; - if (projectId) { - project = await getProjectByID(Number(projectId)); - } else if (projectKey) { - project = await getProjectByKey(projectKey); - } else { - return new Response("missing project key or project id", { status: 400 }); - } + const { projectId, title, description = "", status, assigneeId, sprintId } = parsed.data; + + const project = await getProjectByID(projectId); 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 description = url.searchParams.get("description") || ""; - const sprintIdParam = url.searchParams.get("sprintId"); - const sprintId = sprintIdParam ? Number(sprintIdParam) : undefined; - const assigneeIdParam = url.searchParams.get("assigneeId"); - const assigneeId = assigneeIdParam ? Number(assigneeIdParam) : undefined; - const status = url.searchParams.get("status") || undefined; - - const issue = await createIssue(project.id, title, description, req.userId, sprintId, assigneeId, status); + const issue = await createIssue( + project.id, + title, + description, + req.userId, + sprintId ?? undefined, + assigneeId ?? undefined, + status, + ); return Response.json(issue); } diff --git a/packages/backend/src/routes/issue/delete.ts b/packages/backend/src/routes/issue/delete.ts index 68c2625..c75deb6 100644 --- a/packages/backend/src/routes/issue/delete.ts +++ b/packages/backend/src/routes/issue/delete.ts @@ -1,18 +1,18 @@ +import { IssueDeleteRequestSchema } from "@issue/shared"; import type { BunRequest } from "bun"; import { deleteIssue } from "../../db/queries"; +import { errorResponse, parseJsonBody } from "../../validation"; -// /issue/delete?id=1 export default async function issueDelete(req: BunRequest) { - const url = new URL(req.url); - const id = url.searchParams.get("id"); - if (!id) { - return new Response("missing issue id", { status: 400 }); - } + const parsed = await parseJsonBody(req, IssueDeleteRequestSchema); + if ("error" in parsed) return parsed.error; - const result = await deleteIssue(Number(id)); + const { id } = parsed.data; + + const result = await deleteIssue(id); 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 }); } diff --git a/packages/backend/src/routes/issue/update.ts b/packages/backend/src/routes/issue/update.ts index 9baad23..780fa73 100644 --- a/packages/backend/src/routes/issue/update.ts +++ b/packages/backend/src/routes/issue/update.ts @@ -1,41 +1,26 @@ +import { IssueUpdateRequestSchema } from "@issue/shared"; import type { BunRequest } from "bun"; 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) { - const url = new URL(req.url); - const id = url.searchParams.get("id"); - if (!id) { - return new Response("missing issue id", { status: 400 }); + const parsed = await parseJsonBody(req, IssueUpdateRequestSchema); + if ("error" in parsed) return parsed.error; + + 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 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), { + const issue = await updateIssue(id, { title, description, sprintId, diff --git a/packages/backend/src/routes/issues/by-project.ts b/packages/backend/src/routes/issues/by-project.ts index 9f34e53..8d03a0f 100644 --- a/packages/backend/src/routes/issues/by-project.ts +++ b/packages/backend/src/routes/issues/by-project.ts @@ -1,14 +1,20 @@ +import { IssuesByProjectQuerySchema } from "@issue/shared"; import type { AuthedRequest } from "../../auth/middleware"; import { getIssuesWithUsersByProject, getProjectByID } from "../../db/queries"; +import { errorResponse, parseQueryParams } from "../../validation"; export default async function issuesByProject(req: AuthedRequest) { 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) { - 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); return Response.json(issues); diff --git a/packages/backend/src/routes/issues/replace-status.ts b/packages/backend/src/routes/issues/replace-status.ts index f2ba9c3..fb2784f 100644 --- a/packages/backend/src/routes/issues/replace-status.ts +++ b/packages/backend/src/routes/issues/replace-status.ts @@ -1,38 +1,21 @@ +import { IssuesReplaceStatusRequestSchema } from "@issue/shared"; import type { AuthedRequest } from "../../auth/middleware"; 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) { - const url = new URL(req.url); - const organisationIdParam = url.searchParams.get("organisationId"); - const oldStatus = url.searchParams.get("oldStatus"); - const newStatus = url.searchParams.get("newStatus"); + const parsed = await parseJsonBody(req, IssuesReplaceStatusRequestSchema); + if ("error" in parsed) return parsed.error; - if (!organisationIdParam) { - return new Response("missing organisationId", { status: 400 }); - } + const { organisationId, oldStatus, newStatus } = parsed.data; - 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); 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") { - 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); diff --git a/packages/backend/src/routes/issues/status-count.ts b/packages/backend/src/routes/issues/status-count.ts index 5f8b185..8aa0ed5 100644 --- a/packages/backend/src/routes/issues/status-count.ts +++ b/packages/backend/src/routes/issues/status-count.ts @@ -1,28 +1,18 @@ +import { IssuesStatusCountQuerySchema } from "@issue/shared"; import type { AuthedRequest } from "../../auth/middleware"; 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) { const url = new URL(req.url); - const organisationIdParam = url.searchParams.get("organisationId"); - const status = url.searchParams.get("status"); + const parsed = parseQueryParams(url, IssuesStatusCountQuerySchema); + if ("error" in parsed) return parsed.error; - if (!organisationIdParam) { - 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 { organisationId, status } = parsed.data; const membership = await getOrganisationMemberRole(organisationId, req.userId); 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); diff --git a/packages/backend/src/routes/organisation/add-member.ts b/packages/backend/src/routes/organisation/add-member.ts index 516e944..fac4790 100644 --- a/packages/backend/src/routes/organisation/add-member.ts +++ b/packages/backend/src/routes/organisation/add-member.ts @@ -1,3 +1,4 @@ +import { OrgAddMemberRequestSchema } from "@issue/shared"; import type { AuthedRequest } from "../../auth/middleware"; import { createOrganisationMember, @@ -5,53 +6,39 @@ import { getOrganisationMemberRole, getUserById, } from "../../db/queries"; +import { errorResponse, parseJsonBody } from "../../validation"; -// /organisation/add-member?organisationId=1&userId=2&role=member export default async function organisationAddMember(req: AuthedRequest) { - const url = new URL(req.url); - const organisationId = url.searchParams.get("organisationId"); - const userId = url.searchParams.get("userId"); - const role = url.searchParams.get("role") || "member"; + const parsed = await parseJsonBody(req, OrgAddMemberRequestSchema); + if ("error" in parsed) return parsed.error; - if (!organisationId || !userId) { - return new Response( - `missing parameters: ${!organisationId ? "organisationId " : ""}${!userId ? "userId" : ""}`, - { status: 400 }, - ); - } + const { organisationId, userId, role } = parsed.data; - 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); + const organisation = await getOrganisationById(organisationId); 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) { - 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) { - 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) { - 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") { - 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); } diff --git a/packages/backend/src/routes/organisation/by-id.ts b/packages/backend/src/routes/organisation/by-id.ts index 02756c7..95a9d10 100644 --- a/packages/backend/src/routes/organisation/by-id.ts +++ b/packages/backend/src/routes/organisation/by-id.ts @@ -1,23 +1,18 @@ +import { OrgByIdQuerySchema } from "@issue/shared"; import type { BunRequest } from "bun"; import { getOrganisationById } from "../../db/queries"; +import { errorResponse, parseQueryParams } from "../../validation"; -// /organisation/by-id?id=1 export default async function organisationById(req: BunRequest) { 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) { - return new Response("organisation id is required", { status: 400 }); - } + const { id } = parsed.data; - const organisationId = Number(id); - if (!Number.isInteger(organisationId)) { - return new Response("organisation id must be an integer", { status: 400 }); - } - - const organisation = await getOrganisationById(organisationId); + const organisation = await getOrganisationById(id); 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); diff --git a/packages/backend/src/routes/organisation/by-user.ts b/packages/backend/src/routes/organisation/by-user.ts index 04e0194..5f47abe 100644 --- a/packages/backend/src/routes/organisation/by-user.ts +++ b/packages/backend/src/routes/organisation/by-user.ts @@ -1,31 +1,8 @@ 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) { - const url = new URL(req.url); - 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); + const organisations = await getOrganisationsByUserId(req.userId); return Response.json(organisations); } diff --git a/packages/backend/src/routes/organisation/create.ts b/packages/backend/src/routes/organisation/create.ts index 048424d..d05d5d5 100644 --- a/packages/backend/src/routes/organisation/create.ts +++ b/packages/backend/src/routes/organisation/create.ts @@ -1,37 +1,20 @@ +import { OrgCreateRequestSchema } from "@issue/shared"; import type { AuthedRequest } from "../../auth/middleware"; 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) { - const url = new URL(req.url); - const name = url.searchParams.get("name"); - const slug = url.searchParams.get("slug"); - const userId = url.searchParams.get("userId"); - const description = url.searchParams.get("description") || undefined; + const parsed = await parseJsonBody(req, OrgCreateRequestSchema); + if ("error" in parsed) return parsed.error; - if (!name || !slug || !userId) { - 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 { name, slug, description } = parsed.data; const existingOrganisation = await getOrganisationBySlug(slug); 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); } diff --git a/packages/backend/src/routes/organisation/delete.ts b/packages/backend/src/routes/organisation/delete.ts index 5737916..82c718b 100644 --- a/packages/backend/src/routes/organisation/delete.ts +++ b/packages/backend/src/routes/organisation/delete.ts @@ -1,26 +1,20 @@ +import { OrgDeleteRequestSchema } from "@issue/shared"; import type { BunRequest } from "bun"; import { deleteOrganisation, getOrganisationById } from "../../db/queries"; +import { errorResponse, parseJsonBody } from "../../validation"; -// /organisation/delete?id=1 export default async function organisationDelete(req: BunRequest) { - const url = new URL(req.url); - const id = url.searchParams.get("id"); + const parsed = await parseJsonBody(req, OrgDeleteRequestSchema); + if ("error" in parsed) return parsed.error; - if (!id) { - return new Response("organisation id is required", { status: 400 }); - } + const { id } = parsed.data; - const organisationId = Number(id); - if (!Number.isInteger(organisationId)) { - return new Response("organisation id must be an integer", { status: 400 }); - } - - const organisation = await getOrganisationById(organisationId); + const organisation = await getOrganisationById(id); 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 }); } diff --git a/packages/backend/src/routes/organisation/members.ts b/packages/backend/src/routes/organisation/members.ts index d13f7ba..3a1d511 100644 --- a/packages/backend/src/routes/organisation/members.ts +++ b/packages/backend/src/routes/organisation/members.ts @@ -1,26 +1,21 @@ +import { OrgMembersQuerySchema } from "@issue/shared"; import type { BunRequest } from "bun"; import { getOrganisationById, getOrganisationMembers } from "../../db/queries"; +import { errorResponse, parseQueryParams } from "../../validation"; -// /organisation/members?organisationId=1 export default async function organisationMembers(req: BunRequest) { 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) { - return new Response("organisationId is required", { status: 400 }); - } + const { organisationId } = parsed.data; - const orgIdNumber = Number(organisationId); - if (!Number.isInteger(orgIdNumber)) { - return new Response("organisationId must be an integer", { status: 400 }); - } - - const organisation = await getOrganisationById(orgIdNumber); + const organisation = await getOrganisationById(organisationId); 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); } diff --git a/packages/backend/src/routes/organisation/remove-member.ts b/packages/backend/src/routes/organisation/remove-member.ts index 9122161..f6cd1dd 100644 --- a/packages/backend/src/routes/organisation/remove-member.ts +++ b/packages/backend/src/routes/organisation/remove-member.ts @@ -1,50 +1,38 @@ +import { OrgRemoveMemberRequestSchema } from "@issue/shared"; import type { AuthedRequest } from "../../auth/middleware"; 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) { - const url = new URL(req.url); - const organisationId = url.searchParams.get("organisationId"); - const userId = url.searchParams.get("userId"); + const parsed = await parseJsonBody(req, OrgRemoveMemberRequestSchema); + if ("error" in parsed) return parsed.error; - if (!organisationId || !userId) { - return new Response( - `missing parameters: ${!organisationId ? "organisationId " : ""}${!userId ? "userId" : ""}`, - { status: 400 }, - ); - } + const { organisationId, userId } = parsed.data; - 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); + const organisation = await getOrganisationById(organisationId); 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) { - 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") { - 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) { - 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") { - 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 }); } diff --git a/packages/backend/src/routes/organisation/update-member-role.ts b/packages/backend/src/routes/organisation/update-member-role.ts index f629ad3..c0acae9 100644 --- a/packages/backend/src/routes/organisation/update-member-role.ts +++ b/packages/backend/src/routes/organisation/update-member-role.ts @@ -1,3 +1,4 @@ +import { OrgUpdateMemberRoleRequestSchema } from "@issue/shared"; import type { AuthedRequest } from "../../auth/middleware"; import { getOrganisationById, @@ -5,56 +6,43 @@ import { getUserById, updateOrganisationMemberRole, } 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) { - const url = new URL(req.url); - const organisationId = url.searchParams.get("organisationId"); - const userId = url.searchParams.get("userId"); - const role = url.searchParams.get("role"); - if (!role || !["admin", "member"].includes(role)) { - return new Response("Invalid role: must be either 'admin' or 'member'", { status: 400 }); + const parsed = await parseJsonBody(req, OrgUpdateMemberRoleRequestSchema); + if ("error" in parsed) return parsed.error; + + const { organisationId, userId, role } = parsed.data; + + const organisation = await getOrganisationById(organisationId); + if (!organisation) { + return errorResponse(`organisation with id ${organisationId} not found`, "ORG_NOT_FOUND", 404); } - if (!organisationId || !userId || !role) { - return new Response( - `missing parameters: ${!organisationId ? "organisationId " : ""}${!userId ? "userId " : ""}${!role ? "role" : ""}`, - { status: 400 }, + const user = await getUserById(userId); + if (!user) { + return errorResponse(`user with id ${userId} not found`, "USER_NOT_FOUND", 404); + } + + 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") { - 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); } diff --git a/packages/backend/src/routes/organisation/update.ts b/packages/backend/src/routes/organisation/update.ts index 502b64d..55b8355 100644 --- a/packages/backend/src/routes/organisation/update.ts +++ b/packages/backend/src/routes/organisation/update.ts @@ -1,62 +1,28 @@ -import { ISSUE_STATUS_MAX_LENGTH } from "@issue/shared"; +import { OrgUpdateRequestSchema } from "@issue/shared"; import type { BunRequest } from "bun"; 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) { - const url = new URL(req.url); - const id = url.searchParams.get("id"); - 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"); + const parsed = await parseJsonBody(req, OrgUpdateRequestSchema); + if ("error" in parsed) return parsed.error; - let statuses: Record | undefined; - 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 }); - } - } + const { id, name, description, slug, statuses } = parsed.data; - if (!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); + const existingOrganisation = await getOrganisationById(id); 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) { - return new Response("at least one of name, description, slug, or statuses must be provided", { - status: 400, - }); + return errorResponse( + "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, description, slug, diff --git a/packages/backend/src/routes/project/by-creator.ts b/packages/backend/src/routes/project/by-creator.ts index 36c8db8..8424f35 100644 --- a/packages/backend/src/routes/project/by-creator.ts +++ b/packages/backend/src/routes/project/by-creator.ts @@ -1,23 +1,18 @@ +import { ProjectByCreatorQuerySchema } from "@issue/shared"; import type { BunRequest } from "bun"; import { getProjectsByCreatorID, getUserById } from "../../db/queries"; +import { errorResponse, parseQueryParams } from "../../validation"; -// /projects/by-creator?creatorId=1 export default async function projectsByCreator(req: BunRequest) { 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) { - return new Response("creatorId is required", { status: 400 }); - } + const { creatorId } = parsed.data; - const creatorIdNumber = Number(creatorId); - if (!Number.isInteger(creatorIdNumber)) { - return new Response("creatorId must be an integer", { status: 400 }); - } - - const creator = await getUserById(creatorIdNumber); + const creator = await getUserById(creatorId); 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); diff --git a/packages/backend/src/routes/project/by-organisation.ts b/packages/backend/src/routes/project/by-organisation.ts index 2b55e05..9af0246 100644 --- a/packages/backend/src/routes/project/by-organisation.ts +++ b/packages/backend/src/routes/project/by-organisation.ts @@ -1,33 +1,27 @@ +import { ProjectByOrgQuerySchema } from "@issue/shared"; import type { AuthedRequest } from "../../auth/middleware"; import { getOrganisationById, getOrganisationsByUserId, getProjectsByOrganisationId } from "../../db/queries"; +import { errorResponse, parseQueryParams } from "../../validation"; -// /projects/by-organisation?organisationId=1 export default async function projectsByOrganisation(req: AuthedRequest) { 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) { - return new Response("organisationId is required", { status: 400 }); - } + const { organisationId } = parsed.data; - const orgIdNumber = Number(organisationId); - if (!Number.isInteger(orgIdNumber)) { - return new Response("organisationId must be an integer", { status: 400 }); - } - - const organisation = await getOrganisationById(orgIdNumber); + const organisation = await getOrganisationById(organisationId); 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 hasAccess = userOrganisations.some((item) => item.Organisation.id === orgIdNumber); + const hasAccess = userOrganisations.some((item) => item.Organisation.id === organisationId); 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); } diff --git a/packages/backend/src/routes/project/create.ts b/packages/backend/src/routes/project/create.ts index ca954c0..9a7a048 100644 --- a/packages/backend/src/routes/project/create.ts +++ b/packages/backend/src/routes/project/create.ts @@ -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 { errorResponse, parseJsonBody } from "../../validation"; -// /project/create?key=KEY&name=Testing&creatorId=1&organisationId=1 -export default async function projectCreate(req: BunRequest) { - const url = new URL(req.url); - const key = url.searchParams.get("key"); - const name = url.searchParams.get("name"); - const creatorId = url.searchParams.get("creatorId"); - const organisationId = url.searchParams.get("organisationId"); +export default async function projectCreate(req: AuthedRequest) { + const parsed = await parseJsonBody(req, ProjectCreateRequestSchema); + if ("error" in parsed) return parsed.error; - if (!key || !name || !creatorId || !organisationId) { - return new Response( - `missing parameters: ${!key ? "key " : ""}${!name ? "name " : ""}${!creatorId ? "creatorId " : ""}${!organisationId ? "organisationId" : ""}`, - { status: 400 }, - ); - } + const { key, name, organisationId } = parsed.data; - // check if project with key already exists in the organisation const existingProject = await getProjectByKey(key); - if (existingProject?.organisationId === parseInt(organisationId, 10)) { - return new Response(`project with key ${key} already exists`, { status: 400 }); + if (existingProject?.organisationId === organisationId) { + 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) { - 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); } diff --git a/packages/backend/src/routes/project/delete.ts b/packages/backend/src/routes/project/delete.ts index 18ab66f..e20c7a4 100644 --- a/packages/backend/src/routes/project/delete.ts +++ b/packages/backend/src/routes/project/delete.ts @@ -1,21 +1,20 @@ +import { ProjectDeleteRequestSchema } from "@issue/shared"; import type { BunRequest } from "bun"; import { deleteProject, getProjectByID } from "../../db/queries"; +import { errorResponse, parseJsonBody } from "../../validation"; -// /project/delete?id=1 export default async function projectDelete(req: BunRequest) { - const url = new URL(req.url); - const id = url.searchParams.get("id"); + const parsed = await parseJsonBody(req, ProjectDeleteRequestSchema); + if ("error" in parsed) return parsed.error; - if (!id) { - return new Response(`project id is required`, { status: 400 }); - } + const { id } = parsed.data; - const existingProject = await getProjectByID(Number(id)); + const existingProject = await getProjectByID(id); 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 }); } diff --git a/packages/backend/src/routes/project/update.ts b/packages/backend/src/routes/project/update.ts index 23ffd73..2782ad1 100644 --- a/packages/backend/src/routes/project/update.ts +++ b/packages/backend/src/routes/project/update.ts @@ -1,45 +1,46 @@ +import { ProjectUpdateRequestSchema } from "@issue/shared"; import type { BunRequest } from "bun"; 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) { - const url = new URL(req.url); - const id = url.searchParams.get("id"); - 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; + const parsed = await parseJsonBody(req, ProjectUpdateRequestSchema); + if ("error" in parsed) return parsed.error; - if (!id) { - return new Response(`project id is required`, { status: 400 }); - } + const { id, key, name, creatorId, organisationId } = parsed.data; - const existingProject = await getProjectByID(Number(id)); + const existingProject = await getProjectByID(id); 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) { - return new Response(`at least one of key, name, creatorId, or organisationId must be provided`, { - status: 400, - }); + return errorResponse( + "at least one of key, name, creatorId, or organisationId must be provided", + "NO_UPDATES", + 400, + ); } - const projectWithKey = key ? await getProjectByKey(key) : null; - if (projectWithKey && projectWithKey.id !== Number(id)) { - return new Response(`a project with key "${key}" already exists`, { status: 400 }); + if (key) { + const projectWithKey = await getProjectByKey(key); + 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 && !newCreator) { - return new Response(`user with id ${creatorId} does not exist`, { status: 400 }); + if (creatorId) { + const newCreator = await getUserById(creatorId); + 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, name, - creatorId: newCreator?.id, - organisationId: organisationId ? Number(organisationId) : undefined, + creatorId, + organisationId, }); return Response.json(project); diff --git a/packages/backend/src/routes/project/with-creator.ts b/packages/backend/src/routes/project/with-creator.ts index 032f108..cb2ee13 100644 --- a/packages/backend/src/routes/project/with-creator.ts +++ b/packages/backend/src/routes/project/with-creator.ts @@ -1,23 +1,18 @@ +import { ProjectByIdQuerySchema } from "@issue/shared"; import type { BunRequest } from "bun"; import { getProjectWithCreatorByID } from "../../db/queries"; +import { errorResponse, parseQueryParams } from "../../validation"; -// /project/with-creator?id=1 export default async function projectWithCreatorByID(req: BunRequest) { 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) { - return new Response("project id is required", { status: 400 }); - } + const { id } = parsed.data; - const projectId = Number(id); - if (!Number.isInteger(projectId)) { - return new Response("project id must be an integer", { status: 400 }); - } - - const projectWithCreator = await getProjectWithCreatorByID(projectId); + const projectWithCreator = await getProjectWithCreatorByID(id); 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); diff --git a/packages/backend/src/routes/sprint/create.ts b/packages/backend/src/routes/sprint/create.ts index 566c160..07076d3 100644 --- a/packages/backend/src/routes/sprint/create.ts +++ b/packages/backend/src/routes/sprint/create.ts @@ -1,63 +1,29 @@ +import { SprintCreateRequestSchema } from "@issue/shared"; import type { AuthedRequest } from "../../auth/middleware"; 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) { - const url = new URL(req.url); - const projectId = url.searchParams.get("projectId"); - 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"); + const parsed = await parseJsonBody(req, SprintCreateRequestSchema); + if ("error" in parsed) return parsed.error; - if (!projectId || !name || !startDateParam || !endDateParam) { - return new Response( - `missing parameters: ${!projectId ? "projectId " : ""}${!name ? "name " : ""}${ - !startDateParam ? "startDate " : "" - }${!endDateParam ? "endDate" : ""}`, - { status: 400 }, - ); - } + const { projectId, name, color, startDate, endDate } = parsed.data; - const projectIdNumber = Number(projectId); - if (!Number.isInteger(projectIdNumber)) { - return new Response("projectId must be an integer", { status: 400 }); - } - - const project = await getProjectByID(projectIdNumber); + const project = await getProjectByID(projectId); 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); 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") { - 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(); - 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); + const sprint = await createSprint(project.id, name, color, new Date(startDate), new Date(endDate)); return Response.json(sprint); } diff --git a/packages/backend/src/routes/sprints/by-project.ts b/packages/backend/src/routes/sprints/by-project.ts index d929d48..b4e0e3e 100644 --- a/packages/backend/src/routes/sprints/by-project.ts +++ b/packages/backend/src/routes/sprints/by-project.ts @@ -1,28 +1,23 @@ +import { SprintsByProjectQuerySchema } from "@issue/shared"; import type { AuthedRequest } from "../../auth/middleware"; import { getOrganisationMemberRole, getProjectByID, getSprintsByProject } from "../../db/queries"; +import { errorResponse, parseQueryParams } from "../../validation"; -// /sprints/by-project?projectId=1 export default async function sprintsByProject(req: AuthedRequest) { 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) { - return new Response("missing projectId", { status: 400 }); - } + const { projectId } = parsed.data; - const projectIdNumber = Number(projectId); - if (!Number.isInteger(projectIdNumber)) { - return new Response("projectId must be an integer", { status: 400 }); - } - - const project = await getProjectByID(projectIdNumber); + const project = await getProjectByID(projectId); 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); 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); diff --git a/packages/backend/src/routes/timer/end.ts b/packages/backend/src/routes/timer/end.ts index aad6c82..663778d 100644 --- a/packages/backend/src/routes/timer/end.ts +++ b/packages/backend/src/routes/timer/end.ts @@ -1,21 +1,20 @@ -import { calculateBreakTimeMs, calculateWorkTimeMs } from "@issue/shared"; +import { calculateBreakTimeMs, calculateWorkTimeMs, TimerEndRequestSchema } from "@issue/shared"; import type { AuthedRequest } from "../../auth/middleware"; import { endTimedSession, getActiveTimedSession } from "../../db/queries"; +import { errorResponse, parseJsonBody } from "../../validation"; -// POST /timer/end export default async function timerEnd(req: AuthedRequest) { - const url = new URL(req.url); - const issueId = url.searchParams.get("issueId"); - if (!issueId || Number.isNaN(Number(issueId))) { - return new Response("missing issue id", { status: 400 }); - } - const activeSession = await getActiveTimedSession(req.userId, Number(issueId)); + const parsed = await parseJsonBody(req, TimerEndRequestSchema); + if ("error" in parsed) return parsed.error; + + const { issueId } = parsed.data; + + const activeSession = await getActiveTimedSession(req.userId, issueId); 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) { return Response.json({ ...activeSession, @@ -27,7 +26,7 @@ export default async function timerEnd(req: AuthedRequest) { const ended = await endTimedSession(activeSession.id, activeSession.timestamps); if (!ended) { - return new Response("failed to end timer", { status: 500 }); + return errorResponse("failed to end timer", "END_FAILED", 500); } return Response.json({ diff --git a/packages/backend/src/routes/timer/get-inactive.ts b/packages/backend/src/routes/timer/get-inactive.ts index 9b88b59..aaec18e 100644 --- a/packages/backend/src/routes/timer/get-inactive.ts +++ b/packages/backend/src/routes/timer/get-inactive.ts @@ -1,17 +1,18 @@ -import { calculateBreakTimeMs, calculateWorkTimeMs } from "@issue/shared"; +import { calculateBreakTimeMs, calculateWorkTimeMs, TimerGetQuerySchema } from "@issue/shared"; import type { AuthedRequest } from "../../auth/middleware"; import { getInactiveTimedSessions } from "../../db/queries"; +import { parseQueryParams } from "../../validation"; -// GET /timer?issueId=123 export default async function timerGetInactive(req: AuthedRequest) { const url = new URL(req.url); - const issueId = url.searchParams.get("issueId"); - if (!issueId || Number.isNaN(Number(issueId))) { - return new Response("missing issue id", { status: 400 }); - } - const sessions = await getInactiveTimedSessions(Number(issueId)); + const parsed = parseQueryParams(url, TimerGetQuerySchema); + if ("error" in parsed) return parsed.error; - if (!sessions[0] || !sessions) { + const { issueId } = parsed.data; + + const sessions = await getInactiveTimedSessions(issueId); + + if (!sessions || sessions.length === 0) { return Response.json(null); } diff --git a/packages/backend/src/routes/timer/get.ts b/packages/backend/src/routes/timer/get.ts index 723f786..3e02783 100644 --- a/packages/backend/src/routes/timer/get.ts +++ b/packages/backend/src/routes/timer/get.ts @@ -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 { getActiveTimedSession } from "../../db/queries"; +import { parseQueryParams } from "../../validation"; -// GET /timer?issueId=123 export default async function timerGet(req: AuthedRequest) { const url = new URL(req.url); - const issueId = url.searchParams.get("issueId"); - if (!issueId || Number.isNaN(Number(issueId))) { - return new Response("missing issue id", { status: 400 }); - } - const activeSession = await getActiveTimedSession(req.userId, Number(issueId)); + const parsed = parseQueryParams(url, TimerGetQuerySchema); + if ("error" in parsed) return parsed.error; + + const { issueId } = parsed.data; + + const activeSession = await getActiveTimedSession(req.userId, issueId); if (!activeSession) { return Response.json(null); diff --git a/packages/backend/src/routes/timer/toggle.ts b/packages/backend/src/routes/timer/toggle.ts index 9f0070f..6405a2c 100644 --- a/packages/backend/src/routes/timer/toggle.ts +++ b/packages/backend/src/routes/timer/toggle.ts @@ -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 { appendTimestamp, createTimedSession, getActiveTimedSession } from "../../db/queries"; +import { parseJsonBody } from "../../validation"; -// POST /timer/toggle?issueId=123 export default async function timerToggle(req: AuthedRequest) { - const url = new URL(req.url); - const issueId = url.searchParams.get("issueId"); - if (!issueId || Number.isNaN(Number(issueId))) { - return new Response("missing issue id", { status: 400 }); - } + const parsed = await parseJsonBody(req, TimerToggleRequestSchema); + if ("error" in parsed) return parsed.error; - const activeSession = await getActiveTimedSession(req.userId, Number(issueId)); + const { issueId } = parsed.data; + + const activeSession = await getActiveTimedSession(req.userId, issueId); if (!activeSession) { - // no active session, create new one with first timestamp - const newSession = await createTimedSession(req.userId, Number(issueId)); + const newSession = await createTimedSession(req.userId, issueId); return Response.json({ ...newSession, 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); 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); diff --git a/packages/backend/src/routes/user/by-username.ts b/packages/backend/src/routes/user/by-username.ts index fc703ce..d0c3c99 100644 --- a/packages/backend/src/routes/user/by-username.ts +++ b/packages/backend/src/routes/user/by-username.ts @@ -1,18 +1,18 @@ +import { UserByUsernameQuerySchema } from "@issue/shared"; import type { BunRequest } from "bun"; import { getUserByUsername } from "../../db/queries"; +import { errorResponse, parseQueryParams } from "../../validation"; -// /user/by-username?username=someusername export default async function userByUsername(req: BunRequest) { 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) { - return new Response("username is required", { status: 400 }); - } + const { username } = parsed.data; const user = await getUserByUsername(username); 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); diff --git a/packages/backend/src/routes/user/update.ts b/packages/backend/src/routes/user/update.ts index 94127ca..4010ce4 100644 --- a/packages/backend/src/routes/user/update.ts +++ b/packages/backend/src/routes/user/update.ts @@ -1,25 +1,28 @@ -import type { UserRecord } from "@issue/shared"; +import { UserUpdateRequestSchema } from "@issue/shared"; import type { AuthedRequest } from "../../auth/middleware"; import { hashPassword } from "../../auth/utils"; 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) { - const url = new URL(req.url); - const id = url.searchParams.get("id"); - if (!id) { - return new Response("id is required", { status: 400 }); - } + const parsed = await parseJsonBody(req, UserUpdateRequestSchema); + if ("error" in parsed) return parsed.error; - const user = await getUserById(Number(id)); + const { name, password, avatarURL } = parsed.data; + + const user = await getUserById(req.userId); 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; if (password !== undefined) { passwordHash = await hashPassword(password); @@ -29,8 +32,8 @@ export default async function update(req: AuthedRequest) { const updatedUser = await updateById(user.id, { name, passwordHash, avatarURL }); 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); }