full comments system

This commit is contained in:
Oliver Bryan
2026-01-21 19:10:28 +00:00
parent 0d2195cab4
commit 8f87fc8acf
28 changed files with 1451 additions and 7 deletions

View File

@@ -1,3 +1,4 @@
export * from "./issue-comments";
export * from "./issues";
export * from "./organisations";
export * from "./projects";

View File

@@ -0,0 +1,48 @@
import { Issue, IssueComment, type IssueCommentResponse, Project, User } from "@sprint/shared";
import { asc, eq } from "drizzle-orm";
import { db } from "../client";
export async function createIssueComment(issueId: number, userId: number, body: string) {
const [comment] = await db
.insert(IssueComment)
.values({
issueId,
userId,
body,
})
.returning();
return comment;
}
export async function deleteIssueComment(id: number) {
return await db.delete(IssueComment).where(eq(IssueComment.id, id));
}
export async function getIssueCommentById(id: number) {
const [comment] = await db.select().from(IssueComment).where(eq(IssueComment.id, id));
return comment;
}
export async function getIssueCommentsByIssueId(issueId: number): Promise<IssueCommentResponse[]> {
const comments = await db
.select({
Comment: IssueComment,
User: User,
})
.from(IssueComment)
.where(eq(IssueComment.issueId, issueId))
.innerJoin(User, eq(IssueComment.userId, User.id))
.orderBy(asc(IssueComment.createdAt));
return comments;
}
export async function getIssueOrganisationId(issueId: number) {
const [result] = await db
.select({ organisationId: Project.organisationId })
.from(Issue)
.innerJoin(Project, eq(Issue.projectId, Project.id))
.where(eq(Issue.id, issueId));
return result?.organisationId ?? null;
}

View File

@@ -40,11 +40,14 @@ const main = async () => {
"/issue/create": withCors(withAuth(withCSRF(routes.issueCreate))),
"/issue/update": withCors(withAuth(withCSRF(routes.issueUpdate))),
"/issue/delete": withCors(withAuth(withCSRF(routes.issueDelete))),
"/issue-comment/create": withCors(withAuth(withCSRF(routes.issueCommentCreate))),
"/issue-comment/delete": withCors(withAuth(withCSRF(routes.issueCommentDelete))),
"/issues/by-project": withCors(withAuth(routes.issuesByProject)),
"/issues/replace-status": withCors(withAuth(withCSRF(routes.issuesReplaceStatus))),
"/issues/status-count": withCors(withAuth(routes.issuesStatusCount)),
"/issues/all": withCors(withAuth(routes.issues)),
"/issue-comments/by-issue": withCors(withAuth(routes.issueCommentsByIssue)),
"/organisation/create": withCors(withAuth(withCSRF(routes.organisationCreate))),
"/organisation/by-id": withCors(withAuth(routes.organisationById)),

View File

@@ -5,6 +5,9 @@ import authRegister from "./auth/register";
import issueCreate from "./issue/create";
import issueDelete from "./issue/delete";
import issueUpdate from "./issue/update";
import issueCommentCreate from "./issue-comment/create";
import issueCommentDelete from "./issue-comment/delete";
import issueCommentsByIssue from "./issue-comments/by-issue";
import issues from "./issues/all";
import issuesByProject from "./issues/by-project";
import issuesReplaceStatus from "./issues/replace-status";
@@ -54,6 +57,10 @@ export const routes = {
issueDelete,
issueUpdate,
issueCommentCreate,
issueCommentDelete,
issueCommentsByIssue,
issuesByProject,
issues,
issuesReplaceStatus,

View File

@@ -0,0 +1,34 @@
import { IssueCommentCreateRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware";
import {
createIssueComment,
getIssueByID,
getIssueOrganisationId,
getOrganisationMemberRole,
} from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
export default async function issueCommentCreate(req: AuthedRequest) {
const parsed = await parseJsonBody(req, IssueCommentCreateRequestSchema);
if ("error" in parsed) return parsed.error;
const { issueId, body } = parsed.data;
const issue = await getIssueByID(issueId);
if (!issue) {
return errorResponse(`issue not found: ${issueId}`, "ISSUE_NOT_FOUND", 404);
}
const organisationId = await getIssueOrganisationId(issueId);
if (!organisationId) {
return errorResponse(`organisation not found for issue ${issueId}`, "ORG_NOT_FOUND", 404);
}
const member = await getOrganisationMemberRole(organisationId, req.userId);
if (!member) {
return errorResponse("forbidden", "FORBIDDEN", 403);
}
const comment = await createIssueComment(issueId, req.userId, body);
return Response.json(comment);
}

View File

@@ -0,0 +1,39 @@
import { IssueCommentDeleteRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware";
import {
deleteIssueComment,
getIssueCommentById,
getIssueOrganisationId,
getOrganisationMemberRole,
} from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
export default async function issueCommentDelete(req: AuthedRequest) {
const parsed = await parseJsonBody(req, IssueCommentDeleteRequestSchema);
if ("error" in parsed) return parsed.error;
const { id } = parsed.data;
const comment = await getIssueCommentById(id);
if (!comment) {
return errorResponse(`comment not found: ${id}`, "COMMENT_NOT_FOUND", 404);
}
if (comment.userId !== req.userId) {
return errorResponse("forbidden", "FORBIDDEN", 403);
}
const organisationId = await getIssueOrganisationId(comment.issueId);
if (!organisationId) {
return errorResponse("organisation not found", "ORG_NOT_FOUND", 404);
}
const member = await getOrganisationMemberRole(organisationId, req.userId);
if (!member) {
return errorResponse("forbidden", "FORBIDDEN", 403);
}
await deleteIssueComment(id);
return Response.json({ success: true });
}

View File

@@ -0,0 +1,35 @@
import { IssueCommentsByIssueQuerySchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware";
import {
getIssueByID,
getIssueCommentsByIssueId,
getIssueOrganisationId,
getOrganisationMemberRole,
} from "../../db/queries";
import { errorResponse, parseQueryParams } from "../../validation";
export default async function issueCommentsByIssue(req: AuthedRequest) {
const url = new URL(req.url);
const parsed = parseQueryParams(url, IssueCommentsByIssueQuerySchema);
if ("error" in parsed) return parsed.error;
const { issueId } = parsed.data;
const issue = await getIssueByID(issueId);
if (!issue) {
return errorResponse(`issue not found: ${issueId}`, "ISSUE_NOT_FOUND", 404);
}
const organisationId = await getIssueOrganisationId(issueId);
if (!organisationId) {
return errorResponse(`organisation not found for issue ${issueId}`, "ORG_NOT_FOUND", 404);
}
const member = await getOrganisationMemberRole(organisationId, req.userId);
if (!member) {
return errorResponse("forbidden", "FORBIDDEN", 403);
}
const comments = await getIssueCommentsByIssueId(issueId);
return Response.json(comments);
}