diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts index 78dad0d..8571062 100644 --- a/apps/api/drizzle.config.ts +++ b/apps/api/drizzle.config.ts @@ -5,6 +5,6 @@ export default defineConfig({ schema: "./src/database/schema.ts", dialect: "sqlite", dbCredentials: { - url: "file:local.db", + url: "file:kaneo.db", }, }) satisfies Config; diff --git a/apps/api/drizzle/0000_abandoned_power_man.sql b/apps/api/drizzle/0000_powerful_magneto.sql similarity index 83% rename from apps/api/drizzle/0000_abandoned_power_man.sql rename to apps/api/drizzle/0000_powerful_magneto.sql index d57888d..f8b8508 100644 --- a/apps/api/drizzle/0000_abandoned_power_man.sql +++ b/apps/api/drizzle/0000_powerful_magneto.sql @@ -3,7 +3,7 @@ CREATE TABLE `project` ( `workspace_id` text NOT NULL, `name` text NOT NULL, `description` text, - `created_at` integer DEFAULT '"2025-01-18T14:59:51.091Z"' NOT NULL, + `created_at` integer DEFAULT '"2025-01-25T23:50:45.754Z"' NOT NULL, FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE cascade ON DELETE cascade ); --> statement-breakpoint @@ -21,8 +21,9 @@ CREATE TABLE `task` ( `title` text NOT NULL, `description` text, `status` text DEFAULT 'to-do' NOT NULL, + `priority` text DEFAULT 'low', `due_date` integer, - `created_at` integer DEFAULT '"2025-01-18T14:59:51.091Z"' NOT NULL, + `created_at` integer DEFAULT '"2025-01-25T23:50:45.754Z"' NOT NULL, FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON UPDATE cascade ON DELETE cascade, FOREIGN KEY (`assignee_id`) REFERENCES `user`(`id`) ON UPDATE cascade ON DELETE cascade ); @@ -32,7 +33,7 @@ CREATE TABLE `user` ( `name` text NOT NULL, `password` text NOT NULL, `email` text NOT NULL, - `created_at` integer DEFAULT '"2025-01-18T14:59:51.090Z"' NOT NULL + `created_at` integer DEFAULT '"2025-01-25T23:50:45.753Z"' NOT NULL ); --> statement-breakpoint CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint @@ -41,7 +42,7 @@ CREATE TABLE `workspace` ( `name` text NOT NULL, `description` text, `owner_id` text NOT NULL, - `created_at` integer DEFAULT '"2025-01-18T14:59:51.091Z"' NOT NULL, + `created_at` integer DEFAULT '"2025-01-25T23:50:45.754Z"' NOT NULL, FOREIGN KEY (`owner_id`) REFERENCES `user`(`id`) ON UPDATE cascade ON DELETE cascade ); --> statement-breakpoint @@ -50,7 +51,7 @@ CREATE TABLE `workspace_member` ( `workspace_id` text NOT NULL, `user_id` text NOT NULL, `role` text, - `joined_at` integer DEFAULT '"2025-01-18T14:59:51.091Z"' NOT NULL, + `joined_at` integer DEFAULT '"2025-01-25T23:50:45.754Z"' NOT NULL, FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE cascade ON DELETE cascade, FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE cascade ON DELETE cascade ); diff --git a/apps/api/drizzle/0001_mean_ultimo.sql b/apps/api/drizzle/0001_mean_ultimo.sql deleted file mode 100644 index 9e03e43..0000000 --- a/apps/api/drizzle/0001_mean_ultimo.sql +++ /dev/null @@ -1,67 +0,0 @@ -PRAGMA foreign_keys=OFF;--> statement-breakpoint -CREATE TABLE `__new_project` ( - `id` text PRIMARY KEY NOT NULL, - `workspace_id` text NOT NULL, - `name` text NOT NULL, - `description` text, - `created_at` integer DEFAULT '"2025-01-25T16:10:08.340Z"' NOT NULL, - FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE cascade ON DELETE cascade -); ---> statement-breakpoint -INSERT INTO `__new_project`("id", "workspace_id", "name", "description", "created_at") SELECT "id", "workspace_id", "name", "description", "created_at" FROM `project`;--> statement-breakpoint -DROP TABLE `project`;--> statement-breakpoint -ALTER TABLE `__new_project` RENAME TO `project`;--> statement-breakpoint -PRAGMA foreign_keys=ON;--> statement-breakpoint -CREATE TABLE `__new_task` ( - `id` text PRIMARY KEY NOT NULL, - `project_id` text NOT NULL, - `assignee_id` text NOT NULL, - `title` text NOT NULL, - `description` text, - `status` text DEFAULT 'to-do' NOT NULL, - `due_date` integer, - `created_at` integer DEFAULT '"2025-01-25T16:10:08.340Z"' NOT NULL, - FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON UPDATE cascade ON DELETE cascade, - FOREIGN KEY (`assignee_id`) REFERENCES `user`(`id`) ON UPDATE cascade ON DELETE cascade -); ---> statement-breakpoint -INSERT INTO `__new_task`("id", "project_id", "assignee_id", "title", "description", "status", "due_date", "created_at") SELECT "id", "project_id", "assignee_id", "title", "description", "status", "due_date", "created_at" FROM `task`;--> statement-breakpoint -DROP TABLE `task`;--> statement-breakpoint -ALTER TABLE `__new_task` RENAME TO `task`;--> statement-breakpoint -CREATE TABLE `__new_user` ( - `id` text PRIMARY KEY NOT NULL, - `name` text NOT NULL, - `password` text NOT NULL, - `email` text NOT NULL, - `created_at` integer DEFAULT '"2025-01-25T16:10:08.340Z"' NOT NULL -); ---> statement-breakpoint -INSERT INTO `__new_user`("id", "name", "password", "email", "created_at") SELECT "id", "name", "password", "email", "created_at" FROM `user`;--> statement-breakpoint -DROP TABLE `user`;--> statement-breakpoint -ALTER TABLE `__new_user` RENAME TO `user`;--> statement-breakpoint -CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint -CREATE TABLE `__new_workspace` ( - `id` text PRIMARY KEY NOT NULL, - `name` text NOT NULL, - `description` text, - `owner_id` text NOT NULL, - `created_at` integer DEFAULT '"2025-01-25T16:10:08.340Z"' NOT NULL, - FOREIGN KEY (`owner_id`) REFERENCES `user`(`id`) ON UPDATE cascade ON DELETE cascade -); ---> statement-breakpoint -INSERT INTO `__new_workspace`("id", "name", "description", "owner_id", "created_at") SELECT "id", "name", "description", "owner_id", "created_at" FROM `workspace`;--> statement-breakpoint -DROP TABLE `workspace`;--> statement-breakpoint -ALTER TABLE `__new_workspace` RENAME TO `workspace`;--> statement-breakpoint -CREATE TABLE `__new_workspace_member` ( - `id` text PRIMARY KEY NOT NULL, - `workspace_id` text NOT NULL, - `user_id` text NOT NULL, - `role` text, - `joined_at` integer DEFAULT '"2025-01-25T16:10:08.340Z"' NOT NULL, - FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE cascade ON DELETE cascade, - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE cascade ON DELETE cascade -); ---> statement-breakpoint -INSERT INTO `__new_workspace_member`("id", "workspace_id", "user_id", "role", "joined_at") SELECT "id", "workspace_id", "user_id", "role", "joined_at" FROM `workspace_member`;--> statement-breakpoint -DROP TABLE `workspace_member`;--> statement-breakpoint -ALTER TABLE `__new_workspace_member` RENAME TO `workspace_member`; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0000_snapshot.json b/apps/api/drizzle/meta/0000_snapshot.json index d06e6e5..6d2a10e 100644 --- a/apps/api/drizzle/meta/0000_snapshot.json +++ b/apps/api/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "c0af92e1-37ce-46b9-bd70-337eb6049763", + "id": "0bc2ad49-3384-4972-89cf-56af7f84a77f", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "project": { @@ -41,7 +41,7 @@ "primaryKey": false, "notNull": true, "autoincrement": false, - "default": "'\"2025-01-18T14:59:51.091Z\"'" + "default": "'\"2025-01-25T23:50:45.754Z\"'" } }, "indexes": {}, @@ -147,6 +147,14 @@ "autoincrement": false, "default": "'to-do'" }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'low'" + }, "due_date": { "name": "due_date", "type": "integer", @@ -160,7 +168,7 @@ "primaryKey": false, "notNull": true, "autoincrement": false, - "default": "'\"2025-01-18T14:59:51.091Z\"'" + "default": "'\"2025-01-25T23:50:45.754Z\"'" } }, "indexes": {}, @@ -225,7 +233,7 @@ "primaryKey": false, "notNull": true, "autoincrement": false, - "default": "'\"2025-01-18T14:59:51.090Z\"'" + "default": "'\"2025-01-25T23:50:45.753Z\"'" } }, "indexes": { @@ -277,7 +285,7 @@ "primaryKey": false, "notNull": true, "autoincrement": false, - "default": "'\"2025-01-18T14:59:51.091Z\"'" + "default": "'\"2025-01-25T23:50:45.754Z\"'" } }, "indexes": {}, @@ -333,7 +341,7 @@ "primaryKey": false, "notNull": true, "autoincrement": false, - "default": "'\"2025-01-18T14:59:51.091Z\"'" + "default": "'\"2025-01-25T23:50:45.754Z\"'" } }, "indexes": {}, diff --git a/apps/api/drizzle/meta/0001_snapshot.json b/apps/api/drizzle/meta/0001_snapshot.json deleted file mode 100644 index 62d9ad2..0000000 --- a/apps/api/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,375 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "d77a137d-164b-48be-8ff7-bf1802961d15", - "prevId": "c0af92e1-37ce-46b9-bd70-337eb6049763", - "tables": { - "project": { - "name": "project", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'\"2025-01-25T16:10:08.340Z\"'" - } - }, - "indexes": {}, - "foreignKeys": { - "project_workspace_id_workspace_id_fk": { - "name": "project_workspace_id_workspace_id_fk", - "tableFrom": "project", - "tableTo": "workspace", - "columnsFrom": ["workspace_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "session": { - "name": "session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "task": { - "name": "task", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "assignee_id": { - "name": "assignee_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'to-do'" - }, - "due_date": { - "name": "due_date", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'\"2025-01-25T16:10:08.340Z\"'" - } - }, - "indexes": {}, - "foreignKeys": { - "task_project_id_project_id_fk": { - "name": "task_project_id_project_id_fk", - "tableFrom": "task", - "tableTo": "project", - "columnsFrom": ["project_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - }, - "task_assignee_id_user_id_fk": { - "name": "task_assignee_id_user_id_fk", - "tableFrom": "task", - "tableTo": "user", - "columnsFrom": ["assignee_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "user": { - "name": "user", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'\"2025-01-25T16:10:08.340Z\"'" - } - }, - "indexes": { - "user_email_unique": { - "name": "user_email_unique", - "columns": ["email"], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "workspace": { - "name": "workspace", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "owner_id": { - "name": "owner_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'\"2025-01-25T16:10:08.340Z\"'" - } - }, - "indexes": {}, - "foreignKeys": { - "workspace_owner_id_user_id_fk": { - "name": "workspace_owner_id_user_id_fk", - "tableFrom": "workspace", - "tableTo": "user", - "columnsFrom": ["owner_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "workspace_member": { - "name": "workspace_member", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "joined_at": { - "name": "joined_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'\"2025-01-25T16:10:08.340Z\"'" - } - }, - "indexes": {}, - "foreignKeys": { - "workspace_member_workspace_id_workspace_id_fk": { - "name": "workspace_member_workspace_id_workspace_id_fk", - "tableFrom": "workspace_member", - "tableTo": "workspace", - "columnsFrom": ["workspace_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - }, - "workspace_member_user_id_user_id_fk": { - "name": "workspace_member_user_id_user_id_fk", - "tableFrom": "workspace_member", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index b70cf2f..8114680 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -5,15 +5,8 @@ { "idx": 0, "version": "6", - "when": 1737212391097, - "tag": "0000_abandoned_power_man", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1737821408351, - "tag": "0001_mean_ultimo", + "when": 1737849045760, + "tag": "0000_powerful_magneto", "breakpoints": true } ] diff --git a/apps/api/package.json b/apps/api/package.json index 4c9f808..e80e00c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,6 +7,7 @@ "dev": "bun run --watch src/index.ts" }, "dependencies": { + "@bogeychan/elysia-logger": "^0.1.7", "@elysiajs/cors": "^1.2.0", "@elysiajs/jwt": "^1.2.0", "@elysiajs/websocket": "^0.2.8", diff --git a/apps/api/src/database/schema.ts b/apps/api/src/database/schema.ts index 4fe9ddd..f9af7b3 100644 --- a/apps/api/src/database/schema.ts +++ b/apps/api/src/database/schema.ts @@ -101,6 +101,7 @@ export const taskTable = sqliteTable("task", { title: text("title").notNull(), description: text("description"), status: text("status").notNull().default("to-do"), + priority: text("priority").default("low"), dueDate: integer("due_date", { mode: "timestamp" }), createdAt: integer("created_at", { mode: "timestamp" }) .default(new Date()) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 94e627c..4553ed8 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,3 +1,4 @@ +import { logger } from "@bogeychan/elysia-logger"; import { cors } from "@elysiajs/cors"; import { Elysia } from "elysia"; import project from "./project"; @@ -5,10 +6,12 @@ import task from "./task"; import user from "./user"; import { validateSessionToken } from "./user/controllers/validate-session-token"; import workspace from "./workspace"; +import workspaceUser from "./workspace-user"; const app = new Elysia() .state("userId", "") .use(cors()) + .use(logger()) .use(user) .guard({ async beforeHandle({ store, cookie: { session } }) { @@ -39,6 +42,7 @@ const app = new Elysia() .use(workspace) .use(project) .use(task) + .use(workspaceUser) .onError(({ code, error }) => { switch (code) { case "VALIDATION": diff --git a/apps/api/src/project/controllers/delete-project.ts b/apps/api/src/project/controllers/delete-project.ts index 6711435..4d56af0 100644 --- a/apps/api/src/project/controllers/delete-project.ts +++ b/apps/api/src/project/controllers/delete-project.ts @@ -17,7 +17,7 @@ async function deleteProject({ const isProjectExisting = Boolean(existingProject); if (!isProjectExisting) { - throw new Error("TODO"); + throw new Error("Project doesn't exist"); } const [deletedProject] = await db diff --git a/apps/api/src/project/controllers/update-project.ts b/apps/api/src/project/controllers/update-project.ts index 8ed7851..0a11558 100644 --- a/apps/api/src/project/controllers/update-project.ts +++ b/apps/api/src/project/controllers/update-project.ts @@ -19,7 +19,7 @@ async function updateProject({ const isProjectExisting = Boolean(existingProject); if (!isProjectExisting) { - throw new Error("TODO"); + throw new Error("Project doesn't exist"); } const [updatedWorkspace] = await db diff --git a/apps/api/src/project/index.ts b/apps/api/src/project/index.ts index 389a36a..d1c203f 100644 --- a/apps/api/src/project/index.ts +++ b/apps/api/src/project/index.ts @@ -28,7 +28,7 @@ const project = new Elysia({ prefix: "/project" }) return projects; }) .get("/:id", async ({ params: { id }, query: { workspaceId } }) => { - if (!workspaceId) throw new Error("TODO"); + if (!workspaceId) throw new Error("Workspace ID is required"); const project = await getProject({ id, workspaceId }); @@ -51,7 +51,7 @@ const project = new Elysia({ prefix: "/project" }) }, ) .delete("/:id", async ({ params: { id }, query: { workspaceId } }) => { - if (!workspaceId) throw new Error("TODO"); + if (!workspaceId) throw new Error("Workspace ID is required"); const deletedProject = await deleteProject({ id, workspaceId }); diff --git a/apps/api/src/task/controllers/create-task.ts b/apps/api/src/task/controllers/create-task.ts new file mode 100644 index 0000000..e94fca1 --- /dev/null +++ b/apps/api/src/task/controllers/create-task.ts @@ -0,0 +1,31 @@ +import { eq } from "drizzle-orm"; +import db from "../../database"; +import { taskTable, userTable } from "../../database/schema"; + +async function createTask(body: { + projectId: string; + assigneeId: string; + title: string; + status: string; + dueDate: Date | null; + description: string; + priority: string; +}) { + const [assignee] = await db + .select({ name: userTable.name }) + .from(userTable) + .where(eq(userTable.id, body.assigneeId)); + + if (!assignee) { + throw new Error("Assignee not found"); + } + + const [createdTask] = await db.insert(taskTable).values(body).returning(); + + return { + ...createdTask, + assigneeName: assignee.name, + }; +} + +export default createTask; diff --git a/apps/api/src/task/controllers/get-tasks.ts b/apps/api/src/task/controllers/get-tasks.ts new file mode 100644 index 0000000..da17aed --- /dev/null +++ b/apps/api/src/task/controllers/get-tasks.ts @@ -0,0 +1,56 @@ +import { eq } from "drizzle-orm"; +import db from "../../database"; +import { projectTable, taskTable, userTable } from "../../database/schema"; + +const DEFAULT_COLUMNS = [ + { id: "to-do", name: "To Do" }, + { id: "in-progress", name: "In Progress" }, + { id: "in-review", name: "In Review" }, + { id: "done", name: "Done" }, +] as const; + +async function getTasks(projectId: string) { + const project = await db.query.projectTable.findFirst({ + where: eq(projectTable.id, projectId), + }); + + if (!project) { + throw new Error("Project not found"); + } + + const tasks = await db + .select({ + id: taskTable.id, + title: taskTable.title, + description: taskTable.description, + status: taskTable.status, + priority: taskTable.priority, + dueDate: taskTable.dueDate, + createdAt: taskTable.createdAt, + assigneeId: taskTable.assigneeId, + assigneeName: userTable.name, + assigneeEmail: userTable.email, + }) + .from(taskTable) + .leftJoin(userTable, eq(taskTable.assigneeId, userTable.id)) + .where(eq(taskTable.projectId, projectId)); + + const columns = DEFAULT_COLUMNS.map((column) => ({ + id: column.id, + name: column.name, + tasks: tasks + .filter((task) => task.status === column.id) + .map((task) => ({ + ...task, + })), + })); + + return { + id: project.id, + name: project.name, + workspaceId: project.workspaceId, + columns, + }; +} + +export default getTasks; diff --git a/apps/api/src/task/controllers/update-task-status.ts b/apps/api/src/task/controllers/update-task-status.ts new file mode 100644 index 0000000..365eded --- /dev/null +++ b/apps/api/src/task/controllers/update-task-status.ts @@ -0,0 +1,15 @@ +import { eq } from "drizzle-orm"; +import db from "../../database"; +import { taskTable } from "../../database/schema"; + +async function updateTaskStatus({ + id, + status, +}: { id: string; status: string }) { + await db + .update(taskTable) + .set({ status: status }) + .where(eq(taskTable.id, id)); +} + +export default updateTaskStatus; diff --git a/apps/api/src/task/controllers/update-task.ts b/apps/api/src/task/controllers/update-task.ts new file mode 100644 index 0000000..46c880b --- /dev/null +++ b/apps/api/src/task/controllers/update-task.ts @@ -0,0 +1,46 @@ +import { and, eq } from "drizzle-orm"; +import db from "../../database"; +import { taskTable } from "../../database/schema"; + +async function updateTask({ + id, + projectId, + title, + status, + dueDate, + description, +}: { + id: string; + projectId: string; + assigneeId: string; + title: string; + status: string; + dueDate: Date; + description: string; +}) { + const [existingTask] = await db + .select() + .from(taskTable) + .where(and(eq(taskTable.id, id), eq(taskTable.projectId, projectId))); + + const isTaskExisting = Boolean(existingTask); + + if (!isTaskExisting) { + throw new Error("Task doesn't exist"); + } + + const [updatedWorkspace] = await db + .update(taskTable) + .set({ + title, + description, + status, + dueDate, + }) + .where(and(eq(taskTable.id, id), eq(taskTable.projectId, projectId))) + .returning(); + + return updatedWorkspace; +} + +export default updateTask; diff --git a/apps/api/src/task/index.ts b/apps/api/src/task/index.ts index ac5e1f0..2653c6e 100644 --- a/apps/api/src/task/index.ts +++ b/apps/api/src/task/index.ts @@ -1,9 +1,86 @@ -import Elysia from "elysia"; +import Elysia, { t } from "elysia"; -const task = new Elysia({ prefix: "/task" }).ws("/", { - message(ws, message) { - ws.send(message); - }, -}); +import createTask from "./controllers/create-task"; +import getTasks from "./controllers/get-tasks"; +import updateTaskStatus from "./controllers/update-task-status"; + +const connections = new Map(); + +const task = new Elysia({ prefix: "/task" }) + .post( + "/create", + async ({ body }) => { + const createdTask = await createTask(body); + + return createdTask; + }, + { + body: t.Object({ + projectId: t.String(), + assigneeId: t.String(), + title: t.String(), + status: t.String(), + dueDate: t.Date(), + description: t.String(), + priority: t.String(), + }), + }, + ) + .ws("/ws/:projectId", { + async open(ws) { + const projectId = ws.data.params.projectId; + + if (!connections.has(projectId)) { + connections.set(projectId, new Set()); + } + + connections.get(projectId).add(ws); + + const boardState = await getTasks(projectId); + ws.send(boardState); + }, + async message( + ws, + message: { + type: string; + id: string; + status: string; + }, + ) { + const projectId = ws.data.params.projectId; + + const { type, id, status } = message; + + if (type === "UPDATE_TASK") { + await updateTaskStatus({ + id, + status, + }); + + const clients = connections.get(projectId); + const boardState = await getTasks(projectId); + + if (clients) { + for (const client of clients) { + client.send(boardState); + } + } + } + }, + close(ws) { + const projectId = ws.data.params.projectId; + + const clients = connections.get(projectId); + if (clients) { + clients.delete(ws); + + if (clients.size === 0) { + connections.delete(projectId); + } + } + + console.log(`Client disconnected from project ${projectId}`); + }, + }); export default task; diff --git a/apps/api/src/workspace-user/controllers/get-workspace-users.ts b/apps/api/src/workspace-user/controllers/get-workspace-users.ts new file mode 100644 index 0000000..8f51f20 --- /dev/null +++ b/apps/api/src/workspace-user/controllers/get-workspace-users.ts @@ -0,0 +1,34 @@ +import { eq } from "drizzle-orm"; +import db from "../../database"; +import { + userTable, + workspaceTable, + workspaceUserTable, +} from "../../database/schema"; + +function getWorkspaceUsers({ workspaceId }: { workspaceId: string }) { + return db + .select({ + userId: userTable.id, + userName: userTable.name, + }) + .from(workspaceTable) + .where(eq(workspaceTable.id, workspaceId)) + .leftJoin( + workspaceUserTable, + eq(workspaceTable.id, workspaceUserTable.workspaceId), + ) + .leftJoin(userTable, eq(workspaceUserTable.userId, userTable.id)) + .unionAll( + db + .select({ + userId: userTable.id, + userName: userTable.name, + }) + .from(workspaceTable) + .leftJoin(userTable, eq(workspaceTable.ownerId, userTable.id)) + .where(eq(workspaceTable.id, workspaceId)), + ); +} + +export default getWorkspaceUsers; diff --git a/apps/api/src/workspace-user/index.ts b/apps/api/src/workspace-user/index.ts new file mode 100644 index 0000000..e84a1b6 --- /dev/null +++ b/apps/api/src/workspace-user/index.ts @@ -0,0 +1,13 @@ +import Elysia from "elysia"; +import getWorkspaceUsers from "./controllers/get-workspace-users"; + +const workspaceUser = new Elysia({ prefix: "/workspace-user" }).get( + "/list/:workspaceId", + async ({ params: { workspaceId } }) => { + const workspaceUsersInWorkspace = await getWorkspaceUsers({ workspaceId }); + + return workspaceUsersInWorkspace; + }, +); + +export default workspaceUser; diff --git a/apps/api/src/workspace/controllers/get-workspaces.ts b/apps/api/src/workspace/controllers/get-workspaces.ts index c876598..e4730a5 100644 --- a/apps/api/src/workspace/controllers/get-workspaces.ts +++ b/apps/api/src/workspace/controllers/get-workspaces.ts @@ -7,26 +7,37 @@ import { } from "../../database/schema"; async function getWorkspaces({ userId }: { userId: string }) { - const workspacesWithProjects = await db + const workspaces = await db .select({ id: workspaceTable.id, name: workspaceTable.name, ownerId: workspaceTable.ownerId, - projectId: projectTable.id, - projectName: projectTable.name, }) .from(workspaceTable) .leftJoin( workspaceUserTable, eq(workspaceTable.id, workspaceUserTable.workspaceId), ) - .leftJoin(projectTable, eq(workspaceTable.id, projectTable.workspaceId)) .where( or( eq(workspaceTable.ownerId, userId), eq(workspaceUserTable.userId, userId), ), - ); + ) + .groupBy(workspaceTable.id, workspaceTable.name, workspaceTable.ownerId); + + const workspacesWithProjects = await Promise.all( + workspaces.map(async (workspace) => ({ + ...workspace, + projects: await db + .select({ + id: projectTable.id, + name: projectTable.name, + }) + .from(projectTable) + .where(eq(projectTable.workspaceId, workspace.id)), + })), + ); return workspacesWithProjects; } diff --git a/apps/api/src/workspace/controllers/update-workspace.ts b/apps/api/src/workspace/controllers/update-workspace.ts index 82e3ff9..623fa95 100644 --- a/apps/api/src/workspace/controllers/update-workspace.ts +++ b/apps/api/src/workspace/controllers/update-workspace.ts @@ -25,7 +25,7 @@ async function updateWorkspace({ const isWorkspaceExisting = Boolean(existingWorkspace); if (!isWorkspaceExisting) { - throw new Error("TODO"); + throw new Error("Workspace doesn't exist"); } const updatedWorkspace = await db diff --git a/apps/web/package.json b/apps/web/package.json index 4e7b1ec..a6c8204 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,10 +29,12 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "framer-motion": "^12.0.1", + "immer": "^10.1.1", "lucide-react": "^0.474.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", + "react-use-websocket": "^4.11.1", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "zod": "3.24.1", diff --git a/apps/web/src/components/common/sidebar/sections/projects/add-project-modal.tsx b/apps/web/src/components/common/sidebar/sections/projects/create-project-modal.tsx similarity index 96% rename from apps/web/src/components/common/sidebar/sections/projects/add-project-modal.tsx rename to apps/web/src/components/common/sidebar/sections/projects/create-project-modal.tsx index 525d249..4220d84 100644 --- a/apps/web/src/components/common/sidebar/sections/projects/add-project-modal.tsx +++ b/apps/web/src/components/common/sidebar/sections/projects/create-project-modal.tsx @@ -7,12 +7,12 @@ import { useQueryClient } from "@tanstack/react-query"; import { X } from "lucide-react"; import { useState } from "react"; -type AddProjectModalProps = { +type CreateProjectModalProps = { open: boolean; onClose: () => void; }; -function AddProjectModal({ open, onClose }: AddProjectModalProps) { +function CreateProjectModal({ open, onClose }: CreateProjectModalProps) { const [name, setName] = useState(""); const [description, setDescription] = useState(""); const queryClient = useQueryClient(); @@ -108,4 +108,4 @@ function AddProjectModal({ open, onClose }: AddProjectModalProps) { ); } -export default AddProjectModal; +export default CreateProjectModal; diff --git a/apps/web/src/components/common/sidebar/sections/projects/empty-project-state.tsx b/apps/web/src/components/common/sidebar/sections/projects/empty-project-state.tsx new file mode 100644 index 0000000..323e316 --- /dev/null +++ b/apps/web/src/components/common/sidebar/sections/projects/empty-project-state.tsx @@ -0,0 +1,105 @@ +import { Button } from "@/components/ui/button"; +import useGetWorkspaces from "@/hooks/queries/workspace/use-get-workspace"; +import { motion } from "framer-motion"; +import { Layout, Plus } from "lucide-react"; +import { useState } from "react"; +import { CreateWorkspaceModal } from "../workspaces/components/create-workspace-modal"; +import CreateProjectModal from "./create-project-modal"; + +export function BoardEmptyState() { + const { data: workspaces } = useGetWorkspaces(); + const [isCreateWorkspaceOpen, setIsCreateWorkspaceOpen] = useState(false); + const [isCreateProjectOpen, setIsCreateProjectOpen] = useState(false); + + return ( +
+ +
+
+
+
+ +
+
+ +

+ {workspaces?.data && workspaces?.data.length === 0 + ? "No Workspace Selected" + : "No Project Selected"} +

+ +

+ {workspaces?.data && workspaces?.data.length === 0 + ? "Get started by creating your first workspace and organizing your projects." + : "Select a project from the sidebar or create a new one to get started."} +

+ +
+ {workspaces?.data && workspaces?.data.length === 0 ? ( + + ) : ( + <> + + + )} +
+ +
+

+ Quick Tips +

+
+
+

+ + Create workspaces + {" "} + to organize different areas of work +

+
+
+

+ + Add projects + {" "} + to track specific initiatives or goals +

+
+
+

+ + Invite team members + {" "} + to collaborate on tasks together +

+
+
+
+ + setIsCreateWorkspaceOpen(false)} + /> + setIsCreateProjectOpen(false)} + /> +
+ ); +} diff --git a/apps/web/src/components/common/sidebar/sections/projects/project-item.tsx b/apps/web/src/components/common/sidebar/sections/projects/project-item.tsx index 6e3884b..7d8e037 100644 --- a/apps/web/src/components/common/sidebar/sections/projects/project-item.tsx +++ b/apps/web/src/components/common/sidebar/sections/projects/project-item.tsx @@ -1,5 +1,5 @@ import useGetProject from "@/hooks/queries/project/use-get-project"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; import useProjectStore from "@/store/project"; import { Layout } from "lucide-react"; import { useEffect } from "react"; @@ -21,10 +21,16 @@ function ProjectItem({ workspaceId, projectId }: ProjectItemProps) { } }, [data, setProject, isSelected]); + const handleSelectProject = () => { + if (!isSelected) { + setProject(data); + } + }; + return (
- -
+ ); } diff --git a/apps/web/src/components/kanban-board/column/index.tsx b/apps/web/src/components/kanban-board/column/index.tsx index 36792a7..4b8b071 100644 --- a/apps/web/src/components/kanban-board/column/index.tsx +++ b/apps/web/src/components/kanban-board/column/index.tsx @@ -1,4 +1,4 @@ -import type { Column as ColumnType } from "@/types/workspace"; +import type { Column as ColumnType } from "@/types/project"; import { ColumnDropzone } from "./column-dropzone"; import { ColumnHeader } from "./column-header"; @@ -8,7 +8,7 @@ interface ColumnProps { function Column({ column }: ColumnProps) { return ( -
+
diff --git a/apps/web/src/components/kanban-board/index.tsx b/apps/web/src/components/kanban-board/index.tsx index c0eb1c4..25f6413 100644 --- a/apps/web/src/components/kanban-board/index.tsx +++ b/apps/web/src/components/kanban-board/index.tsx @@ -1,4 +1,4 @@ -import generateProject from "@/lib/workspace/generate-project"; +import useBoardWebsocket from "@/hooks/use-board-websocket"; import useProjectStore from "@/store/project"; import { DndContext, @@ -8,108 +8,71 @@ import { type UniqueIdentifier, closestCorners, } from "@dnd-kit/core"; +import { useSearch } from "@tanstack/react-router"; +import { produce } from "immer"; import { useState } from "react"; +import { BoardEmptyState } from "../common/sidebar/sections/projects/empty-project-state"; import Column from "./column"; import TaskCard from "./task-card"; function KanbanBoard() { - const [project, setProject] = useState( - generateProject({ - projectId: "sample-1", - workspaceId: "workspace-1", - tasksPerColumn: 4, - }), - ); - const { project: selectedProject } = useProjectStore(); + const { project, setProject } = useProjectStore(); const [activeId, setActiveId] = useState(null); + const { ws } = useBoardWebsocket(); const handleDragStart = (event: DragStartEvent) => { setActiveId(event.active.id); }; - // TODO: Simplify this function const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; - - if (!over) return; + if (!over || !project?.columns) return; const activeId = active.id.toString(); const overId = over.id.toString(); - const sourceColumn = project.columns.find((col) => - col.tasks.some((task) => task.id === activeId), - ); - - const destinationColumn = project.columns.find((col) => { - if (col.id === overId) return true; - return col.tasks.some((task) => task.id === overId); - }); - - if (!sourceColumn || !destinationColumn) return; - - setProject((currentProject) => { - const updatedColumns = [...currentProject.columns]; - - const sourceColumnIndex = updatedColumns.findIndex( - (col) => col.id === sourceColumn.id, + const updatedProject = produce(project, (draft) => { + const sourceColumn = draft?.columns?.find((col) => + col.tasks.some((task) => task.id === activeId), ); - const destinationColumnIndex = updatedColumns.findIndex( - (col) => col.id === destinationColumn.id, + const destinationColumn = draft?.columns?.find( + (col) => + col.id === overId || col.tasks.some((task) => task.id === overId), ); + if (!sourceColumn || !destinationColumn) return; + const sourceTaskIndex = sourceColumn.tasks.findIndex( (task) => task.id === activeId, ); const task = sourceColumn.tasks[sourceTaskIndex]; - updatedColumns[sourceColumnIndex] = { - ...sourceColumn, - tasks: sourceColumn.tasks.filter((t) => t.id !== activeId), - }; + sourceColumn.tasks = sourceColumn.tasks.filter((t) => t.id !== activeId); if (sourceColumn.id === destinationColumn.id) { const destinationIndex = destinationColumn.tasks.findIndex( - (task) => task.id === overId, + (t) => t.id === overId, ); - const newTasks = [...destinationColumn.tasks]; - newTasks.splice(sourceTaskIndex, 1); - newTasks.splice(destinationIndex, 0, task); - - updatedColumns[destinationColumnIndex] = { - ...destinationColumn, - tasks: newTasks, - }; + destinationColumn.tasks.splice(destinationIndex, 0, task); } else { const updatedTask = { ...task, status: destinationColumn.id }; + ws?.send(JSON.stringify({ type: "UPDATE_TASK", ...updatedTask })); - if (overId === destinationColumn.id) { - updatedColumns[destinationColumnIndex] = { - ...destinationColumn, - tasks: [...destinationColumn.tasks, updatedTask], - }; - } else { - const destinationIndex = destinationColumn.tasks.findIndex( - (task) => task.id === overId, - ); - const newTasks = [...destinationColumn.tasks]; - newTasks.splice(destinationIndex, 0, updatedTask); - - updatedColumns[destinationColumnIndex] = { - ...destinationColumn, - tasks: newTasks, - }; - } - } + const destinationIndex = + overId === destinationColumn.id + ? destinationColumn.tasks.length + : destinationColumn.tasks.findIndex((t) => t.id === overId); - return { - ...currentProject, - columns: updatedColumns, - }; + destinationColumn.tasks.splice(destinationIndex, 0, updatedTask); + } }); + setProject(updatedProject); setActiveId(null); }; + if (!project || !project?.columns) return ; + const activeTask = activeId ? project.columns .flatMap((col) => col.tasks) @@ -126,20 +89,19 @@ function KanbanBoard() {

- {selectedProject?.name} + {project?.name}

-
- {project.columns.map((column) => ( +
+ {project?.columns.map((column) => ( ))}
- {activeTask ? (
diff --git a/apps/web/src/components/kanban-board/task-card.tsx b/apps/web/src/components/kanban-board/task-card.tsx index cfe52a1..5d505e2 100644 --- a/apps/web/src/components/kanban-board/task-card.tsx +++ b/apps/web/src/components/kanban-board/task-card.tsx @@ -1,8 +1,8 @@ -import type { Task } from "@/types/workspace"; +import type { Task } from "@/types/project"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { format } from "date-fns"; -import { Calendar, Flag } from "lucide-react"; +import { Calendar, Flag, UserIcon } from "lucide-react"; interface TaskCardProps { task: Task; @@ -38,18 +38,15 @@ function TaskCard({ task }: TaskCardProps) { style={style} {...attributes} {...listeners} - className="bg-white dark:bg-zinc-800/50 backdrop-blur-xs rounded-lg border border-zinc-200 dark:border-zinc-700/50 p-3 cursor-move hover:border-zinc-300 dark:hover:border-zinc-700 transition-colors shadow-xs" + className="group bg-white dark:bg-zinc-800/50 backdrop-blur-sm rounded-lg border border-zinc-200 dark:border-zinc-700/50 p-3 cursor-move hover:border-zinc-300 dark:hover:border-zinc-700 transition-colors shadow-sm" > -

- {task.title} -

- -

- {task.description} -

- -
-
+
+
+

+ {task.title} +

+
+
@@ -57,11 +54,40 @@ function TaskCard({ task }: TaskCardProps) { {task.priority}
+
+ +

+ {task.description} +

+ +
+ {task.assigneeName ? ( +
+ + {task.assigneeName} + +
+ ) : ( +
+ + + Unassigned + +
+ )} {task.dueDate && ( -
- - {format(new Date(task.dueDate), "MMM d")} +
+ + + {format(new Date(task.dueDate), "MMM d")} +
)}
diff --git a/apps/web/src/components/task/create-task-modal.tsx b/apps/web/src/components/task/create-task-modal.tsx new file mode 100644 index 0000000..0922633 --- /dev/null +++ b/apps/web/src/components/task/create-task-modal.tsx @@ -0,0 +1,224 @@ +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import useCreateTask from "@/hooks/mutations/task/use-create-task"; +import useGetWorkspaceUsers from "@/hooks/queries/workspace-users/use-get-workspace-users"; +import useProjectStore from "@/store/project"; +import useWorkspaceStore from "@/store/workspace"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as Dialog from "@radix-ui/react-dialog"; +import { produce } from "immer"; +import { X } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +interface CreateTaskModalProps { + open: boolean; + onClose: () => void; + status?: string; +} + +const taskSchema = z.object({ + title: z.string().min(1, { message: "Title is required" }), + description: z.string().optional(), + priority: z.enum(["low", "medium", "high", "urgent"]), + assigneeId: z.string(), +}); + +type TaskFormValues = z.infer; + +export function CreateTaskModal({ + open, + onClose, + status, +}: CreateTaskModalProps) { + const { project, setProject } = useProjectStore(); + const { workspace } = useWorkspaceStore(); + const { data: users } = useGetWorkspaceUsers({ + workspaceId: workspace?.id ?? "", + }); + + const form = useForm({ + resolver: zodResolver(taskSchema), + defaultValues: { + title: "", + description: "", + priority: "low", + assigneeId: "", + }, + }); + const { mutateAsync } = useCreateTask(); + + const onSubmit = async (data: TaskFormValues) => { + if (!project?.id || !workspace?.id) return; + + const newTask = await mutateAsync({ + title: data.title.trim(), + description: data.description?.trim() || "", + assigneeId: data.assigneeId, + priority: data.priority, + projectId: project?.id, + dueDate: new Date(), + status: status ?? "to-do", + }); + + setProject( + produce(project, (draft) => { + const targetColumn = draft.columns?.find( + (col) => col.id === newTask.status, + ); + if (targetColumn) { + targetColumn.tasks.push(newTask); + } + }), + ); + + form.reset(); + onClose(); + }; + + return ( + + + + +
+
+ + New Task + + + + +
+ +
+ +
+ ( + + + Title + + + + + + + )} + /> + + ( + + + Description + + +