From 8f87fc8acfe85708e77b37e7dce540b6d2a466e4 Mon Sep 17 00:00:00 2001 From: Oliver Bryan <04oliverbryan@gmail.com> Date: Wed, 21 Jan 2026 19:10:28 +0000 Subject: [PATCH] full comments system --- .../backend/drizzle/0021_issue_comments.sql | 8 + .../drizzle/0021_skinny_sally_floyd.sql | 11 + .../backend/drizzle/meta/0021_snapshot.json | 909 ++++++++++++++++++ packages/backend/drizzle/meta/_journal.json | 7 + packages/backend/src/db/queries/index.ts | 1 + .../backend/src/db/queries/issue-comments.ts | 48 + packages/backend/src/index.ts | 3 + packages/backend/src/routes/index.ts | 7 + .../src/routes/issue-comment/create.ts | 34 + .../src/routes/issue-comment/delete.ts | 39 + .../src/routes/issue-comments/by-issue.ts | 35 + .../src/components/issue-comments.tsx | 137 +++ .../frontend/src/components/issue-details.tsx | 8 +- .../frontend/src/components/issues-table.tsx | 2 +- .../frontend/src/components/login-form.tsx | 4 +- .../frontend/src/lib/query/hooks/index.ts | 1 + .../src/lib/query/hooks/issue-comments.ts | 44 + packages/frontend/src/lib/query/keys.ts | 4 + packages/frontend/src/lib/server/index.ts | 1 + .../src/lib/server/issue-comment/byIssue.ts | 19 + .../src/lib/server/issue-comment/create.ts | 29 + .../src/lib/server/issue-comment/delete.ts | 24 + .../src/lib/server/issue-comment/index.ts | 3 + packages/frontend/src/main.tsx | 2 +- packages/shared/src/api-schemas.ts | 36 + packages/shared/src/constants.ts | 1 + packages/shared/src/index.ts | 16 + packages/shared/src/schema.ts | 25 + 28 files changed, 1451 insertions(+), 7 deletions(-) create mode 100644 packages/backend/drizzle/0021_issue_comments.sql create mode 100644 packages/backend/drizzle/0021_skinny_sally_floyd.sql create mode 100644 packages/backend/drizzle/meta/0021_snapshot.json create mode 100644 packages/backend/src/db/queries/issue-comments.ts create mode 100644 packages/backend/src/routes/issue-comment/create.ts create mode 100644 packages/backend/src/routes/issue-comment/delete.ts create mode 100644 packages/backend/src/routes/issue-comments/by-issue.ts create mode 100644 packages/frontend/src/components/issue-comments.tsx create mode 100644 packages/frontend/src/lib/query/hooks/issue-comments.ts create mode 100644 packages/frontend/src/lib/server/issue-comment/byIssue.ts create mode 100644 packages/frontend/src/lib/server/issue-comment/create.ts create mode 100644 packages/frontend/src/lib/server/issue-comment/delete.ts create mode 100644 packages/frontend/src/lib/server/issue-comment/index.ts diff --git a/packages/backend/drizzle/0021_issue_comments.sql b/packages/backend/drizzle/0021_issue_comments.sql new file mode 100644 index 0000000..91302e6 --- /dev/null +++ b/packages/backend/drizzle/0021_issue_comments.sql @@ -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() +); diff --git a/packages/backend/drizzle/0021_skinny_sally_floyd.sql b/packages/backend/drizzle/0021_skinny_sally_floyd.sql new file mode 100644 index 0000000..7bbdc52 --- /dev/null +++ b/packages/backend/drizzle/0021_skinny_sally_floyd.sql @@ -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; \ No newline at end of file diff --git a/packages/backend/drizzle/meta/0021_snapshot.json b/packages/backend/drizzle/meta/0021_snapshot.json new file mode 100644 index 0000000..3d0b3a0 --- /dev/null +++ b/packages/backend/drizzle/meta/0021_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/packages/backend/drizzle/meta/_journal.json b/packages/backend/drizzle/meta/_journal.json index 06f7791..81764d3 100644 --- a/packages/backend/drizzle/meta/_journal.json +++ b/packages/backend/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/packages/backend/src/db/queries/index.ts b/packages/backend/src/db/queries/index.ts index b178167..99988cb 100644 --- a/packages/backend/src/db/queries/index.ts +++ b/packages/backend/src/db/queries/index.ts @@ -1,3 +1,4 @@ +export * from "./issue-comments"; export * from "./issues"; export * from "./organisations"; export * from "./projects"; diff --git a/packages/backend/src/db/queries/issue-comments.ts b/packages/backend/src/db/queries/issue-comments.ts new file mode 100644 index 0000000..fa80caa --- /dev/null +++ b/packages/backend/src/db/queries/issue-comments.ts @@ -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 { + 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; +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 846043a..135419a 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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)), diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 00b450e..e00283b 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -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, diff --git a/packages/backend/src/routes/issue-comment/create.ts b/packages/backend/src/routes/issue-comment/create.ts new file mode 100644 index 0000000..a6afe6d --- /dev/null +++ b/packages/backend/src/routes/issue-comment/create.ts @@ -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); +} diff --git a/packages/backend/src/routes/issue-comment/delete.ts b/packages/backend/src/routes/issue-comment/delete.ts new file mode 100644 index 0000000..1ef2cfa --- /dev/null +++ b/packages/backend/src/routes/issue-comment/delete.ts @@ -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 }); +} diff --git a/packages/backend/src/routes/issue-comments/by-issue.ts b/packages/backend/src/routes/issue-comments/by-issue.ts new file mode 100644 index 0000000..c2a4668 --- /dev/null +++ b/packages/backend/src/routes/issue-comments/by-issue.ts @@ -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); +} diff --git a/packages/frontend/src/components/issue-comments.tsx b/packages/frontend/src/components/issue-comments.tsx new file mode 100644 index 0000000..833e3ab --- /dev/null +++ b/packages/frontend/src/components/issue-comments.tsx @@ -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(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 ( +
+
+ Comments + {sortedComments.length} +
+
+