edit + delete capabilities for org, project, sprint

This commit is contained in:
Oliver Bryan
2026-01-18 22:30:41 +00:00
parent e4bc1ea568
commit 303541e656
32 changed files with 1640 additions and 748 deletions

View File

@@ -27,6 +27,8 @@ import projectUpdate from "./project/update";
import projectWithCreator from "./project/with-creator";
import projectsWithCreators from "./project/with-creators";
import sprintCreate from "./sprint/create";
import sprintDelete from "./sprint/delete";
import sprintUpdate from "./sprint/update";
import sprintsByProject from "./sprints/by-project";
import timerEnd from "./timer/end";
import timerGet from "./timer/get";
@@ -78,6 +80,8 @@ export const routes = {
projectsWithCreators,
sprintCreate,
sprintUpdate,
sprintDelete,
sprintsByProject,
timerToggle,

View File

@@ -1,9 +1,9 @@
import { OrgDeleteRequestSchema } from "@sprint/shared";
import type { BunRequest } from "bun";
import { deleteOrganisation, getOrganisationById } from "../../db/queries";
import type { AuthedRequest } from "../../auth/middleware";
import { deleteOrganisation, getOrganisationById, getOrganisationMemberRole } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
export default async function organisationDelete(req: BunRequest) {
export default async function organisationDelete(req: AuthedRequest) {
const parsed = await parseJsonBody(req, OrgDeleteRequestSchema);
if ("error" in parsed) return parsed.error;
@@ -14,6 +14,14 @@ export default async function organisationDelete(req: BunRequest) {
return errorResponse(`organisation with id ${id} not found`, "ORG_NOT_FOUND", 404);
}
const requesterMember = await getOrganisationMemberRole(id, req.userId);
if (!requesterMember) {
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
}
if (requesterMember.role !== "owner") {
return errorResponse("only owners can delete organisations", "PERMISSION_DENIED", 403);
}
await deleteOrganisation(id);
return Response.json({ success: true });

View File

@@ -1,9 +1,9 @@
import { OrgUpdateRequestSchema } from "@sprint/shared";
import type { BunRequest } from "bun";
import { getOrganisationById, updateOrganisation } from "../../db/queries";
import type { AuthedRequest } from "../../auth/middleware";
import { getOrganisationById, getOrganisationMemberRole, updateOrganisation } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
export default async function organisationUpdate(req: BunRequest) {
export default async function organisationUpdate(req: AuthedRequest) {
const parsed = await parseJsonBody(req, OrgUpdateRequestSchema);
if ("error" in parsed) return parsed.error;
@@ -14,6 +14,14 @@ export default async function organisationUpdate(req: BunRequest) {
return errorResponse(`organisation with id ${id} does not exist`, "ORG_NOT_FOUND", 404);
}
const requesterMember = await getOrganisationMemberRole(id, req.userId);
if (!requesterMember) {
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
}
if (requesterMember.role !== "owner" && requesterMember.role !== "admin") {
return errorResponse("only owners and admins can edit organisations", "PERMISSION_DENIED", 403);
}
if (!name && !description && !slug && !statuses) {
return errorResponse(
"at least one of name, description, slug, or statuses must be provided",

View File

@@ -1,9 +1,9 @@
import { ProjectDeleteRequestSchema } from "@sprint/shared";
import type { BunRequest } from "bun";
import { deleteProject, getProjectByID } from "../../db/queries";
import type { AuthedRequest } from "../../auth/middleware";
import { deleteProject, getOrganisationMemberRole, getProjectByID } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
export default async function projectDelete(req: BunRequest) {
export default async function projectDelete(req: AuthedRequest) {
const parsed = await parseJsonBody(req, ProjectDeleteRequestSchema);
if ("error" in parsed) return parsed.error;
@@ -14,6 +14,22 @@ export default async function projectDelete(req: BunRequest) {
return errorResponse(`project with id ${id} does not exist`, "PROJECT_NOT_FOUND", 404);
}
const requesterMember = await getOrganisationMemberRole(existingProject.organisationId, req.userId);
if (!requesterMember) {
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
}
const isOrgOwner = requesterMember.role === "owner";
const isProjectCreator = existingProject.creatorId === req.userId;
if (!isOrgOwner && !isProjectCreator) {
return errorResponse(
"only organisation owners or the project creator can delete projects",
"PERMISSION_DENIED",
403,
);
}
await deleteProject(id);
return Response.json({ success: true });

View File

@@ -1,9 +1,15 @@
import { ProjectUpdateRequestSchema } from "@sprint/shared";
import type { BunRequest } from "bun";
import { getProjectByID, getProjectByKey, getUserById, updateProject } from "../../db/queries";
import type { AuthedRequest } from "../../auth/middleware";
import {
getOrganisationMemberRole,
getProjectByID,
getProjectByKey,
getUserById,
updateProject,
} from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
export default async function projectUpdate(req: BunRequest) {
export default async function projectUpdate(req: AuthedRequest) {
const parsed = await parseJsonBody(req, ProjectUpdateRequestSchema);
if ("error" in parsed) return parsed.error;
@@ -14,6 +20,18 @@ export default async function projectUpdate(req: BunRequest) {
return errorResponse(`project with id ${id} does not exist`, "PROJECT_NOT_FOUND", 404);
}
const requesterMember = await getOrganisationMemberRole(existingProject.organisationId, req.userId);
if (!requesterMember) {
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
}
if (requesterMember.role !== "owner" && requesterMember.role !== "admin") {
return errorResponse(
"only organisation owners and admins can edit projects",
"PERMISSION_DENIED",
403,
);
}
if (!key && !name && !creatorId && !organisationId) {
return errorResponse(
"at least one of key, name, creatorId, or organisationId must be provided",

View File

@@ -0,0 +1,42 @@
import { SprintDeleteRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware";
import { deleteSprint, getOrganisationMemberRole, getProjectByID, getSprintById } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
export default async function sprintDelete(req: AuthedRequest) {
const parsed = await parseJsonBody(req, SprintDeleteRequestSchema);
if ("error" in parsed) return parsed.error;
const { id } = parsed.data;
const existingSprint = await getSprintById(id);
if (!existingSprint) {
return errorResponse(`sprint with id ${id} does not exist`, "SPRINT_NOT_FOUND", 404);
}
const project = await getProjectByID(existingSprint.projectId);
if (!project) {
return errorResponse("project not found", "PROJECT_NOT_FOUND", 404);
}
const requesterMember = await getOrganisationMemberRole(project.organisationId, req.userId);
if (!requesterMember) {
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
}
const isOrgOwner = requesterMember.role === "owner";
const isAdmin = requesterMember.role === "admin";
const isProjectCreator = project.creatorId === req.userId;
if (!isOrgOwner && !isAdmin && !isProjectCreator) {
return errorResponse(
"only organisation owners, admins, or project creators can delete sprints",
"PERMISSION_DENIED",
403,
);
}
await deleteSprint(id);
return Response.json({ success: true });
}

View File

@@ -0,0 +1,76 @@
import { SprintUpdateRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware";
import {
getOrganisationMemberRole,
getProjectByID,
getSprintById,
hasOverlappingSprints,
updateSprint,
} from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
export default async function sprintUpdate(req: AuthedRequest) {
const parsed = await parseJsonBody(req, SprintUpdateRequestSchema);
if ("error" in parsed) return parsed.error;
const { id, name, color, startDate, endDate } = parsed.data;
const existingSprint = await getSprintById(id);
if (!existingSprint) {
return errorResponse(`sprint with id ${id} does not exist`, "SPRINT_NOT_FOUND", 404);
}
const project = await getProjectByID(existingSprint.projectId);
if (!project) {
return errorResponse("project not found", "PROJECT_NOT_FOUND", 404);
}
const requesterMember = await getOrganisationMemberRole(project.organisationId, req.userId);
if (!requesterMember) {
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
}
if (requesterMember.role !== "owner" && requesterMember.role !== "admin") {
return errorResponse(
"only organisation owners and admins can edit sprints",
"PERMISSION_DENIED",
403,
);
}
if (!name && !color && !startDate && !endDate) {
return errorResponse(
"at least one of name, color, startDate, or endDate must be provided",
"NO_UPDATES",
400,
);
}
// validate dates if provided
const newStartDate = startDate ? new Date(startDate) : existingSprint.startDate;
const newEndDate = endDate ? new Date(endDate) : existingSprint.endDate;
if (newStartDate > newEndDate) {
return errorResponse("End date must be after start date", "INVALID_DATES", 400);
}
if (startDate || endDate) {
const hasOverlap = await hasOverlappingSprints(
project.id,
newStartDate,
newEndDate,
existingSprint.id,
);
if (hasOverlap) {
return errorResponse("Sprint dates overlap with an existing sprint", "SPRINT_OVERLAP", 400);
}
}
const sprint = await updateSprint(id, {
name,
color,
startDate: startDate ? newStartDate : undefined,
endDate: endDate ? newEndDate : undefined,
});
return Response.json(sprint);
}