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 { 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<string, unknown>;
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);

View File

@@ -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<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 { 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);

View File

@@ -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);
}

View File

@@ -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 });
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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 });
}

View File

@@ -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);
}

View File

@@ -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 });
}

View File

@@ -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);
}

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 { 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<string, string> | 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,

View File

@@ -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);

View File

@@ -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);
}

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 { 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);
}

View File

@@ -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 });
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);

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 { 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({

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 { 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);
}

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 { 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);

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 { 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);

View File

@@ -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);

View File

@@ -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);
}