From f0fffa303a70b9952abc556a6c4edf109772c87e Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 4 Apr 2026 13:44:48 +0200 Subject: [PATCH] feat: kanban board module --- CLAUDE.md | 2 +- bun.lock | 27 + drizzle.config.ts | 1 + drizzle/0001_futuristic_zodiak.sql | 45 + drizzle/0002_far_deadpool.sql | 12 + drizzle/meta/0001_snapshot.json | 1144 ++++++++++++++++ drizzle/meta/0002_snapshot.json | 1218 +++++++++++++++++ drizzle/meta/_journal.json | 14 + package.json | 6 +- packages/app/package.json | 1 + packages/app/src/app.css.ts | 3 + packages/app/src/app.tsx | 3 +- packages/app/src/entry-server.tsx | 3 +- packages/app/src/pages/sphere.tsx | 5 + packages/app/src/server.ts | 3 +- packages/app/src/ssr-prefetch.ts | 8 + packages/app/vite.config.ts | 7 +- packages/client/src/theme.css.ts | 3 + packages/client/src/ui.css.ts | 14 +- .../core/src/generated/lexicon-records.ts | 49 + .../src/ui/pages/feature-request.tsx | 6 +- packages/indexer/package.json | 1 + packages/indexer/src/modules.ts | 3 +- packages/kanban/package.json | 29 + .../src/__tests__/db-operations.test.ts | 471 +++++++ packages/kanban/src/__tests__/schemas.test.ts | 144 ++ packages/kanban/src/api/columns.ts | 95 ++ packages/kanban/src/api/comments.ts | 255 ++++ packages/kanban/src/api/routes.ts | 14 + packages/kanban/src/api/tasks.ts | 549 ++++++++ packages/kanban/src/client.ssr.ts | 13 + packages/kanban/src/client.ts | 17 + packages/kanban/src/db/operations.ts | 378 +++++ packages/kanban/src/db/schema.ts | 98 ++ packages/kanban/src/index.ts | 19 + packages/kanban/src/indexer.ts | 158 +++ packages/kanban/src/mcp.ts | 113 ++ packages/kanban/src/schemas/column.ts | 17 + packages/kanban/src/schemas/comment.ts | 12 + packages/kanban/src/schemas/task.ts | 37 + packages/kanban/src/types.ts | 33 + packages/kanban/src/ui/api/columns.ts | 40 + packages/kanban/src/ui/api/comments.ts | 38 + packages/kanban/src/ui/api/tasks.ts | 91 ++ .../src/ui/components/column-manager.tsx | 216 +++ packages/kanban/src/ui/components/column.tsx | 66 + .../kanban/src/ui/components/task-card.tsx | 47 + .../kanban/src/ui/components/task-form.tsx | 111 ++ packages/kanban/src/ui/hooks/use-board-dnd.ts | 222 +++ packages/kanban/src/ui/pages/board.tsx | 117 ++ packages/kanban/src/ui/pages/settings.tsx | 67 + packages/kanban/src/ui/pages/task.tsx | 573 ++++++++ packages/kanban/src/ui/ui.css.ts | 269 ++++ 53 files changed, 6871 insertions(+), 16 deletions(-) create mode 100644 drizzle/0001_futuristic_zodiak.sql create mode 100644 drizzle/0002_far_deadpool.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 packages/kanban/package.json create mode 100644 packages/kanban/src/__tests__/db-operations.test.ts create mode 100644 packages/kanban/src/__tests__/schemas.test.ts create mode 100644 packages/kanban/src/api/columns.ts create mode 100644 packages/kanban/src/api/comments.ts create mode 100644 packages/kanban/src/api/routes.ts create mode 100644 packages/kanban/src/api/tasks.ts create mode 100644 packages/kanban/src/client.ssr.ts create mode 100644 packages/kanban/src/client.ts create mode 100644 packages/kanban/src/db/operations.ts create mode 100644 packages/kanban/src/db/schema.ts create mode 100644 packages/kanban/src/index.ts create mode 100644 packages/kanban/src/indexer.ts create mode 100644 packages/kanban/src/mcp.ts create mode 100644 packages/kanban/src/schemas/column.ts create mode 100644 packages/kanban/src/schemas/comment.ts create mode 100644 packages/kanban/src/schemas/task.ts create mode 100644 packages/kanban/src/types.ts create mode 100644 packages/kanban/src/ui/api/columns.ts create mode 100644 packages/kanban/src/ui/api/comments.ts create mode 100644 packages/kanban/src/ui/api/tasks.ts create mode 100644 packages/kanban/src/ui/components/column-manager.tsx create mode 100644 packages/kanban/src/ui/components/column.tsx create mode 100644 packages/kanban/src/ui/components/task-card.tsx create mode 100644 packages/kanban/src/ui/components/task-form.tsx create mode 100644 packages/kanban/src/ui/hooks/use-board-dnd.ts create mode 100644 packages/kanban/src/ui/pages/board.tsx create mode 100644 packages/kanban/src/ui/pages/settings.tsx create mode 100644 packages/kanban/src/ui/pages/task.tsx create mode 100644 packages/kanban/src/ui/ui.css.ts diff --git a/CLAUDE.md b/CLAUDE.md index 006d159..8d29c2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ Modular, self-hostable community platform built on the AT Protocol. ### Commands -- `bunx tsc --emit`: Check project TypeScript types +- `bunx run tsc`: Check project TypeScript types - `bun run fmt`: Foramt with oxfmt - `bun run test`: Run unit tests - `bun run test:e2e`: Run end-to-end tests diff --git a/bun.lock b/bun.lock index 9058ed1..c078357 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "bun-types": "^1.3.11", "drizzle-kit": "^0.31.10", "oxfmt": "^0.43.0", + "typescript": "catalog:", "vitest": "^4.1.2", }, }, @@ -24,6 +25,7 @@ "@exosphere/feature-requests": "workspace:*", "@exosphere/feeds": "workspace:*", "@exosphere/indexer": "workspace:*", + "@exosphere/kanban": "workspace:*", "@exosphere/mcp": "workspace:*", "@preact/signals": "catalog:", "@vanilla-extract/css": "catalog:", @@ -109,7 +111,28 @@ "@exosphere/core": "workspace:*", "@exosphere/feature-requests": "workspace:*", "@exosphere/feeds": "workspace:*", + "@exosphere/kanban": "workspace:*", + "drizzle-orm": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, + "packages/kanban": { + "name": "@exosphere/kanban", + "version": "0.0.1", + "dependencies": { + "@exosphere/client": "workspace:*", + "@exosphere/core": "workspace:*", + "@exosphere/mcp": "workspace:*", + "@preact/signals": "catalog:", + "@vanilla-extract/css": "catalog:", "drizzle-orm": "catalog:", + "hono": "catalog:", + "lucide-preact": "^1.7.0", + "preact": "catalog:", + "zod": "catalog:", }, "devDependencies": { "@types/bun": "catalog:", @@ -309,6 +332,8 @@ "@exosphere/indexer": ["@exosphere/indexer@workspace:packages/indexer"], + "@exosphere/kanban": ["@exosphere/kanban@workspace:packages/kanban"], + "@exosphere/mcp": ["@exosphere/mcp@workspace:packages/mcp"], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -805,6 +830,8 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@exosphere/kanban/lucide-preact": ["lucide-preact@1.7.0", "", { "peerDependencies": { "preact": "^10.27.2" } }, "sha512-gv743hfkCH2MeUmZQ9g4+Yd+/zZyAOLuXvxWo6CUY6kMrnnoDZnnwpSZqWsEveCC9LNp+NgHkeTfztObIue+SA=="], + "@prefresh/vite/@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], diff --git a/drizzle.config.ts b/drizzle.config.ts index 17751fe..5eaaaa4 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ "./packages/core/src/db/schema/*.ts", "./packages/feature-requests/src/db/schema.ts", "./packages/feeds/src/db/schema.ts", + "./packages/kanban/src/db/schema.ts", ], dbCredentials: { url: "exosphere.sqlite", diff --git a/drizzle/0001_futuristic_zodiak.sql b/drizzle/0001_futuristic_zodiak.sql new file mode 100644 index 0000000..ea398c5 --- /dev/null +++ b/drizzle/0001_futuristic_zodiak.sql @@ -0,0 +1,45 @@ +CREATE TABLE `kanban_task_comments` ( + `id` text PRIMARY KEY NOT NULL, + `task_id` text NOT NULL, + `author_did` text NOT NULL, + `content` text NOT NULL, + `pds_uri` text, + `updated_at` text DEFAULT (datetime('now')) NOT NULL, + `hidden_at` text, + `moderated_by` text, + FOREIGN KEY (`task_id`) REFERENCES `kanban_tasks`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `idx_kanban_task_comments_task` ON `kanban_task_comments` (`task_id`);--> statement-breakpoint +CREATE INDEX `idx_kanban_task_comments_author_task` ON `kanban_task_comments` (`author_did`,`task_id`);--> statement-breakpoint +CREATE TABLE `kanban_task_status_changes` ( + `id` text PRIMARY KEY NOT NULL, + `task_id` text NOT NULL, + `author_did` text NOT NULL, + `status` text NOT NULL, + `pds_uri` text, + FOREIGN KEY (`task_id`) REFERENCES `kanban_tasks`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `idx_kanban_task_status_changes_task` ON `kanban_task_status_changes` (`task_id`);--> statement-breakpoint +CREATE TABLE `kanban_tasks` ( + `id` text PRIMARY KEY NOT NULL, + `sphere_id` text NOT NULL, + `number` integer NOT NULL, + `author_did` text NOT NULL, + `title` text NOT NULL, + `description` text DEFAULT '' NOT NULL, + `status` text DEFAULT 'backlog' NOT NULL, + `position` integer DEFAULT 0 NOT NULL, + `assignee_did` text, + `pds_uri` text, + `hidden_at` text, + `moderated_by` text, + `updated_at` text DEFAULT (datetime('now')) NOT NULL, + FOREIGN KEY (`sphere_id`) REFERENCES `spheres`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_kanban_tasks_sphere_number` ON `kanban_tasks` (`sphere_id`,`number`);--> statement-breakpoint +CREATE INDEX `idx_kanban_tasks_sphere` ON `kanban_tasks` (`sphere_id`);--> statement-breakpoint +CREATE INDEX `idx_kanban_tasks_status` ON `kanban_tasks` (`status`);--> statement-breakpoint +CREATE INDEX `idx_kanban_tasks_sphere_status_position` ON `kanban_tasks` (`sphere_id`,`status`,`position`); \ No newline at end of file diff --git a/drizzle/0002_far_deadpool.sql b/drizzle/0002_far_deadpool.sql new file mode 100644 index 0000000..eafbc8c --- /dev/null +++ b/drizzle/0002_far_deadpool.sql @@ -0,0 +1,12 @@ +CREATE TABLE `kanban_columns` ( + `id` text PRIMARY KEY NOT NULL, + `sphere_id` text NOT NULL, + `slug` text NOT NULL, + `label` text NOT NULL, + `position` integer NOT NULL, + `created_at` text DEFAULT (datetime('now')) NOT NULL, + FOREIGN KEY (`sphere_id`) REFERENCES `spheres`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_kanban_columns_sphere_slug` ON `kanban_columns` (`sphere_id`,`slug`);--> statement-breakpoint +CREATE INDEX `idx_kanban_columns_sphere_position` ON `kanban_columns` (`sphere_id`,`position`); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..d7d5dd3 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1144 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cf1efedc-9a33-469b-b835-9cd7d63e63bd", + "prevId": "c420fa1d-50cd-45f4-b70a-0ea7d8c70067", + "tables": { + "oauth_sessions": { + "name": "oauth_sessions", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_states": { + "name": "oauth_states", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "indexer_cursor": { + "name": "indexer_cursor", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'jetstream'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sphere_members": { + "name": "sphere_members", + "columns": { + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'invited'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approval_pds_uri": { + "name": "approval_pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_sphere_members_did": { + "name": "idx_sphere_members_did", + "columns": ["did"], + "isUnique": false + } + }, + "foreignKeys": { + "sphere_members_sphere_id_spheres_id_fk": { + "name": "sphere_members_sphere_id_spheres_id_fk", + "tableFrom": "sphere_members", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sphere_members_sphere_id_did_pk": { + "columns": ["sphere_id", "did"], + "name": "sphere_members_sphere_id_did_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sphere_modules": { + "name": "sphere_modules", + "columns": { + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "module_name": { + "name": "module_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "sphere_modules_sphere_id_spheres_id_fk": { + "name": "sphere_modules_sphere_id_spheres_id_fk", + "tableFrom": "sphere_modules", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sphere_modules_sphere_id_module_name_pk": { + "columns": ["sphere_id", "module_name"], + "name": "sphere_modules_sphere_id_module_name_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sphere_permissions": { + "name": "sphere_permissions", + "columns": { + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_key": { + "name": "action_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "min_role": { + "name": "min_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "sphere_permissions_sphere_id_spheres_id_fk": { + "name": "sphere_permissions_sphere_id_spheres_id_fk", + "tableFrom": "sphere_permissions", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sphere_permissions_sphere_id_action_key_pk": { + "columns": ["sphere_id", "action_key"], + "name": "sphere_permissions_sphere_id_action_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "spheres": { + "name": "spheres", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "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 + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "owner_did": { + "name": "owner_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "spheres_handle_unique": { + "name": "spheres_handle_unique", + "columns": ["handle"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feature_request_comment_votes": { + "name": "feature_request_comment_votes", + "columns": { + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_feature_request_comment_votes_comment": { + "name": "idx_feature_request_comment_votes_comment", + "columns": ["comment_id"], + "isUnique": false + } + }, + "foreignKeys": { + "feature_request_comment_votes_comment_id_feature_request_comments_id_fk": { + "name": "feature_request_comment_votes_comment_id_feature_request_comments_id_fk", + "tableFrom": "feature_request_comment_votes", + "tableTo": "feature_request_comments", + "columnsFrom": ["comment_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "feature_request_comment_votes_comment_id_author_did_pk": { + "columns": ["comment_id", "author_did"], + "name": "feature_request_comment_votes_comment_id_author_did_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feature_request_comments": { + "name": "feature_request_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "hidden_at": { + "name": "hidden_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderated_by": { + "name": "moderated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_feature_request_comments_request": { + "name": "idx_feature_request_comments_request", + "columns": ["request_id"], + "isUnique": false + }, + "idx_feature_request_comments_author_request": { + "name": "idx_feature_request_comments_author_request", + "columns": ["author_did", "request_id"], + "isUnique": false + } + }, + "foreignKeys": { + "feature_request_comments_request_id_feature_requests_id_fk": { + "name": "feature_request_comments_request_id_feature_requests_id_fk", + "tableFrom": "feature_request_comments", + "tableTo": "feature_requests", + "columnsFrom": ["request_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feature_request_statuses": { + "name": "feature_request_statuses", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_feature_request_statuses_request": { + "name": "idx_feature_request_statuses_request", + "columns": ["request_id"], + "isUnique": false + } + }, + "foreignKeys": { + "feature_request_statuses_request_id_feature_requests_id_fk": { + "name": "feature_request_statuses_request_id_feature_requests_id_fk", + "tableFrom": "feature_request_statuses", + "tableTo": "feature_requests", + "columnsFrom": ["request_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feature_request_votes": { + "name": "feature_request_votes", + "columns": { + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_feature_request_votes_request": { + "name": "idx_feature_request_votes_request", + "columns": ["request_id"], + "isUnique": false + } + }, + "foreignKeys": { + "feature_request_votes_request_id_feature_requests_id_fk": { + "name": "feature_request_votes_request_id_feature_requests_id_fk", + "tableFrom": "feature_request_votes", + "tableTo": "feature_requests", + "columnsFrom": ["request_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "feature_request_votes_request_id_author_did_pk": { + "columns": ["request_id", "author_did"], + "name": "feature_request_votes_request_id_author_did_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feature_requests": { + "name": "feature_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "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": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'general'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requested'" + }, + "duplicate_of_id": { + "name": "duplicate_of_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderated_by": { + "name": "moderated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_feature_requests_sphere_number": { + "name": "idx_feature_requests_sphere_number", + "columns": ["sphere_id", "number"], + "isUnique": true + }, + "idx_feature_requests_sphere": { + "name": "idx_feature_requests_sphere", + "columns": ["sphere_id"], + "isUnique": false + }, + "idx_feature_requests_status": { + "name": "idx_feature_requests_status", + "columns": ["status"], + "isUnique": false + }, + "idx_feature_requests_category": { + "name": "idx_feature_requests_category", + "columns": ["category"], + "isUnique": false + } + }, + "foreignKeys": { + "feature_requests_sphere_id_spheres_id_fk": { + "name": "feature_requests_sphere_id_spheres_id_fk", + "tableFrom": "feature_requests", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_posts": { + "name": "feed_posts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_feed_posts_parent": { + "name": "idx_feed_posts_parent", + "columns": ["parent_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "kanban_task_comments": { + "name": "kanban_task_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "hidden_at": { + "name": "hidden_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderated_by": { + "name": "moderated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_kanban_task_comments_task": { + "name": "idx_kanban_task_comments_task", + "columns": ["task_id"], + "isUnique": false + }, + "idx_kanban_task_comments_author_task": { + "name": "idx_kanban_task_comments_author_task", + "columns": ["author_did", "task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "kanban_task_comments_task_id_kanban_tasks_id_fk": { + "name": "kanban_task_comments_task_id_kanban_tasks_id_fk", + "tableFrom": "kanban_task_comments", + "tableTo": "kanban_tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "kanban_task_status_changes": { + "name": "kanban_task_status_changes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_kanban_task_status_changes_task": { + "name": "idx_kanban_task_status_changes_task", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "kanban_task_status_changes_task_id_kanban_tasks_id_fk": { + "name": "kanban_task_status_changes_task_id_kanban_tasks_id_fk", + "tableFrom": "kanban_task_status_changes", + "tableTo": "kanban_tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "kanban_tasks": { + "name": "kanban_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "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": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'backlog'" + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "assignee_did": { + "name": "assignee_did", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderated_by": { + "name": "moderated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_kanban_tasks_sphere_number": { + "name": "idx_kanban_tasks_sphere_number", + "columns": ["sphere_id", "number"], + "isUnique": true + }, + "idx_kanban_tasks_sphere": { + "name": "idx_kanban_tasks_sphere", + "columns": ["sphere_id"], + "isUnique": false + }, + "idx_kanban_tasks_status": { + "name": "idx_kanban_tasks_status", + "columns": ["status"], + "isUnique": false + }, + "idx_kanban_tasks_sphere_status_position": { + "name": "idx_kanban_tasks_sphere_status_position", + "columns": ["sphere_id", "status", "position"], + "isUnique": false + } + }, + "foreignKeys": { + "kanban_tasks_sphere_id_spheres_id_fk": { + "name": "kanban_tasks_sphere_id_spheres_id_fk", + "tableFrom": "kanban_tasks", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..0539942 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1218 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0e06a92f-2199-4052-971b-1fc3be86eb03", + "prevId": "cf1efedc-9a33-469b-b835-9cd7d63e63bd", + "tables": { + "oauth_sessions": { + "name": "oauth_sessions", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_states": { + "name": "oauth_states", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "indexer_cursor": { + "name": "indexer_cursor", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'jetstream'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sphere_members": { + "name": "sphere_members", + "columns": { + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'invited'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approval_pds_uri": { + "name": "approval_pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_sphere_members_did": { + "name": "idx_sphere_members_did", + "columns": ["did"], + "isUnique": false + } + }, + "foreignKeys": { + "sphere_members_sphere_id_spheres_id_fk": { + "name": "sphere_members_sphere_id_spheres_id_fk", + "tableFrom": "sphere_members", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sphere_members_sphere_id_did_pk": { + "columns": ["sphere_id", "did"], + "name": "sphere_members_sphere_id_did_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sphere_modules": { + "name": "sphere_modules", + "columns": { + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "module_name": { + "name": "module_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "sphere_modules_sphere_id_spheres_id_fk": { + "name": "sphere_modules_sphere_id_spheres_id_fk", + "tableFrom": "sphere_modules", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sphere_modules_sphere_id_module_name_pk": { + "columns": ["sphere_id", "module_name"], + "name": "sphere_modules_sphere_id_module_name_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sphere_permissions": { + "name": "sphere_permissions", + "columns": { + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_key": { + "name": "action_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "min_role": { + "name": "min_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "sphere_permissions_sphere_id_spheres_id_fk": { + "name": "sphere_permissions_sphere_id_spheres_id_fk", + "tableFrom": "sphere_permissions", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sphere_permissions_sphere_id_action_key_pk": { + "columns": ["sphere_id", "action_key"], + "name": "sphere_permissions_sphere_id_action_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "spheres": { + "name": "spheres", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "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 + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "owner_did": { + "name": "owner_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "spheres_handle_unique": { + "name": "spheres_handle_unique", + "columns": ["handle"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feature_request_comment_votes": { + "name": "feature_request_comment_votes", + "columns": { + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_feature_request_comment_votes_comment": { + "name": "idx_feature_request_comment_votes_comment", + "columns": ["comment_id"], + "isUnique": false + } + }, + "foreignKeys": { + "feature_request_comment_votes_comment_id_feature_request_comments_id_fk": { + "name": "feature_request_comment_votes_comment_id_feature_request_comments_id_fk", + "tableFrom": "feature_request_comment_votes", + "tableTo": "feature_request_comments", + "columnsFrom": ["comment_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "feature_request_comment_votes_comment_id_author_did_pk": { + "columns": ["comment_id", "author_did"], + "name": "feature_request_comment_votes_comment_id_author_did_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feature_request_comments": { + "name": "feature_request_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "hidden_at": { + "name": "hidden_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderated_by": { + "name": "moderated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_feature_request_comments_request": { + "name": "idx_feature_request_comments_request", + "columns": ["request_id"], + "isUnique": false + }, + "idx_feature_request_comments_author_request": { + "name": "idx_feature_request_comments_author_request", + "columns": ["author_did", "request_id"], + "isUnique": false + } + }, + "foreignKeys": { + "feature_request_comments_request_id_feature_requests_id_fk": { + "name": "feature_request_comments_request_id_feature_requests_id_fk", + "tableFrom": "feature_request_comments", + "tableTo": "feature_requests", + "columnsFrom": ["request_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feature_request_statuses": { + "name": "feature_request_statuses", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_feature_request_statuses_request": { + "name": "idx_feature_request_statuses_request", + "columns": ["request_id"], + "isUnique": false + } + }, + "foreignKeys": { + "feature_request_statuses_request_id_feature_requests_id_fk": { + "name": "feature_request_statuses_request_id_feature_requests_id_fk", + "tableFrom": "feature_request_statuses", + "tableTo": "feature_requests", + "columnsFrom": ["request_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feature_request_votes": { + "name": "feature_request_votes", + "columns": { + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_feature_request_votes_request": { + "name": "idx_feature_request_votes_request", + "columns": ["request_id"], + "isUnique": false + } + }, + "foreignKeys": { + "feature_request_votes_request_id_feature_requests_id_fk": { + "name": "feature_request_votes_request_id_feature_requests_id_fk", + "tableFrom": "feature_request_votes", + "tableTo": "feature_requests", + "columnsFrom": ["request_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "feature_request_votes_request_id_author_did_pk": { + "columns": ["request_id", "author_did"], + "name": "feature_request_votes_request_id_author_did_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feature_requests": { + "name": "feature_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "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": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'general'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requested'" + }, + "duplicate_of_id": { + "name": "duplicate_of_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderated_by": { + "name": "moderated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_feature_requests_sphere_number": { + "name": "idx_feature_requests_sphere_number", + "columns": ["sphere_id", "number"], + "isUnique": true + }, + "idx_feature_requests_sphere": { + "name": "idx_feature_requests_sphere", + "columns": ["sphere_id"], + "isUnique": false + }, + "idx_feature_requests_status": { + "name": "idx_feature_requests_status", + "columns": ["status"], + "isUnique": false + }, + "idx_feature_requests_category": { + "name": "idx_feature_requests_category", + "columns": ["category"], + "isUnique": false + } + }, + "foreignKeys": { + "feature_requests_sphere_id_spheres_id_fk": { + "name": "feature_requests_sphere_id_spheres_id_fk", + "tableFrom": "feature_requests", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_posts": { + "name": "feed_posts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_feed_posts_parent": { + "name": "idx_feed_posts_parent", + "columns": ["parent_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "kanban_columns": { + "name": "kanban_columns", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_kanban_columns_sphere_slug": { + "name": "idx_kanban_columns_sphere_slug", + "columns": ["sphere_id", "slug"], + "isUnique": true + }, + "idx_kanban_columns_sphere_position": { + "name": "idx_kanban_columns_sphere_position", + "columns": ["sphere_id", "position"], + "isUnique": false + } + }, + "foreignKeys": { + "kanban_columns_sphere_id_spheres_id_fk": { + "name": "kanban_columns_sphere_id_spheres_id_fk", + "tableFrom": "kanban_columns", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "kanban_task_comments": { + "name": "kanban_task_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "hidden_at": { + "name": "hidden_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderated_by": { + "name": "moderated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_kanban_task_comments_task": { + "name": "idx_kanban_task_comments_task", + "columns": ["task_id"], + "isUnique": false + }, + "idx_kanban_task_comments_author_task": { + "name": "idx_kanban_task_comments_author_task", + "columns": ["author_did", "task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "kanban_task_comments_task_id_kanban_tasks_id_fk": { + "name": "kanban_task_comments_task_id_kanban_tasks_id_fk", + "tableFrom": "kanban_task_comments", + "tableTo": "kanban_tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "kanban_task_status_changes": { + "name": "kanban_task_status_changes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_kanban_task_status_changes_task": { + "name": "idx_kanban_task_status_changes_task", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "kanban_task_status_changes_task_id_kanban_tasks_id_fk": { + "name": "kanban_task_status_changes_task_id_kanban_tasks_id_fk", + "tableFrom": "kanban_task_status_changes", + "tableTo": "kanban_tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "kanban_tasks": { + "name": "kanban_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "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": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'backlog'" + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "assignee_did": { + "name": "assignee_did", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderated_by": { + "name": "moderated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_kanban_tasks_sphere_number": { + "name": "idx_kanban_tasks_sphere_number", + "columns": ["sphere_id", "number"], + "isUnique": true + }, + "idx_kanban_tasks_sphere": { + "name": "idx_kanban_tasks_sphere", + "columns": ["sphere_id"], + "isUnique": false + }, + "idx_kanban_tasks_status": { + "name": "idx_kanban_tasks_status", + "columns": ["status"], + "isUnique": false + }, + "idx_kanban_tasks_sphere_status_position": { + "name": "idx_kanban_tasks_sphere_status_position", + "columns": ["sphere_id", "status", "position"], + "isUnique": false + } + }, + "foreignKeys": { + "kanban_tasks_sphere_id_spheres_id_fk": { + "name": "kanban_tasks_sphere_id_spheres_id_fk", + "tableFrom": "kanban_tasks", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 3b528c7..cf1dc70 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1775057026164, "tag": "0000_slow_namora", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1775130580734, + "tag": "0001_futuristic_zodiak", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1775163122388, + "tag": "0002_far_deadpool", + "breakpoints": true } ] } diff --git a/package.json b/package.json index 66b9e7e..2787a5a 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,6 @@ "dev": "bun run dev:server & bun run dev:client", "dev:server": "bun run --hot packages/app/src/server.ts", "dev:client": "bun run --filter '@exosphere/app' dev:client", - "fmt": "oxfmt", - "fmt:check": "oxfmt --check", "pds:init": "bun run scripts/pds-init.ts", "pds:up": "docker compose -f docker-compose.dev.yml up -d", "pds:down": "docker compose -f docker-compose.dev.yml down", @@ -35,6 +33,9 @@ "start": "NODE_ENV=production bun run packages/app/src/server.ts", "start:indexer": "bun run packages/indexer/src/main.ts", "preview": "bun run db:generate && bun run db:migrate && bun run build && bun run start", + "tsc": "tsc --noEmit", + "fmt": "oxfmt", + "fmt:check": "oxfmt --check", "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test --config packages/app/e2e/playwright.config.ts" @@ -47,6 +48,7 @@ "bun-types": "^1.3.11", "drizzle-kit": "^0.31.10", "oxfmt": "^0.43.0", + "typescript": "catalog:", "vitest": "^4.1.2" } } diff --git a/packages/app/package.json b/packages/app/package.json index d2e5145..0e46c8a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -17,6 +17,7 @@ "@exosphere/feature-requests": "workspace:*", "@exosphere/feeds": "workspace:*", "@exosphere/indexer": "workspace:*", + "@exosphere/kanban": "workspace:*", "@exosphere/mcp": "workspace:*", "@preact/signals": "catalog:", "@vanilla-extract/css": "catalog:", diff --git a/packages/app/src/app.css.ts b/packages/app/src/app.css.ts index 365d203..aa16b52 100644 --- a/packages/app/src/app.css.ts +++ b/packages/app/src/app.css.ts @@ -2,6 +2,9 @@ import { globalStyle, createTheme } from "@vanilla-extract/css"; import { vars } from "@exosphere/client/theme.css"; const shared = { + size: { + containerMaxWidth: "800px", + }, space: { xs: "4px", sm: "8px", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 9a9b76b..b904776 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -18,9 +18,10 @@ import { Dashboard } from "./pages/dashboard.tsx"; import type { ModuleRoute } from "@exosphere/client/types"; import { feedsModule } from "@exosphere/feeds/client"; import { featureRequestsModule } from "@exosphere/feature-requests/client"; +import { kanbanModule } from "@exosphere/kanban/client"; import { LocationProvider, Router, Route } from "preact-iso"; -const defaultRoutes: ModuleRoute[] = [feedsModule, featureRequestsModule].flatMap( +const defaultRoutes: ModuleRoute[] = [feedsModule, featureRequestsModule, kanbanModule].flatMap( (m) => m.routes ?? [], ); diff --git a/packages/app/src/entry-server.tsx b/packages/app/src/entry-server.tsx index 80db179..25d06f0 100644 --- a/packages/app/src/entry-server.tsx +++ b/packages/app/src/entry-server.tsx @@ -8,8 +8,9 @@ import { lightTheme, darkTheme } from "./app.css.ts"; import type { SphereData } from "@exosphere/core/types"; import { feedsModule } from "@exosphere/feeds/client-ssr"; import { featureRequestsModule } from "@exosphere/feature-requests/client-ssr"; +import { kanbanModule } from "@exosphere/kanban/client-ssr"; -const ssrRoutes = [feedsModule, featureRequestsModule].flatMap((m) => m.routes ?? []); +const ssrRoutes = [feedsModule, featureRequestsModule, kanbanModule].flatMap((m) => m.routes ?? []); export interface SSRData { auth: { authenticated: boolean; did: string | null; handle: string | null }; diff --git a/packages/app/src/pages/sphere.tsx b/packages/app/src/pages/sphere.tsx index f2912d0..9f575e6 100644 --- a/packages/app/src/pages/sphere.tsx +++ b/packages/app/src/pages/sphere.tsx @@ -21,6 +21,11 @@ const moduleLabels: Record c.json({ status: "ok" })); // Each sphere gets its own endpoint: /mcp/:sphereHandle app.route( "/mcp", - createMcpRoutes(featureRequestMcpTools, (sphereHandle) => (path) => { + createMcpRoutes([...featureRequestMcpTools, ...kanbanMcpTools], (sphereHandle) => (path) => { // Rewrite /api/spheres/current → /api/spheres/:handle if (path === "/api/spheres/current") { return app.request(`/api/spheres/${sphereHandle}`); diff --git a/packages/app/src/ssr-prefetch.ts b/packages/app/src/ssr-prefetch.ts index 680be0d..0c924cd 100644 --- a/packages/app/src/ssr-prefetch.ts +++ b/packages/app/src/ssr-prefetch.ts @@ -36,5 +36,13 @@ export function ssrPrefetch( { key: "feature-request", apiUrl: `${apiBase}/feature-requests/${frMatch[1]}` }, { key: "feature-request-votes", apiUrl: `${apiBase}/feature-requests/votes` }, ]; + + // Kanban board + if (modulePath === "/board") return [{ key: "kanban-tasks", apiUrl: `${apiBase}/kanban` }]; + if (modulePath === "/board/settings") + return [{ key: "kanban-columns", apiUrl: `${apiBase}/kanban/columns` }]; + const taskMatch = modulePath.match(/^\/board\/(\d+)$/); + if (taskMatch) return [{ key: "kanban-task", apiUrl: `${apiBase}/kanban/${taskMatch[1]}` }]; + return []; } diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index 81c8d51..0d0fc44 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -130,7 +130,12 @@ export default defineConfig(async ({ mode }) => { }, plugins: [preact(), vanillaExtractPlugin(), ssrDevPlugin({ isMultiSphere })], ssr: { - noExternal: ["@exosphere/client", "@exosphere/feeds", "@exosphere/feature-requests"], + noExternal: [ + "@exosphere/client", + "@exosphere/feeds", + "@exosphere/feature-requests", + "@exosphere/kanban", + ], }, server: { host: true, diff --git a/packages/client/src/theme.css.ts b/packages/client/src/theme.css.ts index e70d39d..3318ccd 100644 --- a/packages/client/src/theme.css.ts +++ b/packages/client/src/theme.css.ts @@ -21,6 +21,9 @@ export const vars = createThemeContract({ shadowStrong: null, focusRing: null, }, + size: { + containerMaxWidth: null, + }, space: { xs: null, sm: null, diff --git a/packages/client/src/ui.css.ts b/packages/client/src/ui.css.ts index da558b4..a483e8f 100644 --- a/packages/client/src/ui.css.ts +++ b/packages/client/src/ui.css.ts @@ -28,7 +28,7 @@ export const themeRoot = style({ // ---- Layout ---- export const container = style({ - maxWidth: "640px", + maxWidth: vars.size.containerMaxWidth, marginInline: "auto", paddingInline: vars.space.md, paddingBlockEnd: vars.space.xl, @@ -211,17 +211,17 @@ const btnBase = { lineHeight: 1.5, cursor: "pointer", fontFamily: vars.font.body, - minBlockSize: "44px", + minBlockSize: "36px", whiteSpace: "nowrap" as const, transition: "background-color 0.15s, opacity 0.15s, box-shadow 0.15s, transform 0.1s", }; export const button = style({ ...btnBase, - paddingBlock: vars.space.sm, + paddingBlock: "6px", paddingInline: vars.space.lg, border: "none", - fontSize: "0.875rem", + fontSize: "0.75rem", backgroundColor: vars.color.primary, color: "#fff", boxShadow: `0 1px 2px ${vars.color.shadow}`, @@ -236,10 +236,10 @@ export const button = style({ export const buttonSecondary = style({ ...btnBase, - paddingBlock: vars.space.sm, + paddingBlock: "6px", paddingInline: vars.space.lg, border: `1px solid ${vars.color.border}`, - fontSize: "0.875rem", + fontSize: "0.75rem", backgroundColor: vars.color.surface, color: vars.color.text, ":hover": { @@ -678,7 +678,7 @@ export const footer = style({ }); export const footerInner = style({ - maxWidth: "640px", + maxWidth: vars.size.containerMaxWidth, marginInline: "auto", paddingInline: vars.space.md, display: "flex", diff --git a/packages/core/src/generated/lexicon-records.ts b/packages/core/src/generated/lexicon-records.ts index 38877ff..979b563 100644 --- a/packages/core/src/generated/lexicon-records.ts +++ b/packages/core/src/generated/lexicon-records.ts @@ -50,6 +50,51 @@ export interface FeatureRequestVoteRecord { subject: string; } +export interface KanbanCommentRecord { + /** at-uri — AT URI of the kanban task being commented on. */ + subject: string; + content: string; + /** datetime */ + updatedAt?: string; +} + +export interface KanbanEntryRecord { + title: string; + description?: string; + /** Column slug the task belongs to. Values are user-defined per Sphere. */ + status: string; + /** did — DID of the Sphere owner (the identity hosting the site.exosphere.sphere.profile record). */ + subject: string; + /** did — DID of the member assigned to this task. */ + assigneeDid?: string; + /** datetime */ + updatedAt?: string; +} + +export interface KanbanPermissionsRecord { + /** Minimum role to create tasks. */ + create?: string; + /** Minimum role to comment on tasks. */ + comment?: string; + /** Minimum role to move tasks between columns. */ + changeStatus?: string; + /** Minimum role to assign tasks. */ + assign?: string; + /** Minimum role to edit/delete others' tasks. */ + manageTasks?: string; + /** Minimum role to manage board settings (columns, etc.). */ + manageSettings?: string; + /** Minimum role to hide/unhide content. */ + moderate?: string; +} + +export interface KanbanStatusRecord { + /** at-uri — AT URI of the kanban task whose status is being changed. */ + subject: string; + /** Column slug the task is moved to. Values are user-defined per Sphere. */ + status: string; +} + export interface ModerationRecord { /** at-uri — AT URI of the Sphere. */ sphere: string; @@ -107,6 +152,10 @@ export interface PdsRecordMap { "site.exosphere.featureRequest.permissions": FeatureRequestPermissionsRecord; "site.exosphere.featureRequest.status": FeatureRequestStatusRecord; "site.exosphere.featureRequest.vote": FeatureRequestVoteRecord; + "site.exosphere.kanban.comment": KanbanCommentRecord; + "site.exosphere.kanban.entry": KanbanEntryRecord; + "site.exosphere.kanban.permissions": KanbanPermissionsRecord; + "site.exosphere.kanban.status": KanbanStatusRecord; "site.exosphere.moderation": ModerationRecord; "site.exosphere.sphere.member": SphereMemberRecord; "site.exosphere.sphere.memberApproval": SphereMemberApprovalRecord; diff --git a/packages/feature-requests/src/ui/pages/feature-request.tsx b/packages/feature-requests/src/ui/pages/feature-request.tsx index 7cf52e9..7e74b3d 100644 --- a/packages/feature-requests/src/ui/pages/feature-request.tsx +++ b/packages/feature-requests/src/ui/pages/feature-request.tsx @@ -324,6 +324,7 @@ function CommentsSection({ requestId, status, isAuthenticated, + canComment, canModerate, currentDid, currentHandle, @@ -331,6 +332,7 @@ function CommentsSection({ requestId: string; status: string; isAuthenticated: boolean; + canComment: boolean; canModerate: boolean; currentDid: string | null; currentHandle: string | null; @@ -445,7 +447,7 @@ function CommentsSection({ )} - {isAuthenticated && + {canComment && !hasCommented.value && status !== "done" && status !== "not-planned" && @@ -553,6 +555,7 @@ export function FeatureRequestPage() { } }, [votesQuery.data]); + const canComment = useCanDo("feature-requests", "comment"); const canModerate = useCanDo("feature-requests", "moderate"); const canChangeStatus = useCanDo("feature-requests", "changeStatus"); const canMarkDuplicate = useCanDo("feature-requests", "markDuplicate"); @@ -683,6 +686,7 @@ export function FeatureRequestPage() { requestId={fr.id} status={fr.status} isAuthenticated={isAuthenticated} + canComment={canComment.value} canModerate={canModerate.value} currentDid={currentDid} currentHandle={currentHandle} diff --git a/packages/indexer/package.json b/packages/indexer/package.json index 64b7749..88838d0 100644 --- a/packages/indexer/package.json +++ b/packages/indexer/package.json @@ -11,6 +11,7 @@ "@exosphere/core": "workspace:*", "@exosphere/feature-requests": "workspace:*", "@exosphere/feeds": "workspace:*", + "@exosphere/kanban": "workspace:*", "drizzle-orm": "catalog:" }, "devDependencies": { diff --git a/packages/indexer/src/modules.ts b/packages/indexer/src/modules.ts index ebf1794..d5f6a80 100644 --- a/packages/indexer/src/modules.ts +++ b/packages/indexer/src/modules.ts @@ -8,8 +8,9 @@ import { } from "@exosphere/core/permissions"; // import { feedsModule } from "@exosphere/feeds"; import { featureRequestsModule } from "@exosphere/feature-requests"; +import { kanbanModule } from "@exosphere/kanban"; -export const modules: ExosphereModule[] = [featureRequestsModule]; +export const modules: ExosphereModule[] = [featureRequestsModule, kanbanModule]; // Register core sphere-level permissions. // This runs as a side effect on import — the server (app/src/server.ts) imports from diff --git a/packages/kanban/package.json b/packages/kanban/package.json new file mode 100644 index 0000000..e443229 --- /dev/null +++ b/packages/kanban/package.json @@ -0,0 +1,29 @@ +{ + "name": "@exosphere/kanban", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./client": "./src/client.ts", + "./client-ssr": "./src/client.ssr.ts", + "./mcp": "./src/mcp.ts", + "./types": "./src/types.ts" + }, + "dependencies": { + "@exosphere/client": "workspace:*", + "@exosphere/core": "workspace:*", + "@exosphere/mcp": "workspace:*", + "@preact/signals": "catalog:", + "@vanilla-extract/css": "catalog:", + "drizzle-orm": "catalog:", + "hono": "catalog:", + "lucide-preact": "^1.7.0", + "preact": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/kanban/src/__tests__/db-operations.test.ts b/packages/kanban/src/__tests__/db-operations.test.ts new file mode 100644 index 0000000..1c9cc72 --- /dev/null +++ b/packages/kanban/src/__tests__/db-operations.test.ts @@ -0,0 +1,471 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; +import { eq } from "drizzle-orm"; + +// Use the shared test-db helper from core (resolves via workspace) +import { createTestDb, seedSphere } from "../../../core/src/__tests__/helpers/test-db.ts"; + +let db: BetterSQLite3Database; + +// Mock getDb so operations use our in-memory test database +vi.mock("@exosphere/core/db", () => ({ + getDb: () => db, +})); + +import { + insertTask, + updateTask, + deleteTaskCascade, + insertStatusChangeAndUpdateTask, + updateTaskPosition, + assignTask, + insertComment, + updateComment, + deleteComment, + hideTask, + unhideTask, + hideComment, + unhideComment, + handleKanbanModeration, +} from "../db/operations.ts"; +import { kanbanTasks, kanbanTaskComments, kanbanTaskStatusChanges } from "../db/schema.ts"; + +const AUTHOR_DID = "did:plc:author1"; +const MOD_DID = "did:plc:mod1"; +const SPHERE_ID = "test-sphere-001"; +// Valid TID string (insertComment derives updatedAt via tidToDate) +const COMMENT_TID_1 = "3mhy7w6tbg22b"; +const COMMENT_TID_2 = "3mhy7w6tbg22c"; + +function seedTask(overrides: Partial & { id: string }) { + const values = { + sphereId: SPHERE_ID, + number: 1, + authorDid: AUTHOR_DID, + title: "Test Task", + description: "A test task", + status: "backlog" as const, + position: 1000, + ...overrides, + }; + db.insert(kanbanTasks).values(values).run(); + return values; +} + +beforeEach(() => { + db = createTestDb(); + seedSphere(db, { id: SPHERE_ID, handle: "test.bsky.social", ownerDid: AUTHOR_DID }); +}); + +// ---- insertTask ---- + +describe("insertTask", () => { + it("inserts a task with auto-incrementing number", () => { + const task1 = insertTask({ + id: "t-1", + sphereId: SPHERE_ID, + authorDid: AUTHOR_DID, + title: "First", + description: "Desc", + status: "backlog", + assigneeDid: null, + pdsUri: null, + }); + expect(task1).toBeDefined(); + expect(task1!.number).toBe(1); + + const task2 = insertTask({ + id: "t-2", + sphereId: SPHERE_ID, + authorDid: AUTHOR_DID, + title: "Second", + description: "Desc", + status: "todo", + assigneeDid: null, + pdsUri: null, + }); + expect(task2!.number).toBe(2); + }); + + it("assigns position with gap of 1000", () => { + const task1 = insertTask({ + id: "t-1", + sphereId: SPHERE_ID, + authorDid: AUTHOR_DID, + title: "First", + description: "", + status: "backlog", + assigneeDid: null, + pdsUri: null, + }); + expect(task1!.position).toBe(1000); + + const task2 = insertTask({ + id: "t-2", + sphereId: SPHERE_ID, + authorDid: AUTHOR_DID, + title: "Second", + description: "", + status: "backlog", + assigneeDid: null, + pdsUri: null, + }); + expect(task2!.position).toBe(2000); + }); + + it("positions are per-status (different columns get independent positions)", () => { + const backlog = insertTask({ + id: "t-1", + sphereId: SPHERE_ID, + authorDid: AUTHOR_DID, + title: "Backlog task", + description: "", + status: "backlog", + assigneeDid: null, + pdsUri: null, + }); + const todo = insertTask({ + id: "t-2", + sphereId: SPHERE_ID, + authorDid: AUTHOR_DID, + title: "Todo task", + description: "", + status: "todo", + assigneeDid: null, + pdsUri: null, + }); + // Both should start at 1000 since they're in different columns + expect(backlog!.position).toBe(1000); + expect(todo!.position).toBe(1000); + }); + + it("handles conflict (duplicate id) gracefully", () => { + insertTask({ + id: "t-1", + sphereId: SPHERE_ID, + authorDid: AUTHOR_DID, + title: "Original", + description: "", + status: "backlog", + assigneeDid: null, + pdsUri: null, + }); + + const dup = insertTask({ + id: "t-1", + sphereId: SPHERE_ID, + authorDid: AUTHOR_DID, + title: "Duplicate", + description: "", + status: "backlog", + assigneeDid: null, + pdsUri: null, + }); + expect(dup).toBeDefined(); + expect(dup!.title).toBe("Original"); + }); +}); + +// ---- updateTask ---- + +describe("updateTask", () => { + it("updates title", () => { + seedTask({ id: "t-1" }); + updateTask("t-1", { title: "New Title" }); + + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); + expect(task!.title).toBe("New Title"); + }); + + it("updates description", () => { + seedTask({ id: "t-1" }); + updateTask("t-1", { description: "Updated description" }); + + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); + expect(task!.description).toBe("Updated description"); + }); + + it("updates updatedAt timestamp", () => { + seedTask({ id: "t-1" }); + const before = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); + + updateTask("t-1", { title: "Changed" }); + + const after = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); + expect(after!.updatedAt).not.toBe(before!.updatedAt); + }); +}); + +// ---- deleteTaskCascade ---- + +describe("deleteTaskCascade", () => { + it("deletes a task and all related records", () => { + seedTask({ id: "t-1" }); + db.insert(kanbanTaskComments) + .values({ id: "c-1", taskId: "t-1", authorDid: AUTHOR_DID, content: "Hello" }) + .run(); + db.insert(kanbanTaskStatusChanges) + .values({ id: "s-1", taskId: "t-1", authorDid: MOD_DID, status: "todo" }) + .run(); + + deleteTaskCascade("t-1"); + + expect(db.select().from(kanbanTasks).all()).toHaveLength(0); + expect(db.select().from(kanbanTaskComments).all()).toHaveLength(0); + expect(db.select().from(kanbanTaskStatusChanges).all()).toHaveLength(0); + }); +}); + +// ---- insertStatusChangeAndUpdateTask ---- + +describe("insertStatusChangeAndUpdateTask", () => { + it("inserts a status record and updates the task status", () => { + seedTask({ id: "t-1", status: "backlog" }); + + insertStatusChangeAndUpdateTask({ + id: "s-1", + taskId: "t-1", + authorDid: MOD_DID, + status: "in-progress", + pdsUri: null, + }); + + const statuses = db.select().from(kanbanTaskStatusChanges).all(); + expect(statuses).toHaveLength(1); + expect(statuses[0].status).toBe("in-progress"); + + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); + expect(task!.status).toBe("in-progress"); + }); + + it("places task at end of target column when no position given", () => { + seedTask({ id: "t-1", status: "backlog", position: 1000 }); + seedTask({ id: "t-2", number: 2, status: "todo", position: 1000 }); + + insertStatusChangeAndUpdateTask({ + id: "s-1", + taskId: "t-1", + authorDid: MOD_DID, + status: "todo", + pdsUri: null, + }); + + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); + expect(task!.status).toBe("todo"); + expect(task!.position).toBe(2000); // 1000 (existing max) + 1000 gap + }); + + it("uses explicit position when provided", () => { + seedTask({ id: "t-1", status: "backlog", position: 1000 }); + + insertStatusChangeAndUpdateTask({ + id: "s-1", + taskId: "t-1", + authorDid: MOD_DID, + status: "todo", + pdsUri: null, + position: 500, + }); + + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); + expect(task!.position).toBe(500); + }); +}); + +// ---- updateTaskPosition ---- + +describe("updateTaskPosition", () => { + it("updates position", () => { + seedTask({ id: "t-1", position: 1000 }); + updateTaskPosition("t-1", 3000); + + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); + expect(task!.position).toBe(3000); + }); +}); + +// ---- assignTask ---- + +describe("assignTask", () => { + it("assigns a member", () => { + seedTask({ id: "t-1" }); + assignTask("t-1", "did:plc:assignee1"); + + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); + expect(task!.assigneeDid).toBe("did:plc:assignee1"); + }); + + it("unassigns with null", () => { + seedTask({ id: "t-1", assigneeDid: "did:plc:assignee1" }); + assignTask("t-1", null); + + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); + expect(task!.assigneeDid).toBeNull(); + }); +}); + +// ---- Comments ---- + +describe("insertComment / updateComment / deleteComment", () => { + it("inserts a comment", () => { + seedTask({ id: "t-1" }); + insertComment({ + id: COMMENT_TID_1, + taskId: "t-1", + authorDid: AUTHOR_DID, + content: "Great idea!", + pdsUri: null, + }); + + const comments = db.select().from(kanbanTaskComments).all(); + expect(comments).toHaveLength(1); + expect(comments[0].content).toBe("Great idea!"); + }); + + it("upserts on conflict (same id updates content)", () => { + seedTask({ id: "t-1" }); + insertComment({ + id: COMMENT_TID_1, + taskId: "t-1", + authorDid: AUTHOR_DID, + content: "Original", + pdsUri: null, + }); + insertComment({ + id: COMMENT_TID_1, + taskId: "t-1", + authorDid: AUTHOR_DID, + content: "Updated via upsert", + pdsUri: null, + }); + + const comment = db + .select() + .from(kanbanTaskComments) + .where(eq(kanbanTaskComments.id, COMMENT_TID_1)) + .get(); + expect(comment!.content).toBe("Updated via upsert"); + }); + + it("updates a comment's content", () => { + seedTask({ id: "t-1" }); + insertComment({ + id: COMMENT_TID_1, + taskId: "t-1", + authorDid: AUTHOR_DID, + content: "Original", + pdsUri: null, + }); + updateComment(COMMENT_TID_1, "Edited content"); + + const comment = db + .select() + .from(kanbanTaskComments) + .where(eq(kanbanTaskComments.id, COMMENT_TID_1)) + .get(); + expect(comment!.content).toBe("Edited content"); + }); + + it("deletes a comment", () => { + seedTask({ id: "t-1" }); + insertComment({ + id: COMMENT_TID_1, + taskId: "t-1", + authorDid: AUTHOR_DID, + content: "Comment", + pdsUri: null, + }); + + deleteComment(COMMENT_TID_1); + expect(db.select().from(kanbanTaskComments).all()).toHaveLength(0); + }); +}); + +// ---- Moderation ---- + +describe("hideTask / unhideTask", () => { + it("hides and unhides a task", () => { + seedTask({ id: "t-1" }); + + hideTask("t-1", MOD_DID); + let task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); + expect(task!.hiddenAt).toBeTruthy(); + expect(task!.moderatedBy).toBe(MOD_DID); + + unhideTask("t-1"); + task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); + expect(task!.hiddenAt).toBeNull(); + expect(task!.moderatedBy).toBeNull(); + }); +}); + +describe("hideComment / unhideComment", () => { + it("hides and unhides a comment", () => { + seedTask({ id: "t-1" }); + insertComment({ + id: COMMENT_TID_1, + taskId: "t-1", + authorDid: AUTHOR_DID, + content: "Comment", + pdsUri: null, + }); + + hideComment(COMMENT_TID_1, MOD_DID); + let comment = db + .select() + .from(kanbanTaskComments) + .where(eq(kanbanTaskComments.id, COMMENT_TID_1)) + .get(); + expect(comment!.hiddenAt).toBeTruthy(); + expect(comment!.moderatedBy).toBe(MOD_DID); + + unhideComment(COMMENT_TID_1); + comment = db + .select() + .from(kanbanTaskComments) + .where(eq(kanbanTaskComments.id, COMMENT_TID_1)) + .get(); + expect(comment!.hiddenAt).toBeNull(); + expect(comment!.moderatedBy).toBeNull(); + }); +}); + +describe("handleKanbanModeration", () => { + it("hides a task by pdsUri and returns true", () => { + const pdsUri = "at://did:plc:author1/site.exosphere.kanban.entry/t-1"; + seedTask({ id: "t-1", pdsUri }); + + const handled = handleKanbanModeration(pdsUri, MOD_DID); + expect(handled).toBe(true); + + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); + expect(task!.hiddenAt).toBeTruthy(); + }); + + it("hides a comment by pdsUri and returns true", () => { + seedTask({ id: "t-1" }); + const commentPdsUri = `at://did:plc:author1/site.exosphere.kanban.comment/${COMMENT_TID_1}`; + insertComment({ + id: COMMENT_TID_1, + taskId: "t-1", + authorDid: AUTHOR_DID, + content: "Comment", + pdsUri: commentPdsUri, + }); + + const handled = handleKanbanModeration(commentPdsUri, MOD_DID); + expect(handled).toBe(true); + + const comment = db + .select() + .from(kanbanTaskComments) + .where(eq(kanbanTaskComments.id, COMMENT_TID_1)) + .get(); + expect(comment!.hiddenAt).toBeTruthy(); + }); + + it("returns false when pdsUri matches nothing", () => { + const handled = handleKanbanModeration("at://unknown/col/rkey", MOD_DID); + expect(handled).toBe(false); + }); +}); diff --git a/packages/kanban/src/__tests__/schemas.test.ts b/packages/kanban/src/__tests__/schemas.test.ts new file mode 100644 index 0000000..c0ae7e6 --- /dev/null +++ b/packages/kanban/src/__tests__/schemas.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from "vitest"; + +import { + createTaskSchema, + updateTaskSchema, + updateStatusSchema, + assignTaskSchema, + reorderSchema, +} from "../schemas/task.ts"; +import { createCommentSchema, updateCommentSchema } from "../schemas/comment.ts"; + +describe("createTaskSchema", () => { + const valid = { title: "Implement auth flow" }; + + it("accepts valid input with defaults", () => { + const result = createTaskSchema.parse(valid); + expect(result.status).toBe("backlog"); + expect(result.description).toBe(""); + }); + + it("accepts explicit status and description", () => { + const result = createTaskSchema.parse({ + ...valid, + status: "todo", + description: "Detailed description", + }); + expect(result.status).toBe("todo"); + expect(result.description).toBe("Detailed description"); + }); + + it("accepts assigneeDid", () => { + const result = createTaskSchema.parse({ ...valid, assigneeDid: "did:plc:abc" }); + expect(result.assigneeDid).toBe("did:plc:abc"); + }); + + it("accepts null assigneeDid", () => { + const result = createTaskSchema.parse({ ...valid, assigneeDid: null }); + expect(result.assigneeDid).toBeNull(); + }); + + it("accepts any string status (validated at API layer against sphere columns)", () => { + const result = createTaskSchema.parse({ ...valid, status: "custom-column" }); + expect(result.status).toBe("custom-column"); + }); + + it("rejects empty title", () => { + expect(() => createTaskSchema.parse({ title: "" })).toThrow(); + }); + + it("rejects title over 200 chars", () => { + expect(() => createTaskSchema.parse({ title: "x".repeat(201) })).toThrow(); + }); + + it("rejects description over 10 000 chars", () => { + expect(() => createTaskSchema.parse({ ...valid, description: "x".repeat(10001) })).toThrow(); + }); +}); + +describe("updateTaskSchema", () => { + it("accepts partial update", () => { + const result = updateTaskSchema.parse({ title: "New title" }); + expect(result.title).toBe("New title"); + expect(result.description).toBeUndefined(); + }); + + it("accepts empty object", () => { + const result = updateTaskSchema.parse({}); + expect(result.title).toBeUndefined(); + }); + + it("rejects empty title", () => { + expect(() => updateTaskSchema.parse({ title: "" })).toThrow(); + }); +}); + +describe("updateStatusSchema", () => { + it("accepts a valid status", () => { + const result = updateStatusSchema.parse({ status: "in-progress" }); + expect(result.status).toBe("in-progress"); + }); + + it("accepts optional position", () => { + const result = updateStatusSchema.parse({ status: "todo", position: 2000 }); + expect(result.position).toBe(2000); + }); + + it("accepts any string status (validated at API layer)", () => { + const result = updateStatusSchema.parse({ status: "custom-col" }); + expect(result.status).toBe("custom-col"); + }); +}); + +describe("assignTaskSchema", () => { + it("accepts a DID", () => { + const result = assignTaskSchema.parse({ assigneeDid: "did:plc:abc" }); + expect(result.assigneeDid).toBe("did:plc:abc"); + }); + + it("accepts null to unassign", () => { + const result = assignTaskSchema.parse({ assigneeDid: null }); + expect(result.assigneeDid).toBeNull(); + }); + + it("rejects empty string", () => { + expect(() => assignTaskSchema.parse({ assigneeDid: "" })).toThrow(); + }); +}); + +describe("reorderSchema", () => { + it("accepts integer position", () => { + const result = reorderSchema.parse({ position: 3000 }); + expect(result.position).toBe(3000); + }); + + it("rejects float position", () => { + expect(() => reorderSchema.parse({ position: 1.5 })).toThrow(); + }); +}); + +describe("createCommentSchema", () => { + it("accepts valid input", () => { + const result = createCommentSchema.parse({ content: "Looks good!" }); + expect(result.content).toBe("Looks good!"); + }); + + it("rejects empty content", () => { + expect(() => createCommentSchema.parse({ content: "" })).toThrow(); + }); + + it("rejects content over 5 000 chars", () => { + expect(() => createCommentSchema.parse({ content: "x".repeat(5001) })).toThrow(); + }); +}); + +describe("updateCommentSchema", () => { + it("accepts valid input", () => { + const result = updateCommentSchema.parse({ content: "Updated!" }); + expect(result.content).toBe("Updated!"); + }); + + it("rejects empty content", () => { + expect(() => updateCommentSchema.parse({ content: "" })).toThrow(); + }); +}); diff --git a/packages/kanban/src/api/columns.ts b/packages/kanban/src/api/columns.ts new file mode 100644 index 0000000..3703fe1 --- /dev/null +++ b/packages/kanban/src/api/columns.ts @@ -0,0 +1,95 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { requireAuth, type AuthEnv } from "@exosphere/core/auth"; +import { requirePermission } from "@exosphere/core/permissions"; +import type { SphereEnv } from "@exosphere/core/types"; +import { + createColumnSchema, + updateColumnSchema, + reorderColumnsSchema, + deleteColumnSchema, +} from "../schemas/column.ts"; +import { + getColumns, + insertColumn, + updateColumnLabel, + deleteColumnAndReassign, + reorderColumns, +} from "../db/operations.ts"; +import type { KanbanColumnDef } from "../types.ts"; + +const MODULE = "kanban"; + +function toColumnDef(col: { + id: string; + slug: string; + label: string; + position: number; +}): KanbanColumnDef { + return { id: col.id, slug: col.slug, label: col.label, position: col.position }; +} + +const app = new Hono(); + +// List columns +app.get("/columns", (c) => { + const cols = getColumns(c.var.sphereId); + return c.json({ columns: cols.map(toColumnDef) }); +}); + +// Create column +app.post("/columns", requireAuth, requirePermission(MODULE, "manageSettings"), async (c) => { + const body = await c.req.json(); + const result = createColumnSchema.safeParse(body); + if (!result.success) return c.json({ error: z.flattenError(result.error) }, 400); + + const col = insertColumn({ sphereId: c.var.sphereId, label: result.data.label }); + return c.json({ column: toColumnDef(col) }, 201); +}); + +// Rename column +app.put("/columns/:id", requireAuth, requirePermission(MODULE, "manageSettings"), async (c) => { + const id = c.req.param("id"); + const body = await c.req.json(); + const result = updateColumnSchema.safeParse(body); + if (!result.success) return c.json({ error: z.flattenError(result.error) }, 400); + + const cols = getColumns(c.var.sphereId); + const existing = cols.find((col) => col.id === id); + if (!existing) return c.json({ error: "Column not found" }, 404); + + updateColumnLabel(id, result.data.label); + return c.json({ column: toColumnDef({ ...existing, label: result.data.label }) }); +}); + +// Delete column (reassign tasks to another) +app.delete("/columns/:id", requireAuth, requirePermission(MODULE, "manageSettings"), async (c) => { + const id = c.req.param("id"); + const body = await c.req.json(); + const result = deleteColumnSchema.safeParse(body); + if (!result.success) return c.json({ error: z.flattenError(result.error) }, 400); + + const sphereId = c.var.sphereId; + const cols = getColumns(sphereId); + if (cols.length <= 1) return c.json({ error: "Cannot delete the last column" }, 400); + if (!cols.find((col) => col.id === id)) return c.json({ error: "Column not found" }, 404); + + try { + deleteColumnAndReassign(sphereId, id, result.data.reassignTo); + } catch { + return c.json({ error: "Failed to delete column" }, 400); + } + return c.json({ ok: true }); +}); + +// Reorder columns +app.post("/columns/reorder", requireAuth, requirePermission(MODULE, "manageSettings"), async (c) => { + const body = await c.req.json(); + const result = reorderColumnsSchema.safeParse(body); + if (!result.success) return c.json({ error: z.flattenError(result.error) }, 400); + + reorderColumns(c.var.sphereId, result.data.columnIds); + return c.json({ ok: true }); +}); + +export { app as columnsApi }; diff --git a/packages/kanban/src/api/comments.ts b/packages/kanban/src/api/comments.ts new file mode 100644 index 0000000..90ffceb --- /dev/null +++ b/packages/kanban/src/api/comments.ts @@ -0,0 +1,255 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { getDb } from "@exosphere/core/db"; +import { eq, and, sql } from "@exosphere/core/db/drizzle"; +import { requireAuth, type AuthEnv } from "@exosphere/core/auth"; +import { getActiveMemberRole } from "@exosphere/core/sphere"; +import { requirePermission, checkPermission } from "@exosphere/core/permissions"; +import { putPdsRecord, deletePdsRecord, generateRkey, tidToDate } from "@exosphere/core/pds"; +import { resolveDidHandles } from "@exosphere/core/identity"; +import type { SphereEnv } from "@exosphere/core/types"; +import { kanbanTasks, kanbanTaskComments } from "../db/schema.ts"; +import { createCommentSchema, updateCommentSchema } from "../schemas/comment.ts"; +import { + insertComment, + updateComment, + deleteComment, + hideComment, + unhideComment, +} from "../db/operations.ts"; + +const COMMENT_COLLECTION = "site.exosphere.kanban.comment"; +const MODERATION_COLLECTION = "site.exosphere.moderation"; + +const MODULE = "kanban"; + +const app = new Hono(); + +/** Verify a comment belongs to the current sphere by joining through its parent task. */ +function findCommentInSphere(commentId: string, sphereId: string) { + return getDb() + .select() + .from(kanbanTaskComments) + .innerJoin(kanbanTasks, eq(kanbanTasks.id, kanbanTaskComments.taskId)) + .where(and(eq(kanbanTaskComments.id, commentId), eq(kanbanTasks.sphereId, sphereId))) + .get(); +} + +// List comments for a task +app.get("/:id/comments", async (c) => { + const taskId = c.req.param("id"); + const sphereId = c.var.sphereId; + const db = getDb(); + + // Verify the task belongs to this sphere + const task = db + .select({ id: kanbanTasks.id }) + .from(kanbanTasks) + .where(and(eq(kanbanTasks.id, taskId), eq(kanbanTasks.sphereId, sphereId))) + .get(); + if (!task) { + return c.json({ comments: [] }); + } + + const rows = db + .select({ + id: kanbanTaskComments.id, + taskId: kanbanTaskComments.taskId, + authorDid: kanbanTaskComments.authorDid, + content: kanbanTaskComments.content, + pdsUri: kanbanTaskComments.pdsUri, + updatedAt: kanbanTaskComments.updatedAt, + hiddenAt: kanbanTaskComments.hiddenAt, + moderatedBy: kanbanTaskComments.moderatedBy, + }) + .from(kanbanTaskComments) + .where(and(eq(kanbanTaskComments.taskId, taskId), sql`${kanbanTaskComments.hiddenAt} is null`)) + .orderBy(kanbanTaskComments.id) + .all(); + + const handleMap = await resolveDidHandles(rows.map((r) => r.authorDid)); + const comments = rows.map((r) => ({ + ...r, + createdAt: tidToDate(r.id), + authorHandle: handleMap.get(r.authorDid) ?? null, + })); + + return c.json({ comments }); +}); + +// Create a comment +app.post("/:id/comments", requireAuth, requirePermission(MODULE, "comment"), async (c) => { + const taskId = c.req.param("id"); + const body = await c.req.json(); + const result = createCommentSchema.safeParse(body); + if (!result.success) { + return c.json({ error: z.flattenError(result.error) }, 400); + } + + const { content } = result.data; + const sphereId = c.var.sphereId; + const sphereVisibility = c.var.sphereVisibility; + const db = getDb(); + const did = c.var.did; + + const existing = db + .select({ id: kanbanTasks.id, pdsUri: kanbanTasks.pdsUri }) + .from(kanbanTasks) + .where( + and( + eq(kanbanTasks.id, taskId), + eq(kanbanTasks.sphereId, sphereId), + sql`${kanbanTasks.hiddenAt} is null`, + ), + ) + .get(); + if (!existing) { + return c.json({ error: "Task not found" }, 404); + } + + const id = generateRkey(); + let pdsUri: string | null = null; + + if (sphereVisibility === "public" && existing.pdsUri) { + const session = c.var.session; + pdsUri = await putPdsRecord(session, COMMENT_COLLECTION, id, { + subject: existing.pdsUri, + content, + }); + } + + insertComment({ id, taskId, authorDid: did, content, pdsUri }); + + const comment = db.select().from(kanbanTaskComments).where(eq(kanbanTaskComments.id, id)).get(); + + return c.json({ comment: comment ? { ...comment, createdAt: tidToDate(id) } : comment }, 201); +}); + +// Update own comment +app.put("/comments/:id", requireAuth, async (c) => { + const id = c.req.param("id"); + const body = await c.req.json(); + const result = updateCommentSchema.safeParse(body); + if (!result.success) { + return c.json({ error: z.flattenError(result.error) }, 400); + } + + const { content } = result.data; + const sphereId = c.var.sphereId; + const db = getDb(); + const did = c.var.did; + + const row = findCommentInSphere(id, sphereId); + if (!row) { + return c.json({ error: "Comment not found" }, 404); + } + const comment = row.kanban_task_comments; + if (comment.authorDid !== did) { + return c.json({ error: "Forbidden" }, 403); + } + + if (comment.pdsUri) { + const session = c.var.session; + const parent = db + .select({ pdsUri: kanbanTasks.pdsUri }) + .from(kanbanTasks) + .where(eq(kanbanTasks.id, comment.taskId)) + .get(); + if (!parent?.pdsUri) { + return c.json({ error: "Parent task not found" }, 404); + } + await putPdsRecord(session, COMMENT_COLLECTION, id, { + subject: parent.pdsUri, + content, + updatedAt: new Date().toISOString(), + }); + } + + updateComment(id, content); + + const updated = db.select().from(kanbanTaskComments).where(eq(kanbanTaskComments.id, id)).get(); + + return c.json({ comment: updated ? { ...updated, createdAt: tidToDate(id) } : updated }); +}); + +// Delete own comment or admin-moderate +app.delete("/comments/:id", requireAuth, async (c) => { + const id = c.req.param("id"); + const db = getDb(); + const did = c.var.did; + const sphereId = c.var.sphereId; + const sphereOwnerDid = c.var.sphereOwnerDid; + const spherePdsUri = c.var.spherePdsUri; + + const row = findCommentInSphere(id, sphereId); + if (!row) { + return c.json({ error: "Comment not found" }, 404); + } + const comment = row.kanban_task_comments; + const isAuthor = comment.authorDid === did; + + if (!isAuthor) { + const role = getActiveMemberRole(sphereId, did); + if (!checkPermission(sphereId, MODULE, "moderate", role)) { + return c.json({ error: "Forbidden" }, 403); + } + + if (comment.hiddenAt) { + return c.json({ ok: true }); + } + + if (comment.pdsUri) { + const sphereUri = spherePdsUri ?? `at://${sphereOwnerDid}/site.exosphere.sphere.profile/self`; + const session = c.var.session; + const pdsUri = await putPdsRecord(session, MODERATION_COLLECTION, id, { + sphere: sphereUri, + subject: comment.pdsUri, + action: "remove", + }); + if (!pdsUri) { + console.warn( + "[kanban] Moderation PDS record failed for comment %s — hiding locally only", + id, + ); + } + } + + hideComment(id, did); + return c.json({ ok: true }); + } + + // Author deleting their own comment + if (comment.pdsUri) { + const session = c.var.session; + await deletePdsRecord(session, COMMENT_COLLECTION, id); + } + + deleteComment(id); + return c.json({ ok: true }); +}); + +// Unhide a moderated comment +app.post("/comments/:id/unhide", requireAuth, requirePermission(MODULE, "moderate"), async (c) => { + const id = c.req.param("id"); + const sphereId = c.var.sphereId; + const did = c.var.did; + + const row = findCommentInSphere(id, sphereId); + if (!row) { + return c.json({ error: "Comment not found" }, 404); + } + const comment = row.kanban_task_comments; + if (!comment.hiddenAt) { + return c.json({ ok: true }); + } + + if (comment.moderatedBy === did) { + const session = c.var.session; + await deletePdsRecord(session, MODERATION_COLLECTION, id); + } + + unhideComment(id); + return c.json({ ok: true }); +}); + +export { app as commentsApi }; diff --git a/packages/kanban/src/api/routes.ts b/packages/kanban/src/api/routes.ts new file mode 100644 index 0000000..11b29ba --- /dev/null +++ b/packages/kanban/src/api/routes.ts @@ -0,0 +1,14 @@ +import { Hono } from "hono"; +import type { AuthEnv } from "@exosphere/core/auth"; +import type { SphereEnv } from "@exosphere/core/types"; +import { tasksApi } from "./tasks.ts"; +import { commentsApi } from "./comments.ts"; +import { columnsApi } from "./columns.ts"; + +const app = new Hono(); + +app.route("/", columnsApi); +app.route("/", tasksApi); +app.route("/", commentsApi); + +export { app as kanbanApi }; diff --git a/packages/kanban/src/api/tasks.ts b/packages/kanban/src/api/tasks.ts new file mode 100644 index 0000000..708773b --- /dev/null +++ b/packages/kanban/src/api/tasks.ts @@ -0,0 +1,549 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { getDb } from "@exosphere/core/db"; +import { eq, and, sql, asc } from "@exosphere/core/db/drizzle"; +import { requireAuth, type AuthEnv } from "@exosphere/core/auth"; +import { getActiveMemberRole } from "@exosphere/core/sphere"; +import { requirePermission, checkPermission } from "@exosphere/core/permissions"; +import { putPdsRecord, deletePdsRecord, generateRkey, tidToDate } from "@exosphere/core/pds"; +import { resolveDidHandles } from "@exosphere/core/identity"; +import type { SphereEnv } from "@exosphere/core/types"; +import { kanbanTasks, kanbanTaskComments, kanbanTaskStatusChanges } from "../db/schema.ts"; +import { + createTaskSchema, + updateTaskSchema, + updateStatusSchema, + assignTaskSchema, + reorderSchema, +} from "../schemas/task.ts"; +import { + getColumns, + getColumnBySlug, + insertTask, + updateTask, + deleteTaskCascade, + insertStatusChangeAndUpdateTask, + updateTaskPosition, + assignTask, + hideTask, + unhideTask, +} from "../db/operations.ts"; + +const COLLECTION = "site.exosphere.kanban.entry"; +const STATUS_COLLECTION = "site.exosphere.kanban.status"; +const MODERATION_COLLECTION = "site.exosphere.moderation"; + +const MODULE = "kanban"; + +const app = new Hono(); + +// Get single task by number +app.get("/:number{[0-9]+}", async (c) => { + const number = parseInt(c.req.param("number"), 10); + const sphereId = c.var.sphereId; + const db = getDb(); + + const row = db + .select({ + id: kanbanTasks.id, + sphereId: kanbanTasks.sphereId, + number: kanbanTasks.number, + authorDid: kanbanTasks.authorDid, + title: kanbanTasks.title, + description: kanbanTasks.description, + status: kanbanTasks.status, + position: kanbanTasks.position, + assigneeDid: kanbanTasks.assigneeDid, + pdsUri: kanbanTasks.pdsUri, + hiddenAt: kanbanTasks.hiddenAt, + moderatedBy: kanbanTasks.moderatedBy, + updatedAt: kanbanTasks.updatedAt, + commentCount: + sql`(select count(*) from ${kanbanTaskComments} where ${kanbanTaskComments.taskId} = ${kanbanTasks.id} and ${kanbanTaskComments.hiddenAt} is null)`.as( + "comment_count", + ), + }) + .from(kanbanTasks) + .where( + and( + eq(kanbanTasks.number, number), + eq(kanbanTasks.sphereId, sphereId), + sql`${kanbanTasks.hiddenAt} is null`, + ), + ) + .get(); + + if (!row) { + return c.json({ error: "Task not found" }, 404); + } + + const dids = [row.authorDid]; + if (row.assigneeDid) dids.push(row.assigneeDid); + const handleMap = await resolveDidHandles(dids); + + return c.json({ + task: { + ...row, + createdAt: tidToDate(row.id), + authorHandle: handleMap.get(row.authorDid) ?? null, + assigneeHandle: row.assigneeDid ? (handleMap.get(row.assigneeDid) ?? null) : null, + }, + }); +}); + +// List all tasks (grouped by status, ordered by position) +app.get("/", async (c) => { + const db = getDb(); + const sphereId = c.var.sphereId; + + const rows = db + .select({ + id: kanbanTasks.id, + sphereId: kanbanTasks.sphereId, + number: kanbanTasks.number, + authorDid: kanbanTasks.authorDid, + title: kanbanTasks.title, + description: kanbanTasks.description, + status: kanbanTasks.status, + position: kanbanTasks.position, + assigneeDid: kanbanTasks.assigneeDid, + pdsUri: kanbanTasks.pdsUri, + hiddenAt: kanbanTasks.hiddenAt, + moderatedBy: kanbanTasks.moderatedBy, + updatedAt: kanbanTasks.updatedAt, + commentCount: + sql`(select count(*) from ${kanbanTaskComments} where ${kanbanTaskComments.taskId} = ${kanbanTasks.id} and ${kanbanTaskComments.hiddenAt} is null)`.as( + "comment_count", + ), + }) + .from(kanbanTasks) + .where(and(eq(kanbanTasks.sphereId, sphereId), sql`${kanbanTasks.hiddenAt} is null`)) + .orderBy(asc(kanbanTasks.position)) + .all(); + + const allDids = new Set(); + for (const r of rows) { + allDids.add(r.authorDid); + if (r.assigneeDid) allDids.add(r.assigneeDid); + } + const handleMap = await resolveDidHandles([...allDids]); + + const tasks = rows.map((r) => ({ + ...r, + createdAt: tidToDate(r.id), + authorHandle: handleMap.get(r.authorDid) ?? null, + assigneeHandle: r.assigneeDid ? (handleMap.get(r.assigneeDid) ?? null) : null, + })); + + // Group by column + const cols = getColumns(sphereId); + const tasksByColumn: Record = {}; + for (const col of cols) { + tasksByColumn[col.slug] = []; + } + for (const task of tasks) { + (tasksByColumn[task.status] ??= []).push(task); + } + + return c.json({ + columns: cols.map(({ id, slug, label, position }) => ({ id, slug, label, position })), + tasksByColumn, + tasks, + }); +}); + +// Create task +app.post("/", requireAuth, requirePermission(MODULE, "create"), async (c) => { + const body = await c.req.json(); + const result = createTaskSchema.safeParse(body); + if (!result.success) { + return c.json({ error: z.flattenError(result.error) }, 400); + } + + const { title, description, assigneeDid } = result.data; + let { status } = result.data; + const sphereId = c.var.sphereId; + const sphereOwnerDid = c.var.sphereOwnerDid; + const sphereVisibility = c.var.sphereVisibility; + const id = generateRkey(); + const did = c.var.did; + + // Validate status against this sphere's columns; fall back to first column + const cols = getColumns(sphereId); + if (!cols.some((col) => col.slug === status)) { + status = cols[0].slug; + } + + let pdsUri: string | null = null; + + if (sphereVisibility === "public") { + const session = c.var.session; + pdsUri = await putPdsRecord(session, COLLECTION, id, { + title, + description, + status, + subject: sphereOwnerDid, + assigneeDid: assigneeDid ?? undefined, + }); + } + + const row = insertTask({ + id, + sphereId, + authorDid: did, + title, + description, + status, + assigneeDid: assigneeDid ?? null, + pdsUri, + }); + + return c.json({ task: row ? { ...row, createdAt: tidToDate(id) } : row }, 201); +}); + +// Update task (author or manage permission) +app.put("/:id", requireAuth, async (c) => { + const id = c.req.param("id"); + const body = await c.req.json(); + const result = updateTaskSchema.safeParse(body); + if (!result.success) { + return c.json({ error: z.flattenError(result.error) }, 400); + } + + const db = getDb(); + const did = c.var.did; + const sphereId = c.var.sphereId; + const sphereOwnerDid = c.var.sphereOwnerDid; + + const existing = db + .select({ + id: kanbanTasks.id, + authorDid: kanbanTasks.authorDid, + title: kanbanTasks.title, + description: kanbanTasks.description, + status: kanbanTasks.status, + assigneeDid: kanbanTasks.assigneeDid, + pdsUri: kanbanTasks.pdsUri, + }) + .from(kanbanTasks) + .where(and(eq(kanbanTasks.id, id), eq(kanbanTasks.sphereId, sphereId))) + .get(); + if (!existing) { + return c.json({ error: "Task not found" }, 404); + } + + const isAuthor = existing.authorDid === did; + if (!isAuthor) { + const role = getActiveMemberRole(sphereId, did); + if (!checkPermission(sphereId, MODULE, "manageTasks", role)) { + return c.json({ error: "Forbidden" }, 403); + } + } + + const fields: { title?: string; description?: string } = {}; + if (result.data.title !== undefined) fields.title = result.data.title; + if (result.data.description !== undefined) fields.description = result.data.description; + + if (Object.keys(fields).length > 0) { + updateTask(id, fields); + + if (existing.pdsUri) { + const session = c.var.session; + await putPdsRecord(session, COLLECTION, id, { + title: fields.title ?? existing.title, + description: fields.description ?? existing.description, + status: existing.status, + subject: sphereOwnerDid, + assigneeDid: existing.assigneeDid ?? undefined, + updatedAt: new Date().toISOString(), + }); + } + } + + const updated = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, id)).get(); + return c.json({ task: updated ? { ...updated, createdAt: tidToDate(id) } : updated }); +}); + +// Delete task (author or manage permission) +app.delete("/:id", requireAuth, async (c) => { + const id = c.req.param("id"); + const db = getDb(); + const did = c.var.did; + const sphereId = c.var.sphereId; + + const existing = db + .select({ + id: kanbanTasks.id, + authorDid: kanbanTasks.authorDid, + pdsUri: kanbanTasks.pdsUri, + }) + .from(kanbanTasks) + .where(and(eq(kanbanTasks.id, id), eq(kanbanTasks.sphereId, sphereId))) + .get(); + if (!existing) { + return c.json({ error: "Task not found" }, 404); + } + + const isAuthor = existing.authorDid === did; + if (!isAuthor) { + const role = getActiveMemberRole(sphereId, did); + if (!checkPermission(sphereId, MODULE, "manageTasks", role)) { + return c.json({ error: "Forbidden" }, 403); + } + } + + if (existing.pdsUri) { + const session = c.var.session; + await deletePdsRecord(session, COLLECTION, id); + } + + deleteTaskCascade(id); + return c.json({ ok: true }); +}); + +// Move task to a different column +app.post("/:id/status", requireAuth, requirePermission(MODULE, "changeStatus"), async (c) => { + const id = c.req.param("id"); + const body = await c.req.json(); + const result = updateStatusSchema.safeParse(body); + if (!result.success) { + return c.json({ error: z.flattenError(result.error) }, 400); + } + + const { status, position } = result.data; + const db = getDb(); + const did = c.var.did; + const sphereId = c.var.sphereId; + const sphereVisibility = c.var.sphereVisibility; + + if (!getColumnBySlug(sphereId, status)) { + return c.json({ error: "Unknown column" }, 400); + } + + const existing = db + .select({ + id: kanbanTasks.id, + status: kanbanTasks.status, + pdsUri: kanbanTasks.pdsUri, + }) + .from(kanbanTasks) + .where(and(eq(kanbanTasks.id, id), eq(kanbanTasks.sphereId, sphereId))) + .get(); + if (!existing) { + return c.json({ error: "Task not found" }, 404); + } + + const statusId = generateRkey(); + + let pdsUri: string | null = null; + if (sphereVisibility === "public" && existing.pdsUri) { + const session = c.var.session; + pdsUri = await putPdsRecord(session, STATUS_COLLECTION, statusId, { + subject: existing.pdsUri, + status, + }); + } + insertStatusChangeAndUpdateTask({ + id: statusId, + taskId: id, + authorDid: did, + status, + pdsUri, + position, + }); + + return c.json({ status }); +}); + +// Assign/unassign a member +app.post("/:id/assign", requireAuth, requirePermission(MODULE, "assign"), async (c) => { + const id = c.req.param("id"); + const body = await c.req.json(); + const result = assignTaskSchema.safeParse(body); + if (!result.success) { + return c.json({ error: z.flattenError(result.error) }, 400); + } + + const db = getDb(); + const sphereId = c.var.sphereId; + + const existing = db + .select({ id: kanbanTasks.id }) + .from(kanbanTasks) + .where(and(eq(kanbanTasks.id, id), eq(kanbanTasks.sphereId, sphereId))) + .get(); + if (!existing) { + return c.json({ error: "Task not found" }, 404); + } + + assignTask(id, result.data.assigneeDid); + + return c.json({ ok: true }); +}); + +// Reorder task within its column +app.post("/:id/reorder", requireAuth, requirePermission(MODULE, "changeStatus"), async (c) => { + const id = c.req.param("id"); + const body = await c.req.json(); + const result = reorderSchema.safeParse(body); + if (!result.success) { + return c.json({ error: z.flattenError(result.error) }, 400); + } + + const db = getDb(); + const sphereId = c.var.sphereId; + + const existing = db + .select({ id: kanbanTasks.id }) + .from(kanbanTasks) + .where(and(eq(kanbanTasks.id, id), eq(kanbanTasks.sphereId, sphereId))) + .get(); + if (!existing) { + return c.json({ error: "Task not found" }, 404); + } + + updateTaskPosition(id, result.data.position); + + return c.json({ ok: true }); +}); + +// Hide task (moderation) +app.post("/:id/hide", requireAuth, requirePermission(MODULE, "moderate"), async (c) => { + const id = c.req.param("id"); + const db = getDb(); + const did = c.var.did; + const sphereId = c.var.sphereId; + const sphereOwnerDid = c.var.sphereOwnerDid; + const spherePdsUri = c.var.spherePdsUri; + + const existing = db + .select({ + id: kanbanTasks.id, + pdsUri: kanbanTasks.pdsUri, + hiddenAt: kanbanTasks.hiddenAt, + }) + .from(kanbanTasks) + .where(and(eq(kanbanTasks.id, id), eq(kanbanTasks.sphereId, sphereId))) + .get(); + if (!existing) { + return c.json({ error: "Task not found" }, 404); + } + + if (existing.hiddenAt) { + return c.json({ ok: true }); + } + + if (existing.pdsUri) { + const sphereUri = spherePdsUri ?? `at://${sphereOwnerDid}/site.exosphere.sphere.profile/self`; + const session = c.var.session; + const pdsUri = await putPdsRecord(session, MODERATION_COLLECTION, id, { + sphere: sphereUri, + subject: existing.pdsUri, + action: "remove", + }); + if (!pdsUri) { + console.warn("[kanban] Moderation PDS record failed for task %s — hiding locally only", id); + } + } + + hideTask(id, did); + return c.json({ ok: true }); +}); + +// Unhide task +app.post("/:id/unhide", requireAuth, requirePermission(MODULE, "moderate"), async (c) => { + const id = c.req.param("id"); + const db = getDb(); + const did = c.var.did; + const sphereId = c.var.sphereId; + + const existing = db + .select({ + id: kanbanTasks.id, + hiddenAt: kanbanTasks.hiddenAt, + moderatedBy: kanbanTasks.moderatedBy, + }) + .from(kanbanTasks) + .where(and(eq(kanbanTasks.id, id), eq(kanbanTasks.sphereId, sphereId))) + .get(); + if (!existing) { + return c.json({ error: "Task not found" }, 404); + } + + if (!existing.hiddenAt) { + return c.json({ ok: true }); + } + + if (existing.moderatedBy === did) { + const session = c.var.session; + await deletePdsRecord(session, MODERATION_COLLECTION, id); + } + + unhideTask(id); + return c.json({ ok: true }); +}); + +// Status change history +app.get("/:id/statuses", async (c) => { + const id = c.req.param("id"); + const db = getDb(); + + const rows = db + .select({ + id: kanbanTaskStatusChanges.id, + authorDid: kanbanTaskStatusChanges.authorDid, + status: kanbanTaskStatusChanges.status, + }) + .from(kanbanTaskStatusChanges) + .where(eq(kanbanTaskStatusChanges.taskId, id)) + .orderBy(sql`${kanbanTaskStatusChanges.id} desc`) + .all(); + + const handleMap = await resolveDidHandles(rows.map((r) => r.authorDid)); + const statuses = rows.map((r) => ({ + ...r, + createdAt: tidToDate(r.id), + authorHandle: handleMap.get(r.authorDid) ?? null, + })); + + return c.json({ statuses }); +}); + +// Search tasks +app.get("/search", (c) => { + const q = c.req.query("q")?.trim(); + const sphereId = c.var.sphereId; + if (!q) { + return c.json({ results: [] }); + } + + const db = getDb(); + const conditions = [eq(kanbanTasks.sphereId, sphereId), sql`${kanbanTasks.hiddenAt} is null`]; + + const hashMatch = q.startsWith("#") ? q.slice(1) : null; + const numberVal = parseInt(hashMatch ?? q, 10); + if (hashMatch !== null && !isNaN(numberVal)) { + conditions.push(eq(kanbanTasks.number, numberVal)); + } else if (!isNaN(numberVal) && String(numberVal) === q) { + conditions.push(eq(kanbanTasks.number, numberVal)); + } else { + const escaped = q.replace(/[%_\\]/g, "\\$&"); + conditions.push(sql`${kanbanTasks.title} LIKE ${"%" + escaped + "%"} ESCAPE '\\'`); + } + + const rows = db + .select({ + id: kanbanTasks.id, + number: kanbanTasks.number, + title: kanbanTasks.title, + status: kanbanTasks.status, + }) + .from(kanbanTasks) + .where(and(...conditions)) + .limit(10) + .all(); + + return c.json({ results: rows }); +}); + +export { app as tasksApi }; diff --git a/packages/kanban/src/client.ssr.ts b/packages/kanban/src/client.ssr.ts new file mode 100644 index 0000000..b2d499d --- /dev/null +++ b/packages/kanban/src/client.ssr.ts @@ -0,0 +1,13 @@ +import type { ClientModule } from "@exosphere/client/types"; +import { BoardPage } from "./ui/pages/board.tsx"; +import { TaskPage } from "./ui/pages/task.tsx"; +import { BoardSettingsPage } from "./ui/pages/settings.tsx"; + +export const kanbanModule: ClientModule = { + name: "board", + routes: [ + { path: "/board", component: BoardPage }, + { path: "/board/settings", component: BoardSettingsPage }, + { path: "/board/:number", component: TaskPage }, + ], +}; diff --git a/packages/kanban/src/client.ts b/packages/kanban/src/client.ts new file mode 100644 index 0000000..47ad492 --- /dev/null +++ b/packages/kanban/src/client.ts @@ -0,0 +1,17 @@ +import { lazy } from "@exosphere/client/router"; +import type { ClientModule } from "@exosphere/client/types"; + +const BoardPage = lazy(() => import("./ui/pages/board.tsx").then((m) => m.BoardPage)); +const TaskPage = lazy(() => import("./ui/pages/task.tsx").then((m) => m.TaskPage)); +const BoardSettingsPage = lazy(() => + import("./ui/pages/settings.tsx").then((m) => m.BoardSettingsPage), +); + +export const kanbanModule: ClientModule = { + name: "board", + routes: [ + { path: "/board", component: BoardPage }, + { path: "/board/settings", component: BoardSettingsPage }, + { path: "/board/:number", component: TaskPage }, + ], +}; diff --git a/packages/kanban/src/db/operations.ts b/packages/kanban/src/db/operations.ts new file mode 100644 index 0000000..f876d16 --- /dev/null +++ b/packages/kanban/src/db/operations.ts @@ -0,0 +1,378 @@ +import { getDb } from "@exosphere/core/db"; +import { eq, and, max, asc } from "@exosphere/core/db/drizzle"; +import { tidToDate, generateRkey } from "@exosphere/core/pds"; +import type { ModerationHandler } from "@exosphere/core/sphere"; +import { + kanbanColumns, + kanbanTasks, + kanbanTaskComments, + kanbanTaskStatusChanges, +} from "./schema.ts"; +import type { KanbanColumn } from "./schema.ts"; +import { defaultColumns } from "../schemas/task.ts"; + +const POSITION_GAP = 1000; + +// ---- Columns ---- + +export function getColumns(sphereId: string): KanbanColumn[] { + const db = getDb(); + const cols = db + .select() + .from(kanbanColumns) + .where(eq(kanbanColumns.sphereId, sphereId)) + .orderBy(asc(kanbanColumns.position)) + .all(); + + if (cols.length === 0) { + return seedDefaultColumns(sphereId); + } + return cols; +} + +export function seedDefaultColumns(sphereId: string): KanbanColumn[] { + const db = getDb(); + return db.transaction((tx) => { + const existing = tx + .select() + .from(kanbanColumns) + .where(eq(kanbanColumns.sphereId, sphereId)) + .all(); + if (existing.length > 0) return existing; + + for (let i = 0; i < defaultColumns.length; i++) { + tx.insert(kanbanColumns) + .values({ + id: generateRkey(), + sphereId, + slug: defaultColumns[i].slug, + label: defaultColumns[i].label, + position: (i + 1) * POSITION_GAP, + }) + .run(); + } + + return tx + .select() + .from(kanbanColumns) + .where(eq(kanbanColumns.sphereId, sphereId)) + .orderBy(asc(kanbanColumns.position)) + .all(); + }); +} + +function generateSlug(label: string, sphereId: string): string { + const base = + label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 50) || "column"; + + const db = getDb(); + let slug = base; + let suffix = 2; + for (;;) { + const exists = db + .select({ id: kanbanColumns.id }) + .from(kanbanColumns) + .where(and(eq(kanbanColumns.sphereId, sphereId), eq(kanbanColumns.slug, slug))) + .get(); + if (!exists) return slug; + slug = `${base}-${suffix}`; + suffix++; + } +} + +export function insertColumn(params: { sphereId: string; label: string }): KanbanColumn { + const db = getDb(); + const id = generateRkey(); + const slug = generateSlug(params.label, params.sphereId); + + const lastPosition = db + .select({ maxPosition: max(kanbanColumns.position) }) + .from(kanbanColumns) + .where(eq(kanbanColumns.sphereId, params.sphereId)) + .get(); + const position = (lastPosition?.maxPosition ?? 0) + POSITION_GAP; + + db.insert(kanbanColumns) + .values({ id, sphereId: params.sphereId, slug, label: params.label, position }) + .run(); + + return db.select().from(kanbanColumns).where(eq(kanbanColumns.id, id)).get()!; +} + +export function updateColumnLabel(id: string, label: string): void { + getDb().update(kanbanColumns).set({ label }).where(eq(kanbanColumns.id, id)).run(); +} + +export function deleteColumnAndReassign( + sphereId: string, + columnId: string, + reassignToSlug: string, +): void { + const db = getDb(); + db.transaction((tx) => { + const col = tx + .select() + .from(kanbanColumns) + .where(and(eq(kanbanColumns.id, columnId), eq(kanbanColumns.sphereId, sphereId))) + .get(); + if (!col) throw new Error("Column not found"); + + const target = tx + .select() + .from(kanbanColumns) + .where(and(eq(kanbanColumns.sphereId, sphereId), eq(kanbanColumns.slug, reassignToSlug))) + .get(); + if (!target) throw new Error("Target column not found"); + + tx.update(kanbanTasks) + .set({ status: reassignToSlug, updatedAt: new Date().toISOString() }) + .where(and(eq(kanbanTasks.sphereId, sphereId), eq(kanbanTasks.status, col.slug))) + .run(); + + tx.delete(kanbanColumns).where(eq(kanbanColumns.id, columnId)).run(); + }); +} + +export function reorderColumns(sphereId: string, columnIds: string[]): void { + const db = getDb(); + db.transaction((tx) => { + for (let i = 0; i < columnIds.length; i++) { + tx.update(kanbanColumns) + .set({ position: (i + 1) * POSITION_GAP }) + .where(and(eq(kanbanColumns.id, columnIds[i]), eq(kanbanColumns.sphereId, sphereId))) + .run(); + } + }); +} + +export function getColumnBySlug(sphereId: string, slug: string): KanbanColumn | undefined { + return getDb() + .select() + .from(kanbanColumns) + .where(and(eq(kanbanColumns.sphereId, sphereId), eq(kanbanColumns.slug, slug))) + .get(); +} + +// ---- Tasks ---- + +export function insertTask(params: { + id: string; + sphereId: string; + authorDid: string; + title: string; + description: string; + status: string; + assigneeDid: string | null; + pdsUri: string | null; +}): typeof kanbanTasks.$inferSelect | undefined { + const db = getDb(); + return db.transaction((tx) => { + const lastNumber = tx + .select({ maxNumber: max(kanbanTasks.number) }) + .from(kanbanTasks) + .where(eq(kanbanTasks.sphereId, params.sphereId)) + .get(); + const number = (lastNumber?.maxNumber ?? 0) + 1; + + const lastPosition = tx + .select({ maxPosition: max(kanbanTasks.position) }) + .from(kanbanTasks) + .where(and(eq(kanbanTasks.sphereId, params.sphereId), eq(kanbanTasks.status, params.status))) + .get(); + const position = (lastPosition?.maxPosition ?? 0) + POSITION_GAP; + + tx.insert(kanbanTasks) + .values({ + id: params.id, + sphereId: params.sphereId, + number, + authorDid: params.authorDid, + title: params.title, + description: params.description, + status: params.status, + position, + assigneeDid: params.assigneeDid, + pdsUri: params.pdsUri, + }) + .onConflictDoNothing() + .run(); + + return tx.select().from(kanbanTasks).where(eq(kanbanTasks.id, params.id)).get(); + }); +} + +export function updateTask(id: string, fields: { title?: string; description?: string }): void { + getDb() + .update(kanbanTasks) + .set({ ...fields, updatedAt: new Date().toISOString() }) + .where(eq(kanbanTasks.id, id)) + .run(); +} + +export function deleteTaskCascade(id: string): void { + const db = getDb(); + db.transaction((tx) => { + tx.delete(kanbanTaskComments).where(eq(kanbanTaskComments.taskId, id)).run(); + tx.delete(kanbanTaskStatusChanges).where(eq(kanbanTaskStatusChanges.taskId, id)).run(); + tx.delete(kanbanTasks).where(eq(kanbanTasks.id, id)).run(); + }); +} + +// ---- Status changes ---- + +export function insertStatusChangeAndUpdateTask(params: { + id: string; + taskId: string; + authorDid: string; + status: string; + pdsUri: string | null; + position?: number; +}): void { + const db = getDb(); + db.transaction((tx) => { + tx.insert(kanbanTaskStatusChanges) + .values({ + id: params.id, + taskId: params.taskId, + authorDid: params.authorDid, + status: params.status, + pdsUri: params.pdsUri, + }) + .onConflictDoNothing() + .run(); + + // If no position provided, place at end of target column + let position = params.position; + if (position === undefined) { + const task = tx.select().from(kanbanTasks).where(eq(kanbanTasks.id, params.taskId)).get(); + if (!task) return; + const sphereId = task.sphereId; + const lastPosition = tx + .select({ maxPosition: max(kanbanTasks.position) }) + .from(kanbanTasks) + .where(and(eq(kanbanTasks.sphereId, sphereId), eq(kanbanTasks.status, params.status))) + .get(); + position = (lastPosition?.maxPosition ?? 0) + POSITION_GAP; + } + + tx.update(kanbanTasks) + .set({ status: params.status, position, updatedAt: new Date().toISOString() }) + .where(eq(kanbanTasks.id, params.taskId)) + .run(); + }); +} + +export function updateTaskPosition(id: string, position: number): void { + getDb() + .update(kanbanTasks) + .set({ position, updatedAt: new Date().toISOString() }) + .where(eq(kanbanTasks.id, id)) + .run(); +} + +// ---- Assignment ---- + +export function assignTask(id: string, assigneeDid: string | null): void { + getDb() + .update(kanbanTasks) + .set({ assigneeDid, updatedAt: new Date().toISOString() }) + .where(eq(kanbanTasks.id, id)) + .run(); +} + +// ---- Comments ---- + +export function insertComment(params: { + id: string; + taskId: string; + authorDid: string; + content: string; + pdsUri: string | null; +}): void { + const updatedAt = tidToDate(params.id); + getDb() + .insert(kanbanTaskComments) + .values({ ...params, updatedAt }) + .onConflictDoUpdate({ + target: kanbanTaskComments.id, + set: { content: params.content, updatedAt: new Date().toISOString() }, + }) + .run(); +} + +export function updateComment(id: string, content: string): void { + getDb() + .update(kanbanTaskComments) + .set({ content, updatedAt: new Date().toISOString() }) + .where(eq(kanbanTaskComments.id, id)) + .run(); +} + +export function deleteComment(id: string): void { + getDb().delete(kanbanTaskComments).where(eq(kanbanTaskComments.id, id)).run(); +} + +// ---- Moderation ---- + +export function hideTask(id: string, moderatorDid: string): void { + getDb() + .update(kanbanTasks) + .set({ hiddenAt: new Date().toISOString(), moderatedBy: moderatorDid }) + .where(eq(kanbanTasks.id, id)) + .run(); +} + +export function unhideTask(id: string): void { + getDb() + .update(kanbanTasks) + .set({ hiddenAt: null, moderatedBy: null }) + .where(eq(kanbanTasks.id, id)) + .run(); +} + +export function hideComment(id: string, moderatorDid: string): void { + getDb() + .update(kanbanTaskComments) + .set({ hiddenAt: new Date().toISOString(), moderatedBy: moderatorDid }) + .where(eq(kanbanTaskComments.id, id)) + .run(); +} + +export function unhideComment(id: string): void { + getDb() + .update(kanbanTaskComments) + .set({ hiddenAt: null, moderatedBy: null }) + .where(eq(kanbanTaskComments.id, id)) + .run(); +} + +/** Moderation handler for kanban tasks and comments. Returns true if it handled the subject. */ +export const handleKanbanModeration: ModerationHandler = (subjectUri, moderatorDid) => { + const db = getDb(); + + const task = db + .select({ id: kanbanTasks.id }) + .from(kanbanTasks) + .where(eq(kanbanTasks.pdsUri, subjectUri)) + .get(); + if (task) { + hideTask(task.id, moderatorDid); + return true; + } + + const comment = db + .select({ id: kanbanTaskComments.id }) + .from(kanbanTaskComments) + .where(eq(kanbanTaskComments.pdsUri, subjectUri)) + .get(); + if (comment) { + hideComment(comment.id, moderatorDid); + return true; + } + + return false; +}; diff --git a/packages/kanban/src/db/schema.ts b/packages/kanban/src/db/schema.ts new file mode 100644 index 0000000..621dcb8 --- /dev/null +++ b/packages/kanban/src/db/schema.ts @@ -0,0 +1,98 @@ +import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; +import type { InferSelectModel } from "drizzle-orm"; +import { spheres } from "@exosphere/core/db/schema"; + +export const kanbanColumns = sqliteTable( + "kanban_columns", + { + id: text("id").primaryKey(), + sphereId: text("sphere_id") + .notNull() + .references(() => spheres.id), + slug: text("slug").notNull(), + label: text("label").notNull(), + position: integer("position").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(datetime('now'))`), + }, + (table) => [ + uniqueIndex("idx_kanban_columns_sphere_slug").on(table.sphereId, table.slug), + index("idx_kanban_columns_sphere_position").on(table.sphereId, table.position), + ], +); + +export const kanbanTasks = sqliteTable( + "kanban_tasks", + { + id: text("id").primaryKey(), + sphereId: text("sphere_id") + .notNull() + .references(() => spheres.id), + number: integer("number").notNull(), + authorDid: text("author_did").notNull(), + title: text("title").notNull(), + description: text("description").notNull().default(""), + status: text("status").notNull().default("backlog"), + position: integer("position").notNull().default(0), + assigneeDid: text("assignee_did"), + pdsUri: text("pds_uri"), + hiddenAt: text("hidden_at"), + moderatedBy: text("moderated_by"), + updatedAt: text("updated_at") + .notNull() + .default(sql`(datetime('now'))`), + }, + (table) => [ + uniqueIndex("idx_kanban_tasks_sphere_number").on(table.sphereId, table.number), + index("idx_kanban_tasks_sphere").on(table.sphereId), + index("idx_kanban_tasks_status").on(table.status), + index("idx_kanban_tasks_sphere_status_position").on( + table.sphereId, + table.status, + table.position, + ), + ], +); + +export const kanbanTaskComments = sqliteTable( + "kanban_task_comments", + { + id: text("id").primaryKey(), + taskId: text("task_id") + .notNull() + .references(() => kanbanTasks.id), + authorDid: text("author_did").notNull(), + content: text("content").notNull(), + pdsUri: text("pds_uri"), + updatedAt: text("updated_at") + .notNull() + .default(sql`(datetime('now'))`), + hiddenAt: text("hidden_at"), + moderatedBy: text("moderated_by"), + }, + (table) => [ + index("idx_kanban_task_comments_task").on(table.taskId), + index("idx_kanban_task_comments_author_task").on(table.authorDid, table.taskId), + ], +); + +export const kanbanTaskStatusChanges = sqliteTable( + "kanban_task_status_changes", + { + id: text("id").primaryKey(), + taskId: text("task_id") + .notNull() + .references(() => kanbanTasks.id), + authorDid: text("author_did").notNull(), + status: text("status").notNull(), + pdsUri: text("pds_uri"), + }, + (table) => [index("idx_kanban_task_status_changes_task").on(table.taskId)], +); + +export type KanbanColumn = InferSelectModel; +export type KanbanTask = InferSelectModel; +export type KanbanTaskComment = InferSelectModel; +export type KanbanTaskStatusChange = InferSelectModel; diff --git a/packages/kanban/src/index.ts b/packages/kanban/src/index.ts new file mode 100644 index 0000000..31f76bc --- /dev/null +++ b/packages/kanban/src/index.ts @@ -0,0 +1,19 @@ +import type { ExosphereModule } from "@exosphere/core/types"; +import { kanbanApi } from "./api/routes.ts"; +import { kanbanIndexer } from "./indexer.ts"; + +export const kanbanModule: ExosphereModule = { + name: "kanban", + api: kanbanApi, + indexer: kanbanIndexer, + permissions: { + create: { label: "Create tasks", defaultRole: "member" }, + comment: { label: "Comment on tasks", defaultRole: "member" }, + changeStatus: { label: "Move tasks between columns", defaultRole: "member" }, + assign: { label: "Assign tasks", defaultRole: "admin" }, + manageTasks: { label: "Edit/delete others' tasks", defaultRole: "admin" }, + manageSettings: { label: "Manage board settings", defaultRole: "admin" }, + moderate: { label: "Hide/unhide content", defaultRole: "admin" }, + }, + permissionsCollection: "site.exosphere.kanban.permissions", +}; diff --git a/packages/kanban/src/indexer.ts b/packages/kanban/src/indexer.ts new file mode 100644 index 0000000..d2f348f --- /dev/null +++ b/packages/kanban/src/indexer.ts @@ -0,0 +1,158 @@ +import type { ModuleIndexer, JetstreamCommitEvent } from "@exosphere/core/types"; +import { buildAtUri, parseAtUri } from "@exosphere/core/indexer"; +import { registerModerationHandler, getActiveMemberRole } from "@exosphere/core/sphere"; +import { checkPermission } from "@exosphere/core/permissions"; +import { getDb } from "@exosphere/core/db"; +import { eq, and } from "@exosphere/core/db/drizzle"; +import { spheres } from "@exosphere/core/db/schema"; +import { kanbanTasks, kanbanTaskComments } from "./db/schema.ts"; +import { + getColumns, + getColumnBySlug, + insertTask, + deleteTaskCascade, + insertComment, + deleteComment, + insertStatusChangeAndUpdateTask, + handleKanbanModeration, +} from "./db/operations.ts"; + +// Register this module's moderation handler with core +registerModerationHandler(handleKanbanModeration); + +const MODULE_NAME = "kanban"; +const COLLECTION = "site.exosphere.kanban.entry"; +const COMMENT_COLLECTION = "site.exosphere.kanban.comment"; +const STATUS_COLLECTION = "site.exosphere.kanban.status"; + +function findSphereForAccess( + sphereOwnerDid: string, + did: string, + action: string, +): { allowed: boolean; sphereId: string | null } { + const db = getDb(); + const sphere = db + .select({ id: spheres.id }) + .from(spheres) + .where(eq(spheres.ownerDid, sphereOwnerDid)) + .get(); + if (!sphere) return { allowed: false, sphereId: null }; + const role = getActiveMemberRole(sphere.id, did); + const allowed = checkPermission(sphere.id, MODULE_NAME, action, role); + return { allowed, sphereId: sphere.id }; +} + +export const kanbanIndexer: ModuleIndexer = { + collections: [COLLECTION, COMMENT_COLLECTION, STATUS_COLLECTION], + + handleCreateOrUpdate(event: JetstreamCommitEvent) { + const { did, commit } = event; + const { collection, rkey, record } = commit; + if (!record) return; + + const pdsUri = buildAtUri(did, collection, rkey); + + if (collection === COLLECTION) { + const subject = record.subject as string; + if (!subject || !subject.startsWith("did:")) return; + + const access = findSphereForAccess(subject, did, "create"); + if (!access.allowed || !access.sphereId) return; + + const rawStatus = record.status as string; + const cols = getColumns(access.sphereId); + const status = cols.some((c) => c.slug === rawStatus) ? rawStatus : cols[0].slug; + + insertTask({ + id: rkey, + sphereId: access.sphereId, + authorDid: did, + title: (record.title as string) ?? "", + description: (record.description as string) ?? "", + status, + assigneeDid: (record.assigneeDid as string) ?? null, + pdsUri, + }); + return; + } + + // Comment and status reference a subject via AT URI + const subjectUri = record.subject as string; + if (!subjectUri) return; + const parsed = parseAtUri(subjectUri); + if (!parsed) return; + + if (collection === COMMENT_COLLECTION) { + const task = getDb() + .select({ id: kanbanTasks.id, sphereId: kanbanTasks.sphereId }) + .from(kanbanTasks) + .where(eq(kanbanTasks.id, parsed.rkey)) + .get(); + if (!task) { + console.warn("[indexer] Kanban comment references unknown task rkey=%s", parsed.rkey); + return; + } + const role = getActiveMemberRole(task.sphereId, did); + if (!checkPermission(task.sphereId, MODULE_NAME, "comment", role)) return; + insertComment({ + id: rkey, + taskId: task.id, + authorDid: did, + content: (record.content as string) ?? "", + pdsUri, + }); + return; + } + + if (collection === STATUS_COLLECTION) { + const task = getDb() + .select({ id: kanbanTasks.id, sphereId: kanbanTasks.sphereId }) + .from(kanbanTasks) + .where(eq(kanbanTasks.id, parsed.rkey)) + .get(); + if (!task) { + console.warn("[indexer] Kanban status references unknown task rkey=%s", parsed.rkey); + return; + } + const role = getActiveMemberRole(task.sphereId, did); + if (!checkPermission(task.sphereId, MODULE_NAME, "changeStatus", role)) return; + + const rawStatus = record.status as string; + if (!rawStatus || !getColumnBySlug(task.sphereId, rawStatus)) return; + + insertStatusChangeAndUpdateTask({ + id: rkey, + taskId: task.id, + authorDid: did, + status: rawStatus, + pdsUri, + }); + } + }, + + handleDelete(event: JetstreamCommitEvent) { + const { did, commit } = event; + const { collection, rkey } = commit; + + if (collection === COLLECTION) { + const task = getDb() + .select({ id: kanbanTasks.id }) + .from(kanbanTasks) + .where(and(eq(kanbanTasks.id, rkey), eq(kanbanTasks.authorDid, did))) + .get(); + if (!task) return; + deleteTaskCascade(rkey); + return; + } + + if (collection === COMMENT_COLLECTION) { + const comment = getDb() + .select({ id: kanbanTaskComments.id }) + .from(kanbanTaskComments) + .where(and(eq(kanbanTaskComments.id, rkey), eq(kanbanTaskComments.authorDid, did))) + .get(); + if (!comment) return; + deleteComment(rkey); + } + }, +}; diff --git a/packages/kanban/src/mcp.ts b/packages/kanban/src/mcp.ts new file mode 100644 index 0000000..a53d283 --- /dev/null +++ b/packages/kanban/src/mcp.ts @@ -0,0 +1,113 @@ +import type { McpTool, ApiFetch } from "@exosphere/mcp"; + +/** Resolve a task number to its internal ID via the API. */ +async function resolveTaskId( + number: number, + apiFetch: ApiFetch, +): Promise<{ id: string } | { error: string }> { + const res = await apiFetch(`/api/kanban/${number}`); + if (!res.ok) return { error: "Task not found" }; + const data = (await res.json()) as { task: { id: string } }; + return { id: data.task.id }; +} + +export const kanbanMcpTools: McpTool[] = [ + { + name: "list_kanban_tasks", + description: + "List all kanban board tasks grouped by the board's configured columns. Returns title, description, status, assignee, and comment count for each.", + inputSchema: { + type: "object", + properties: {}, + }, + handler: async (_args, apiFetch) => { + const res = await apiFetch("/api/kanban"); + const data = await res.json(); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + }, + + { + name: "get_kanban_task", + description: + "Get a single kanban task by its number, including description, assignee, status, and comment count", + inputSchema: { + type: "object", + properties: { + number: { type: "integer", description: "The task number" }, + }, + required: ["number"], + }, + handler: async (args, apiFetch) => { + const res = await apiFetch(`/api/kanban/${args.number}`); + if (!res.ok) { + return { content: [{ type: "text", text: "Task not found" }], isError: true }; + } + const data = await res.json(); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + }, + + { + name: "search_kanban_tasks", + description: + "Search kanban tasks by title or by number. Use '#42' or just '42' to search by number, or any text to search by title.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Search query (title text or #number)" }, + }, + required: ["query"], + }, + handler: async (args, apiFetch) => { + const q = encodeURIComponent(String(args.query)); + const res = await apiFetch(`/api/kanban/search?q=${q}`); + const data = await res.json(); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + }, + + { + name: "get_kanban_task_comments", + description: "Get comments on a kanban task, identified by its number", + inputSchema: { + type: "object", + properties: { + number: { type: "integer", description: "The task number" }, + }, + required: ["number"], + }, + handler: async (args, apiFetch) => { + const resolved = await resolveTaskId(Number(args.number), apiFetch); + if ("error" in resolved) { + return { content: [{ type: "text", text: resolved.error }], isError: true }; + } + + const res = await apiFetch(`/api/kanban/${resolved.id}/comments`); + const data = await res.json(); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + }, + + { + name: "get_kanban_task_statuses", + description: "Get the status change history of a kanban task, identified by its number", + inputSchema: { + type: "object", + properties: { + number: { type: "integer", description: "The task number" }, + }, + required: ["number"], + }, + handler: async (args, apiFetch) => { + const resolved = await resolveTaskId(Number(args.number), apiFetch); + if ("error" in resolved) { + return { content: [{ type: "text", text: resolved.error }], isError: true }; + } + + const res = await apiFetch(`/api/kanban/${resolved.id}/statuses`); + const data = await res.json(); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + }, +]; diff --git a/packages/kanban/src/schemas/column.ts b/packages/kanban/src/schemas/column.ts new file mode 100644 index 0000000..0d1bf6b --- /dev/null +++ b/packages/kanban/src/schemas/column.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const createColumnSchema = z.object({ + label: z.string().min(1).max(50), +}); + +export const updateColumnSchema = z.object({ + label: z.string().min(1).max(50), +}); + +export const reorderColumnsSchema = z.object({ + columnIds: z.array(z.string().min(1)), +}); + +export const deleteColumnSchema = z.object({ + reassignTo: z.string().min(1), +}); diff --git a/packages/kanban/src/schemas/comment.ts b/packages/kanban/src/schemas/comment.ts new file mode 100644 index 0000000..e058b65 --- /dev/null +++ b/packages/kanban/src/schemas/comment.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const createCommentSchema = z.object({ + content: z.string().min(1).max(5000), +}); + +export const updateCommentSchema = z.object({ + content: z.string().min(1).max(5000), +}); + +export type CreateComment = z.infer; +export type UpdateComment = z.infer; diff --git a/packages/kanban/src/schemas/task.ts b/packages/kanban/src/schemas/task.ts new file mode 100644 index 0000000..1667c7f --- /dev/null +++ b/packages/kanban/src/schemas/task.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +/** Default columns seeded when a sphere's kanban board is first accessed. */ +export const defaultColumns = [ + { slug: "backlog", label: "Backlog" }, + { slug: "todo", label: "To do" }, + { slug: "in-progress", label: "In progress" }, + { slug: "done", label: "Done" }, +] as const; + +export const createTaskSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().max(10000).default(""), + status: z.string().min(1).max(100).default("backlog"), + assigneeDid: z.string().min(1).nullable().optional(), +}); + +export const updateTaskSchema = z.object({ + title: z.string().min(1).max(200).optional(), + description: z.string().max(10000).optional(), +}); + +export const updateStatusSchema = z.object({ + status: z.string().min(1).max(100), + position: z.number().int().optional(), +}); + +export const assignTaskSchema = z.object({ + assigneeDid: z.string().min(1).nullable(), +}); + +export const reorderSchema = z.object({ + position: z.number().int(), +}); + +export type CreateTask = z.infer; +export type UpdateTask = z.infer; diff --git a/packages/kanban/src/types.ts b/packages/kanban/src/types.ts new file mode 100644 index 0000000..cdfbd20 --- /dev/null +++ b/packages/kanban/src/types.ts @@ -0,0 +1,33 @@ +export type { + KanbanColumn, + KanbanTask, + KanbanTaskComment, + KanbanTaskStatusChange, +} from "./db/schema.ts"; + +import type { KanbanTask, KanbanTaskComment } from "./db/schema.ts"; + +/** Column definition as returned by the API. */ +export type KanbanColumnDef = { + id: string; + slug: string; + label: string; + position: number; +}; + +/** Shape returned by the GET /kanban list endpoint. */ +export type KanbanTaskListItem = KanbanTask & { + createdAt: string; + commentCount: number; + authorHandle?: string | null; + assigneeHandle?: string | null; +}; + +/** Shape returned by the GET /kanban/:number detail endpoint. */ +export type KanbanTaskDetail = KanbanTaskListItem; + +/** Shape returned by the GET comments endpoint. */ +export type KanbanTaskCommentListItem = KanbanTaskComment & { + createdAt: string; + authorHandle: string | null; +}; diff --git a/packages/kanban/src/ui/api/columns.ts b/packages/kanban/src/ui/api/columns.ts new file mode 100644 index 0000000..71920ea --- /dev/null +++ b/packages/kanban/src/ui/api/columns.ts @@ -0,0 +1,40 @@ +import { moduleFetch } from "@exosphere/client/module-api"; +import type { KanbanColumnDef } from "../../types.ts"; + +export type { KanbanColumnDef }; + +export function getColumns() { + return moduleFetch<{ columns: KanbanColumnDef[] }>("/kanban/columns"); +} + +export function createColumn(label: string) { + return moduleFetch<{ column: KanbanColumnDef }>("/kanban/columns", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label }), + }); +} + +export function renameColumn(id: string, label: string) { + return moduleFetch<{ column: KanbanColumnDef }>(`/kanban/columns/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label }), + }); +} + +export function deleteColumnApi(id: string, reassignTo: string) { + return moduleFetch<{ ok: true }>(`/kanban/columns/${encodeURIComponent(id)}`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reassignTo }), + }); +} + +export function reorderColumnsApi(columnIds: string[]) { + return moduleFetch<{ ok: true }>("/kanban/columns/reorder", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ columnIds }), + }); +} diff --git a/packages/kanban/src/ui/api/comments.ts b/packages/kanban/src/ui/api/comments.ts new file mode 100644 index 0000000..d577ae4 --- /dev/null +++ b/packages/kanban/src/ui/api/comments.ts @@ -0,0 +1,38 @@ +import { moduleFetch } from "@exosphere/client/module-api"; +import type { KanbanTaskComment, KanbanTaskCommentListItem } from "../../types.ts"; + +export type { KanbanTaskComment, KanbanTaskCommentListItem }; + +export function getComments(taskId: string) { + return moduleFetch<{ comments: KanbanTaskCommentListItem[] }>( + `/kanban/${encodeURIComponent(taskId)}/comments`, + ); +} + +export function createComment(taskId: string, content: string) { + return moduleFetch<{ comment: KanbanTaskComment & { createdAt: string } }>( + `/kanban/${encodeURIComponent(taskId)}/comments`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }, + ); +} + +export function updateCommentApi(id: string, content: string) { + return moduleFetch<{ comment: KanbanTaskComment & { createdAt: string } }>( + `/kanban/comments/${encodeURIComponent(id)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }, + ); +} + +export function deleteComment(id: string) { + return moduleFetch<{ ok: true }>(`/kanban/comments/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} diff --git a/packages/kanban/src/ui/api/tasks.ts b/packages/kanban/src/ui/api/tasks.ts new file mode 100644 index 0000000..e662778 --- /dev/null +++ b/packages/kanban/src/ui/api/tasks.ts @@ -0,0 +1,91 @@ +import { moduleFetch } from "@exosphere/client/module-api"; +import type { KanbanColumnDef, KanbanTaskListItem, KanbanTaskDetail } from "../../types.ts"; + +export type { KanbanColumnDef, KanbanTaskListItem, KanbanTaskDetail }; + +export function getTasks() { + return moduleFetch<{ + columns: KanbanColumnDef[]; + tasksByColumn: Record; + tasks: KanbanTaskListItem[]; + }>("/kanban"); +} + +export function getTask(number: number) { + return moduleFetch<{ task: KanbanTaskDetail }>(`/kanban/${number}`); +} + +export function createTask(body: { + title: string; + description?: string; + status?: string; + assigneeDid?: string | null; +}) { + return moduleFetch<{ task: KanbanTaskListItem }>("/kanban", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +export function updateTask(id: string, body: { title?: string; description?: string }) { + return moduleFetch<{ task: KanbanTaskListItem }>(`/kanban/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +export function deleteTask(id: string) { + return moduleFetch<{ ok: true }>(`/kanban/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + +export function updateTaskStatus(id: string, status: string, position?: number) { + return moduleFetch<{ status: string }>(`/kanban/${encodeURIComponent(id)}/status`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status, position }), + }); +} + +export function assignTaskApi(id: string, assigneeDid: string | null) { + return moduleFetch<{ ok: true }>(`/kanban/${encodeURIComponent(id)}/assign`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ assigneeDid }), + }); +} + +export function hideTask(id: string) { + return moduleFetch<{ ok: true }>(`/kanban/${encodeURIComponent(id)}/hide`, { + method: "POST", + }); +} + +export function reorderTask(id: string, position: number) { + return moduleFetch<{ ok: true }>(`/kanban/${encodeURIComponent(id)}/reorder`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ position }), + }); +} + +export function unhideTask(id: string) { + return moduleFetch<{ ok: true }>(`/kanban/${encodeURIComponent(id)}/unhide`, { + method: "POST", + }); +} + +export function getStatusHistory(taskId: string) { + return moduleFetch<{ + statuses: Array<{ + id: string; + authorDid: string; + authorHandle: string | null; + status: string; + createdAt: string; + }>; + }>(`/kanban/${encodeURIComponent(taskId)}/statuses`); +} diff --git a/packages/kanban/src/ui/components/column-manager.tsx b/packages/kanban/src/ui/components/column-manager.tsx new file mode 100644 index 0000000..2ca6ace --- /dev/null +++ b/packages/kanban/src/ui/components/column-manager.tsx @@ -0,0 +1,216 @@ +import { useSignal } from "@preact/signals"; +import * as ui from "@exosphere/client/ui.css"; +import * as kbUi from "../ui.css.ts"; +import type { KanbanColumnDef } from "../../types.ts"; +import { createColumn, renameColumn, deleteColumnApi, reorderColumnsApi } from "../api/columns.ts"; + +export function ColumnManager({ + columns, + onChanged, +}: { + columns: KanbanColumnDef[]; + onChanged: () => void; +}) { + const newLabel = useSignal(""); + const adding = useSignal(false); + const editingId = useSignal(null); + const editLabel = useSignal(""); + const deletingId = useSignal(null); + const reassignSlug = useSignal(""); + const busy = useSignal(false); + + const handleAdd = async () => { + if (!newLabel.value.trim() || adding.value) return; + adding.value = true; + try { + await createColumn(newLabel.value.trim()); + newLabel.value = ""; + onChanged(); + } catch (err) { + console.error("Failed to add column:", err); + } finally { + adding.value = false; + } + }; + + const handleRename = async (id: string) => { + if (!editLabel.value.trim() || busy.value) return; + busy.value = true; + try { + await renameColumn(id, editLabel.value.trim()); + editingId.value = null; + onChanged(); + } catch (err) { + console.error("Failed to rename column:", err); + } finally { + busy.value = false; + } + }; + + const handleDelete = async (id: string) => { + if (!reassignSlug.value || busy.value) return; + busy.value = true; + try { + await deleteColumnApi(id, reassignSlug.value); + deletingId.value = null; + onChanged(); + } catch (err) { + console.error("Failed to delete column:", err); + } finally { + busy.value = false; + } + }; + + const handleMove = async (index: number, direction: -1 | 1) => { + const newIndex = index + direction; + if (newIndex < 0 || newIndex >= columns.length) return; + const reordered = [...columns]; + const [item] = reordered.splice(index, 1); + reordered.splice(newIndex, 0, item); + try { + await reorderColumnsApi(reordered.map((c) => c.id)); + onChanged(); + } catch (err) { + console.error("Failed to reorder columns:", err); + } + }; + + const startEdit = (col: KanbanColumnDef) => { + editingId.value = col.id; + editLabel.value = col.label; + deletingId.value = null; + }; + + const startDelete = (col: KanbanColumnDef) => { + deletingId.value = col.id; + editingId.value = null; + const other = columns.find((c) => c.id !== col.id); + reassignSlug.value = other?.slug ?? ""; + }; + + return ( +
+
+ {columns.map((col, i) => ( +
+
+
+ + +
+ + {editingId.value === col.id ? ( + (editLabel.value = (e.target as HTMLInputElement).value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleRename(col.id); + if (e.key === "Escape") editingId.value = null; + }} + autoFocus + /> + ) : ( + + {col.label} ({col.slug}) + + )} + + {editingId.value === col.id ? ( + <> + + + + ) : ( + <> + + {columns.length > 1 && ( + + )} + + )} +
+ + {deletingId.value === col.id && ( +
+ Move tasks to: + + + +
+ )} +
+ ))} +
+ +
+ (newLabel.value = (e.target as HTMLInputElement).value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleAdd(); + }} + /> + +
+
+ ); +} diff --git a/packages/kanban/src/ui/components/column.tsx b/packages/kanban/src/ui/components/column.tsx new file mode 100644 index 0000000..174c8c6 --- /dev/null +++ b/packages/kanban/src/ui/components/column.tsx @@ -0,0 +1,66 @@ +import type { Signal } from "@preact/signals"; +import { Fragment } from "preact"; +import * as kbUi from "../ui.css.ts"; +import type { KanbanTaskListItem } from "../../types.ts"; +import type { DropTarget } from "../hooks/use-board-dnd.ts"; +import { TaskCard } from "./task-card.tsx"; + +export function Column({ + title, + status, + tasks, + canDrag, + draggedTaskId, + dropTarget, + onDragOver, + onDragLeave, + onDrop, + onCardDragStart, + onCardDragEnd, +}: { + title: string; + status: string; + tasks: KanbanTaskListItem[]; + canDrag?: boolean; + draggedTaskId?: string | null; + dropTarget?: Signal; + onDragOver?: (e: DragEvent) => void; + onDragLeave?: (e: DragEvent) => void; + onDrop?: (e: DragEvent) => void; + onCardDragStart?: (task: KanbanTaskListItem, status: string) => (e: DragEvent) => void; + onCardDragEnd?: () => void; +}) { + const dt = dropTarget?.value; + const isOver = draggedTaskId && dt?.status === status; + const indicatorIndex = isOver ? dt.insertIndex : -1; + + const columnClasses = [kbUi.column]; + if (isOver) columnClasses.push(kbUi.columnDragOver); + + return ( +
+
+ {title} + {tasks.length} +
+ {tasks.map((task, index) => ( + + {indicatorIndex === index &&
} + + + ))} + {indicatorIndex === tasks.length &&
} +
+ ); +} diff --git a/packages/kanban/src/ui/components/task-card.tsx b/packages/kanban/src/ui/components/task-card.tsx new file mode 100644 index 0000000..617f1ea --- /dev/null +++ b/packages/kanban/src/ui/components/task-card.tsx @@ -0,0 +1,47 @@ +import { spherePath } from "@exosphere/client/router"; +import * as kbUi from "../ui.css.ts"; +import type { KanbanTaskListItem } from "../../types.ts"; + +export function TaskCard({ + task, + canDrag, + isDragging, + onDragStart, + onDragEnd, +}: { + task: KanbanTaskListItem; + canDrag?: boolean; + isDragging?: boolean; + onDragStart?: (e: DragEvent) => void; + onDragEnd?: () => void; +}) { + const classes = [kbUi.taskCard]; + if (canDrag) classes.push(kbUi.taskCardDraggable); + if (isDragging) classes.push(kbUi.taskCardDragging); + + return ( + + #{task.number} +
{task.title}
+
+ {task.assigneeHandle ? ( + @{task.assigneeHandle} + ) : task.assigneeDid ? ( + {task.assigneeDid.slice(0, 16)}... + ) : null} + {task.commentCount > 0 && ( + + {task.commentCount} comment{task.commentCount !== 1 ? "s" : ""} + + )} +
+
+ ); +} diff --git a/packages/kanban/src/ui/components/task-form.tsx b/packages/kanban/src/ui/components/task-form.tsx new file mode 100644 index 0000000..f12f630 --- /dev/null +++ b/packages/kanban/src/ui/components/task-form.tsx @@ -0,0 +1,111 @@ +import { useSignal } from "@preact/signals"; +import * as ui from "@exosphere/client/ui.css"; +import type { KanbanColumnDef } from "../../types.ts"; + +export function TaskForm({ + columns, + onCreated, +}: { + columns: KanbanColumnDef[]; + onCreated: () => void; +}) { + const title = useSignal(""); + const description = useSignal(""); + const status = useSignal(columns[0]?.slug ?? "backlog"); + const error = useSignal(""); + const submitting = useSignal(false); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + submit(); + } + }; + + const submit = async (e?: Event) => { + e?.preventDefault(); + if (!title.value.trim()) { + error.value = "Title is required."; + return; + } + + submitting.value = true; + error.value = ""; + + try { + const { createTask } = await import("../api/tasks.ts"); + await createTask({ + title: title.value.trim(), + description: description.value.trim(), + status: status.value, + }); + title.value = ""; + description.value = ""; + status.value = columns[0]?.slug ?? "backlog"; + onCreated(); + } catch (err) { + error.value = err instanceof Error ? err.message : "Something went wrong."; + } finally { + submitting.value = false; + } + }; + + return ( +
+
+ + (title.value = (e.target as HTMLInputElement).value)} + /> +
+ +
+ + +
+ +
+ +