mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
edit + delete capabilities for org, project, sprint
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Issue, Organisation, Project, User } from "@sprint/shared";
|
||||
import { Issue, Organisation, Project, Sprint, User } from "@sprint/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../client";
|
||||
|
||||
@@ -26,6 +26,8 @@ export async function updateProject(
|
||||
export async function deleteProject(projectId: number) {
|
||||
// delete all of the project's issues first
|
||||
await db.delete(Issue).where(eq(Issue.projectId, projectId));
|
||||
// delete all of the project's sprints
|
||||
await db.delete(Sprint).where(eq(Sprint.projectId, projectId));
|
||||
// delete actual project
|
||||
await db.delete(Project).where(eq(Project.id, projectId));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Sprint } from "@sprint/shared";
|
||||
import { and, eq, gte, lte } from "drizzle-orm";
|
||||
import { Issue, Sprint } from "@sprint/shared";
|
||||
import { and, desc, eq, gte, lte, ne } from "drizzle-orm";
|
||||
import { db } from "../client";
|
||||
|
||||
export async function createSprint(
|
||||
@@ -22,21 +22,55 @@ export async function createSprint(
|
||||
return sprint;
|
||||
}
|
||||
|
||||
export async function getSprintsByProject(projectId: number) {
|
||||
return await db.select().from(Sprint).where(eq(Sprint.projectId, projectId));
|
||||
export async function getSprintById(sprintId: number) {
|
||||
const [sprint] = await db.select().from(Sprint).where(eq(Sprint.id, sprintId));
|
||||
return sprint;
|
||||
}
|
||||
|
||||
export async function hasOverlappingSprints(projectId: number, startDate: Date, endDate: Date) {
|
||||
export async function getSprintsByProject(projectId: number) {
|
||||
return await db
|
||||
.select()
|
||||
.from(Sprint)
|
||||
.where(eq(Sprint.projectId, projectId))
|
||||
.orderBy(desc(Sprint.startDate));
|
||||
}
|
||||
|
||||
export async function hasOverlappingSprints(
|
||||
projectId: number,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
excludeSprintId?: number,
|
||||
) {
|
||||
const conditions = [
|
||||
eq(Sprint.projectId, projectId),
|
||||
lte(Sprint.startDate, endDate),
|
||||
gte(Sprint.endDate, startDate),
|
||||
];
|
||||
|
||||
if (excludeSprintId !== undefined) {
|
||||
console.log("Excluding sprint ID:", excludeSprintId);
|
||||
conditions.push(ne(Sprint.id, excludeSprintId));
|
||||
}
|
||||
|
||||
const overlapping = await db
|
||||
.select({ id: Sprint.id })
|
||||
.from(Sprint)
|
||||
.where(
|
||||
and(
|
||||
eq(Sprint.projectId, projectId),
|
||||
lte(Sprint.startDate, endDate),
|
||||
gte(Sprint.endDate, startDate),
|
||||
),
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.limit(1);
|
||||
|
||||
console.log(overlapping);
|
||||
return overlapping.length > 0;
|
||||
}
|
||||
|
||||
export async function updateSprint(
|
||||
sprintId: number,
|
||||
updates: { name?: string; color?: string; startDate?: Date; endDate?: Date },
|
||||
) {
|
||||
const [sprint] = await db.update(Sprint).set(updates).where(eq(Sprint.id, sprintId)).returning();
|
||||
return sprint;
|
||||
}
|
||||
|
||||
export async function deleteSprint(sprintId: number) {
|
||||
await db.update(Issue).set({ sprintId: null }).where(eq(Issue.sprintId, sprintId));
|
||||
await db.delete(Sprint).where(eq(Sprint.id, sprintId));
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ const main = async () => {
|
||||
"/projects/with-creators": withCors(withAuth(routes.projectsWithCreators)),
|
||||
|
||||
"/sprint/create": withCors(withAuth(withCSRF(routes.sprintCreate))),
|
||||
"/sprint/update": withCors(withAuth(withCSRF(routes.sprintUpdate))),
|
||||
"/sprint/delete": withCors(withAuth(withCSRF(routes.sprintDelete))),
|
||||
"/sprints/by-project": withCors(withAuth(routes.sprintsByProject)),
|
||||
|
||||
"/timer/toggle": withCors(withAuth(withCSRF(routes.timerToggle))),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
42
packages/backend/src/routes/sprint/delete.ts
Normal file
42
packages/backend/src/routes/sprint/delete.ts
Normal 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 });
|
||||
}
|
||||
76
packages/backend/src/routes/sprint/update.ts
Normal file
76
packages/backend/src/routes/sprint/update.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user