kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #1065 from ONREZA/feat/api-improvements

feat(api): API improvements — pagination, bulk ops, comments, members, archival

authored by

Andrej and committed by
GitHub
d86c1e9e 3218979b

+962 -54
+14
apps/api/drizzle/0015_add_comment_and_archival.sql
··· 1 + CREATE TABLE "comment" ( 2 + "id" text PRIMARY KEY NOT NULL, 3 + "task_id" text NOT NULL, 4 + "user_id" text NOT NULL, 5 + "content" text NOT NULL, 6 + "created_at" timestamp DEFAULT now() NOT NULL, 7 + "updated_at" timestamp DEFAULT now() NOT NULL 8 + ); 9 + --> statement-breakpoint 10 + ALTER TABLE "project" ADD COLUMN "archived_at" timestamp;--> statement-breakpoint 11 + ALTER TABLE "comment" ADD CONSTRAINT "comment_task_id_task_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."task"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint 12 + ALTER TABLE "comment" ADD CONSTRAINT "comment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint 13 + CREATE INDEX "comment_task_idx" ON "comment" USING btree ("task_id");--> statement-breakpoint 14 + CREATE INDEX "comment_user_idx" ON "comment" USING btree ("user_id");
+7
apps/api/drizzle/meta/_journal.json
··· 106 106 "when": 1773259300000, 107 107 "tag": "0014_private_assets", 108 108 "breakpoints": true 109 + }, 110 + { 111 + "idx": 15, 112 + "version": "7", 113 + "when": 1773947411304, 114 + "tag": "0015_add_comment_and_archival", 115 + "breakpoints": true 109 116 } 110 117 ] 111 118 }
+22
apps/api/src/comment/controllers/create-comment.ts
··· 1 + import { HTTPException } from "hono/http-exception"; 2 + import db from "../../database"; 3 + import { commentTable } from "../../database/schema"; 4 + 5 + async function createComment(taskId: string, userId: string, content: string) { 6 + const [comment] = await db 7 + .insert(commentTable) 8 + .values({ 9 + taskId, 10 + userId, 11 + content, 12 + }) 13 + .returning(); 14 + 15 + if (!comment) { 16 + throw new HTTPException(500, { message: "Failed to create comment" }); 17 + } 18 + 19 + return comment; 20 + } 21 + 22 + export default createComment;
+35
apps/api/src/comment/controllers/delete-comment.ts
··· 1 + import { eq } from "drizzle-orm"; 2 + import { HTTPException } from "hono/http-exception"; 3 + import db from "../../database"; 4 + import { commentTable } from "../../database/schema"; 5 + 6 + async function deleteComment(userId: string, id: string) { 7 + const [existing] = await db 8 + .select({ userId: commentTable.userId }) 9 + .from(commentTable) 10 + .where(eq(commentTable.id, id)) 11 + .limit(1); 12 + 13 + if (!existing) { 14 + throw new HTTPException(404, { message: "Comment not found" }); 15 + } 16 + 17 + if (existing.userId !== userId) { 18 + throw new HTTPException(403, { 19 + message: "Only the author can delete this comment", 20 + }); 21 + } 22 + 23 + const [deleted] = await db 24 + .delete(commentTable) 25 + .where(eq(commentTable.id, id)) 26 + .returning(); 27 + 28 + if (!deleted) { 29 + throw new HTTPException(500, { message: "Failed to delete comment" }); 30 + } 31 + 32 + return deleted; 33 + } 34 + 35 + export default deleteComment;
+36
apps/api/src/comment/controllers/get-comments.ts
··· 1 + import { asc, eq } from "drizzle-orm"; 2 + import db from "../../database"; 3 + import { commentTable, userTable } from "../../database/schema"; 4 + 5 + async function getComments(taskId: string) { 6 + const comments = await db 7 + .select({ 8 + id: commentTable.id, 9 + taskId: commentTable.taskId, 10 + userId: commentTable.userId, 11 + content: commentTable.content, 12 + createdAt: commentTable.createdAt, 13 + updatedAt: commentTable.updatedAt, 14 + userName: userTable.name, 15 + userImage: userTable.image, 16 + }) 17 + .from(commentTable) 18 + .leftJoin(userTable, eq(commentTable.userId, userTable.id)) 19 + .where(eq(commentTable.taskId, taskId)) 20 + .orderBy(asc(commentTable.createdAt)); 21 + 22 + return comments.map((c) => ({ 23 + id: c.id, 24 + taskId: c.taskId, 25 + userId: c.userId, 26 + content: c.content, 27 + createdAt: c.createdAt, 28 + updatedAt: c.updatedAt, 29 + user: { 30 + name: c.userName ?? "", 31 + image: c.userImage, 32 + }, 33 + })); 34 + } 35 + 36 + export default getComments;
+36
apps/api/src/comment/controllers/update-comment.ts
··· 1 + import { eq } from "drizzle-orm"; 2 + import { HTTPException } from "hono/http-exception"; 3 + import db from "../../database"; 4 + import { commentTable } from "../../database/schema"; 5 + 6 + async function updateComment(userId: string, id: string, content: string) { 7 + const [existing] = await db 8 + .select({ userId: commentTable.userId }) 9 + .from(commentTable) 10 + .where(eq(commentTable.id, id)) 11 + .limit(1); 12 + 13 + if (!existing) { 14 + throw new HTTPException(404, { message: "Comment not found" }); 15 + } 16 + 17 + if (existing.userId !== userId) { 18 + throw new HTTPException(403, { 19 + message: "Only the author can edit this comment", 20 + }); 21 + } 22 + 23 + const [updated] = await db 24 + .update(commentTable) 25 + .set({ content }) 26 + .where(eq(commentTable.id, id)) 27 + .returning(); 28 + 29 + if (!updated) { 30 + throw new HTTPException(500, { message: "Failed to update comment" }); 31 + } 32 + 33 + return updated; 34 + } 35 + 36 + export default updateComment;
+124
apps/api/src/comment/index.ts
··· 1 + import { Hono } from "hono"; 2 + import { describeRoute, resolver, validator } from "hono-openapi"; 3 + import * as v from "valibot"; 4 + import { commentSchema } from "../schemas"; 5 + import { workspaceAccess } from "../utils/workspace-access-middleware"; 6 + import createComment from "./controllers/create-comment"; 7 + import deleteComment from "./controllers/delete-comment"; 8 + import getComments from "./controllers/get-comments"; 9 + import updateComment from "./controllers/update-comment"; 10 + 11 + const comment = new Hono<{ 12 + Variables: { 13 + userId: string; 14 + }; 15 + }>() 16 + .get( 17 + "/:taskId", 18 + describeRoute({ 19 + operationId: "getTaskComments", 20 + tags: ["Comments"], 21 + description: "Get all comments for a specific task", 22 + responses: { 23 + 200: { 24 + description: "List of comments for the task", 25 + content: { 26 + "application/json": { 27 + schema: resolver(v.array(commentSchema)), 28 + }, 29 + }, 30 + }, 31 + }, 32 + }), 33 + validator("param", v.object({ taskId: v.string() })), 34 + workspaceAccess.fromTaskId(), 35 + async (c) => { 36 + const { taskId } = c.req.valid("param"); 37 + const comments = await getComments(taskId); 38 + return c.json(comments); 39 + }, 40 + ) 41 + .post( 42 + "/:taskId", 43 + describeRoute({ 44 + operationId: "createTaskComment", 45 + tags: ["Comments"], 46 + description: "Create a new comment on a task", 47 + responses: { 48 + 200: { 49 + description: "Comment created successfully", 50 + content: { 51 + "application/json": { schema: resolver(commentSchema) }, 52 + }, 53 + }, 54 + }, 55 + }), 56 + validator("param", v.object({ taskId: v.string() })), 57 + validator( 58 + "json", 59 + v.object({ content: v.pipe(v.string(), v.minLength(1)) }), 60 + ), 61 + workspaceAccess.fromTaskId(), 62 + async (c) => { 63 + const { taskId } = c.req.valid("param"); 64 + const { content } = c.req.valid("json"); 65 + const userId = c.get("userId"); 66 + const newComment = await createComment(taskId, userId, content); 67 + return c.json(newComment); 68 + }, 69 + ) 70 + .put( 71 + "/:id", 72 + describeRoute({ 73 + operationId: "updateTaskComment", 74 + tags: ["Comments"], 75 + description: "Update an existing comment (author only)", 76 + responses: { 77 + 200: { 78 + description: "Comment updated successfully", 79 + content: { 80 + "application/json": { schema: resolver(commentSchema) }, 81 + }, 82 + }, 83 + }, 84 + }), 85 + validator("param", v.object({ id: v.string() })), 86 + validator( 87 + "json", 88 + v.object({ content: v.pipe(v.string(), v.minLength(1)) }), 89 + ), 90 + workspaceAccess.fromComment(), 91 + async (c) => { 92 + const { id } = c.req.valid("param"); 93 + const { content } = c.req.valid("json"); 94 + const userId = c.get("userId"); 95 + const updated = await updateComment(userId, id, content); 96 + return c.json(updated); 97 + }, 98 + ) 99 + .delete( 100 + "/:id", 101 + describeRoute({ 102 + operationId: "deleteTaskComment", 103 + tags: ["Comments"], 104 + description: "Delete a comment (author only)", 105 + responses: { 106 + 200: { 107 + description: "Comment deleted successfully", 108 + content: { 109 + "application/json": { schema: resolver(commentSchema) }, 110 + }, 111 + }, 112 + }, 113 + }), 114 + validator("param", v.object({ id: v.string() })), 115 + workspaceAccess.fromComment(), 116 + async (c) => { 117 + const { id } = c.req.valid("param"); 118 + const userId = c.get("userId"); 119 + const deleted = await deleteComment(userId, id); 120 + return c.json(deleted); 121 + }, 122 + ); 123 + 124 + export default comment;
+4
apps/api/src/database/index.ts
··· 7 7 apikeyTableRelations, 8 8 assetTableRelations, 9 9 columnTableRelations, 10 + commentTableRelations, 10 11 externalLinkTableRelations, 11 12 githubIntegrationTableRelations, 12 13 integrationTableRelations, ··· 31 32 apikeyTable, 32 33 assetTable, 33 34 columnTable, 35 + commentTable, 34 36 externalLinkTable, 35 37 githubIntegrationTable, 36 38 integrationTable, ··· 64 66 activityTable, 65 67 apikeyTable, 66 68 columnTable, 69 + commentTable, 67 70 externalLinkTable, 68 71 githubIntegrationTable, 69 72 integrationTable, ··· 86 89 activityTableRelations, 87 90 apikeyTableRelations, 88 91 columnTableRelations, 92 + commentTableRelations, 89 93 externalLinkTableRelations, 90 94 githubIntegrationTableRelations, 91 95 integrationTableRelations,
+14
apps/api/src/database/relations.ts
··· 5 5 apikeyTable, 6 6 assetTable, 7 7 columnTable, 8 + commentTable, 8 9 externalLinkTable, 9 10 githubIntegrationTable, 10 11 integrationTable, ··· 33 34 assignedTasks: many(taskTable), 34 35 timeEntries: many(timeEntryTable), 35 36 activities: many(activityTable), 37 + comments: many(commentTable), 36 38 assets: many(assetTable), 37 39 notifications: many(notificationTable), 38 40 sentInvitations: many(invitationTable), ··· 137 139 }), 138 140 timeEntries: many(timeEntryTable), 139 141 activities: many(activityTable), 142 + comments: many(commentTable), 140 143 assets: many(assetTable), 141 144 labels: many(labelTable), 142 145 externalLinks: many(externalLinkTable), ··· 281 284 }), 282 285 }), 283 286 ); 287 + 288 + export const commentTableRelations = relations(commentTable, ({ one }) => ({ 289 + task: one(taskTable, { 290 + fields: [commentTable.taskId], 291 + references: [taskTable.id], 292 + }), 293 + user: one(userTable, { 294 + fields: [commentTable.userId], 295 + references: [userTable.id], 296 + }), 297 + }));
+32
apps/api/src/database/schema.ts
··· 208 208 description: text("description"), 209 209 createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), 210 210 isPublic: boolean("is_public").default(false), 211 + archivedAt: timestamp("archived_at", { mode: "date" }), 211 212 }); 212 213 213 214 export const columnTable = pgTable( ··· 502 503 index("external_link_integrationId_idx").on(table.integrationId), 503 504 index("external_link_externalId_idx").on(table.externalId), 504 505 index("external_link_resourceType_idx").on(table.resourceType), 506 + ], 507 + ); 508 + 509 + export const commentTable = pgTable( 510 + "comment", 511 + { 512 + id: text("id") 513 + .$defaultFn(() => createId()) 514 + .primaryKey(), 515 + taskId: text("task_id") 516 + .notNull() 517 + .references(() => taskTable.id, { 518 + onDelete: "cascade", 519 + onUpdate: "cascade", 520 + }), 521 + userId: text("user_id") 522 + .notNull() 523 + .references(() => userTable.id, { 524 + onDelete: "cascade", 525 + onUpdate: "cascade", 526 + }), 527 + content: text("content").notNull(), 528 + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), 529 + updatedAt: timestamp("updated_at", { mode: "date" }) 530 + .defaultNow() 531 + .$onUpdate(() => new Date()) 532 + .notNull(), 533 + }, 534 + (table) => [ 535 + index("comment_task_idx").on(table.taskId), 536 + index("comment_user_idx").on(table.userId), 505 537 ], 506 538 ); 507 539
+6
apps/api/src/index.ts
··· 15 15 import activity from "./activity"; 16 16 import { auth } from "./auth"; 17 17 import column from "./column"; 18 + import comment from "./comment"; 18 19 import config from "./config"; 19 20 import db, { schema } from "./database"; 20 21 import externalLink from "./external-link"; ··· 49 50 import { validateWorkspaceAccess } from "./utils/validate-workspace-access"; 50 51 import { verifyApiKey } from "./utils/verify-api-key"; 51 52 import workflowRule from "./workflow-rule"; 53 + import workspace from "./workspace"; 52 54 53 55 type ApiKey = { 54 56 id: string; ··· 380 382 const taskApi = api.route("/task", task); 381 383 const columnApi = api.route("/column", column); 382 384 const activityApi = api.route("/activity", activity); 385 + const commentApi = api.route("/comment", comment); 383 386 const timeEntryApi = api.route("/time-entry", timeEntry); 384 387 const labelApi = api.route("/label", label); 385 388 const notificationApi = api.route("/notification", notification); ··· 391 394 const externalLinkApi = api.route("/external-link", externalLink); 392 395 const workflowRuleApi = api.route("/workflow-rule", workflowRule); 393 396 const invitationApi = api.route("/invitation", invitation); 397 + const workspaceApi = api.route("/workspace", workspace); 394 398 395 399 app.route("/api", api); 396 400 ··· 434 438 | typeof taskApi 435 439 | typeof columnApi 436 440 | typeof activityApi 441 + | typeof commentApi 437 442 | typeof timeEntryApi 438 443 | typeof labelApi 439 444 | typeof notificationApi ··· 442 447 | typeof externalLinkApi 443 448 | typeof workflowRuleApi 444 449 | typeof invitationApi 450 + | typeof workspaceApi 445 451 | typeof publicProjectApi 446 452 | typeof invitationPublicApi; 447 453
+36
apps/api/src/project/controllers/archive-project.ts
··· 1 + import { and, eq } from "drizzle-orm"; 2 + import { HTTPException } from "hono/http-exception"; 3 + import db from "../../database"; 4 + import { projectTable } from "../../database/schema"; 5 + 6 + async function archiveProject(id: string, workspaceId: string) { 7 + const [existingProject] = await db 8 + .select() 9 + .from(projectTable) 10 + .where( 11 + and(eq(projectTable.id, id), eq(projectTable.workspaceId, workspaceId)), 12 + ); 13 + 14 + if (!existingProject) { 15 + throw new HTTPException(404, { 16 + message: 17 + "Project doesn't exist or doesn't belong to the specified workspace", 18 + }); 19 + } 20 + 21 + const [archivedProject] = await db 22 + .update(projectTable) 23 + .set({ archivedAt: new Date() }) 24 + .where(eq(projectTable.id, id)) 25 + .returning(); 26 + 27 + if (!archivedProject) { 28 + throw new HTTPException(500, { 29 + message: "Failed to archive project", 30 + }); 31 + } 32 + 33 + return archivedProject; 34 + } 35 + 36 + export default archiveProject;
+8 -3
apps/api/src/project/controllers/get-projects.ts
··· 1 - import { eq } from "drizzle-orm"; 1 + import { and, eq, isNull } from "drizzle-orm"; 2 2 import db from "../../database"; 3 3 import { projectTable } from "../../database/schema"; 4 4 5 - async function getProjects(workspaceId: string) { 5 + async function getProjects(workspaceId: string, includeArchived = false) { 6 6 const projects = await db.query.projectTable.findMany({ 7 - where: eq(projectTable.workspaceId, workspaceId), 7 + where: includeArchived 8 + ? eq(projectTable.workspaceId, workspaceId) 9 + : and( 10 + eq(projectTable.workspaceId, workspaceId), 11 + isNull(projectTable.archivedAt), 12 + ), 8 13 with: { 9 14 tasks: true, 10 15 },
+36
apps/api/src/project/controllers/unarchive-project.ts
··· 1 + import { and, eq } from "drizzle-orm"; 2 + import { HTTPException } from "hono/http-exception"; 3 + import db from "../../database"; 4 + import { projectTable } from "../../database/schema"; 5 + 6 + async function unarchiveProject(id: string, workspaceId: string) { 7 + const [existingProject] = await db 8 + .select() 9 + .from(projectTable) 10 + .where( 11 + and(eq(projectTable.id, id), eq(projectTable.workspaceId, workspaceId)), 12 + ); 13 + 14 + if (!existingProject) { 15 + throw new HTTPException(404, { 16 + message: 17 + "Project doesn't exist or doesn't belong to the specified workspace", 18 + }); 19 + } 20 + 21 + const [unarchivedProject] = await db 22 + .update(projectTable) 23 + .set({ archivedAt: null }) 24 + .where(eq(projectTable.id, id)) 25 + .returning(); 26 + 27 + if (!unarchivedProject) { 28 + throw new HTTPException(500, { 29 + message: "Failed to unarchive project", 30 + }); 31 + } 32 + 33 + return unarchivedProject; 34 + } 35 + 36 + export default unarchiveProject;
+62 -2
apps/api/src/project/index.ts
··· 3 3 import * as v from "valibot"; 4 4 import { projectSchema } from "../schemas"; 5 5 import { workspaceAccess } from "../utils/workspace-access-middleware"; 6 + import archiveProjectCtrl from "./controllers/archive-project"; 6 7 import createProjectCtrl from "./controllers/create-project"; 7 8 import deleteProjectCtrl from "./controllers/delete-project"; 8 9 import getProjectCtrl from "./controllers/get-project"; 9 10 import getProjectsCtrl from "./controllers/get-projects"; 11 + import unarchiveProjectCtrl from "./controllers/unarchive-project"; 10 12 import updateProjectCtrl from "./controllers/update-project"; 11 13 12 14 const project = new Hono<{ ··· 30 32 }, 31 33 }, 32 34 }), 33 - validator("query", v.object({ workspaceId: v.string() })), 35 + validator( 36 + "query", 37 + v.object({ 38 + workspaceId: v.string(), 39 + includeArchived: v.optional(v.string()), 40 + }), 41 + ), 34 42 workspaceAccess.fromQuery(), 35 43 async (c) => { 36 44 const workspaceId = c.get("workspaceId"); 37 - const projects = await getProjectsCtrl(workspaceId); 45 + const { includeArchived } = c.req.valid("query"); 46 + const projects = await getProjectsCtrl( 47 + workspaceId, 48 + includeArchived === "true", 49 + ); 38 50 return c.json(projects); 39 51 }, 40 52 ) ··· 159 171 const workspaceId = c.get("workspaceId"); 160 172 const deletedProject = await deleteProjectCtrl(id, workspaceId); 161 173 return c.json(deletedProject); 174 + }, 175 + ) 176 + .put( 177 + "/:id/archive", 178 + describeRoute({ 179 + operationId: "archiveProject", 180 + tags: ["Projects"], 181 + description: "Archive a project by ID", 182 + responses: { 183 + 200: { 184 + description: "Project archived successfully", 185 + content: { 186 + "application/json": { schema: resolver(projectSchema) }, 187 + }, 188 + }, 189 + }, 190 + }), 191 + validator("param", v.object({ id: v.string() })), 192 + workspaceAccess.fromProject(), 193 + async (c) => { 194 + const { id } = c.req.valid("param"); 195 + const workspaceId = c.get("workspaceId"); 196 + const archivedProject = await archiveProjectCtrl(id, workspaceId); 197 + return c.json(archivedProject); 198 + }, 199 + ) 200 + .put( 201 + "/:id/unarchive", 202 + describeRoute({ 203 + operationId: "unarchiveProject", 204 + tags: ["Projects"], 205 + description: "Unarchive a project by ID", 206 + responses: { 207 + 200: { 208 + description: "Project unarchived successfully", 209 + content: { 210 + "application/json": { schema: resolver(projectSchema) }, 211 + }, 212 + }, 213 + }, 214 + }), 215 + validator("param", v.object({ id: v.string() })), 216 + workspaceAccess.fromProject(), 217 + async (c) => { 218 + const { id } = c.req.valid("param"); 219 + const workspaceId = c.get("workspaceId"); 220 + const unarchivedProject = await unarchiveProjectCtrl(id, workspaceId); 221 + return c.json(unarchivedProject); 162 222 }, 163 223 ); 164 224
+16
apps/api/src/schemas.ts
··· 18 18 description: v.nullable(v.string()), 19 19 createdAt: v.date(), 20 20 isPublic: v.nullable(v.boolean()), 21 + archivedAt: v.nullable(v.date()), 21 22 }); 22 23 23 24 export const taskSchema = v.object({ ··· 103 104 isActive: v.nullable(v.boolean()), 104 105 createdAt: v.date(), 105 106 updatedAt: v.date(), 107 + }); 108 + 109 + export const commentSchema = v.object({ 110 + id: v.string(), 111 + taskId: v.string(), 112 + userId: v.string(), 113 + content: v.string(), 114 + createdAt: v.date(), 115 + updatedAt: v.date(), 116 + user: v.optional( 117 + v.object({ 118 + name: v.string(), 119 + image: v.nullable(v.string()), 120 + }), 121 + ), 106 122 }); 107 123 108 124 export const configSchema = v.object({
+198
apps/api/src/task/controllers/bulk-update-tasks.ts
··· 1 + import { and, eq, inArray } from "drizzle-orm"; 2 + import { HTTPException } from "hono/http-exception"; 3 + import db from "../../database"; 4 + import { 5 + columnTable, 6 + labelTable, 7 + projectTable, 8 + taskTable, 9 + workspaceUserTable, 10 + } from "../../database/schema"; 11 + 12 + type BulkOperation = 13 + | "updateStatus" 14 + | "updatePriority" 15 + | "updateAssignee" 16 + | "delete" 17 + | "addLabel" 18 + | "removeLabel"; 19 + 20 + async function bulkUpdateTasks({ 21 + taskIds, 22 + operation, 23 + value, 24 + userId, 25 + }: { 26 + taskIds: string[]; 27 + operation: BulkOperation; 28 + value?: string | null; 29 + userId: string; 30 + }) { 31 + const tasks = await db 32 + .select({ 33 + id: taskTable.id, 34 + projectId: taskTable.projectId, 35 + workspaceId: projectTable.workspaceId, 36 + }) 37 + .from(taskTable) 38 + .innerJoin(projectTable, eq(taskTable.projectId, projectTable.id)) 39 + .where(inArray(taskTable.id, taskIds)); 40 + 41 + if (tasks.length === 0) { 42 + throw new HTTPException(404, { 43 + message: "No tasks found", 44 + }); 45 + } 46 + 47 + const workspaceIds = [...new Set(tasks.map((t) => t.workspaceId))]; 48 + 49 + if (workspaceIds.length > 1) { 50 + throw new HTTPException(400, { 51 + message: "All tasks must belong to the same workspace", 52 + }); 53 + } 54 + 55 + const workspaceId = workspaceIds[0]; 56 + 57 + if (!workspaceId) { 58 + throw new HTTPException(400, { 59 + message: "Could not determine workspace", 60 + }); 61 + } 62 + 63 + const [membership] = await db 64 + .select({ id: workspaceUserTable.id }) 65 + .from(workspaceUserTable) 66 + .where( 67 + and( 68 + eq(workspaceUserTable.userId, userId), 69 + eq(workspaceUserTable.workspaceId, workspaceId), 70 + ), 71 + ) 72 + .limit(1); 73 + 74 + if (!membership) { 75 + throw new HTTPException(403, { 76 + message: "You don't have access to this workspace", 77 + }); 78 + } 79 + 80 + const foundIds = tasks.map((t) => t.id); 81 + let updatedCount = 0; 82 + 83 + switch (operation) { 84 + case "updateStatus": { 85 + if (!value) { 86 + throw new HTTPException(400, { message: "Status value is required" }); 87 + } 88 + const projectIds = [...new Set(tasks.map((t) => t.projectId))]; 89 + 90 + for (const projectId of projectIds) { 91 + const column = await db.query.columnTable.findFirst({ 92 + where: and( 93 + eq(columnTable.projectId, projectId), 94 + eq(columnTable.slug, value), 95 + ), 96 + }); 97 + 98 + const projectTaskIds = tasks 99 + .filter((t) => t.projectId === projectId) 100 + .map((t) => t.id); 101 + 102 + const result = await db 103 + .update(taskTable) 104 + .set({ status: value, columnId: column?.id ?? null }) 105 + .where(inArray(taskTable.id, projectTaskIds)); 106 + 107 + updatedCount += result.rowCount ?? projectTaskIds.length; 108 + } 109 + break; 110 + } 111 + 112 + case "updatePriority": { 113 + if (!value) { 114 + throw new HTTPException(400, { message: "Priority value is required" }); 115 + } 116 + const result = await db 117 + .update(taskTable) 118 + .set({ priority: value }) 119 + .where(inArray(taskTable.id, foundIds)); 120 + 121 + updatedCount = result.rowCount ?? foundIds.length; 122 + break; 123 + } 124 + 125 + case "updateAssignee": { 126 + const result = await db 127 + .update(taskTable) 128 + .set({ userId: value || null }) 129 + .where(inArray(taskTable.id, foundIds)); 130 + 131 + updatedCount = result.rowCount ?? foundIds.length; 132 + break; 133 + } 134 + 135 + case "delete": { 136 + const result = await db 137 + .delete(taskTable) 138 + .where(inArray(taskTable.id, foundIds)); 139 + 140 + updatedCount = result.rowCount ?? foundIds.length; 141 + break; 142 + } 143 + 144 + case "addLabel": { 145 + if (!value) { 146 + throw new HTTPException(400, { message: "Label ID is required" }); 147 + } 148 + 149 + const label = await db.query.labelTable.findFirst({ 150 + where: eq(labelTable.id, value), 151 + }); 152 + 153 + if (!label) { 154 + throw new HTTPException(404, { message: "Label not found" }); 155 + } 156 + 157 + for (const task of tasks) { 158 + const existingAssignment = await db.query.labelTable.findFirst({ 159 + where: and( 160 + eq(labelTable.name, label.name), 161 + eq(labelTable.color, label.color), 162 + eq(labelTable.taskId, task.id), 163 + ), 164 + }); 165 + 166 + if (!existingAssignment) { 167 + await db.insert(labelTable).values({ 168 + name: label.name, 169 + color: label.color, 170 + workspaceId: workspaceId, 171 + taskId: task.id, 172 + }); 173 + updatedCount++; 174 + } 175 + } 176 + break; 177 + } 178 + 179 + case "removeLabel": { 180 + if (!value) { 181 + throw new HTTPException(400, { message: "Label ID is required" }); 182 + } 183 + const result = await db 184 + .update(labelTable) 185 + .set({ taskId: null }) 186 + .where( 187 + and(eq(labelTable.id, value), inArray(labelTable.taskId, foundIds)), 188 + ); 189 + 190 + updatedCount = result.rowCount ?? foundIds.length; 191 + break; 192 + } 193 + } 194 + 195 + return { success: true, updatedCount }; 196 + } 197 + 198 + export default bulkUpdateTasks;
+96 -41
apps/api/src/task/controllers/get-tasks.ts
··· 1 - import { and, asc, eq, inArray, sql } from "drizzle-orm"; 1 + import { 2 + and, 3 + asc, 4 + desc, 5 + eq, 6 + gte, 7 + inArray, 8 + lte, 9 + type SQL, 10 + sql, 11 + } from "drizzle-orm"; 2 12 import { HTTPException } from "hono/http-exception"; 3 13 import db from "../../database"; 4 14 import { ··· 12 22 13 23 type GetTasksOptions = { 14 24 assigneeId?: string; 25 + dueAfter?: string; 26 + dueBefore?: string; 15 27 limit?: number; 16 28 page?: number; 17 29 priority?: string; 30 + sortBy?: 31 + | "createdAt" 32 + | "priority" 33 + | "dueDate" 34 + | "position" 35 + | "title" 36 + | "number"; 37 + sortOrder?: "asc" | "desc"; 18 38 status?: string; 19 39 }; 20 40 41 + const priorityCaseExpr = sql<number>`CASE 42 + WHEN ${taskTable.priority} = 'urgent' THEN 4 43 + WHEN ${taskTable.priority} = 'high' THEN 3 44 + WHEN ${taskTable.priority} = 'medium' THEN 2 45 + WHEN ${taskTable.priority} = 'low' THEN 1 46 + ELSE 0 47 + END`; 48 + 49 + function buildOrderBy( 50 + sortBy: GetTasksOptions["sortBy"], 51 + sortOrder: GetTasksOptions["sortOrder"], 52 + ): SQL { 53 + const direction = sortOrder === "desc" ? desc : asc; 54 + 55 + switch (sortBy) { 56 + case "createdAt": 57 + return direction(taskTable.createdAt); 58 + case "priority": 59 + return direction(priorityCaseExpr); 60 + case "dueDate": 61 + return direction(taskTable.dueDate); 62 + case "title": 63 + return direction(taskTable.title); 64 + case "number": 65 + return direction(taskTable.number); 66 + default: 67 + return direction(taskTable.position); 68 + } 69 + } 70 + 21 71 async function getTasks(projectId: string, options: GetTasksOptions = {}) { 22 72 const project = await db.query.projectTable.findFirst({ 23 73 where: eq(projectTable.id, projectId), ··· 43 93 conditions.push(eq(taskTable.userId, options.assigneeId)); 44 94 } 45 95 96 + if (options.dueBefore) { 97 + conditions.push(lte(taskTable.dueDate, new Date(options.dueBefore))); 98 + } 99 + 100 + if (options.dueAfter) { 101 + conditions.push(gte(taskTable.dueDate, new Date(options.dueAfter))); 102 + } 103 + 46 104 const whereClause = and(...conditions); 47 105 const page = options.page && options.page > 0 ? options.page : 1; 48 - const limit = 49 - options.limit && options.limit > 0 ? Math.min(options.limit, 100) : null; 50 - const offset = limit ? (page - 1) * limit : 0; 106 + const pageSize = 107 + options.limit && options.limit > 0 ? Math.min(options.limit, 100) : 50; 108 + const offset = (page - 1) * pageSize; 109 + 110 + const orderByClause = buildOrderBy( 111 + options.sortBy ?? "position", 112 + options.sortOrder ?? "asc", 113 + ); 51 114 52 115 const [taskCount] = await db 53 116 .select({ count: sql<number>`count(*)` }) 54 117 .from(taskTable) 55 118 .where(whereClause); 119 + 120 + const total = Number(taskCount?.count ?? 0); 56 121 57 122 const taskSelection = { 58 123 id: taskTable.id, ··· 71 136 projectId: taskTable.projectId, 72 137 }; 73 138 74 - const paginatedTasks = limit 75 - ? await db 76 - .select(taskSelection) 77 - .from(taskTable) 78 - .leftJoin(userTable, eq(taskTable.userId, userTable.id)) 79 - .leftJoin(projectTable, eq(taskTable.projectId, projectTable.id)) 80 - .where(whereClause) 81 - .orderBy(taskTable.position) 82 - .limit(limit) 83 - .offset(offset) 84 - : await db 85 - .select(taskSelection) 86 - .from(taskTable) 87 - .leftJoin(userTable, eq(taskTable.userId, userTable.id)) 88 - .leftJoin(projectTable, eq(taskTable.projectId, projectTable.id)) 89 - .where(whereClause) 90 - .orderBy(taskTable.position); 139 + const paginatedTasks = await db 140 + .select(taskSelection) 141 + .from(taskTable) 142 + .leftJoin(userTable, eq(taskTable.userId, userTable.id)) 143 + .leftJoin(projectTable, eq(taskTable.projectId, projectTable.id)) 144 + .where(whereClause) 145 + .orderBy(orderByClause) 146 + .limit(pageSize) 147 + .offset(offset); 91 148 92 149 const taskIds = paginatedTasks.map((task) => task.id); 93 150 ··· 190 247 })); 191 248 192 249 return { 193 - id: project.id, 194 - name: project.name, 195 - slug: project.slug, 196 - icon: project.icon, 197 - description: project.description, 198 - isPublic: project.isPublic, 199 - workspaceId: project.workspaceId, 200 - columns, 201 - archivedTasks, 202 - plannedTasks, 203 - pagination: limit 204 - ? { 205 - page, 206 - limit, 207 - total: taskCount?.count ?? 0, 208 - totalPages: Math.max(1, Math.ceil((taskCount?.count ?? 0) / limit)), 209 - hasNextPage: offset + paginatedTasks.length < (taskCount?.count ?? 0), 210 - hasPreviousPage: page > 1, 211 - } 212 - : undefined, 250 + data: { 251 + id: project.id, 252 + name: project.name, 253 + slug: project.slug, 254 + icon: project.icon, 255 + description: project.description, 256 + isPublic: project.isPublic, 257 + workspaceId: project.workspaceId, 258 + columns, 259 + archivedTasks, 260 + plannedTasks, 261 + }, 262 + pagination: { 263 + total, 264 + page, 265 + pageSize, 266 + totalPages: Math.max(1, Math.ceil(total / pageSize)), 267 + }, 213 268 }; 214 269 } 215 270
+75
apps/api/src/task/index.ts
··· 21 21 validateTaskAssetUploadInput, 22 22 } from "../storage/s3"; 23 23 import { workspaceAccess } from "../utils/workspace-access-middleware"; 24 + import bulkUpdateTasks from "./controllers/bulk-update-tasks"; 24 25 import createTask from "./controllers/create-task"; 25 26 import deleteTask from "./controllers/delete-task"; 26 27 import exportTasks from "./controllers/export-tasks"; ··· 65 66 assigneeId: v.optional(v.string()), 66 67 page: v.optional(v.pipe(v.string(), v.transform(Number))), 67 68 limit: v.optional(v.pipe(v.string(), v.transform(Number))), 69 + sortBy: v.optional( 70 + v.picklist([ 71 + "createdAt", 72 + "priority", 73 + "dueDate", 74 + "position", 75 + "title", 76 + "number", 77 + ]), 78 + ), 79 + sortOrder: v.optional(v.picklist(["asc", "desc"])), 80 + dueBefore: v.optional(v.string()), 81 + dueAfter: v.optional(v.string()), 68 82 }), 69 83 ), 70 84 ), ··· 76 90 const tasks = await getTasks(projectId, filters); 77 91 78 92 return c.json(tasks); 93 + }, 94 + ) 95 + .patch( 96 + "/bulk", 97 + describeRoute({ 98 + operationId: "bulkUpdateTasks", 99 + tags: ["Tasks"], 100 + description: "Perform bulk operations on multiple tasks", 101 + responses: { 102 + 200: { 103 + description: "Bulk operation completed successfully", 104 + content: { 105 + "application/json": { 106 + schema: resolver( 107 + v.object({ 108 + success: v.boolean(), 109 + updatedCount: v.number(), 110 + }), 111 + ), 112 + }, 113 + }, 114 + }, 115 + }, 116 + }), 117 + validator( 118 + "json", 119 + v.object({ 120 + taskIds: v.pipe(v.array(v.string()), v.minLength(1)), 121 + operation: v.picklist([ 122 + "updateStatus", 123 + "updatePriority", 124 + "updateAssignee", 125 + "delete", 126 + "addLabel", 127 + "removeLabel", 128 + ] as const), 129 + value: v.optional(v.nullable(v.string())), 130 + }), 131 + ), 132 + async (c) => { 133 + const { taskIds, operation, value } = c.req.valid("json"); 134 + const userId = c.get("userId"); 135 + 136 + if (!userId) { 137 + throw new HTTPException(401, { message: "Unauthorized" }); 138 + } 139 + 140 + if (operation !== "delete" && value === undefined) { 141 + throw new HTTPException(400, { 142 + message: "Value is required for this operation", 143 + }); 144 + } 145 + 146 + const result = await bulkUpdateTasks({ 147 + taskIds, 148 + operation, 149 + value, 150 + userId, 151 + }); 152 + 153 + return c.json(result); 79 154 }, 80 155 ) 81 156 .post(
+29
apps/api/src/utils/workspace-access-middleware.ts
··· 16 16 | "label" 17 17 | "timeEntry" 18 18 | "activity" 19 + | "comment" 19 20 | "column" 20 21 | "workflowRule"; 21 22 idKey: string; ··· 85 86 | "label" 86 87 | "timeEntry" 87 88 | "activity" 89 + | "comment" 88 90 | "column" 89 91 | "workflowRule", 90 92 id: string, ··· 162 164 return activity?.workspaceId || null; 163 165 } 164 166 167 + case "comment": { 168 + const [comment] = await db 169 + .select({ 170 + workspaceId: schema.projectTable.workspaceId, 171 + }) 172 + .from(schema.commentTable) 173 + .innerJoin( 174 + schema.taskTable, 175 + eq(schema.commentTable.taskId, schema.taskTable.id), 176 + ) 177 + .innerJoin( 178 + schema.projectTable, 179 + eq(schema.taskTable.projectId, schema.projectTable.id), 180 + ) 181 + .where(eq(schema.commentTable.id, id)) 182 + .limit(1); 183 + return comment?.workspaceId || null; 184 + } 185 + 165 186 case "column": { 166 187 const [column] = await db 167 188 .select({ ··· 255 276 workspaceAccessMiddleware({ 256 277 sources: [ 257 278 { type: "lookup", resource: "activity", idKey }, 279 + { type: "query", key: "workspaceId" }, 280 + ], 281 + }), 282 + 283 + fromComment: (idKey = "id") => 284 + workspaceAccessMiddleware({ 285 + sources: [ 286 + { type: "lookup", resource: "comment", idKey }, 258 287 { type: "query", key: "workspaceId" }, 259 288 ], 260 289 }),
+21
apps/api/src/workspace/controllers/get-workspace-members.ts
··· 1 + import { eq } from "drizzle-orm"; 2 + import db from "../../database"; 3 + import { userTable, workspaceUserTable } from "../../database/schema"; 4 + 5 + async function getWorkspaceMembers(workspaceId: string) { 6 + const members = await db 7 + .select({ 8 + id: userTable.id, 9 + name: userTable.name, 10 + email: userTable.email, 11 + image: userTable.image, 12 + role: workspaceUserTable.role, 13 + }) 14 + .from(workspaceUserTable) 15 + .innerJoin(userTable, eq(workspaceUserTable.userId, userTable.id)) 16 + .where(eq(workspaceUserTable.workspaceId, workspaceId)); 17 + 18 + return members; 19 + } 20 + 21 + export default getWorkspaceMembers;
+48
apps/api/src/workspace/index.ts
··· 1 + import { Hono } from "hono"; 2 + import { describeRoute, resolver, validator } from "hono-openapi"; 3 + import * as v from "valibot"; 4 + import { workspaceAccess } from "../utils/workspace-access-middleware"; 5 + import getWorkspaceMembersCtrl from "./controllers/get-workspace-members"; 6 + 7 + const workspace = new Hono<{ 8 + Variables: { 9 + userId: string; 10 + workspaceId: string; 11 + }; 12 + }>().get( 13 + "/:workspaceId/members", 14 + describeRoute({ 15 + operationId: "getWorkspaceMembers", 16 + tags: ["Workspaces"], 17 + description: "Get all members of a workspace", 18 + responses: { 19 + 200: { 20 + description: "List of workspace members", 21 + content: { 22 + "application/json": { 23 + schema: resolver( 24 + v.array( 25 + v.object({ 26 + id: v.string(), 27 + name: v.string(), 28 + email: v.string(), 29 + image: v.nullable(v.string()), 30 + role: v.string(), 31 + }), 32 + ), 33 + ), 34 + }, 35 + }, 36 + }, 37 + }, 38 + }), 39 + validator("param", v.object({ workspaceId: v.string() })), 40 + workspaceAccess.fromParam("workspaceId"), 41 + async (c) => { 42 + const workspaceId = c.get("workspaceId"); 43 + const members = await getWorkspaceMembersCtrl(workspaceId); 44 + return c.json(members); 45 + }, 46 + ); 47 + 48 + export default workspace;
+2 -2
apps/web/src/fetchers/task/get-tasks.ts
··· 10 10 throw new Error(error); 11 11 } 12 12 13 - const data = await response.json(); 13 + const json = await response.json(); 14 14 15 - return data; 15 + return json.data; 16 16 } 17 17 18 18 export default getTasks;
+5 -6
apps/web/src/types/project/index.ts
··· 7 7 { id: string } 8 8 >; 9 9 10 - type ProjectWithTasksRaw = Extract< 11 - InferResponseType< 12 - (typeof client)["task"]["tasks"][":projectId"]["$get"], 13 - 200 14 - >, 15 - { id: string } 10 + type TasksApiResponse = InferResponseType< 11 + (typeof client)["task"]["tasks"][":projectId"]["$get"], 12 + 200 16 13 >; 14 + 15 + type ProjectWithTasksRaw = TasksApiResponse["data"]; 17 16 18 17 export type ProjectWithTasks = Omit< 19 18 ProjectWithTasksRaw,