mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 02:33:01 +00:00
backend routes with zod schemas
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user