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

@@ -0,0 +1,8 @@
CREATE TABLE "IssueComment" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
"issueId" integer NOT NULL REFERENCES "Issue" ("id") ON DELETE CASCADE,
"userId" integer NOT NULL REFERENCES "User" ("id") ON DELETE CASCADE,
"body" varchar(2048) NOT NULL,
"createdAt" timestamp NOT NULL DEFAULT now(),
"updatedAt" timestamp NOT NULL DEFAULT now()
);

View File

@@ -0,0 +1,11 @@
CREATE TABLE "IssueComment" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "IssueComment_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"issueId" integer NOT NULL,
"userId" integer NOT NULL,
"body" varchar(2048) NOT NULL,
"createdAt" timestamp DEFAULT now(),
"updatedAt" timestamp DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "IssueComment" ADD CONSTRAINT "IssueComment_issueId_Issue_id_fk" FOREIGN KEY ("issueId") REFERENCES "public"."Issue"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "IssueComment" ADD CONSTRAINT "IssueComment_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,909 @@
{
"id": "c19db523-bbc5-4069-9eea-bdd37cf36c64",
"prevId": "fb4c4093-9a4d-43b2-844d-f8e9912c564a",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.Issue": {
"name": "Issue",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Issue_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"projectId": {
"name": "projectId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"number": {
"name": "number",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "varchar(2048)",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "varchar(24)",
"primaryKey": false,
"notNull": true,
"default": "'TO DO'"
},
"creatorId": {
"name": "creatorId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sprintId": {
"name": "sprintId",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"unique_project_issue_number": {
"name": "unique_project_issue_number",
"columns": [
{
"expression": "projectId",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "number",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"Issue_projectId_Project_id_fk": {
"name": "Issue_projectId_Project_id_fk",
"tableFrom": "Issue",
"tableTo": "Project",
"columnsFrom": [
"projectId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"Issue_creatorId_User_id_fk": {
"name": "Issue_creatorId_User_id_fk",
"tableFrom": "Issue",
"tableTo": "User",
"columnsFrom": [
"creatorId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"Issue_sprintId_Sprint_id_fk": {
"name": "Issue_sprintId_Sprint_id_fk",
"tableFrom": "Issue",
"tableTo": "Sprint",
"columnsFrom": [
"sprintId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.IssueAssignee": {
"name": "IssueAssignee",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "IssueAssignee_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"issueId": {
"name": "issueId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"assignedAt": {
"name": "assignedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"unique_issue_user": {
"name": "unique_issue_user",
"columns": [
{
"expression": "issueId",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "userId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"IssueAssignee_issueId_Issue_id_fk": {
"name": "IssueAssignee_issueId_Issue_id_fk",
"tableFrom": "IssueAssignee",
"tableTo": "Issue",
"columnsFrom": [
"issueId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"IssueAssignee_userId_User_id_fk": {
"name": "IssueAssignee_userId_User_id_fk",
"tableFrom": "IssueAssignee",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.IssueComment": {
"name": "IssueComment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "IssueComment_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"issueId": {
"name": "issueId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "varchar(2048)",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"IssueComment_issueId_Issue_id_fk": {
"name": "IssueComment_issueId_Issue_id_fk",
"tableFrom": "IssueComment",
"tableTo": "Issue",
"columnsFrom": [
"issueId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"IssueComment_userId_User_id_fk": {
"name": "IssueComment_userId_User_id_fk",
"tableFrom": "IssueComment",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Organisation": {
"name": "Organisation",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Organisation_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "varchar(1024)",
"primaryKey": false,
"notNull": false
},
"slug": {
"name": "slug",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"iconURL": {
"name": "iconURL",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false
},
"statuses": {
"name": "statuses",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{\"TO DO\":\"#fafafa\",\"IN PROGRESS\":\"#f97316\",\"REVIEW\":\"#8952bc\",\"DONE\":\"#22c55e\",\"REJECTED\":\"#ef4444\",\"ARCHIVED\":\"#a1a1a1\",\"MERGED\":\"#a1a1a1\"}'::json"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"Organisation_slug_unique": {
"name": "Organisation_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.OrganisationMember": {
"name": "OrganisationMember",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "OrganisationMember_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"organisationId": {
"name": "organisationId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"OrganisationMember_organisationId_Organisation_id_fk": {
"name": "OrganisationMember_organisationId_Organisation_id_fk",
"tableFrom": "OrganisationMember",
"tableTo": "Organisation",
"columnsFrom": [
"organisationId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"OrganisationMember_userId_User_id_fk": {
"name": "OrganisationMember_userId_User_id_fk",
"tableFrom": "OrganisationMember",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Project": {
"name": "Project",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Project_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"key": {
"name": "key",
"type": "varchar(4)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"organisationId": {
"name": "organisationId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"creatorId": {
"name": "creatorId",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Project_organisationId_Organisation_id_fk": {
"name": "Project_organisationId_Organisation_id_fk",
"tableFrom": "Project",
"tableTo": "Organisation",
"columnsFrom": [
"organisationId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"Project_creatorId_User_id_fk": {
"name": "Project_creatorId_User_id_fk",
"tableFrom": "Project",
"tableTo": "User",
"columnsFrom": [
"creatorId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Session": {
"name": "Session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Session_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"csrfToken": {
"name": "csrfToken",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"expiresAt": {
"name": "expiresAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"Session_userId_User_id_fk": {
"name": "Session_userId_User_id_fk",
"tableFrom": "Session",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Sprint": {
"name": "Sprint",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Sprint_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"projectId": {
"name": "projectId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "varchar(7)",
"primaryKey": false,
"notNull": true,
"default": "'#a1a1a1'"
},
"startDate": {
"name": "startDate",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"endDate": {
"name": "endDate",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"Sprint_projectId_Project_id_fk": {
"name": "Sprint_projectId_Project_id_fk",
"tableFrom": "Sprint",
"tableTo": "Project",
"columnsFrom": [
"projectId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.TimedSession": {
"name": "TimedSession",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "TimedSession_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"issueId": {
"name": "issueId",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"timestamps": {
"name": "timestamps",
"type": "timestamp[]",
"primaryKey": false,
"notNull": true
},
"endedAt": {
"name": "endedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"TimedSession_userId_User_id_fk": {
"name": "TimedSession_userId_User_id_fk",
"tableFrom": "TimedSession",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"TimedSession_issueId_Issue_id_fk": {
"name": "TimedSession_issueId_Issue_id_fk",
"tableFrom": "TimedSession",
"tableTo": "Issue",
"columnsFrom": [
"issueId"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.User": {
"name": "User",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "User_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true
},
"passwordHash": {
"name": "passwordHash",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"avatarURL": {
"name": "avatarURL",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false
},
"iconPreference": {
"name": "iconPreference",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true,
"default": "'lucide'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"User_username_unique": {
"name": "User_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -148,6 +148,13 @@
"when": 1768999911628,
"tag": "0020_little_power_pack",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1769021149911,
"tag": "0021_skinny_sally_floyd",
"breakpoints": true
}
]
}

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

View File

@@ -0,0 +1,137 @@
import type { IssueCommentResponse } from "@sprint/shared";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { useSession } from "@/components/session-provider";
import SmallUserDisplay from "@/components/small-user-display";
import { Button } from "@/components/ui/button";
import Icon from "@/components/ui/icon";
import { IconButton } from "@/components/ui/icon-button";
import { Textarea } from "@/components/ui/textarea";
import { useCreateIssueComment, useDeleteIssueComment, useIssueComments } from "@/lib/query/hooks";
import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils";
const formatTimestamp = (value?: string | Date | null) => {
if (!value) return "";
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
};
export function IssueComments({ issueId, className }: { issueId: number; className?: string }) {
const { user } = useSession();
const { data = [], isLoading } = useIssueComments(issueId);
const createComment = useCreateIssueComment();
const deleteComment = useDeleteIssueComment();
const [body, setBody] = useState("");
const [deletingId, setDeletingId] = useState<number | null>(null);
const sortedComments = useMemo(() => {
return [...data].sort((a, b) => {
const aDate =
a.Comment.createdAt instanceof Date ? a.Comment.createdAt : new Date(a.Comment.createdAt ?? 0);
const bDate =
b.Comment.createdAt instanceof Date ? b.Comment.createdAt : new Date(b.Comment.createdAt ?? 0);
return aDate.getTime() - bDate.getTime();
});
}, [data]);
const handleSubmit = async () => {
const trimmed = body.trim();
if (!trimmed) return;
try {
await createComment.mutateAsync({
issueId,
body: trimmed,
});
setBody("");
} catch (error) {
toast.error(`Error adding comment: ${parseError(error as Error)}`);
}
};
const handleDelete = async (comment: IssueCommentResponse) => {
setDeletingId(comment.Comment.id);
try {
await deleteComment.mutateAsync({ id: comment.Comment.id });
} catch (error) {
toast.error(`Error deleting comment: ${parseError(error as Error)}`);
} finally {
setDeletingId(null);
}
};
return (
<div className={cn("flex flex-col gap-3", className)}>
<div className="flex items-center justify-between">
<span className="text-sm font-600">Comments</span>
<span className="text-xs text-muted-foreground">{sortedComments.length}</span>
</div>
<div className="flex gap-2">
<Textarea
value={body}
onChange={(event) => setBody(event.target.value)}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
void handleSubmit();
}
}}
placeholder="Leave a comment..."
className="text-sm resize-none !bg-background h-"
disabled={createComment.isPending}
/>
<Button
size="lg"
onClick={handleSubmit}
disabled={createComment.isPending || body.trim() === ""}
className="px-4"
>
{createComment.isPending ? "Posting..." : "Post comment"}
</Button>
</div>
<div className="flex flex-col gap-2">
{isLoading ? (
<div className="text-sm text-muted-foreground">Loading comments...</div>
) : sortedComments.length === 0 ? (
<div className="text-sm text-muted-foreground">No comments yet.</div>
) : (
sortedComments.map((comment) => {
const isAuthor = user?.id === comment.Comment.userId;
const timestamp = formatTimestamp(comment.Comment.createdAt);
return (
<div key={comment.Comment.id} className="border border-border/60 bg-muted/20 p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-3">
<SmallUserDisplay user={comment.User} className="text-sm" />
{timestamp ? (
<span className="text-[11px] text-muted-foreground">{timestamp}</span>
) : null}
</div>
{isAuthor ? (
<IconButton
variant="ghost"
onClick={() => handleDelete(comment)}
disabled={deletingId === comment.Comment.id}
title="Delete comment"
>
<Icon icon="trash" className="size-4" />
</IconButton>
) : null}
</div>
<p className="text-sm whitespace-pre-wrap pt-2">{comment.Comment.body}</p>
</div>
);
})
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import type { IssueResponse, SprintRecord, UserRecord } from "@sprint/shared";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { IssueComments } from "@/components/issue-comments";
import { MultiAssigneeSelect } from "@/components/multi-assignee-select";
import { useSession } from "@/components/session-provider";
import SmallSprintDisplay from "@/components/small-sprint-display";
@@ -317,7 +318,7 @@ export function IssueDetails({
</div>
)}
<div className="flex flex-col w-full p-2 py-2 gap-2">
<div className="flex flex-col w-full p-2 py-2 gap-2 max-h-[75vh] overflow-y-scroll">
<div className="flex gap-2">
<StatusSelect
statuses={statuses}
@@ -373,7 +374,7 @@ export function IssueDetails({
}}
placeholder="Add a description..."
disabled={isSavingDescription}
className="text-sm border-input/50 hover:border-input focus:border-input resize-none !bg-background"
className="text-sm border-input/50 hover:border-input focus:border-input resize-none !bg-background min-h-fit"
/>
) : (
<Button
@@ -388,7 +389,6 @@ export function IssueDetails({
Add description
</Button>
)}
<div className="flex items-center gap-2">
<span className="text-sm">Sprint:</span>
<SprintSelect sprints={sprints} value={sprintId} onChange={handleSprintChange} />
@@ -423,6 +423,8 @@ export function IssueDetails({
</div>
)}
<IssueComments issueId={issueData.Issue.id} className="pt-2" />
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}

View File

@@ -27,7 +27,7 @@ export function IssuesTable({
params.set("o", selectedOrganisation.Organisation.slug.toLowerCase());
params.set("p", selectedProject.Project.key.toLowerCase());
params.set("i", issueNumber.toString());
return `/app?${params.toString()}`;
return `/issues?${params.toString()}`;
};
const handleLinkClick = (e: React.MouseEvent) => {

View File

@@ -56,7 +56,7 @@ export default function LogInForm() {
const data = await res.json();
setCsrfToken(data.csrfToken);
setUser(data.user);
const next = searchParams.get("next") || "/app";
const next = searchParams.get("next") || "/issues";
navigate(next, { replace: true });
}
// unauthorized
@@ -94,7 +94,7 @@ export default function LogInForm() {
const data = await res.json();
setCsrfToken(data.csrfToken);
setUser(data.user);
const next = searchParams.get("next") || "/app";
const next = searchParams.get("next") || "/issues";
navigate(next, { replace: true });
}
// bad request (probably a bad user input)

View File

@@ -1,4 +1,5 @@
export * from "@/lib/query/hooks/derived";
export * from "@/lib/query/hooks/issue-comments";
export * from "@/lib/query/hooks/issues";
export * from "@/lib/query/hooks/organisations";
export * from "@/lib/query/hooks/projects";

View File

@@ -0,0 +1,44 @@
import type {
IssueCommentCreateRequest,
IssueCommentDeleteRequest,
IssueCommentRecord,
IssueCommentResponse,
SuccessResponse,
} from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
import { issueComment } from "@/lib/server";
export function useIssueComments(issueId?: number | null) {
return useQuery<IssueCommentResponse[]>({
queryKey: queryKeys.issueComments.byIssue(issueId ?? 0),
queryFn: () => issueComment.byIssue(issueId ?? 0),
enabled: Boolean(issueId),
});
}
export function useCreateIssueComment() {
const queryClient = useQueryClient();
return useMutation<IssueCommentRecord, Error, IssueCommentCreateRequest>({
mutationKey: ["issue-comments", "create"],
mutationFn: issueComment.create,
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.issueComments.byIssue(variables.issueId),
});
},
});
}
export function useDeleteIssueComment() {
const queryClient = useQueryClient();
return useMutation<SuccessResponse, Error, IssueCommentDeleteRequest>({
mutationKey: ["issue-comments", "delete"],
mutationFn: issueComment.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issueComments.all });
},
});
}

View File

@@ -16,6 +16,10 @@ export const queryKeys = {
statusCount: (organisationId: number, status: string) =>
[...queryKeys.issues.all, "status-count", organisationId, status] as const,
},
issueComments: {
all: ["issue-comments"] as const,
byIssue: (issueId: number) => [...queryKeys.issueComments.all, "by-issue", issueId] as const,
},
sprints: {
all: ["sprints"] as const,
byProject: (projectId: number) => [...queryKeys.sprints.all, "by-project", projectId] as const,

View File

@@ -1,6 +1,7 @@
import type { ApiError } from "@sprint/shared";
export * as issue from "@/lib/server/issue";
export * as issueComment from "@/lib/server/issue-comment";
export * as organisation from "@/lib/server/organisation";
export * as project from "@/lib/server/project";
export * as sprint from "@/lib/server/sprint";

View File

@@ -0,0 +1,19 @@
import type { IssueCommentResponse } from "@sprint/shared";
import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function byIssue(issueId: number): Promise<IssueCommentResponse[]> {
const url = new URL(`${getServerURL()}/issue-comments/by-issue`);
url.searchParams.set("issueId", `${issueId}`);
const res = await fetch(url.toString(), {
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to get issue comments (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -0,0 +1,29 @@
import type { IssueCommentCreateRequest, IssueCommentRecord } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function create(request: IssueCommentCreateRequest): Promise<IssueCommentRecord> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issue-comment/create`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to create comment (${res.status})`);
throw new Error(message);
}
const data = (await res.json()) as IssueCommentRecord;
if (!data.id) {
throw new Error(`failed to create comment (${res.status})`);
}
return data;
}

View File

@@ -0,0 +1,24 @@
import type { IssueCommentDeleteRequest, SuccessResponse } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function remove(request: IssueCommentDeleteRequest): Promise<SuccessResponse> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issue-comment/delete`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to delete comment (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -0,0 +1,3 @@
export { byIssue } from "@/lib/server/issue-comment/byIssue";
export { create } from "@/lib/server/issue-comment/create";
export { remove as delete } from "@/lib/server/issue-comment/delete";

View File

@@ -7,8 +7,8 @@ import { SelectionProvider } from "@/components/selection-provider";
import { RequireAuth, SessionProvider } from "@/components/session-provider";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import Issues from "@/pages/Issues";
import Font from "@/pages/Font";
import Issues from "@/pages/Issues";
import Landing from "@/pages/Landing";
import Login from "@/pages/Login";
import NotFound from "@/pages/NotFound";

View File

@@ -1,6 +1,7 @@
import { z } from "zod";
import {
ISSUE_DESCRIPTION_MAX_LENGTH,
ISSUE_COMMENT_MAX_LENGTH,
ISSUE_STATUS_MAX_LENGTH,
ISSUE_TITLE_MAX_LENGTH,
ORG_DESCRIPTION_MAX_LENGTH,
@@ -111,6 +112,25 @@ export const IssuesReplaceStatusRequestSchema = z.object({
export type IssuesReplaceStatusRequest = z.infer<typeof IssuesReplaceStatusRequestSchema>;
export const IssueCommentCreateRequestSchema = z.object({
issueId: z.number().int().positive("issueId must be a positive integer"),
body: z.string().min(1, "Comment is required").max(ISSUE_COMMENT_MAX_LENGTH),
});
export type IssueCommentCreateRequest = z.infer<typeof IssueCommentCreateRequestSchema>;
export const IssueCommentDeleteRequestSchema = z.object({
id: z.number().int().positive("id must be a positive integer"),
});
export type IssueCommentDeleteRequest = z.infer<typeof IssueCommentDeleteRequestSchema>;
export const IssueCommentsByIssueQuerySchema = z.object({
issueId: z.coerce.number().int().positive("issueId must be a positive integer"),
});
export type IssueCommentsByIssueQuery = z.infer<typeof IssueCommentsByIssueQuerySchema>;
// organisation schemas
export const OrgCreateRequestSchema = z.object({
@@ -375,6 +395,22 @@ export const IssueResponseSchema = z.object({
export type IssueResponseType = z.infer<typeof IssueResponseSchema>;
export const IssueCommentRecordSchema = z.object({
id: z.number(),
issueId: z.number(),
userId: z.number(),
body: z.string(),
createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(),
});
export const IssueCommentResponseSchema = z.object({
Comment: IssueCommentRecordSchema,
User: UserResponseSchema,
});
export type IssueCommentResponseType = z.infer<typeof IssueCommentResponseSchema>;
export const OrganisationRecordSchema = z.object({
id: z.number(),
name: z.string(),

View File

@@ -12,3 +12,4 @@ export const PROJECT_SLUG_MAX_LENGTH = 64;
export const ISSUE_TITLE_MAX_LENGTH = 64;
export const ISSUE_DESCRIPTION_MAX_LENGTH = 2048;
export const ISSUE_STATUS_MAX_LENGTH = 24;
export const ISSUE_COMMENT_MAX_LENGTH = 2048;

View File

@@ -2,8 +2,12 @@ export type {
ApiError,
AuthResponse,
IssueCreateRequest,
IssueCommentCreateRequest,
IssueCommentDeleteRequest,
IssueCommentsByIssueQuery,
IssueDeleteRequest,
IssueResponseType,
IssueCommentResponseType,
IssuesByProjectQuery,
IssuesReplaceStatusRequest,
IssuesStatusCountQuery,
@@ -47,7 +51,12 @@ export {
ApiErrorSchema,
AuthResponseSchema,
IssueCreateRequestSchema,
IssueCommentCreateRequestSchema,
IssueCommentDeleteRequestSchema,
IssueCommentsByIssueQuerySchema,
IssueDeleteRequestSchema,
IssueCommentResponseSchema,
IssueCommentRecordSchema,
IssueRecordSchema,
IssueResponseSchema,
IssuesByProjectQuerySchema,
@@ -93,6 +102,7 @@ export {
} from "./api-schemas";
export {
ISSUE_DESCRIPTION_MAX_LENGTH,
ISSUE_COMMENT_MAX_LENGTH,
ISSUE_STATUS_MAX_LENGTH,
ISSUE_TITLE_MAX_LENGTH,
ORG_DESCRIPTION_MAX_LENGTH,
@@ -108,6 +118,9 @@ export type {
IconStyle,
IssueAssigneeInsert,
IssueAssigneeRecord,
IssueCommentInsert,
IssueCommentRecord,
IssueCommentResponse,
IssueInsert,
IssueRecord,
IssueResponse,
@@ -138,6 +151,9 @@ export {
IssueAssignee,
IssueAssigneeInsertSchema,
IssueAssigneeSelectSchema,
IssueComment,
IssueCommentInsertSchema,
IssueCommentSelectSchema,
IssueInsertSchema,
IssueSelectSchema,
iconStyles,

View File

@@ -3,6 +3,7 @@ import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import type { z } from "zod";
import {
ISSUE_DESCRIPTION_MAX_LENGTH,
ISSUE_COMMENT_MAX_LENGTH,
ISSUE_STATUS_MAX_LENGTH,
ISSUE_TITLE_MAX_LENGTH,
ORG_DESCRIPTION_MAX_LENGTH,
@@ -151,6 +152,19 @@ export const IssueAssignee = pgTable(
(t) => [uniqueIndex("unique_issue_user").on(t.issueId, t.userId)],
);
export const IssueComment = pgTable("IssueComment", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
issueId: integer()
.notNull()
.references(() => Issue.id, { onDelete: "cascade" }),
userId: integer()
.notNull()
.references(() => User.id, { onDelete: "cascade" }),
body: varchar({ length: ISSUE_COMMENT_MAX_LENGTH }).notNull(),
createdAt: timestamp({ withTimezone: false }).defaultNow(),
updatedAt: timestamp({ withTimezone: false }).defaultNow(),
});
// Zod schemas
export const UserSelectSchema = createSelectSchema(User);
export const UserInsertSchema = createInsertSchema(User);
@@ -173,6 +187,9 @@ export const IssueInsertSchema = createInsertSchema(Issue);
export const IssueAssigneeSelectSchema = createSelectSchema(IssueAssignee);
export const IssueAssigneeInsertSchema = createInsertSchema(IssueAssignee);
export const IssueCommentSelectSchema = createSelectSchema(IssueComment);
export const IssueCommentInsertSchema = createInsertSchema(IssueComment);
export const SessionSelectSchema = createSelectSchema(Session);
export const SessionInsertSchema = createInsertSchema(Session);
@@ -203,6 +220,9 @@ export type IssueInsert = z.infer<typeof IssueInsertSchema>;
export type IssueAssigneeRecord = z.infer<typeof IssueAssigneeSelectSchema>;
export type IssueAssigneeInsert = z.infer<typeof IssueAssigneeInsertSchema>;
export type IssueCommentRecord = z.infer<typeof IssueCommentSelectSchema>;
export type IssueCommentInsert = z.infer<typeof IssueCommentInsertSchema>;
export type SessionRecord = z.infer<typeof SessionSelectSchema>;
export type SessionInsert = z.infer<typeof SessionInsertSchema>;
@@ -217,6 +237,11 @@ export type IssueResponse = {
Assignees: UserRecord[];
};
export type IssueCommentResponse = {
Comment: IssueCommentRecord;
User: UserRecord;
};
export type ProjectResponse = {
Project: ProjectRecord;
Organisation: OrganisationRecord;