Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
7
fork

Configure Feed

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

feat: kanban board module

Hugo f0fffa30 140a5627

+6871 -16
+1 -1
CLAUDE.md
··· 14 14 15 15 ### Commands 16 16 17 - - `bunx tsc --emit`: Check project TypeScript types 17 + - `bunx run tsc`: Check project TypeScript types 18 18 - `bun run fmt`: Foramt with oxfmt 19 19 - `bun run test`: Run unit tests 20 20 - `bun run test:e2e`: Run end-to-end tests
+27
bun.lock
··· 12 12 "bun-types": "^1.3.11", 13 13 "drizzle-kit": "^0.31.10", 14 14 "oxfmt": "^0.43.0", 15 + "typescript": "catalog:", 15 16 "vitest": "^4.1.2", 16 17 }, 17 18 }, ··· 24 25 "@exosphere/feature-requests": "workspace:*", 25 26 "@exosphere/feeds": "workspace:*", 26 27 "@exosphere/indexer": "workspace:*", 28 + "@exosphere/kanban": "workspace:*", 27 29 "@exosphere/mcp": "workspace:*", 28 30 "@preact/signals": "catalog:", 29 31 "@vanilla-extract/css": "catalog:", ··· 109 111 "@exosphere/core": "workspace:*", 110 112 "@exosphere/feature-requests": "workspace:*", 111 113 "@exosphere/feeds": "workspace:*", 114 + "@exosphere/kanban": "workspace:*", 112 115 "drizzle-orm": "catalog:", 116 + }, 117 + "devDependencies": { 118 + "@types/bun": "catalog:", 119 + "typescript": "catalog:", 120 + }, 121 + }, 122 + "packages/kanban": { 123 + "name": "@exosphere/kanban", 124 + "version": "0.0.1", 125 + "dependencies": { 126 + "@exosphere/client": "workspace:*", 127 + "@exosphere/core": "workspace:*", 128 + "@exosphere/mcp": "workspace:*", 129 + "@preact/signals": "catalog:", 130 + "@vanilla-extract/css": "catalog:", 131 + "drizzle-orm": "catalog:", 132 + "hono": "catalog:", 133 + "lucide-preact": "^1.7.0", 134 + "preact": "catalog:", 135 + "zod": "catalog:", 113 136 }, 114 137 "devDependencies": { 115 138 "@types/bun": "catalog:", ··· 308 331 "@exosphere/feeds": ["@exosphere/feeds@workspace:packages/feeds"], 309 332 310 333 "@exosphere/indexer": ["@exosphere/indexer@workspace:packages/indexer"], 334 + 335 + "@exosphere/kanban": ["@exosphere/kanban@workspace:packages/kanban"], 311 336 312 337 "@exosphere/mcp": ["@exosphere/mcp@workspace:packages/mcp"], 313 338 ··· 804 829 "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], 805 830 806 831 "@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=="], 832 + 833 + "@exosphere/kanban/lucide-preact": ["lucide-preact@1.7.0", "", { "peerDependencies": { "preact": "^10.27.2" } }, "sha512-gv743hfkCH2MeUmZQ9g4+Yd+/zZyAOLuXvxWo6CUY6kMrnnoDZnnwpSZqWsEveCC9LNp+NgHkeTfztObIue+SA=="], 807 834 808 835 "@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=="], 809 836
+1
drizzle.config.ts
··· 6 6 "./packages/core/src/db/schema/*.ts", 7 7 "./packages/feature-requests/src/db/schema.ts", 8 8 "./packages/feeds/src/db/schema.ts", 9 + "./packages/kanban/src/db/schema.ts", 9 10 ], 10 11 dbCredentials: { 11 12 url: "exosphere.sqlite",
+45
drizzle/0001_futuristic_zodiak.sql
··· 1 + CREATE TABLE `kanban_task_comments` ( 2 + `id` text PRIMARY KEY NOT NULL, 3 + `task_id` text NOT NULL, 4 + `author_did` text NOT NULL, 5 + `content` text NOT NULL, 6 + `pds_uri` text, 7 + `updated_at` text DEFAULT (datetime('now')) NOT NULL, 8 + `hidden_at` text, 9 + `moderated_by` text, 10 + FOREIGN KEY (`task_id`) REFERENCES `kanban_tasks`(`id`) ON UPDATE no action ON DELETE no action 11 + ); 12 + --> statement-breakpoint 13 + CREATE INDEX `idx_kanban_task_comments_task` ON `kanban_task_comments` (`task_id`);--> statement-breakpoint 14 + CREATE INDEX `idx_kanban_task_comments_author_task` ON `kanban_task_comments` (`author_did`,`task_id`);--> statement-breakpoint 15 + CREATE TABLE `kanban_task_status_changes` ( 16 + `id` text PRIMARY KEY NOT NULL, 17 + `task_id` text NOT NULL, 18 + `author_did` text NOT NULL, 19 + `status` text NOT NULL, 20 + `pds_uri` text, 21 + FOREIGN KEY (`task_id`) REFERENCES `kanban_tasks`(`id`) ON UPDATE no action ON DELETE no action 22 + ); 23 + --> statement-breakpoint 24 + CREATE INDEX `idx_kanban_task_status_changes_task` ON `kanban_task_status_changes` (`task_id`);--> statement-breakpoint 25 + CREATE TABLE `kanban_tasks` ( 26 + `id` text PRIMARY KEY NOT NULL, 27 + `sphere_id` text NOT NULL, 28 + `number` integer NOT NULL, 29 + `author_did` text NOT NULL, 30 + `title` text NOT NULL, 31 + `description` text DEFAULT '' NOT NULL, 32 + `status` text DEFAULT 'backlog' NOT NULL, 33 + `position` integer DEFAULT 0 NOT NULL, 34 + `assignee_did` text, 35 + `pds_uri` text, 36 + `hidden_at` text, 37 + `moderated_by` text, 38 + `updated_at` text DEFAULT (datetime('now')) NOT NULL, 39 + FOREIGN KEY (`sphere_id`) REFERENCES `spheres`(`id`) ON UPDATE no action ON DELETE no action 40 + ); 41 + --> statement-breakpoint 42 + CREATE UNIQUE INDEX `idx_kanban_tasks_sphere_number` ON `kanban_tasks` (`sphere_id`,`number`);--> statement-breakpoint 43 + CREATE INDEX `idx_kanban_tasks_sphere` ON `kanban_tasks` (`sphere_id`);--> statement-breakpoint 44 + CREATE INDEX `idx_kanban_tasks_status` ON `kanban_tasks` (`status`);--> statement-breakpoint 45 + CREATE INDEX `idx_kanban_tasks_sphere_status_position` ON `kanban_tasks` (`sphere_id`,`status`,`position`);
+12
drizzle/0002_far_deadpool.sql
··· 1 + CREATE TABLE `kanban_columns` ( 2 + `id` text PRIMARY KEY NOT NULL, 3 + `sphere_id` text NOT NULL, 4 + `slug` text NOT NULL, 5 + `label` text NOT NULL, 6 + `position` integer NOT NULL, 7 + `created_at` text DEFAULT (datetime('now')) NOT NULL, 8 + FOREIGN KEY (`sphere_id`) REFERENCES `spheres`(`id`) ON UPDATE no action ON DELETE no action 9 + ); 10 + --> statement-breakpoint 11 + CREATE UNIQUE INDEX `idx_kanban_columns_sphere_slug` ON `kanban_columns` (`sphere_id`,`slug`);--> statement-breakpoint 12 + CREATE INDEX `idx_kanban_columns_sphere_position` ON `kanban_columns` (`sphere_id`,`position`);
+1144
drizzle/meta/0001_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "cf1efedc-9a33-469b-b835-9cd7d63e63bd", 5 + "prevId": "c420fa1d-50cd-45f4-b70a-0ea7d8c70067", 6 + "tables": { 7 + "oauth_sessions": { 8 + "name": "oauth_sessions", 9 + "columns": { 10 + "key": { 11 + "name": "key", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "session": { 18 + "name": "session", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "created_at": { 25 + "name": "created_at", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false, 30 + "default": "(datetime('now'))" 31 + }, 32 + "updated_at": { 33 + "name": "updated_at", 34 + "type": "text", 35 + "primaryKey": false, 36 + "notNull": true, 37 + "autoincrement": false, 38 + "default": "(datetime('now'))" 39 + } 40 + }, 41 + "indexes": {}, 42 + "foreignKeys": {}, 43 + "compositePrimaryKeys": {}, 44 + "uniqueConstraints": {}, 45 + "checkConstraints": {} 46 + }, 47 + "oauth_states": { 48 + "name": "oauth_states", 49 + "columns": { 50 + "key": { 51 + "name": "key", 52 + "type": "text", 53 + "primaryKey": true, 54 + "notNull": true, 55 + "autoincrement": false 56 + }, 57 + "state": { 58 + "name": "state", 59 + "type": "text", 60 + "primaryKey": false, 61 + "notNull": true, 62 + "autoincrement": false 63 + }, 64 + "created_at": { 65 + "name": "created_at", 66 + "type": "text", 67 + "primaryKey": false, 68 + "notNull": true, 69 + "autoincrement": false, 70 + "default": "(datetime('now'))" 71 + } 72 + }, 73 + "indexes": {}, 74 + "foreignKeys": {}, 75 + "compositePrimaryKeys": {}, 76 + "uniqueConstraints": {}, 77 + "checkConstraints": {} 78 + }, 79 + "indexer_cursor": { 80 + "name": "indexer_cursor", 81 + "columns": { 82 + "id": { 83 + "name": "id", 84 + "type": "text", 85 + "primaryKey": true, 86 + "notNull": true, 87 + "autoincrement": false, 88 + "default": "'jetstream'" 89 + }, 90 + "cursor": { 91 + "name": "cursor", 92 + "type": "integer", 93 + "primaryKey": false, 94 + "notNull": true, 95 + "autoincrement": false 96 + }, 97 + "updated_at": { 98 + "name": "updated_at", 99 + "type": "text", 100 + "primaryKey": false, 101 + "notNull": true, 102 + "autoincrement": false, 103 + "default": "(datetime('now'))" 104 + } 105 + }, 106 + "indexes": {}, 107 + "foreignKeys": {}, 108 + "compositePrimaryKeys": {}, 109 + "uniqueConstraints": {}, 110 + "checkConstraints": {} 111 + }, 112 + "sphere_members": { 113 + "name": "sphere_members", 114 + "columns": { 115 + "sphere_id": { 116 + "name": "sphere_id", 117 + "type": "text", 118 + "primaryKey": false, 119 + "notNull": true, 120 + "autoincrement": false 121 + }, 122 + "did": { 123 + "name": "did", 124 + "type": "text", 125 + "primaryKey": false, 126 + "notNull": true, 127 + "autoincrement": false 128 + }, 129 + "role": { 130 + "name": "role", 131 + "type": "text", 132 + "primaryKey": false, 133 + "notNull": true, 134 + "autoincrement": false, 135 + "default": "'member'" 136 + }, 137 + "status": { 138 + "name": "status", 139 + "type": "text", 140 + "primaryKey": false, 141 + "notNull": true, 142 + "autoincrement": false, 143 + "default": "'invited'" 144 + }, 145 + "invited_by": { 146 + "name": "invited_by", 147 + "type": "text", 148 + "primaryKey": false, 149 + "notNull": false, 150 + "autoincrement": false 151 + }, 152 + "pds_uri": { 153 + "name": "pds_uri", 154 + "type": "text", 155 + "primaryKey": false, 156 + "notNull": false, 157 + "autoincrement": false 158 + }, 159 + "approval_pds_uri": { 160 + "name": "approval_pds_uri", 161 + "type": "text", 162 + "primaryKey": false, 163 + "notNull": false, 164 + "autoincrement": false 165 + }, 166 + "created_at": { 167 + "name": "created_at", 168 + "type": "text", 169 + "primaryKey": false, 170 + "notNull": true, 171 + "autoincrement": false, 172 + "default": "(datetime('now'))" 173 + } 174 + }, 175 + "indexes": { 176 + "idx_sphere_members_did": { 177 + "name": "idx_sphere_members_did", 178 + "columns": ["did"], 179 + "isUnique": false 180 + } 181 + }, 182 + "foreignKeys": { 183 + "sphere_members_sphere_id_spheres_id_fk": { 184 + "name": "sphere_members_sphere_id_spheres_id_fk", 185 + "tableFrom": "sphere_members", 186 + "tableTo": "spheres", 187 + "columnsFrom": ["sphere_id"], 188 + "columnsTo": ["id"], 189 + "onDelete": "no action", 190 + "onUpdate": "no action" 191 + } 192 + }, 193 + "compositePrimaryKeys": { 194 + "sphere_members_sphere_id_did_pk": { 195 + "columns": ["sphere_id", "did"], 196 + "name": "sphere_members_sphere_id_did_pk" 197 + } 198 + }, 199 + "uniqueConstraints": {}, 200 + "checkConstraints": {} 201 + }, 202 + "sphere_modules": { 203 + "name": "sphere_modules", 204 + "columns": { 205 + "sphere_id": { 206 + "name": "sphere_id", 207 + "type": "text", 208 + "primaryKey": false, 209 + "notNull": true, 210 + "autoincrement": false 211 + }, 212 + "module_name": { 213 + "name": "module_name", 214 + "type": "text", 215 + "primaryKey": false, 216 + "notNull": true, 217 + "autoincrement": false 218 + }, 219 + "enabled_at": { 220 + "name": "enabled_at", 221 + "type": "text", 222 + "primaryKey": false, 223 + "notNull": true, 224 + "autoincrement": false, 225 + "default": "(datetime('now'))" 226 + } 227 + }, 228 + "indexes": {}, 229 + "foreignKeys": { 230 + "sphere_modules_sphere_id_spheres_id_fk": { 231 + "name": "sphere_modules_sphere_id_spheres_id_fk", 232 + "tableFrom": "sphere_modules", 233 + "tableTo": "spheres", 234 + "columnsFrom": ["sphere_id"], 235 + "columnsTo": ["id"], 236 + "onDelete": "no action", 237 + "onUpdate": "no action" 238 + } 239 + }, 240 + "compositePrimaryKeys": { 241 + "sphere_modules_sphere_id_module_name_pk": { 242 + "columns": ["sphere_id", "module_name"], 243 + "name": "sphere_modules_sphere_id_module_name_pk" 244 + } 245 + }, 246 + "uniqueConstraints": {}, 247 + "checkConstraints": {} 248 + }, 249 + "sphere_permissions": { 250 + "name": "sphere_permissions", 251 + "columns": { 252 + "sphere_id": { 253 + "name": "sphere_id", 254 + "type": "text", 255 + "primaryKey": false, 256 + "notNull": true, 257 + "autoincrement": false 258 + }, 259 + "action_key": { 260 + "name": "action_key", 261 + "type": "text", 262 + "primaryKey": false, 263 + "notNull": true, 264 + "autoincrement": false 265 + }, 266 + "min_role": { 267 + "name": "min_role", 268 + "type": "text", 269 + "primaryKey": false, 270 + "notNull": true, 271 + "autoincrement": false 272 + }, 273 + "updated_at": { 274 + "name": "updated_at", 275 + "type": "text", 276 + "primaryKey": false, 277 + "notNull": true, 278 + "autoincrement": false, 279 + "default": "(datetime('now'))" 280 + } 281 + }, 282 + "indexes": {}, 283 + "foreignKeys": { 284 + "sphere_permissions_sphere_id_spheres_id_fk": { 285 + "name": "sphere_permissions_sphere_id_spheres_id_fk", 286 + "tableFrom": "sphere_permissions", 287 + "tableTo": "spheres", 288 + "columnsFrom": ["sphere_id"], 289 + "columnsTo": ["id"], 290 + "onDelete": "no action", 291 + "onUpdate": "no action" 292 + } 293 + }, 294 + "compositePrimaryKeys": { 295 + "sphere_permissions_sphere_id_action_key_pk": { 296 + "columns": ["sphere_id", "action_key"], 297 + "name": "sphere_permissions_sphere_id_action_key_pk" 298 + } 299 + }, 300 + "uniqueConstraints": {}, 301 + "checkConstraints": {} 302 + }, 303 + "spheres": { 304 + "name": "spheres", 305 + "columns": { 306 + "id": { 307 + "name": "id", 308 + "type": "text", 309 + "primaryKey": true, 310 + "notNull": true, 311 + "autoincrement": false 312 + }, 313 + "handle": { 314 + "name": "handle", 315 + "type": "text", 316 + "primaryKey": false, 317 + "notNull": true, 318 + "autoincrement": false 319 + }, 320 + "name": { 321 + "name": "name", 322 + "type": "text", 323 + "primaryKey": false, 324 + "notNull": true, 325 + "autoincrement": false 326 + }, 327 + "description": { 328 + "name": "description", 329 + "type": "text", 330 + "primaryKey": false, 331 + "notNull": false, 332 + "autoincrement": false 333 + }, 334 + "visibility": { 335 + "name": "visibility", 336 + "type": "text", 337 + "primaryKey": false, 338 + "notNull": true, 339 + "autoincrement": false, 340 + "default": "'public'" 341 + }, 342 + "owner_did": { 343 + "name": "owner_did", 344 + "type": "text", 345 + "primaryKey": false, 346 + "notNull": true, 347 + "autoincrement": false 348 + }, 349 + "pds_uri": { 350 + "name": "pds_uri", 351 + "type": "text", 352 + "primaryKey": false, 353 + "notNull": false, 354 + "autoincrement": false 355 + }, 356 + "created_at": { 357 + "name": "created_at", 358 + "type": "text", 359 + "primaryKey": false, 360 + "notNull": true, 361 + "autoincrement": false, 362 + "default": "(datetime('now'))" 363 + }, 364 + "updated_at": { 365 + "name": "updated_at", 366 + "type": "text", 367 + "primaryKey": false, 368 + "notNull": true, 369 + "autoincrement": false, 370 + "default": "(datetime('now'))" 371 + } 372 + }, 373 + "indexes": { 374 + "spheres_handle_unique": { 375 + "name": "spheres_handle_unique", 376 + "columns": ["handle"], 377 + "isUnique": true 378 + } 379 + }, 380 + "foreignKeys": {}, 381 + "compositePrimaryKeys": {}, 382 + "uniqueConstraints": {}, 383 + "checkConstraints": {} 384 + }, 385 + "feature_request_comment_votes": { 386 + "name": "feature_request_comment_votes", 387 + "columns": { 388 + "comment_id": { 389 + "name": "comment_id", 390 + "type": "text", 391 + "primaryKey": false, 392 + "notNull": true, 393 + "autoincrement": false 394 + }, 395 + "author_did": { 396 + "name": "author_did", 397 + "type": "text", 398 + "primaryKey": false, 399 + "notNull": true, 400 + "autoincrement": false 401 + }, 402 + "pds_uri": { 403 + "name": "pds_uri", 404 + "type": "text", 405 + "primaryKey": false, 406 + "notNull": false, 407 + "autoincrement": false 408 + }, 409 + "created_at": { 410 + "name": "created_at", 411 + "type": "text", 412 + "primaryKey": false, 413 + "notNull": true, 414 + "autoincrement": false, 415 + "default": "(datetime('now'))" 416 + } 417 + }, 418 + "indexes": { 419 + "idx_feature_request_comment_votes_comment": { 420 + "name": "idx_feature_request_comment_votes_comment", 421 + "columns": ["comment_id"], 422 + "isUnique": false 423 + } 424 + }, 425 + "foreignKeys": { 426 + "feature_request_comment_votes_comment_id_feature_request_comments_id_fk": { 427 + "name": "feature_request_comment_votes_comment_id_feature_request_comments_id_fk", 428 + "tableFrom": "feature_request_comment_votes", 429 + "tableTo": "feature_request_comments", 430 + "columnsFrom": ["comment_id"], 431 + "columnsTo": ["id"], 432 + "onDelete": "no action", 433 + "onUpdate": "no action" 434 + } 435 + }, 436 + "compositePrimaryKeys": { 437 + "feature_request_comment_votes_comment_id_author_did_pk": { 438 + "columns": ["comment_id", "author_did"], 439 + "name": "feature_request_comment_votes_comment_id_author_did_pk" 440 + } 441 + }, 442 + "uniqueConstraints": {}, 443 + "checkConstraints": {} 444 + }, 445 + "feature_request_comments": { 446 + "name": "feature_request_comments", 447 + "columns": { 448 + "id": { 449 + "name": "id", 450 + "type": "text", 451 + "primaryKey": true, 452 + "notNull": true, 453 + "autoincrement": false 454 + }, 455 + "request_id": { 456 + "name": "request_id", 457 + "type": "text", 458 + "primaryKey": false, 459 + "notNull": true, 460 + "autoincrement": false 461 + }, 462 + "author_did": { 463 + "name": "author_did", 464 + "type": "text", 465 + "primaryKey": false, 466 + "notNull": true, 467 + "autoincrement": false 468 + }, 469 + "content": { 470 + "name": "content", 471 + "type": "text", 472 + "primaryKey": false, 473 + "notNull": true, 474 + "autoincrement": false 475 + }, 476 + "pds_uri": { 477 + "name": "pds_uri", 478 + "type": "text", 479 + "primaryKey": false, 480 + "notNull": false, 481 + "autoincrement": false 482 + }, 483 + "updated_at": { 484 + "name": "updated_at", 485 + "type": "text", 486 + "primaryKey": false, 487 + "notNull": true, 488 + "autoincrement": false, 489 + "default": "(datetime('now'))" 490 + }, 491 + "hidden_at": { 492 + "name": "hidden_at", 493 + "type": "text", 494 + "primaryKey": false, 495 + "notNull": false, 496 + "autoincrement": false 497 + }, 498 + "moderated_by": { 499 + "name": "moderated_by", 500 + "type": "text", 501 + "primaryKey": false, 502 + "notNull": false, 503 + "autoincrement": false 504 + } 505 + }, 506 + "indexes": { 507 + "idx_feature_request_comments_request": { 508 + "name": "idx_feature_request_comments_request", 509 + "columns": ["request_id"], 510 + "isUnique": false 511 + }, 512 + "idx_feature_request_comments_author_request": { 513 + "name": "idx_feature_request_comments_author_request", 514 + "columns": ["author_did", "request_id"], 515 + "isUnique": false 516 + } 517 + }, 518 + "foreignKeys": { 519 + "feature_request_comments_request_id_feature_requests_id_fk": { 520 + "name": "feature_request_comments_request_id_feature_requests_id_fk", 521 + "tableFrom": "feature_request_comments", 522 + "tableTo": "feature_requests", 523 + "columnsFrom": ["request_id"], 524 + "columnsTo": ["id"], 525 + "onDelete": "no action", 526 + "onUpdate": "no action" 527 + } 528 + }, 529 + "compositePrimaryKeys": {}, 530 + "uniqueConstraints": {}, 531 + "checkConstraints": {} 532 + }, 533 + "feature_request_statuses": { 534 + "name": "feature_request_statuses", 535 + "columns": { 536 + "id": { 537 + "name": "id", 538 + "type": "text", 539 + "primaryKey": true, 540 + "notNull": true, 541 + "autoincrement": false 542 + }, 543 + "request_id": { 544 + "name": "request_id", 545 + "type": "text", 546 + "primaryKey": false, 547 + "notNull": true, 548 + "autoincrement": false 549 + }, 550 + "author_did": { 551 + "name": "author_did", 552 + "type": "text", 553 + "primaryKey": false, 554 + "notNull": true, 555 + "autoincrement": false 556 + }, 557 + "status": { 558 + "name": "status", 559 + "type": "text", 560 + "primaryKey": false, 561 + "notNull": true, 562 + "autoincrement": false 563 + }, 564 + "pds_uri": { 565 + "name": "pds_uri", 566 + "type": "text", 567 + "primaryKey": false, 568 + "notNull": false, 569 + "autoincrement": false 570 + } 571 + }, 572 + "indexes": { 573 + "idx_feature_request_statuses_request": { 574 + "name": "idx_feature_request_statuses_request", 575 + "columns": ["request_id"], 576 + "isUnique": false 577 + } 578 + }, 579 + "foreignKeys": { 580 + "feature_request_statuses_request_id_feature_requests_id_fk": { 581 + "name": "feature_request_statuses_request_id_feature_requests_id_fk", 582 + "tableFrom": "feature_request_statuses", 583 + "tableTo": "feature_requests", 584 + "columnsFrom": ["request_id"], 585 + "columnsTo": ["id"], 586 + "onDelete": "no action", 587 + "onUpdate": "no action" 588 + } 589 + }, 590 + "compositePrimaryKeys": {}, 591 + "uniqueConstraints": {}, 592 + "checkConstraints": {} 593 + }, 594 + "feature_request_votes": { 595 + "name": "feature_request_votes", 596 + "columns": { 597 + "request_id": { 598 + "name": "request_id", 599 + "type": "text", 600 + "primaryKey": false, 601 + "notNull": true, 602 + "autoincrement": false 603 + }, 604 + "author_did": { 605 + "name": "author_did", 606 + "type": "text", 607 + "primaryKey": false, 608 + "notNull": true, 609 + "autoincrement": false 610 + }, 611 + "pds_uri": { 612 + "name": "pds_uri", 613 + "type": "text", 614 + "primaryKey": false, 615 + "notNull": false, 616 + "autoincrement": false 617 + }, 618 + "created_at": { 619 + "name": "created_at", 620 + "type": "text", 621 + "primaryKey": false, 622 + "notNull": true, 623 + "autoincrement": false, 624 + "default": "(datetime('now'))" 625 + } 626 + }, 627 + "indexes": { 628 + "idx_feature_request_votes_request": { 629 + "name": "idx_feature_request_votes_request", 630 + "columns": ["request_id"], 631 + "isUnique": false 632 + } 633 + }, 634 + "foreignKeys": { 635 + "feature_request_votes_request_id_feature_requests_id_fk": { 636 + "name": "feature_request_votes_request_id_feature_requests_id_fk", 637 + "tableFrom": "feature_request_votes", 638 + "tableTo": "feature_requests", 639 + "columnsFrom": ["request_id"], 640 + "columnsTo": ["id"], 641 + "onDelete": "no action", 642 + "onUpdate": "no action" 643 + } 644 + }, 645 + "compositePrimaryKeys": { 646 + "feature_request_votes_request_id_author_did_pk": { 647 + "columns": ["request_id", "author_did"], 648 + "name": "feature_request_votes_request_id_author_did_pk" 649 + } 650 + }, 651 + "uniqueConstraints": {}, 652 + "checkConstraints": {} 653 + }, 654 + "feature_requests": { 655 + "name": "feature_requests", 656 + "columns": { 657 + "id": { 658 + "name": "id", 659 + "type": "text", 660 + "primaryKey": true, 661 + "notNull": true, 662 + "autoincrement": false 663 + }, 664 + "sphere_id": { 665 + "name": "sphere_id", 666 + "type": "text", 667 + "primaryKey": false, 668 + "notNull": true, 669 + "autoincrement": false 670 + }, 671 + "number": { 672 + "name": "number", 673 + "type": "integer", 674 + "primaryKey": false, 675 + "notNull": true, 676 + "autoincrement": false 677 + }, 678 + "author_did": { 679 + "name": "author_did", 680 + "type": "text", 681 + "primaryKey": false, 682 + "notNull": true, 683 + "autoincrement": false 684 + }, 685 + "title": { 686 + "name": "title", 687 + "type": "text", 688 + "primaryKey": false, 689 + "notNull": true, 690 + "autoincrement": false 691 + }, 692 + "description": { 693 + "name": "description", 694 + "type": "text", 695 + "primaryKey": false, 696 + "notNull": true, 697 + "autoincrement": false 698 + }, 699 + "category": { 700 + "name": "category", 701 + "type": "text", 702 + "primaryKey": false, 703 + "notNull": true, 704 + "autoincrement": false, 705 + "default": "'general'" 706 + }, 707 + "status": { 708 + "name": "status", 709 + "type": "text", 710 + "primaryKey": false, 711 + "notNull": true, 712 + "autoincrement": false, 713 + "default": "'requested'" 714 + }, 715 + "duplicate_of_id": { 716 + "name": "duplicate_of_id", 717 + "type": "text", 718 + "primaryKey": false, 719 + "notNull": false, 720 + "autoincrement": false 721 + }, 722 + "pds_uri": { 723 + "name": "pds_uri", 724 + "type": "text", 725 + "primaryKey": false, 726 + "notNull": false, 727 + "autoincrement": false 728 + }, 729 + "hidden_at": { 730 + "name": "hidden_at", 731 + "type": "text", 732 + "primaryKey": false, 733 + "notNull": false, 734 + "autoincrement": false 735 + }, 736 + "moderated_by": { 737 + "name": "moderated_by", 738 + "type": "text", 739 + "primaryKey": false, 740 + "notNull": false, 741 + "autoincrement": false 742 + }, 743 + "updated_at": { 744 + "name": "updated_at", 745 + "type": "text", 746 + "primaryKey": false, 747 + "notNull": true, 748 + "autoincrement": false, 749 + "default": "(datetime('now'))" 750 + } 751 + }, 752 + "indexes": { 753 + "idx_feature_requests_sphere_number": { 754 + "name": "idx_feature_requests_sphere_number", 755 + "columns": ["sphere_id", "number"], 756 + "isUnique": true 757 + }, 758 + "idx_feature_requests_sphere": { 759 + "name": "idx_feature_requests_sphere", 760 + "columns": ["sphere_id"], 761 + "isUnique": false 762 + }, 763 + "idx_feature_requests_status": { 764 + "name": "idx_feature_requests_status", 765 + "columns": ["status"], 766 + "isUnique": false 767 + }, 768 + "idx_feature_requests_category": { 769 + "name": "idx_feature_requests_category", 770 + "columns": ["category"], 771 + "isUnique": false 772 + } 773 + }, 774 + "foreignKeys": { 775 + "feature_requests_sphere_id_spheres_id_fk": { 776 + "name": "feature_requests_sphere_id_spheres_id_fk", 777 + "tableFrom": "feature_requests", 778 + "tableTo": "spheres", 779 + "columnsFrom": ["sphere_id"], 780 + "columnsTo": ["id"], 781 + "onDelete": "no action", 782 + "onUpdate": "no action" 783 + } 784 + }, 785 + "compositePrimaryKeys": {}, 786 + "uniqueConstraints": {}, 787 + "checkConstraints": {} 788 + }, 789 + "feed_posts": { 790 + "name": "feed_posts", 791 + "columns": { 792 + "id": { 793 + "name": "id", 794 + "type": "text", 795 + "primaryKey": true, 796 + "notNull": true, 797 + "autoincrement": false 798 + }, 799 + "author_did": { 800 + "name": "author_did", 801 + "type": "text", 802 + "primaryKey": false, 803 + "notNull": true, 804 + "autoincrement": false 805 + }, 806 + "content": { 807 + "name": "content", 808 + "type": "text", 809 + "primaryKey": false, 810 + "notNull": true, 811 + "autoincrement": false 812 + }, 813 + "parent_id": { 814 + "name": "parent_id", 815 + "type": "text", 816 + "primaryKey": false, 817 + "notNull": false, 818 + "autoincrement": false 819 + }, 820 + "pds_uri": { 821 + "name": "pds_uri", 822 + "type": "text", 823 + "primaryKey": false, 824 + "notNull": false, 825 + "autoincrement": false 826 + }, 827 + "updated_at": { 828 + "name": "updated_at", 829 + "type": "text", 830 + "primaryKey": false, 831 + "notNull": true, 832 + "autoincrement": false, 833 + "default": "(datetime('now'))" 834 + } 835 + }, 836 + "indexes": { 837 + "idx_feed_posts_parent": { 838 + "name": "idx_feed_posts_parent", 839 + "columns": ["parent_id"], 840 + "isUnique": false 841 + } 842 + }, 843 + "foreignKeys": {}, 844 + "compositePrimaryKeys": {}, 845 + "uniqueConstraints": {}, 846 + "checkConstraints": {} 847 + }, 848 + "kanban_task_comments": { 849 + "name": "kanban_task_comments", 850 + "columns": { 851 + "id": { 852 + "name": "id", 853 + "type": "text", 854 + "primaryKey": true, 855 + "notNull": true, 856 + "autoincrement": false 857 + }, 858 + "task_id": { 859 + "name": "task_id", 860 + "type": "text", 861 + "primaryKey": false, 862 + "notNull": true, 863 + "autoincrement": false 864 + }, 865 + "author_did": { 866 + "name": "author_did", 867 + "type": "text", 868 + "primaryKey": false, 869 + "notNull": true, 870 + "autoincrement": false 871 + }, 872 + "content": { 873 + "name": "content", 874 + "type": "text", 875 + "primaryKey": false, 876 + "notNull": true, 877 + "autoincrement": false 878 + }, 879 + "pds_uri": { 880 + "name": "pds_uri", 881 + "type": "text", 882 + "primaryKey": false, 883 + "notNull": false, 884 + "autoincrement": false 885 + }, 886 + "updated_at": { 887 + "name": "updated_at", 888 + "type": "text", 889 + "primaryKey": false, 890 + "notNull": true, 891 + "autoincrement": false, 892 + "default": "(datetime('now'))" 893 + }, 894 + "hidden_at": { 895 + "name": "hidden_at", 896 + "type": "text", 897 + "primaryKey": false, 898 + "notNull": false, 899 + "autoincrement": false 900 + }, 901 + "moderated_by": { 902 + "name": "moderated_by", 903 + "type": "text", 904 + "primaryKey": false, 905 + "notNull": false, 906 + "autoincrement": false 907 + } 908 + }, 909 + "indexes": { 910 + "idx_kanban_task_comments_task": { 911 + "name": "idx_kanban_task_comments_task", 912 + "columns": ["task_id"], 913 + "isUnique": false 914 + }, 915 + "idx_kanban_task_comments_author_task": { 916 + "name": "idx_kanban_task_comments_author_task", 917 + "columns": ["author_did", "task_id"], 918 + "isUnique": false 919 + } 920 + }, 921 + "foreignKeys": { 922 + "kanban_task_comments_task_id_kanban_tasks_id_fk": { 923 + "name": "kanban_task_comments_task_id_kanban_tasks_id_fk", 924 + "tableFrom": "kanban_task_comments", 925 + "tableTo": "kanban_tasks", 926 + "columnsFrom": ["task_id"], 927 + "columnsTo": ["id"], 928 + "onDelete": "no action", 929 + "onUpdate": "no action" 930 + } 931 + }, 932 + "compositePrimaryKeys": {}, 933 + "uniqueConstraints": {}, 934 + "checkConstraints": {} 935 + }, 936 + "kanban_task_status_changes": { 937 + "name": "kanban_task_status_changes", 938 + "columns": { 939 + "id": { 940 + "name": "id", 941 + "type": "text", 942 + "primaryKey": true, 943 + "notNull": true, 944 + "autoincrement": false 945 + }, 946 + "task_id": { 947 + "name": "task_id", 948 + "type": "text", 949 + "primaryKey": false, 950 + "notNull": true, 951 + "autoincrement": false 952 + }, 953 + "author_did": { 954 + "name": "author_did", 955 + "type": "text", 956 + "primaryKey": false, 957 + "notNull": true, 958 + "autoincrement": false 959 + }, 960 + "status": { 961 + "name": "status", 962 + "type": "text", 963 + "primaryKey": false, 964 + "notNull": true, 965 + "autoincrement": false 966 + }, 967 + "pds_uri": { 968 + "name": "pds_uri", 969 + "type": "text", 970 + "primaryKey": false, 971 + "notNull": false, 972 + "autoincrement": false 973 + } 974 + }, 975 + "indexes": { 976 + "idx_kanban_task_status_changes_task": { 977 + "name": "idx_kanban_task_status_changes_task", 978 + "columns": ["task_id"], 979 + "isUnique": false 980 + } 981 + }, 982 + "foreignKeys": { 983 + "kanban_task_status_changes_task_id_kanban_tasks_id_fk": { 984 + "name": "kanban_task_status_changes_task_id_kanban_tasks_id_fk", 985 + "tableFrom": "kanban_task_status_changes", 986 + "tableTo": "kanban_tasks", 987 + "columnsFrom": ["task_id"], 988 + "columnsTo": ["id"], 989 + "onDelete": "no action", 990 + "onUpdate": "no action" 991 + } 992 + }, 993 + "compositePrimaryKeys": {}, 994 + "uniqueConstraints": {}, 995 + "checkConstraints": {} 996 + }, 997 + "kanban_tasks": { 998 + "name": "kanban_tasks", 999 + "columns": { 1000 + "id": { 1001 + "name": "id", 1002 + "type": "text", 1003 + "primaryKey": true, 1004 + "notNull": true, 1005 + "autoincrement": false 1006 + }, 1007 + "sphere_id": { 1008 + "name": "sphere_id", 1009 + "type": "text", 1010 + "primaryKey": false, 1011 + "notNull": true, 1012 + "autoincrement": false 1013 + }, 1014 + "number": { 1015 + "name": "number", 1016 + "type": "integer", 1017 + "primaryKey": false, 1018 + "notNull": true, 1019 + "autoincrement": false 1020 + }, 1021 + "author_did": { 1022 + "name": "author_did", 1023 + "type": "text", 1024 + "primaryKey": false, 1025 + "notNull": true, 1026 + "autoincrement": false 1027 + }, 1028 + "title": { 1029 + "name": "title", 1030 + "type": "text", 1031 + "primaryKey": false, 1032 + "notNull": true, 1033 + "autoincrement": false 1034 + }, 1035 + "description": { 1036 + "name": "description", 1037 + "type": "text", 1038 + "primaryKey": false, 1039 + "notNull": true, 1040 + "autoincrement": false, 1041 + "default": "''" 1042 + }, 1043 + "status": { 1044 + "name": "status", 1045 + "type": "text", 1046 + "primaryKey": false, 1047 + "notNull": true, 1048 + "autoincrement": false, 1049 + "default": "'backlog'" 1050 + }, 1051 + "position": { 1052 + "name": "position", 1053 + "type": "integer", 1054 + "primaryKey": false, 1055 + "notNull": true, 1056 + "autoincrement": false, 1057 + "default": 0 1058 + }, 1059 + "assignee_did": { 1060 + "name": "assignee_did", 1061 + "type": "text", 1062 + "primaryKey": false, 1063 + "notNull": false, 1064 + "autoincrement": false 1065 + }, 1066 + "pds_uri": { 1067 + "name": "pds_uri", 1068 + "type": "text", 1069 + "primaryKey": false, 1070 + "notNull": false, 1071 + "autoincrement": false 1072 + }, 1073 + "hidden_at": { 1074 + "name": "hidden_at", 1075 + "type": "text", 1076 + "primaryKey": false, 1077 + "notNull": false, 1078 + "autoincrement": false 1079 + }, 1080 + "moderated_by": { 1081 + "name": "moderated_by", 1082 + "type": "text", 1083 + "primaryKey": false, 1084 + "notNull": false, 1085 + "autoincrement": false 1086 + }, 1087 + "updated_at": { 1088 + "name": "updated_at", 1089 + "type": "text", 1090 + "primaryKey": false, 1091 + "notNull": true, 1092 + "autoincrement": false, 1093 + "default": "(datetime('now'))" 1094 + } 1095 + }, 1096 + "indexes": { 1097 + "idx_kanban_tasks_sphere_number": { 1098 + "name": "idx_kanban_tasks_sphere_number", 1099 + "columns": ["sphere_id", "number"], 1100 + "isUnique": true 1101 + }, 1102 + "idx_kanban_tasks_sphere": { 1103 + "name": "idx_kanban_tasks_sphere", 1104 + "columns": ["sphere_id"], 1105 + "isUnique": false 1106 + }, 1107 + "idx_kanban_tasks_status": { 1108 + "name": "idx_kanban_tasks_status", 1109 + "columns": ["status"], 1110 + "isUnique": false 1111 + }, 1112 + "idx_kanban_tasks_sphere_status_position": { 1113 + "name": "idx_kanban_tasks_sphere_status_position", 1114 + "columns": ["sphere_id", "status", "position"], 1115 + "isUnique": false 1116 + } 1117 + }, 1118 + "foreignKeys": { 1119 + "kanban_tasks_sphere_id_spheres_id_fk": { 1120 + "name": "kanban_tasks_sphere_id_spheres_id_fk", 1121 + "tableFrom": "kanban_tasks", 1122 + "tableTo": "spheres", 1123 + "columnsFrom": ["sphere_id"], 1124 + "columnsTo": ["id"], 1125 + "onDelete": "no action", 1126 + "onUpdate": "no action" 1127 + } 1128 + }, 1129 + "compositePrimaryKeys": {}, 1130 + "uniqueConstraints": {}, 1131 + "checkConstraints": {} 1132 + } 1133 + }, 1134 + "views": {}, 1135 + "enums": {}, 1136 + "_meta": { 1137 + "schemas": {}, 1138 + "tables": {}, 1139 + "columns": {} 1140 + }, 1141 + "internal": { 1142 + "indexes": {} 1143 + } 1144 + }
+1218
drizzle/meta/0002_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "0e06a92f-2199-4052-971b-1fc3be86eb03", 5 + "prevId": "cf1efedc-9a33-469b-b835-9cd7d63e63bd", 6 + "tables": { 7 + "oauth_sessions": { 8 + "name": "oauth_sessions", 9 + "columns": { 10 + "key": { 11 + "name": "key", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "session": { 18 + "name": "session", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "created_at": { 25 + "name": "created_at", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false, 30 + "default": "(datetime('now'))" 31 + }, 32 + "updated_at": { 33 + "name": "updated_at", 34 + "type": "text", 35 + "primaryKey": false, 36 + "notNull": true, 37 + "autoincrement": false, 38 + "default": "(datetime('now'))" 39 + } 40 + }, 41 + "indexes": {}, 42 + "foreignKeys": {}, 43 + "compositePrimaryKeys": {}, 44 + "uniqueConstraints": {}, 45 + "checkConstraints": {} 46 + }, 47 + "oauth_states": { 48 + "name": "oauth_states", 49 + "columns": { 50 + "key": { 51 + "name": "key", 52 + "type": "text", 53 + "primaryKey": true, 54 + "notNull": true, 55 + "autoincrement": false 56 + }, 57 + "state": { 58 + "name": "state", 59 + "type": "text", 60 + "primaryKey": false, 61 + "notNull": true, 62 + "autoincrement": false 63 + }, 64 + "created_at": { 65 + "name": "created_at", 66 + "type": "text", 67 + "primaryKey": false, 68 + "notNull": true, 69 + "autoincrement": false, 70 + "default": "(datetime('now'))" 71 + } 72 + }, 73 + "indexes": {}, 74 + "foreignKeys": {}, 75 + "compositePrimaryKeys": {}, 76 + "uniqueConstraints": {}, 77 + "checkConstraints": {} 78 + }, 79 + "indexer_cursor": { 80 + "name": "indexer_cursor", 81 + "columns": { 82 + "id": { 83 + "name": "id", 84 + "type": "text", 85 + "primaryKey": true, 86 + "notNull": true, 87 + "autoincrement": false, 88 + "default": "'jetstream'" 89 + }, 90 + "cursor": { 91 + "name": "cursor", 92 + "type": "integer", 93 + "primaryKey": false, 94 + "notNull": true, 95 + "autoincrement": false 96 + }, 97 + "updated_at": { 98 + "name": "updated_at", 99 + "type": "text", 100 + "primaryKey": false, 101 + "notNull": true, 102 + "autoincrement": false, 103 + "default": "(datetime('now'))" 104 + } 105 + }, 106 + "indexes": {}, 107 + "foreignKeys": {}, 108 + "compositePrimaryKeys": {}, 109 + "uniqueConstraints": {}, 110 + "checkConstraints": {} 111 + }, 112 + "sphere_members": { 113 + "name": "sphere_members", 114 + "columns": { 115 + "sphere_id": { 116 + "name": "sphere_id", 117 + "type": "text", 118 + "primaryKey": false, 119 + "notNull": true, 120 + "autoincrement": false 121 + }, 122 + "did": { 123 + "name": "did", 124 + "type": "text", 125 + "primaryKey": false, 126 + "notNull": true, 127 + "autoincrement": false 128 + }, 129 + "role": { 130 + "name": "role", 131 + "type": "text", 132 + "primaryKey": false, 133 + "notNull": true, 134 + "autoincrement": false, 135 + "default": "'member'" 136 + }, 137 + "status": { 138 + "name": "status", 139 + "type": "text", 140 + "primaryKey": false, 141 + "notNull": true, 142 + "autoincrement": false, 143 + "default": "'invited'" 144 + }, 145 + "invited_by": { 146 + "name": "invited_by", 147 + "type": "text", 148 + "primaryKey": false, 149 + "notNull": false, 150 + "autoincrement": false 151 + }, 152 + "pds_uri": { 153 + "name": "pds_uri", 154 + "type": "text", 155 + "primaryKey": false, 156 + "notNull": false, 157 + "autoincrement": false 158 + }, 159 + "approval_pds_uri": { 160 + "name": "approval_pds_uri", 161 + "type": "text", 162 + "primaryKey": false, 163 + "notNull": false, 164 + "autoincrement": false 165 + }, 166 + "created_at": { 167 + "name": "created_at", 168 + "type": "text", 169 + "primaryKey": false, 170 + "notNull": true, 171 + "autoincrement": false, 172 + "default": "(datetime('now'))" 173 + } 174 + }, 175 + "indexes": { 176 + "idx_sphere_members_did": { 177 + "name": "idx_sphere_members_did", 178 + "columns": ["did"], 179 + "isUnique": false 180 + } 181 + }, 182 + "foreignKeys": { 183 + "sphere_members_sphere_id_spheres_id_fk": { 184 + "name": "sphere_members_sphere_id_spheres_id_fk", 185 + "tableFrom": "sphere_members", 186 + "tableTo": "spheres", 187 + "columnsFrom": ["sphere_id"], 188 + "columnsTo": ["id"], 189 + "onDelete": "no action", 190 + "onUpdate": "no action" 191 + } 192 + }, 193 + "compositePrimaryKeys": { 194 + "sphere_members_sphere_id_did_pk": { 195 + "columns": ["sphere_id", "did"], 196 + "name": "sphere_members_sphere_id_did_pk" 197 + } 198 + }, 199 + "uniqueConstraints": {}, 200 + "checkConstraints": {} 201 + }, 202 + "sphere_modules": { 203 + "name": "sphere_modules", 204 + "columns": { 205 + "sphere_id": { 206 + "name": "sphere_id", 207 + "type": "text", 208 + "primaryKey": false, 209 + "notNull": true, 210 + "autoincrement": false 211 + }, 212 + "module_name": { 213 + "name": "module_name", 214 + "type": "text", 215 + "primaryKey": false, 216 + "notNull": true, 217 + "autoincrement": false 218 + }, 219 + "enabled_at": { 220 + "name": "enabled_at", 221 + "type": "text", 222 + "primaryKey": false, 223 + "notNull": true, 224 + "autoincrement": false, 225 + "default": "(datetime('now'))" 226 + } 227 + }, 228 + "indexes": {}, 229 + "foreignKeys": { 230 + "sphere_modules_sphere_id_spheres_id_fk": { 231 + "name": "sphere_modules_sphere_id_spheres_id_fk", 232 + "tableFrom": "sphere_modules", 233 + "tableTo": "spheres", 234 + "columnsFrom": ["sphere_id"], 235 + "columnsTo": ["id"], 236 + "onDelete": "no action", 237 + "onUpdate": "no action" 238 + } 239 + }, 240 + "compositePrimaryKeys": { 241 + "sphere_modules_sphere_id_module_name_pk": { 242 + "columns": ["sphere_id", "module_name"], 243 + "name": "sphere_modules_sphere_id_module_name_pk" 244 + } 245 + }, 246 + "uniqueConstraints": {}, 247 + "checkConstraints": {} 248 + }, 249 + "sphere_permissions": { 250 + "name": "sphere_permissions", 251 + "columns": { 252 + "sphere_id": { 253 + "name": "sphere_id", 254 + "type": "text", 255 + "primaryKey": false, 256 + "notNull": true, 257 + "autoincrement": false 258 + }, 259 + "action_key": { 260 + "name": "action_key", 261 + "type": "text", 262 + "primaryKey": false, 263 + "notNull": true, 264 + "autoincrement": false 265 + }, 266 + "min_role": { 267 + "name": "min_role", 268 + "type": "text", 269 + "primaryKey": false, 270 + "notNull": true, 271 + "autoincrement": false 272 + }, 273 + "updated_at": { 274 + "name": "updated_at", 275 + "type": "text", 276 + "primaryKey": false, 277 + "notNull": true, 278 + "autoincrement": false, 279 + "default": "(datetime('now'))" 280 + } 281 + }, 282 + "indexes": {}, 283 + "foreignKeys": { 284 + "sphere_permissions_sphere_id_spheres_id_fk": { 285 + "name": "sphere_permissions_sphere_id_spheres_id_fk", 286 + "tableFrom": "sphere_permissions", 287 + "tableTo": "spheres", 288 + "columnsFrom": ["sphere_id"], 289 + "columnsTo": ["id"], 290 + "onDelete": "no action", 291 + "onUpdate": "no action" 292 + } 293 + }, 294 + "compositePrimaryKeys": { 295 + "sphere_permissions_sphere_id_action_key_pk": { 296 + "columns": ["sphere_id", "action_key"], 297 + "name": "sphere_permissions_sphere_id_action_key_pk" 298 + } 299 + }, 300 + "uniqueConstraints": {}, 301 + "checkConstraints": {} 302 + }, 303 + "spheres": { 304 + "name": "spheres", 305 + "columns": { 306 + "id": { 307 + "name": "id", 308 + "type": "text", 309 + "primaryKey": true, 310 + "notNull": true, 311 + "autoincrement": false 312 + }, 313 + "handle": { 314 + "name": "handle", 315 + "type": "text", 316 + "primaryKey": false, 317 + "notNull": true, 318 + "autoincrement": false 319 + }, 320 + "name": { 321 + "name": "name", 322 + "type": "text", 323 + "primaryKey": false, 324 + "notNull": true, 325 + "autoincrement": false 326 + }, 327 + "description": { 328 + "name": "description", 329 + "type": "text", 330 + "primaryKey": false, 331 + "notNull": false, 332 + "autoincrement": false 333 + }, 334 + "visibility": { 335 + "name": "visibility", 336 + "type": "text", 337 + "primaryKey": false, 338 + "notNull": true, 339 + "autoincrement": false, 340 + "default": "'public'" 341 + }, 342 + "owner_did": { 343 + "name": "owner_did", 344 + "type": "text", 345 + "primaryKey": false, 346 + "notNull": true, 347 + "autoincrement": false 348 + }, 349 + "pds_uri": { 350 + "name": "pds_uri", 351 + "type": "text", 352 + "primaryKey": false, 353 + "notNull": false, 354 + "autoincrement": false 355 + }, 356 + "created_at": { 357 + "name": "created_at", 358 + "type": "text", 359 + "primaryKey": false, 360 + "notNull": true, 361 + "autoincrement": false, 362 + "default": "(datetime('now'))" 363 + }, 364 + "updated_at": { 365 + "name": "updated_at", 366 + "type": "text", 367 + "primaryKey": false, 368 + "notNull": true, 369 + "autoincrement": false, 370 + "default": "(datetime('now'))" 371 + } 372 + }, 373 + "indexes": { 374 + "spheres_handle_unique": { 375 + "name": "spheres_handle_unique", 376 + "columns": ["handle"], 377 + "isUnique": true 378 + } 379 + }, 380 + "foreignKeys": {}, 381 + "compositePrimaryKeys": {}, 382 + "uniqueConstraints": {}, 383 + "checkConstraints": {} 384 + }, 385 + "feature_request_comment_votes": { 386 + "name": "feature_request_comment_votes", 387 + "columns": { 388 + "comment_id": { 389 + "name": "comment_id", 390 + "type": "text", 391 + "primaryKey": false, 392 + "notNull": true, 393 + "autoincrement": false 394 + }, 395 + "author_did": { 396 + "name": "author_did", 397 + "type": "text", 398 + "primaryKey": false, 399 + "notNull": true, 400 + "autoincrement": false 401 + }, 402 + "pds_uri": { 403 + "name": "pds_uri", 404 + "type": "text", 405 + "primaryKey": false, 406 + "notNull": false, 407 + "autoincrement": false 408 + }, 409 + "created_at": { 410 + "name": "created_at", 411 + "type": "text", 412 + "primaryKey": false, 413 + "notNull": true, 414 + "autoincrement": false, 415 + "default": "(datetime('now'))" 416 + } 417 + }, 418 + "indexes": { 419 + "idx_feature_request_comment_votes_comment": { 420 + "name": "idx_feature_request_comment_votes_comment", 421 + "columns": ["comment_id"], 422 + "isUnique": false 423 + } 424 + }, 425 + "foreignKeys": { 426 + "feature_request_comment_votes_comment_id_feature_request_comments_id_fk": { 427 + "name": "feature_request_comment_votes_comment_id_feature_request_comments_id_fk", 428 + "tableFrom": "feature_request_comment_votes", 429 + "tableTo": "feature_request_comments", 430 + "columnsFrom": ["comment_id"], 431 + "columnsTo": ["id"], 432 + "onDelete": "no action", 433 + "onUpdate": "no action" 434 + } 435 + }, 436 + "compositePrimaryKeys": { 437 + "feature_request_comment_votes_comment_id_author_did_pk": { 438 + "columns": ["comment_id", "author_did"], 439 + "name": "feature_request_comment_votes_comment_id_author_did_pk" 440 + } 441 + }, 442 + "uniqueConstraints": {}, 443 + "checkConstraints": {} 444 + }, 445 + "feature_request_comments": { 446 + "name": "feature_request_comments", 447 + "columns": { 448 + "id": { 449 + "name": "id", 450 + "type": "text", 451 + "primaryKey": true, 452 + "notNull": true, 453 + "autoincrement": false 454 + }, 455 + "request_id": { 456 + "name": "request_id", 457 + "type": "text", 458 + "primaryKey": false, 459 + "notNull": true, 460 + "autoincrement": false 461 + }, 462 + "author_did": { 463 + "name": "author_did", 464 + "type": "text", 465 + "primaryKey": false, 466 + "notNull": true, 467 + "autoincrement": false 468 + }, 469 + "content": { 470 + "name": "content", 471 + "type": "text", 472 + "primaryKey": false, 473 + "notNull": true, 474 + "autoincrement": false 475 + }, 476 + "pds_uri": { 477 + "name": "pds_uri", 478 + "type": "text", 479 + "primaryKey": false, 480 + "notNull": false, 481 + "autoincrement": false 482 + }, 483 + "updated_at": { 484 + "name": "updated_at", 485 + "type": "text", 486 + "primaryKey": false, 487 + "notNull": true, 488 + "autoincrement": false, 489 + "default": "(datetime('now'))" 490 + }, 491 + "hidden_at": { 492 + "name": "hidden_at", 493 + "type": "text", 494 + "primaryKey": false, 495 + "notNull": false, 496 + "autoincrement": false 497 + }, 498 + "moderated_by": { 499 + "name": "moderated_by", 500 + "type": "text", 501 + "primaryKey": false, 502 + "notNull": false, 503 + "autoincrement": false 504 + } 505 + }, 506 + "indexes": { 507 + "idx_feature_request_comments_request": { 508 + "name": "idx_feature_request_comments_request", 509 + "columns": ["request_id"], 510 + "isUnique": false 511 + }, 512 + "idx_feature_request_comments_author_request": { 513 + "name": "idx_feature_request_comments_author_request", 514 + "columns": ["author_did", "request_id"], 515 + "isUnique": false 516 + } 517 + }, 518 + "foreignKeys": { 519 + "feature_request_comments_request_id_feature_requests_id_fk": { 520 + "name": "feature_request_comments_request_id_feature_requests_id_fk", 521 + "tableFrom": "feature_request_comments", 522 + "tableTo": "feature_requests", 523 + "columnsFrom": ["request_id"], 524 + "columnsTo": ["id"], 525 + "onDelete": "no action", 526 + "onUpdate": "no action" 527 + } 528 + }, 529 + "compositePrimaryKeys": {}, 530 + "uniqueConstraints": {}, 531 + "checkConstraints": {} 532 + }, 533 + "feature_request_statuses": { 534 + "name": "feature_request_statuses", 535 + "columns": { 536 + "id": { 537 + "name": "id", 538 + "type": "text", 539 + "primaryKey": true, 540 + "notNull": true, 541 + "autoincrement": false 542 + }, 543 + "request_id": { 544 + "name": "request_id", 545 + "type": "text", 546 + "primaryKey": false, 547 + "notNull": true, 548 + "autoincrement": false 549 + }, 550 + "author_did": { 551 + "name": "author_did", 552 + "type": "text", 553 + "primaryKey": false, 554 + "notNull": true, 555 + "autoincrement": false 556 + }, 557 + "status": { 558 + "name": "status", 559 + "type": "text", 560 + "primaryKey": false, 561 + "notNull": true, 562 + "autoincrement": false 563 + }, 564 + "pds_uri": { 565 + "name": "pds_uri", 566 + "type": "text", 567 + "primaryKey": false, 568 + "notNull": false, 569 + "autoincrement": false 570 + } 571 + }, 572 + "indexes": { 573 + "idx_feature_request_statuses_request": { 574 + "name": "idx_feature_request_statuses_request", 575 + "columns": ["request_id"], 576 + "isUnique": false 577 + } 578 + }, 579 + "foreignKeys": { 580 + "feature_request_statuses_request_id_feature_requests_id_fk": { 581 + "name": "feature_request_statuses_request_id_feature_requests_id_fk", 582 + "tableFrom": "feature_request_statuses", 583 + "tableTo": "feature_requests", 584 + "columnsFrom": ["request_id"], 585 + "columnsTo": ["id"], 586 + "onDelete": "no action", 587 + "onUpdate": "no action" 588 + } 589 + }, 590 + "compositePrimaryKeys": {}, 591 + "uniqueConstraints": {}, 592 + "checkConstraints": {} 593 + }, 594 + "feature_request_votes": { 595 + "name": "feature_request_votes", 596 + "columns": { 597 + "request_id": { 598 + "name": "request_id", 599 + "type": "text", 600 + "primaryKey": false, 601 + "notNull": true, 602 + "autoincrement": false 603 + }, 604 + "author_did": { 605 + "name": "author_did", 606 + "type": "text", 607 + "primaryKey": false, 608 + "notNull": true, 609 + "autoincrement": false 610 + }, 611 + "pds_uri": { 612 + "name": "pds_uri", 613 + "type": "text", 614 + "primaryKey": false, 615 + "notNull": false, 616 + "autoincrement": false 617 + }, 618 + "created_at": { 619 + "name": "created_at", 620 + "type": "text", 621 + "primaryKey": false, 622 + "notNull": true, 623 + "autoincrement": false, 624 + "default": "(datetime('now'))" 625 + } 626 + }, 627 + "indexes": { 628 + "idx_feature_request_votes_request": { 629 + "name": "idx_feature_request_votes_request", 630 + "columns": ["request_id"], 631 + "isUnique": false 632 + } 633 + }, 634 + "foreignKeys": { 635 + "feature_request_votes_request_id_feature_requests_id_fk": { 636 + "name": "feature_request_votes_request_id_feature_requests_id_fk", 637 + "tableFrom": "feature_request_votes", 638 + "tableTo": "feature_requests", 639 + "columnsFrom": ["request_id"], 640 + "columnsTo": ["id"], 641 + "onDelete": "no action", 642 + "onUpdate": "no action" 643 + } 644 + }, 645 + "compositePrimaryKeys": { 646 + "feature_request_votes_request_id_author_did_pk": { 647 + "columns": ["request_id", "author_did"], 648 + "name": "feature_request_votes_request_id_author_did_pk" 649 + } 650 + }, 651 + "uniqueConstraints": {}, 652 + "checkConstraints": {} 653 + }, 654 + "feature_requests": { 655 + "name": "feature_requests", 656 + "columns": { 657 + "id": { 658 + "name": "id", 659 + "type": "text", 660 + "primaryKey": true, 661 + "notNull": true, 662 + "autoincrement": false 663 + }, 664 + "sphere_id": { 665 + "name": "sphere_id", 666 + "type": "text", 667 + "primaryKey": false, 668 + "notNull": true, 669 + "autoincrement": false 670 + }, 671 + "number": { 672 + "name": "number", 673 + "type": "integer", 674 + "primaryKey": false, 675 + "notNull": true, 676 + "autoincrement": false 677 + }, 678 + "author_did": { 679 + "name": "author_did", 680 + "type": "text", 681 + "primaryKey": false, 682 + "notNull": true, 683 + "autoincrement": false 684 + }, 685 + "title": { 686 + "name": "title", 687 + "type": "text", 688 + "primaryKey": false, 689 + "notNull": true, 690 + "autoincrement": false 691 + }, 692 + "description": { 693 + "name": "description", 694 + "type": "text", 695 + "primaryKey": false, 696 + "notNull": true, 697 + "autoincrement": false 698 + }, 699 + "category": { 700 + "name": "category", 701 + "type": "text", 702 + "primaryKey": false, 703 + "notNull": true, 704 + "autoincrement": false, 705 + "default": "'general'" 706 + }, 707 + "status": { 708 + "name": "status", 709 + "type": "text", 710 + "primaryKey": false, 711 + "notNull": true, 712 + "autoincrement": false, 713 + "default": "'requested'" 714 + }, 715 + "duplicate_of_id": { 716 + "name": "duplicate_of_id", 717 + "type": "text", 718 + "primaryKey": false, 719 + "notNull": false, 720 + "autoincrement": false 721 + }, 722 + "pds_uri": { 723 + "name": "pds_uri", 724 + "type": "text", 725 + "primaryKey": false, 726 + "notNull": false, 727 + "autoincrement": false 728 + }, 729 + "hidden_at": { 730 + "name": "hidden_at", 731 + "type": "text", 732 + "primaryKey": false, 733 + "notNull": false, 734 + "autoincrement": false 735 + }, 736 + "moderated_by": { 737 + "name": "moderated_by", 738 + "type": "text", 739 + "primaryKey": false, 740 + "notNull": false, 741 + "autoincrement": false 742 + }, 743 + "updated_at": { 744 + "name": "updated_at", 745 + "type": "text", 746 + "primaryKey": false, 747 + "notNull": true, 748 + "autoincrement": false, 749 + "default": "(datetime('now'))" 750 + } 751 + }, 752 + "indexes": { 753 + "idx_feature_requests_sphere_number": { 754 + "name": "idx_feature_requests_sphere_number", 755 + "columns": ["sphere_id", "number"], 756 + "isUnique": true 757 + }, 758 + "idx_feature_requests_sphere": { 759 + "name": "idx_feature_requests_sphere", 760 + "columns": ["sphere_id"], 761 + "isUnique": false 762 + }, 763 + "idx_feature_requests_status": { 764 + "name": "idx_feature_requests_status", 765 + "columns": ["status"], 766 + "isUnique": false 767 + }, 768 + "idx_feature_requests_category": { 769 + "name": "idx_feature_requests_category", 770 + "columns": ["category"], 771 + "isUnique": false 772 + } 773 + }, 774 + "foreignKeys": { 775 + "feature_requests_sphere_id_spheres_id_fk": { 776 + "name": "feature_requests_sphere_id_spheres_id_fk", 777 + "tableFrom": "feature_requests", 778 + "tableTo": "spheres", 779 + "columnsFrom": ["sphere_id"], 780 + "columnsTo": ["id"], 781 + "onDelete": "no action", 782 + "onUpdate": "no action" 783 + } 784 + }, 785 + "compositePrimaryKeys": {}, 786 + "uniqueConstraints": {}, 787 + "checkConstraints": {} 788 + }, 789 + "feed_posts": { 790 + "name": "feed_posts", 791 + "columns": { 792 + "id": { 793 + "name": "id", 794 + "type": "text", 795 + "primaryKey": true, 796 + "notNull": true, 797 + "autoincrement": false 798 + }, 799 + "author_did": { 800 + "name": "author_did", 801 + "type": "text", 802 + "primaryKey": false, 803 + "notNull": true, 804 + "autoincrement": false 805 + }, 806 + "content": { 807 + "name": "content", 808 + "type": "text", 809 + "primaryKey": false, 810 + "notNull": true, 811 + "autoincrement": false 812 + }, 813 + "parent_id": { 814 + "name": "parent_id", 815 + "type": "text", 816 + "primaryKey": false, 817 + "notNull": false, 818 + "autoincrement": false 819 + }, 820 + "pds_uri": { 821 + "name": "pds_uri", 822 + "type": "text", 823 + "primaryKey": false, 824 + "notNull": false, 825 + "autoincrement": false 826 + }, 827 + "updated_at": { 828 + "name": "updated_at", 829 + "type": "text", 830 + "primaryKey": false, 831 + "notNull": true, 832 + "autoincrement": false, 833 + "default": "(datetime('now'))" 834 + } 835 + }, 836 + "indexes": { 837 + "idx_feed_posts_parent": { 838 + "name": "idx_feed_posts_parent", 839 + "columns": ["parent_id"], 840 + "isUnique": false 841 + } 842 + }, 843 + "foreignKeys": {}, 844 + "compositePrimaryKeys": {}, 845 + "uniqueConstraints": {}, 846 + "checkConstraints": {} 847 + }, 848 + "kanban_columns": { 849 + "name": "kanban_columns", 850 + "columns": { 851 + "id": { 852 + "name": "id", 853 + "type": "text", 854 + "primaryKey": true, 855 + "notNull": true, 856 + "autoincrement": false 857 + }, 858 + "sphere_id": { 859 + "name": "sphere_id", 860 + "type": "text", 861 + "primaryKey": false, 862 + "notNull": true, 863 + "autoincrement": false 864 + }, 865 + "slug": { 866 + "name": "slug", 867 + "type": "text", 868 + "primaryKey": false, 869 + "notNull": true, 870 + "autoincrement": false 871 + }, 872 + "label": { 873 + "name": "label", 874 + "type": "text", 875 + "primaryKey": false, 876 + "notNull": true, 877 + "autoincrement": false 878 + }, 879 + "position": { 880 + "name": "position", 881 + "type": "integer", 882 + "primaryKey": false, 883 + "notNull": true, 884 + "autoincrement": false 885 + }, 886 + "created_at": { 887 + "name": "created_at", 888 + "type": "text", 889 + "primaryKey": false, 890 + "notNull": true, 891 + "autoincrement": false, 892 + "default": "(datetime('now'))" 893 + } 894 + }, 895 + "indexes": { 896 + "idx_kanban_columns_sphere_slug": { 897 + "name": "idx_kanban_columns_sphere_slug", 898 + "columns": ["sphere_id", "slug"], 899 + "isUnique": true 900 + }, 901 + "idx_kanban_columns_sphere_position": { 902 + "name": "idx_kanban_columns_sphere_position", 903 + "columns": ["sphere_id", "position"], 904 + "isUnique": false 905 + } 906 + }, 907 + "foreignKeys": { 908 + "kanban_columns_sphere_id_spheres_id_fk": { 909 + "name": "kanban_columns_sphere_id_spheres_id_fk", 910 + "tableFrom": "kanban_columns", 911 + "tableTo": "spheres", 912 + "columnsFrom": ["sphere_id"], 913 + "columnsTo": ["id"], 914 + "onDelete": "no action", 915 + "onUpdate": "no action" 916 + } 917 + }, 918 + "compositePrimaryKeys": {}, 919 + "uniqueConstraints": {}, 920 + "checkConstraints": {} 921 + }, 922 + "kanban_task_comments": { 923 + "name": "kanban_task_comments", 924 + "columns": { 925 + "id": { 926 + "name": "id", 927 + "type": "text", 928 + "primaryKey": true, 929 + "notNull": true, 930 + "autoincrement": false 931 + }, 932 + "task_id": { 933 + "name": "task_id", 934 + "type": "text", 935 + "primaryKey": false, 936 + "notNull": true, 937 + "autoincrement": false 938 + }, 939 + "author_did": { 940 + "name": "author_did", 941 + "type": "text", 942 + "primaryKey": false, 943 + "notNull": true, 944 + "autoincrement": false 945 + }, 946 + "content": { 947 + "name": "content", 948 + "type": "text", 949 + "primaryKey": false, 950 + "notNull": true, 951 + "autoincrement": false 952 + }, 953 + "pds_uri": { 954 + "name": "pds_uri", 955 + "type": "text", 956 + "primaryKey": false, 957 + "notNull": false, 958 + "autoincrement": false 959 + }, 960 + "updated_at": { 961 + "name": "updated_at", 962 + "type": "text", 963 + "primaryKey": false, 964 + "notNull": true, 965 + "autoincrement": false, 966 + "default": "(datetime('now'))" 967 + }, 968 + "hidden_at": { 969 + "name": "hidden_at", 970 + "type": "text", 971 + "primaryKey": false, 972 + "notNull": false, 973 + "autoincrement": false 974 + }, 975 + "moderated_by": { 976 + "name": "moderated_by", 977 + "type": "text", 978 + "primaryKey": false, 979 + "notNull": false, 980 + "autoincrement": false 981 + } 982 + }, 983 + "indexes": { 984 + "idx_kanban_task_comments_task": { 985 + "name": "idx_kanban_task_comments_task", 986 + "columns": ["task_id"], 987 + "isUnique": false 988 + }, 989 + "idx_kanban_task_comments_author_task": { 990 + "name": "idx_kanban_task_comments_author_task", 991 + "columns": ["author_did", "task_id"], 992 + "isUnique": false 993 + } 994 + }, 995 + "foreignKeys": { 996 + "kanban_task_comments_task_id_kanban_tasks_id_fk": { 997 + "name": "kanban_task_comments_task_id_kanban_tasks_id_fk", 998 + "tableFrom": "kanban_task_comments", 999 + "tableTo": "kanban_tasks", 1000 + "columnsFrom": ["task_id"], 1001 + "columnsTo": ["id"], 1002 + "onDelete": "no action", 1003 + "onUpdate": "no action" 1004 + } 1005 + }, 1006 + "compositePrimaryKeys": {}, 1007 + "uniqueConstraints": {}, 1008 + "checkConstraints": {} 1009 + }, 1010 + "kanban_task_status_changes": { 1011 + "name": "kanban_task_status_changes", 1012 + "columns": { 1013 + "id": { 1014 + "name": "id", 1015 + "type": "text", 1016 + "primaryKey": true, 1017 + "notNull": true, 1018 + "autoincrement": false 1019 + }, 1020 + "task_id": { 1021 + "name": "task_id", 1022 + "type": "text", 1023 + "primaryKey": false, 1024 + "notNull": true, 1025 + "autoincrement": false 1026 + }, 1027 + "author_did": { 1028 + "name": "author_did", 1029 + "type": "text", 1030 + "primaryKey": false, 1031 + "notNull": true, 1032 + "autoincrement": false 1033 + }, 1034 + "status": { 1035 + "name": "status", 1036 + "type": "text", 1037 + "primaryKey": false, 1038 + "notNull": true, 1039 + "autoincrement": false 1040 + }, 1041 + "pds_uri": { 1042 + "name": "pds_uri", 1043 + "type": "text", 1044 + "primaryKey": false, 1045 + "notNull": false, 1046 + "autoincrement": false 1047 + } 1048 + }, 1049 + "indexes": { 1050 + "idx_kanban_task_status_changes_task": { 1051 + "name": "idx_kanban_task_status_changes_task", 1052 + "columns": ["task_id"], 1053 + "isUnique": false 1054 + } 1055 + }, 1056 + "foreignKeys": { 1057 + "kanban_task_status_changes_task_id_kanban_tasks_id_fk": { 1058 + "name": "kanban_task_status_changes_task_id_kanban_tasks_id_fk", 1059 + "tableFrom": "kanban_task_status_changes", 1060 + "tableTo": "kanban_tasks", 1061 + "columnsFrom": ["task_id"], 1062 + "columnsTo": ["id"], 1063 + "onDelete": "no action", 1064 + "onUpdate": "no action" 1065 + } 1066 + }, 1067 + "compositePrimaryKeys": {}, 1068 + "uniqueConstraints": {}, 1069 + "checkConstraints": {} 1070 + }, 1071 + "kanban_tasks": { 1072 + "name": "kanban_tasks", 1073 + "columns": { 1074 + "id": { 1075 + "name": "id", 1076 + "type": "text", 1077 + "primaryKey": true, 1078 + "notNull": true, 1079 + "autoincrement": false 1080 + }, 1081 + "sphere_id": { 1082 + "name": "sphere_id", 1083 + "type": "text", 1084 + "primaryKey": false, 1085 + "notNull": true, 1086 + "autoincrement": false 1087 + }, 1088 + "number": { 1089 + "name": "number", 1090 + "type": "integer", 1091 + "primaryKey": false, 1092 + "notNull": true, 1093 + "autoincrement": false 1094 + }, 1095 + "author_did": { 1096 + "name": "author_did", 1097 + "type": "text", 1098 + "primaryKey": false, 1099 + "notNull": true, 1100 + "autoincrement": false 1101 + }, 1102 + "title": { 1103 + "name": "title", 1104 + "type": "text", 1105 + "primaryKey": false, 1106 + "notNull": true, 1107 + "autoincrement": false 1108 + }, 1109 + "description": { 1110 + "name": "description", 1111 + "type": "text", 1112 + "primaryKey": false, 1113 + "notNull": true, 1114 + "autoincrement": false, 1115 + "default": "''" 1116 + }, 1117 + "status": { 1118 + "name": "status", 1119 + "type": "text", 1120 + "primaryKey": false, 1121 + "notNull": true, 1122 + "autoincrement": false, 1123 + "default": "'backlog'" 1124 + }, 1125 + "position": { 1126 + "name": "position", 1127 + "type": "integer", 1128 + "primaryKey": false, 1129 + "notNull": true, 1130 + "autoincrement": false, 1131 + "default": 0 1132 + }, 1133 + "assignee_did": { 1134 + "name": "assignee_did", 1135 + "type": "text", 1136 + "primaryKey": false, 1137 + "notNull": false, 1138 + "autoincrement": false 1139 + }, 1140 + "pds_uri": { 1141 + "name": "pds_uri", 1142 + "type": "text", 1143 + "primaryKey": false, 1144 + "notNull": false, 1145 + "autoincrement": false 1146 + }, 1147 + "hidden_at": { 1148 + "name": "hidden_at", 1149 + "type": "text", 1150 + "primaryKey": false, 1151 + "notNull": false, 1152 + "autoincrement": false 1153 + }, 1154 + "moderated_by": { 1155 + "name": "moderated_by", 1156 + "type": "text", 1157 + "primaryKey": false, 1158 + "notNull": false, 1159 + "autoincrement": false 1160 + }, 1161 + "updated_at": { 1162 + "name": "updated_at", 1163 + "type": "text", 1164 + "primaryKey": false, 1165 + "notNull": true, 1166 + "autoincrement": false, 1167 + "default": "(datetime('now'))" 1168 + } 1169 + }, 1170 + "indexes": { 1171 + "idx_kanban_tasks_sphere_number": { 1172 + "name": "idx_kanban_tasks_sphere_number", 1173 + "columns": ["sphere_id", "number"], 1174 + "isUnique": true 1175 + }, 1176 + "idx_kanban_tasks_sphere": { 1177 + "name": "idx_kanban_tasks_sphere", 1178 + "columns": ["sphere_id"], 1179 + "isUnique": false 1180 + }, 1181 + "idx_kanban_tasks_status": { 1182 + "name": "idx_kanban_tasks_status", 1183 + "columns": ["status"], 1184 + "isUnique": false 1185 + }, 1186 + "idx_kanban_tasks_sphere_status_position": { 1187 + "name": "idx_kanban_tasks_sphere_status_position", 1188 + "columns": ["sphere_id", "status", "position"], 1189 + "isUnique": false 1190 + } 1191 + }, 1192 + "foreignKeys": { 1193 + "kanban_tasks_sphere_id_spheres_id_fk": { 1194 + "name": "kanban_tasks_sphere_id_spheres_id_fk", 1195 + "tableFrom": "kanban_tasks", 1196 + "tableTo": "spheres", 1197 + "columnsFrom": ["sphere_id"], 1198 + "columnsTo": ["id"], 1199 + "onDelete": "no action", 1200 + "onUpdate": "no action" 1201 + } 1202 + }, 1203 + "compositePrimaryKeys": {}, 1204 + "uniqueConstraints": {}, 1205 + "checkConstraints": {} 1206 + } 1207 + }, 1208 + "views": {}, 1209 + "enums": {}, 1210 + "_meta": { 1211 + "schemas": {}, 1212 + "tables": {}, 1213 + "columns": {} 1214 + }, 1215 + "internal": { 1216 + "indexes": {} 1217 + } 1218 + }
+14
drizzle/meta/_journal.json
··· 8 8 "when": 1775057026164, 9 9 "tag": "0000_slow_namora", 10 10 "breakpoints": true 11 + }, 12 + { 13 + "idx": 1, 14 + "version": "6", 15 + "when": 1775130580734, 16 + "tag": "0001_futuristic_zodiak", 17 + "breakpoints": true 18 + }, 19 + { 20 + "idx": 2, 21 + "version": "6", 22 + "when": 1775163122388, 23 + "tag": "0002_far_deadpool", 24 + "breakpoints": true 11 25 } 12 26 ] 13 27 }
+4 -2
package.json
··· 21 21 "dev": "bun run dev:server & bun run dev:client", 22 22 "dev:server": "bun run --hot packages/app/src/server.ts", 23 23 "dev:client": "bun run --filter '@exosphere/app' dev:client", 24 - "fmt": "oxfmt", 25 - "fmt:check": "oxfmt --check", 26 24 "pds:init": "bun run scripts/pds-init.ts", 27 25 "pds:up": "docker compose -f docker-compose.dev.yml up -d", 28 26 "pds:down": "docker compose -f docker-compose.dev.yml down", ··· 35 33 "start": "NODE_ENV=production bun run packages/app/src/server.ts", 36 34 "start:indexer": "bun run packages/indexer/src/main.ts", 37 35 "preview": "bun run db:generate && bun run db:migrate && bun run build && bun run start", 36 + "tsc": "tsc --noEmit", 37 + "fmt": "oxfmt", 38 + "fmt:check": "oxfmt --check", 38 39 "test": "vitest run", 39 40 "test:watch": "vitest", 40 41 "test:e2e": "playwright test --config packages/app/e2e/playwright.config.ts" ··· 47 48 "bun-types": "^1.3.11", 48 49 "drizzle-kit": "^0.31.10", 49 50 "oxfmt": "^0.43.0", 51 + "typescript": "catalog:", 50 52 "vitest": "^4.1.2" 51 53 } 52 54 }
+1
packages/app/package.json
··· 17 17 "@exosphere/feature-requests": "workspace:*", 18 18 "@exosphere/feeds": "workspace:*", 19 19 "@exosphere/indexer": "workspace:*", 20 + "@exosphere/kanban": "workspace:*", 20 21 "@exosphere/mcp": "workspace:*", 21 22 "@preact/signals": "catalog:", 22 23 "@vanilla-extract/css": "catalog:",
+3
packages/app/src/app.css.ts
··· 2 2 import { vars } from "@exosphere/client/theme.css"; 3 3 4 4 const shared = { 5 + size: { 6 + containerMaxWidth: "800px", 7 + }, 5 8 space: { 6 9 xs: "4px", 7 10 sm: "8px",
+2 -1
packages/app/src/app.tsx
··· 18 18 import type { ModuleRoute } from "@exosphere/client/types"; 19 19 import { feedsModule } from "@exosphere/feeds/client"; 20 20 import { featureRequestsModule } from "@exosphere/feature-requests/client"; 21 + import { kanbanModule } from "@exosphere/kanban/client"; 21 22 import { LocationProvider, Router, Route } from "preact-iso"; 22 23 23 - const defaultRoutes: ModuleRoute[] = [feedsModule, featureRequestsModule].flatMap( 24 + const defaultRoutes: ModuleRoute[] = [feedsModule, featureRequestsModule, kanbanModule].flatMap( 24 25 (m) => m.routes ?? [], 25 26 ); 26 27
+2 -1
packages/app/src/entry-server.tsx
··· 8 8 import type { SphereData } from "@exosphere/core/types"; 9 9 import { feedsModule } from "@exosphere/feeds/client-ssr"; 10 10 import { featureRequestsModule } from "@exosphere/feature-requests/client-ssr"; 11 + import { kanbanModule } from "@exosphere/kanban/client-ssr"; 11 12 12 - const ssrRoutes = [feedsModule, featureRequestsModule].flatMap((m) => m.routes ?? []); 13 + const ssrRoutes = [feedsModule, featureRequestsModule, kanbanModule].flatMap((m) => m.routes ?? []); 13 14 14 15 export interface SSRData { 15 16 auth: { authenticated: boolean; did: string | null; handle: string | null };
+5
packages/app/src/pages/sphere.tsx
··· 21 21 path: "infuse", 22 22 description: "Submit, vote on, and track feature requests.", 23 23 }, 24 + kanban: { 25 + label: "Board", 26 + path: "board", 27 + description: "Track tasks across columns with a simple kanban board.", 28 + }, 24 29 }; 25 30 26 31 export function SpherePage() {
+2 -1
packages/app/src/server.ts
··· 14 14 import { modules, coreIndexer } from "@exosphere/indexer/modules"; 15 15 import { createMcpRoutes } from "@exosphere/mcp"; 16 16 import { featureRequestMcpTools } from "@exosphere/feature-requests/mcp"; 17 + import { kanbanMcpTools } from "@exosphere/kanban/mcp"; 17 18 import { ssrPrefetch } from "./ssr-prefetch.ts"; 18 19 19 20 const app = new Hono(); ··· 50 51 // Each sphere gets its own endpoint: /mcp/:sphereHandle 51 52 app.route( 52 53 "/mcp", 53 - createMcpRoutes(featureRequestMcpTools, (sphereHandle) => (path) => { 54 + createMcpRoutes([...featureRequestMcpTools, ...kanbanMcpTools], (sphereHandle) => (path) => { 54 55 // Rewrite /api/spheres/current → /api/spheres/:handle 55 56 if (path === "/api/spheres/current") { 56 57 return app.request(`/api/spheres/${sphereHandle}`);
+8
packages/app/src/ssr-prefetch.ts
··· 36 36 { key: "feature-request", apiUrl: `${apiBase}/feature-requests/${frMatch[1]}` }, 37 37 { key: "feature-request-votes", apiUrl: `${apiBase}/feature-requests/votes` }, 38 38 ]; 39 + 40 + // Kanban board 41 + if (modulePath === "/board") return [{ key: "kanban-tasks", apiUrl: `${apiBase}/kanban` }]; 42 + if (modulePath === "/board/settings") 43 + return [{ key: "kanban-columns", apiUrl: `${apiBase}/kanban/columns` }]; 44 + const taskMatch = modulePath.match(/^\/board\/(\d+)$/); 45 + if (taskMatch) return [{ key: "kanban-task", apiUrl: `${apiBase}/kanban/${taskMatch[1]}` }]; 46 + 39 47 return []; 40 48 }
+6 -1
packages/app/vite.config.ts
··· 130 130 }, 131 131 plugins: [preact(), vanillaExtractPlugin(), ssrDevPlugin({ isMultiSphere })], 132 132 ssr: { 133 - noExternal: ["@exosphere/client", "@exosphere/feeds", "@exosphere/feature-requests"], 133 + noExternal: [ 134 + "@exosphere/client", 135 + "@exosphere/feeds", 136 + "@exosphere/feature-requests", 137 + "@exosphere/kanban", 138 + ], 134 139 }, 135 140 server: { 136 141 host: true,
+3
packages/client/src/theme.css.ts
··· 21 21 shadowStrong: null, 22 22 focusRing: null, 23 23 }, 24 + size: { 25 + containerMaxWidth: null, 26 + }, 24 27 space: { 25 28 xs: null, 26 29 sm: null,
+7 -7
packages/client/src/ui.css.ts
··· 28 28 // ---- Layout ---- 29 29 30 30 export const container = style({ 31 - maxWidth: "640px", 31 + maxWidth: vars.size.containerMaxWidth, 32 32 marginInline: "auto", 33 33 paddingInline: vars.space.md, 34 34 paddingBlockEnd: vars.space.xl, ··· 211 211 lineHeight: 1.5, 212 212 cursor: "pointer", 213 213 fontFamily: vars.font.body, 214 - minBlockSize: "44px", 214 + minBlockSize: "36px", 215 215 whiteSpace: "nowrap" as const, 216 216 transition: "background-color 0.15s, opacity 0.15s, box-shadow 0.15s, transform 0.1s", 217 217 }; 218 218 219 219 export const button = style({ 220 220 ...btnBase, 221 - paddingBlock: vars.space.sm, 221 + paddingBlock: "6px", 222 222 paddingInline: vars.space.lg, 223 223 border: "none", 224 - fontSize: "0.875rem", 224 + fontSize: "0.75rem", 225 225 backgroundColor: vars.color.primary, 226 226 color: "#fff", 227 227 boxShadow: `0 1px 2px ${vars.color.shadow}`, ··· 236 236 237 237 export const buttonSecondary = style({ 238 238 ...btnBase, 239 - paddingBlock: vars.space.sm, 239 + paddingBlock: "6px", 240 240 paddingInline: vars.space.lg, 241 241 border: `1px solid ${vars.color.border}`, 242 - fontSize: "0.875rem", 242 + fontSize: "0.75rem", 243 243 backgroundColor: vars.color.surface, 244 244 color: vars.color.text, 245 245 ":hover": { ··· 678 678 }); 679 679 680 680 export const footerInner = style({ 681 - maxWidth: "640px", 681 + maxWidth: vars.size.containerMaxWidth, 682 682 marginInline: "auto", 683 683 paddingInline: vars.space.md, 684 684 display: "flex",
+49
packages/core/src/generated/lexicon-records.ts
··· 50 50 subject: string; 51 51 } 52 52 53 + export interface KanbanCommentRecord { 54 + /** at-uri — AT URI of the kanban task being commented on. */ 55 + subject: string; 56 + content: string; 57 + /** datetime */ 58 + updatedAt?: string; 59 + } 60 + 61 + export interface KanbanEntryRecord { 62 + title: string; 63 + description?: string; 64 + /** Column slug the task belongs to. Values are user-defined per Sphere. */ 65 + status: string; 66 + /** did — DID of the Sphere owner (the identity hosting the site.exosphere.sphere.profile record). */ 67 + subject: string; 68 + /** did — DID of the member assigned to this task. */ 69 + assigneeDid?: string; 70 + /** datetime */ 71 + updatedAt?: string; 72 + } 73 + 74 + export interface KanbanPermissionsRecord { 75 + /** Minimum role to create tasks. */ 76 + create?: string; 77 + /** Minimum role to comment on tasks. */ 78 + comment?: string; 79 + /** Minimum role to move tasks between columns. */ 80 + changeStatus?: string; 81 + /** Minimum role to assign tasks. */ 82 + assign?: string; 83 + /** Minimum role to edit/delete others' tasks. */ 84 + manageTasks?: string; 85 + /** Minimum role to manage board settings (columns, etc.). */ 86 + manageSettings?: string; 87 + /** Minimum role to hide/unhide content. */ 88 + moderate?: string; 89 + } 90 + 91 + export interface KanbanStatusRecord { 92 + /** at-uri — AT URI of the kanban task whose status is being changed. */ 93 + subject: string; 94 + /** Column slug the task is moved to. Values are user-defined per Sphere. */ 95 + status: string; 96 + } 97 + 53 98 export interface ModerationRecord { 54 99 /** at-uri — AT URI of the Sphere. */ 55 100 sphere: string; ··· 107 152 "site.exosphere.featureRequest.permissions": FeatureRequestPermissionsRecord; 108 153 "site.exosphere.featureRequest.status": FeatureRequestStatusRecord; 109 154 "site.exosphere.featureRequest.vote": FeatureRequestVoteRecord; 155 + "site.exosphere.kanban.comment": KanbanCommentRecord; 156 + "site.exosphere.kanban.entry": KanbanEntryRecord; 157 + "site.exosphere.kanban.permissions": KanbanPermissionsRecord; 158 + "site.exosphere.kanban.status": KanbanStatusRecord; 110 159 "site.exosphere.moderation": ModerationRecord; 111 160 "site.exosphere.sphere.member": SphereMemberRecord; 112 161 "site.exosphere.sphere.memberApproval": SphereMemberApprovalRecord;
+5 -1
packages/feature-requests/src/ui/pages/feature-request.tsx
··· 324 324 requestId, 325 325 status, 326 326 isAuthenticated, 327 + canComment, 327 328 canModerate, 328 329 currentDid, 329 330 currentHandle, ··· 331 332 requestId: string; 332 333 status: string; 333 334 isAuthenticated: boolean; 335 + canComment: boolean; 334 336 canModerate: boolean; 335 337 currentDid: string | null; 336 338 currentHandle: string | null; ··· 445 447 </div> 446 448 )} 447 449 448 - {isAuthenticated && 450 + {canComment && 449 451 !hasCommented.value && 450 452 status !== "done" && 451 453 status !== "not-planned" && ··· 553 555 } 554 556 }, [votesQuery.data]); 555 557 558 + const canComment = useCanDo("feature-requests", "comment"); 556 559 const canModerate = useCanDo("feature-requests", "moderate"); 557 560 const canChangeStatus = useCanDo("feature-requests", "changeStatus"); 558 561 const canMarkDuplicate = useCanDo("feature-requests", "markDuplicate"); ··· 683 686 requestId={fr.id} 684 687 status={fr.status} 685 688 isAuthenticated={isAuthenticated} 689 + canComment={canComment.value} 686 690 canModerate={canModerate.value} 687 691 currentDid={currentDid} 688 692 currentHandle={currentHandle}
+1
packages/indexer/package.json
··· 11 11 "@exosphere/core": "workspace:*", 12 12 "@exosphere/feature-requests": "workspace:*", 13 13 "@exosphere/feeds": "workspace:*", 14 + "@exosphere/kanban": "workspace:*", 14 15 "drizzle-orm": "catalog:" 15 16 }, 16 17 "devDependencies": {
+2 -1
packages/indexer/src/modules.ts
··· 8 8 } from "@exosphere/core/permissions"; 9 9 // import { feedsModule } from "@exosphere/feeds"; 10 10 import { featureRequestsModule } from "@exosphere/feature-requests"; 11 + import { kanbanModule } from "@exosphere/kanban"; 11 12 12 - export const modules: ExosphereModule[] = [featureRequestsModule]; 13 + export const modules: ExosphereModule[] = [featureRequestsModule, kanbanModule]; 13 14 14 15 // Register core sphere-level permissions. 15 16 // This runs as a side effect on import — the server (app/src/server.ts) imports from
+29
packages/kanban/package.json
··· 1 + { 2 + "name": "@exosphere/kanban", 3 + "version": "0.0.1", 4 + "private": true, 5 + "type": "module", 6 + "exports": { 7 + ".": "./src/index.ts", 8 + "./client": "./src/client.ts", 9 + "./client-ssr": "./src/client.ssr.ts", 10 + "./mcp": "./src/mcp.ts", 11 + "./types": "./src/types.ts" 12 + }, 13 + "dependencies": { 14 + "@exosphere/client": "workspace:*", 15 + "@exosphere/core": "workspace:*", 16 + "@exosphere/mcp": "workspace:*", 17 + "@preact/signals": "catalog:", 18 + "@vanilla-extract/css": "catalog:", 19 + "drizzle-orm": "catalog:", 20 + "hono": "catalog:", 21 + "lucide-preact": "^1.7.0", 22 + "preact": "catalog:", 23 + "zod": "catalog:" 24 + }, 25 + "devDependencies": { 26 + "@types/bun": "catalog:", 27 + "typescript": "catalog:" 28 + } 29 + }
+471
packages/kanban/src/__tests__/db-operations.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach } from "vitest"; 2 + import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; 3 + import { eq } from "drizzle-orm"; 4 + 5 + // Use the shared test-db helper from core (resolves via workspace) 6 + import { createTestDb, seedSphere } from "../../../core/src/__tests__/helpers/test-db.ts"; 7 + 8 + let db: BetterSQLite3Database; 9 + 10 + // Mock getDb so operations use our in-memory test database 11 + vi.mock("@exosphere/core/db", () => ({ 12 + getDb: () => db, 13 + })); 14 + 15 + import { 16 + insertTask, 17 + updateTask, 18 + deleteTaskCascade, 19 + insertStatusChangeAndUpdateTask, 20 + updateTaskPosition, 21 + assignTask, 22 + insertComment, 23 + updateComment, 24 + deleteComment, 25 + hideTask, 26 + unhideTask, 27 + hideComment, 28 + unhideComment, 29 + handleKanbanModeration, 30 + } from "../db/operations.ts"; 31 + import { kanbanTasks, kanbanTaskComments, kanbanTaskStatusChanges } from "../db/schema.ts"; 32 + 33 + const AUTHOR_DID = "did:plc:author1"; 34 + const MOD_DID = "did:plc:mod1"; 35 + const SPHERE_ID = "test-sphere-001"; 36 + // Valid TID string (insertComment derives updatedAt via tidToDate) 37 + const COMMENT_TID_1 = "3mhy7w6tbg22b"; 38 + const COMMENT_TID_2 = "3mhy7w6tbg22c"; 39 + 40 + function seedTask(overrides: Partial<typeof kanbanTasks.$inferInsert> & { id: string }) { 41 + const values = { 42 + sphereId: SPHERE_ID, 43 + number: 1, 44 + authorDid: AUTHOR_DID, 45 + title: "Test Task", 46 + description: "A test task", 47 + status: "backlog" as const, 48 + position: 1000, 49 + ...overrides, 50 + }; 51 + db.insert(kanbanTasks).values(values).run(); 52 + return values; 53 + } 54 + 55 + beforeEach(() => { 56 + db = createTestDb(); 57 + seedSphere(db, { id: SPHERE_ID, handle: "test.bsky.social", ownerDid: AUTHOR_DID }); 58 + }); 59 + 60 + // ---- insertTask ---- 61 + 62 + describe("insertTask", () => { 63 + it("inserts a task with auto-incrementing number", () => { 64 + const task1 = insertTask({ 65 + id: "t-1", 66 + sphereId: SPHERE_ID, 67 + authorDid: AUTHOR_DID, 68 + title: "First", 69 + description: "Desc", 70 + status: "backlog", 71 + assigneeDid: null, 72 + pdsUri: null, 73 + }); 74 + expect(task1).toBeDefined(); 75 + expect(task1!.number).toBe(1); 76 + 77 + const task2 = insertTask({ 78 + id: "t-2", 79 + sphereId: SPHERE_ID, 80 + authorDid: AUTHOR_DID, 81 + title: "Second", 82 + description: "Desc", 83 + status: "todo", 84 + assigneeDid: null, 85 + pdsUri: null, 86 + }); 87 + expect(task2!.number).toBe(2); 88 + }); 89 + 90 + it("assigns position with gap of 1000", () => { 91 + const task1 = insertTask({ 92 + id: "t-1", 93 + sphereId: SPHERE_ID, 94 + authorDid: AUTHOR_DID, 95 + title: "First", 96 + description: "", 97 + status: "backlog", 98 + assigneeDid: null, 99 + pdsUri: null, 100 + }); 101 + expect(task1!.position).toBe(1000); 102 + 103 + const task2 = insertTask({ 104 + id: "t-2", 105 + sphereId: SPHERE_ID, 106 + authorDid: AUTHOR_DID, 107 + title: "Second", 108 + description: "", 109 + status: "backlog", 110 + assigneeDid: null, 111 + pdsUri: null, 112 + }); 113 + expect(task2!.position).toBe(2000); 114 + }); 115 + 116 + it("positions are per-status (different columns get independent positions)", () => { 117 + const backlog = insertTask({ 118 + id: "t-1", 119 + sphereId: SPHERE_ID, 120 + authorDid: AUTHOR_DID, 121 + title: "Backlog task", 122 + description: "", 123 + status: "backlog", 124 + assigneeDid: null, 125 + pdsUri: null, 126 + }); 127 + const todo = insertTask({ 128 + id: "t-2", 129 + sphereId: SPHERE_ID, 130 + authorDid: AUTHOR_DID, 131 + title: "Todo task", 132 + description: "", 133 + status: "todo", 134 + assigneeDid: null, 135 + pdsUri: null, 136 + }); 137 + // Both should start at 1000 since they're in different columns 138 + expect(backlog!.position).toBe(1000); 139 + expect(todo!.position).toBe(1000); 140 + }); 141 + 142 + it("handles conflict (duplicate id) gracefully", () => { 143 + insertTask({ 144 + id: "t-1", 145 + sphereId: SPHERE_ID, 146 + authorDid: AUTHOR_DID, 147 + title: "Original", 148 + description: "", 149 + status: "backlog", 150 + assigneeDid: null, 151 + pdsUri: null, 152 + }); 153 + 154 + const dup = insertTask({ 155 + id: "t-1", 156 + sphereId: SPHERE_ID, 157 + authorDid: AUTHOR_DID, 158 + title: "Duplicate", 159 + description: "", 160 + status: "backlog", 161 + assigneeDid: null, 162 + pdsUri: null, 163 + }); 164 + expect(dup).toBeDefined(); 165 + expect(dup!.title).toBe("Original"); 166 + }); 167 + }); 168 + 169 + // ---- updateTask ---- 170 + 171 + describe("updateTask", () => { 172 + it("updates title", () => { 173 + seedTask({ id: "t-1" }); 174 + updateTask("t-1", { title: "New Title" }); 175 + 176 + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); 177 + expect(task!.title).toBe("New Title"); 178 + }); 179 + 180 + it("updates description", () => { 181 + seedTask({ id: "t-1" }); 182 + updateTask("t-1", { description: "Updated description" }); 183 + 184 + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); 185 + expect(task!.description).toBe("Updated description"); 186 + }); 187 + 188 + it("updates updatedAt timestamp", () => { 189 + seedTask({ id: "t-1" }); 190 + const before = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); 191 + 192 + updateTask("t-1", { title: "Changed" }); 193 + 194 + const after = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); 195 + expect(after!.updatedAt).not.toBe(before!.updatedAt); 196 + }); 197 + }); 198 + 199 + // ---- deleteTaskCascade ---- 200 + 201 + describe("deleteTaskCascade", () => { 202 + it("deletes a task and all related records", () => { 203 + seedTask({ id: "t-1" }); 204 + db.insert(kanbanTaskComments) 205 + .values({ id: "c-1", taskId: "t-1", authorDid: AUTHOR_DID, content: "Hello" }) 206 + .run(); 207 + db.insert(kanbanTaskStatusChanges) 208 + .values({ id: "s-1", taskId: "t-1", authorDid: MOD_DID, status: "todo" }) 209 + .run(); 210 + 211 + deleteTaskCascade("t-1"); 212 + 213 + expect(db.select().from(kanbanTasks).all()).toHaveLength(0); 214 + expect(db.select().from(kanbanTaskComments).all()).toHaveLength(0); 215 + expect(db.select().from(kanbanTaskStatusChanges).all()).toHaveLength(0); 216 + }); 217 + }); 218 + 219 + // ---- insertStatusChangeAndUpdateTask ---- 220 + 221 + describe("insertStatusChangeAndUpdateTask", () => { 222 + it("inserts a status record and updates the task status", () => { 223 + seedTask({ id: "t-1", status: "backlog" }); 224 + 225 + insertStatusChangeAndUpdateTask({ 226 + id: "s-1", 227 + taskId: "t-1", 228 + authorDid: MOD_DID, 229 + status: "in-progress", 230 + pdsUri: null, 231 + }); 232 + 233 + const statuses = db.select().from(kanbanTaskStatusChanges).all(); 234 + expect(statuses).toHaveLength(1); 235 + expect(statuses[0].status).toBe("in-progress"); 236 + 237 + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); 238 + expect(task!.status).toBe("in-progress"); 239 + }); 240 + 241 + it("places task at end of target column when no position given", () => { 242 + seedTask({ id: "t-1", status: "backlog", position: 1000 }); 243 + seedTask({ id: "t-2", number: 2, status: "todo", position: 1000 }); 244 + 245 + insertStatusChangeAndUpdateTask({ 246 + id: "s-1", 247 + taskId: "t-1", 248 + authorDid: MOD_DID, 249 + status: "todo", 250 + pdsUri: null, 251 + }); 252 + 253 + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); 254 + expect(task!.status).toBe("todo"); 255 + expect(task!.position).toBe(2000); // 1000 (existing max) + 1000 gap 256 + }); 257 + 258 + it("uses explicit position when provided", () => { 259 + seedTask({ id: "t-1", status: "backlog", position: 1000 }); 260 + 261 + insertStatusChangeAndUpdateTask({ 262 + id: "s-1", 263 + taskId: "t-1", 264 + authorDid: MOD_DID, 265 + status: "todo", 266 + pdsUri: null, 267 + position: 500, 268 + }); 269 + 270 + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); 271 + expect(task!.position).toBe(500); 272 + }); 273 + }); 274 + 275 + // ---- updateTaskPosition ---- 276 + 277 + describe("updateTaskPosition", () => { 278 + it("updates position", () => { 279 + seedTask({ id: "t-1", position: 1000 }); 280 + updateTaskPosition("t-1", 3000); 281 + 282 + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); 283 + expect(task!.position).toBe(3000); 284 + }); 285 + }); 286 + 287 + // ---- assignTask ---- 288 + 289 + describe("assignTask", () => { 290 + it("assigns a member", () => { 291 + seedTask({ id: "t-1" }); 292 + assignTask("t-1", "did:plc:assignee1"); 293 + 294 + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); 295 + expect(task!.assigneeDid).toBe("did:plc:assignee1"); 296 + }); 297 + 298 + it("unassigns with null", () => { 299 + seedTask({ id: "t-1", assigneeDid: "did:plc:assignee1" }); 300 + assignTask("t-1", null); 301 + 302 + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); 303 + expect(task!.assigneeDid).toBeNull(); 304 + }); 305 + }); 306 + 307 + // ---- Comments ---- 308 + 309 + describe("insertComment / updateComment / deleteComment", () => { 310 + it("inserts a comment", () => { 311 + seedTask({ id: "t-1" }); 312 + insertComment({ 313 + id: COMMENT_TID_1, 314 + taskId: "t-1", 315 + authorDid: AUTHOR_DID, 316 + content: "Great idea!", 317 + pdsUri: null, 318 + }); 319 + 320 + const comments = db.select().from(kanbanTaskComments).all(); 321 + expect(comments).toHaveLength(1); 322 + expect(comments[0].content).toBe("Great idea!"); 323 + }); 324 + 325 + it("upserts on conflict (same id updates content)", () => { 326 + seedTask({ id: "t-1" }); 327 + insertComment({ 328 + id: COMMENT_TID_1, 329 + taskId: "t-1", 330 + authorDid: AUTHOR_DID, 331 + content: "Original", 332 + pdsUri: null, 333 + }); 334 + insertComment({ 335 + id: COMMENT_TID_1, 336 + taskId: "t-1", 337 + authorDid: AUTHOR_DID, 338 + content: "Updated via upsert", 339 + pdsUri: null, 340 + }); 341 + 342 + const comment = db 343 + .select() 344 + .from(kanbanTaskComments) 345 + .where(eq(kanbanTaskComments.id, COMMENT_TID_1)) 346 + .get(); 347 + expect(comment!.content).toBe("Updated via upsert"); 348 + }); 349 + 350 + it("updates a comment's content", () => { 351 + seedTask({ id: "t-1" }); 352 + insertComment({ 353 + id: COMMENT_TID_1, 354 + taskId: "t-1", 355 + authorDid: AUTHOR_DID, 356 + content: "Original", 357 + pdsUri: null, 358 + }); 359 + updateComment(COMMENT_TID_1, "Edited content"); 360 + 361 + const comment = db 362 + .select() 363 + .from(kanbanTaskComments) 364 + .where(eq(kanbanTaskComments.id, COMMENT_TID_1)) 365 + .get(); 366 + expect(comment!.content).toBe("Edited content"); 367 + }); 368 + 369 + it("deletes a comment", () => { 370 + seedTask({ id: "t-1" }); 371 + insertComment({ 372 + id: COMMENT_TID_1, 373 + taskId: "t-1", 374 + authorDid: AUTHOR_DID, 375 + content: "Comment", 376 + pdsUri: null, 377 + }); 378 + 379 + deleteComment(COMMENT_TID_1); 380 + expect(db.select().from(kanbanTaskComments).all()).toHaveLength(0); 381 + }); 382 + }); 383 + 384 + // ---- Moderation ---- 385 + 386 + describe("hideTask / unhideTask", () => { 387 + it("hides and unhides a task", () => { 388 + seedTask({ id: "t-1" }); 389 + 390 + hideTask("t-1", MOD_DID); 391 + let task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); 392 + expect(task!.hiddenAt).toBeTruthy(); 393 + expect(task!.moderatedBy).toBe(MOD_DID); 394 + 395 + unhideTask("t-1"); 396 + task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); 397 + expect(task!.hiddenAt).toBeNull(); 398 + expect(task!.moderatedBy).toBeNull(); 399 + }); 400 + }); 401 + 402 + describe("hideComment / unhideComment", () => { 403 + it("hides and unhides a comment", () => { 404 + seedTask({ id: "t-1" }); 405 + insertComment({ 406 + id: COMMENT_TID_1, 407 + taskId: "t-1", 408 + authorDid: AUTHOR_DID, 409 + content: "Comment", 410 + pdsUri: null, 411 + }); 412 + 413 + hideComment(COMMENT_TID_1, MOD_DID); 414 + let comment = db 415 + .select() 416 + .from(kanbanTaskComments) 417 + .where(eq(kanbanTaskComments.id, COMMENT_TID_1)) 418 + .get(); 419 + expect(comment!.hiddenAt).toBeTruthy(); 420 + expect(comment!.moderatedBy).toBe(MOD_DID); 421 + 422 + unhideComment(COMMENT_TID_1); 423 + comment = db 424 + .select() 425 + .from(kanbanTaskComments) 426 + .where(eq(kanbanTaskComments.id, COMMENT_TID_1)) 427 + .get(); 428 + expect(comment!.hiddenAt).toBeNull(); 429 + expect(comment!.moderatedBy).toBeNull(); 430 + }); 431 + }); 432 + 433 + describe("handleKanbanModeration", () => { 434 + it("hides a task by pdsUri and returns true", () => { 435 + const pdsUri = "at://did:plc:author1/site.exosphere.kanban.entry/t-1"; 436 + seedTask({ id: "t-1", pdsUri }); 437 + 438 + const handled = handleKanbanModeration(pdsUri, MOD_DID); 439 + expect(handled).toBe(true); 440 + 441 + const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); 442 + expect(task!.hiddenAt).toBeTruthy(); 443 + }); 444 + 445 + it("hides a comment by pdsUri and returns true", () => { 446 + seedTask({ id: "t-1" }); 447 + const commentPdsUri = `at://did:plc:author1/site.exosphere.kanban.comment/${COMMENT_TID_1}`; 448 + insertComment({ 449 + id: COMMENT_TID_1, 450 + taskId: "t-1", 451 + authorDid: AUTHOR_DID, 452 + content: "Comment", 453 + pdsUri: commentPdsUri, 454 + }); 455 + 456 + const handled = handleKanbanModeration(commentPdsUri, MOD_DID); 457 + expect(handled).toBe(true); 458 + 459 + const comment = db 460 + .select() 461 + .from(kanbanTaskComments) 462 + .where(eq(kanbanTaskComments.id, COMMENT_TID_1)) 463 + .get(); 464 + expect(comment!.hiddenAt).toBeTruthy(); 465 + }); 466 + 467 + it("returns false when pdsUri matches nothing", () => { 468 + const handled = handleKanbanModeration("at://unknown/col/rkey", MOD_DID); 469 + expect(handled).toBe(false); 470 + }); 471 + });
+144
packages/kanban/src/__tests__/schemas.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + 3 + import { 4 + createTaskSchema, 5 + updateTaskSchema, 6 + updateStatusSchema, 7 + assignTaskSchema, 8 + reorderSchema, 9 + } from "../schemas/task.ts"; 10 + import { createCommentSchema, updateCommentSchema } from "../schemas/comment.ts"; 11 + 12 + describe("createTaskSchema", () => { 13 + const valid = { title: "Implement auth flow" }; 14 + 15 + it("accepts valid input with defaults", () => { 16 + const result = createTaskSchema.parse(valid); 17 + expect(result.status).toBe("backlog"); 18 + expect(result.description).toBe(""); 19 + }); 20 + 21 + it("accepts explicit status and description", () => { 22 + const result = createTaskSchema.parse({ 23 + ...valid, 24 + status: "todo", 25 + description: "Detailed description", 26 + }); 27 + expect(result.status).toBe("todo"); 28 + expect(result.description).toBe("Detailed description"); 29 + }); 30 + 31 + it("accepts assigneeDid", () => { 32 + const result = createTaskSchema.parse({ ...valid, assigneeDid: "did:plc:abc" }); 33 + expect(result.assigneeDid).toBe("did:plc:abc"); 34 + }); 35 + 36 + it("accepts null assigneeDid", () => { 37 + const result = createTaskSchema.parse({ ...valid, assigneeDid: null }); 38 + expect(result.assigneeDid).toBeNull(); 39 + }); 40 + 41 + it("accepts any string status (validated at API layer against sphere columns)", () => { 42 + const result = createTaskSchema.parse({ ...valid, status: "custom-column" }); 43 + expect(result.status).toBe("custom-column"); 44 + }); 45 + 46 + it("rejects empty title", () => { 47 + expect(() => createTaskSchema.parse({ title: "" })).toThrow(); 48 + }); 49 + 50 + it("rejects title over 200 chars", () => { 51 + expect(() => createTaskSchema.parse({ title: "x".repeat(201) })).toThrow(); 52 + }); 53 + 54 + it("rejects description over 10 000 chars", () => { 55 + expect(() => createTaskSchema.parse({ ...valid, description: "x".repeat(10001) })).toThrow(); 56 + }); 57 + }); 58 + 59 + describe("updateTaskSchema", () => { 60 + it("accepts partial update", () => { 61 + const result = updateTaskSchema.parse({ title: "New title" }); 62 + expect(result.title).toBe("New title"); 63 + expect(result.description).toBeUndefined(); 64 + }); 65 + 66 + it("accepts empty object", () => { 67 + const result = updateTaskSchema.parse({}); 68 + expect(result.title).toBeUndefined(); 69 + }); 70 + 71 + it("rejects empty title", () => { 72 + expect(() => updateTaskSchema.parse({ title: "" })).toThrow(); 73 + }); 74 + }); 75 + 76 + describe("updateStatusSchema", () => { 77 + it("accepts a valid status", () => { 78 + const result = updateStatusSchema.parse({ status: "in-progress" }); 79 + expect(result.status).toBe("in-progress"); 80 + }); 81 + 82 + it("accepts optional position", () => { 83 + const result = updateStatusSchema.parse({ status: "todo", position: 2000 }); 84 + expect(result.position).toBe(2000); 85 + }); 86 + 87 + it("accepts any string status (validated at API layer)", () => { 88 + const result = updateStatusSchema.parse({ status: "custom-col" }); 89 + expect(result.status).toBe("custom-col"); 90 + }); 91 + }); 92 + 93 + describe("assignTaskSchema", () => { 94 + it("accepts a DID", () => { 95 + const result = assignTaskSchema.parse({ assigneeDid: "did:plc:abc" }); 96 + expect(result.assigneeDid).toBe("did:plc:abc"); 97 + }); 98 + 99 + it("accepts null to unassign", () => { 100 + const result = assignTaskSchema.parse({ assigneeDid: null }); 101 + expect(result.assigneeDid).toBeNull(); 102 + }); 103 + 104 + it("rejects empty string", () => { 105 + expect(() => assignTaskSchema.parse({ assigneeDid: "" })).toThrow(); 106 + }); 107 + }); 108 + 109 + describe("reorderSchema", () => { 110 + it("accepts integer position", () => { 111 + const result = reorderSchema.parse({ position: 3000 }); 112 + expect(result.position).toBe(3000); 113 + }); 114 + 115 + it("rejects float position", () => { 116 + expect(() => reorderSchema.parse({ position: 1.5 })).toThrow(); 117 + }); 118 + }); 119 + 120 + describe("createCommentSchema", () => { 121 + it("accepts valid input", () => { 122 + const result = createCommentSchema.parse({ content: "Looks good!" }); 123 + expect(result.content).toBe("Looks good!"); 124 + }); 125 + 126 + it("rejects empty content", () => { 127 + expect(() => createCommentSchema.parse({ content: "" })).toThrow(); 128 + }); 129 + 130 + it("rejects content over 5 000 chars", () => { 131 + expect(() => createCommentSchema.parse({ content: "x".repeat(5001) })).toThrow(); 132 + }); 133 + }); 134 + 135 + describe("updateCommentSchema", () => { 136 + it("accepts valid input", () => { 137 + const result = updateCommentSchema.parse({ content: "Updated!" }); 138 + expect(result.content).toBe("Updated!"); 139 + }); 140 + 141 + it("rejects empty content", () => { 142 + expect(() => updateCommentSchema.parse({ content: "" })).toThrow(); 143 + }); 144 + });
+95
packages/kanban/src/api/columns.ts
··· 1 + import { Hono } from "hono"; 2 + import { z } from "zod"; 3 + import { requireAuth, type AuthEnv } from "@exosphere/core/auth"; 4 + import { requirePermission } from "@exosphere/core/permissions"; 5 + import type { SphereEnv } from "@exosphere/core/types"; 6 + import { 7 + createColumnSchema, 8 + updateColumnSchema, 9 + reorderColumnsSchema, 10 + deleteColumnSchema, 11 + } from "../schemas/column.ts"; 12 + import { 13 + getColumns, 14 + insertColumn, 15 + updateColumnLabel, 16 + deleteColumnAndReassign, 17 + reorderColumns, 18 + } from "../db/operations.ts"; 19 + import type { KanbanColumnDef } from "../types.ts"; 20 + 21 + const MODULE = "kanban"; 22 + 23 + function toColumnDef(col: { 24 + id: string; 25 + slug: string; 26 + label: string; 27 + position: number; 28 + }): KanbanColumnDef { 29 + return { id: col.id, slug: col.slug, label: col.label, position: col.position }; 30 + } 31 + 32 + const app = new Hono<AuthEnv & SphereEnv>(); 33 + 34 + // List columns 35 + app.get("/columns", (c) => { 36 + const cols = getColumns(c.var.sphereId); 37 + return c.json({ columns: cols.map(toColumnDef) }); 38 + }); 39 + 40 + // Create column 41 + app.post("/columns", requireAuth, requirePermission(MODULE, "manageSettings"), async (c) => { 42 + const body = await c.req.json(); 43 + const result = createColumnSchema.safeParse(body); 44 + if (!result.success) return c.json({ error: z.flattenError(result.error) }, 400); 45 + 46 + const col = insertColumn({ sphereId: c.var.sphereId, label: result.data.label }); 47 + return c.json({ column: toColumnDef(col) }, 201); 48 + }); 49 + 50 + // Rename column 51 + app.put("/columns/:id", requireAuth, requirePermission(MODULE, "manageSettings"), async (c) => { 52 + const id = c.req.param("id"); 53 + const body = await c.req.json(); 54 + const result = updateColumnSchema.safeParse(body); 55 + if (!result.success) return c.json({ error: z.flattenError(result.error) }, 400); 56 + 57 + const cols = getColumns(c.var.sphereId); 58 + const existing = cols.find((col) => col.id === id); 59 + if (!existing) return c.json({ error: "Column not found" }, 404); 60 + 61 + updateColumnLabel(id, result.data.label); 62 + return c.json({ column: toColumnDef({ ...existing, label: result.data.label }) }); 63 + }); 64 + 65 + // Delete column (reassign tasks to another) 66 + app.delete("/columns/:id", requireAuth, requirePermission(MODULE, "manageSettings"), async (c) => { 67 + const id = c.req.param("id"); 68 + const body = await c.req.json(); 69 + const result = deleteColumnSchema.safeParse(body); 70 + if (!result.success) return c.json({ error: z.flattenError(result.error) }, 400); 71 + 72 + const sphereId = c.var.sphereId; 73 + const cols = getColumns(sphereId); 74 + if (cols.length <= 1) return c.json({ error: "Cannot delete the last column" }, 400); 75 + if (!cols.find((col) => col.id === id)) return c.json({ error: "Column not found" }, 404); 76 + 77 + try { 78 + deleteColumnAndReassign(sphereId, id, result.data.reassignTo); 79 + } catch { 80 + return c.json({ error: "Failed to delete column" }, 400); 81 + } 82 + return c.json({ ok: true }); 83 + }); 84 + 85 + // Reorder columns 86 + app.post("/columns/reorder", requireAuth, requirePermission(MODULE, "manageSettings"), async (c) => { 87 + const body = await c.req.json(); 88 + const result = reorderColumnsSchema.safeParse(body); 89 + if (!result.success) return c.json({ error: z.flattenError(result.error) }, 400); 90 + 91 + reorderColumns(c.var.sphereId, result.data.columnIds); 92 + return c.json({ ok: true }); 93 + }); 94 + 95 + export { app as columnsApi };
+255
packages/kanban/src/api/comments.ts
··· 1 + import { Hono } from "hono"; 2 + import { z } from "zod"; 3 + import { getDb } from "@exosphere/core/db"; 4 + import { eq, and, sql } from "@exosphere/core/db/drizzle"; 5 + import { requireAuth, type AuthEnv } from "@exosphere/core/auth"; 6 + import { getActiveMemberRole } from "@exosphere/core/sphere"; 7 + import { requirePermission, checkPermission } from "@exosphere/core/permissions"; 8 + import { putPdsRecord, deletePdsRecord, generateRkey, tidToDate } from "@exosphere/core/pds"; 9 + import { resolveDidHandles } from "@exosphere/core/identity"; 10 + import type { SphereEnv } from "@exosphere/core/types"; 11 + import { kanbanTasks, kanbanTaskComments } from "../db/schema.ts"; 12 + import { createCommentSchema, updateCommentSchema } from "../schemas/comment.ts"; 13 + import { 14 + insertComment, 15 + updateComment, 16 + deleteComment, 17 + hideComment, 18 + unhideComment, 19 + } from "../db/operations.ts"; 20 + 21 + const COMMENT_COLLECTION = "site.exosphere.kanban.comment"; 22 + const MODERATION_COLLECTION = "site.exosphere.moderation"; 23 + 24 + const MODULE = "kanban"; 25 + 26 + const app = new Hono<AuthEnv & SphereEnv>(); 27 + 28 + /** Verify a comment belongs to the current sphere by joining through its parent task. */ 29 + function findCommentInSphere(commentId: string, sphereId: string) { 30 + return getDb() 31 + .select() 32 + .from(kanbanTaskComments) 33 + .innerJoin(kanbanTasks, eq(kanbanTasks.id, kanbanTaskComments.taskId)) 34 + .where(and(eq(kanbanTaskComments.id, commentId), eq(kanbanTasks.sphereId, sphereId))) 35 + .get(); 36 + } 37 + 38 + // List comments for a task 39 + app.get("/:id/comments", async (c) => { 40 + const taskId = c.req.param("id"); 41 + const sphereId = c.var.sphereId; 42 + const db = getDb(); 43 + 44 + // Verify the task belongs to this sphere 45 + const task = db 46 + .select({ id: kanbanTasks.id }) 47 + .from(kanbanTasks) 48 + .where(and(eq(kanbanTasks.id, taskId), eq(kanbanTasks.sphereId, sphereId))) 49 + .get(); 50 + if (!task) { 51 + return c.json({ comments: [] }); 52 + } 53 + 54 + const rows = db 55 + .select({ 56 + id: kanbanTaskComments.id, 57 + taskId: kanbanTaskComments.taskId, 58 + authorDid: kanbanTaskComments.authorDid, 59 + content: kanbanTaskComments.content, 60 + pdsUri: kanbanTaskComments.pdsUri, 61 + updatedAt: kanbanTaskComments.updatedAt, 62 + hiddenAt: kanbanTaskComments.hiddenAt, 63 + moderatedBy: kanbanTaskComments.moderatedBy, 64 + }) 65 + .from(kanbanTaskComments) 66 + .where(and(eq(kanbanTaskComments.taskId, taskId), sql`${kanbanTaskComments.hiddenAt} is null`)) 67 + .orderBy(kanbanTaskComments.id) 68 + .all(); 69 + 70 + const handleMap = await resolveDidHandles(rows.map((r) => r.authorDid)); 71 + const comments = rows.map((r) => ({ 72 + ...r, 73 + createdAt: tidToDate(r.id), 74 + authorHandle: handleMap.get(r.authorDid) ?? null, 75 + })); 76 + 77 + return c.json({ comments }); 78 + }); 79 + 80 + // Create a comment 81 + app.post("/:id/comments", requireAuth, requirePermission(MODULE, "comment"), async (c) => { 82 + const taskId = c.req.param("id"); 83 + const body = await c.req.json(); 84 + const result = createCommentSchema.safeParse(body); 85 + if (!result.success) { 86 + return c.json({ error: z.flattenError(result.error) }, 400); 87 + } 88 + 89 + const { content } = result.data; 90 + const sphereId = c.var.sphereId; 91 + const sphereVisibility = c.var.sphereVisibility; 92 + const db = getDb(); 93 + const did = c.var.did; 94 + 95 + const existing = db 96 + .select({ id: kanbanTasks.id, pdsUri: kanbanTasks.pdsUri }) 97 + .from(kanbanTasks) 98 + .where( 99 + and( 100 + eq(kanbanTasks.id, taskId), 101 + eq(kanbanTasks.sphereId, sphereId), 102 + sql`${kanbanTasks.hiddenAt} is null`, 103 + ), 104 + ) 105 + .get(); 106 + if (!existing) { 107 + return c.json({ error: "Task not found" }, 404); 108 + } 109 + 110 + const id = generateRkey(); 111 + let pdsUri: string | null = null; 112 + 113 + if (sphereVisibility === "public" && existing.pdsUri) { 114 + const session = c.var.session; 115 + pdsUri = await putPdsRecord(session, COMMENT_COLLECTION, id, { 116 + subject: existing.pdsUri, 117 + content, 118 + }); 119 + } 120 + 121 + insertComment({ id, taskId, authorDid: did, content, pdsUri }); 122 + 123 + const comment = db.select().from(kanbanTaskComments).where(eq(kanbanTaskComments.id, id)).get(); 124 + 125 + return c.json({ comment: comment ? { ...comment, createdAt: tidToDate(id) } : comment }, 201); 126 + }); 127 + 128 + // Update own comment 129 + app.put("/comments/:id", requireAuth, async (c) => { 130 + const id = c.req.param("id"); 131 + const body = await c.req.json(); 132 + const result = updateCommentSchema.safeParse(body); 133 + if (!result.success) { 134 + return c.json({ error: z.flattenError(result.error) }, 400); 135 + } 136 + 137 + const { content } = result.data; 138 + const sphereId = c.var.sphereId; 139 + const db = getDb(); 140 + const did = c.var.did; 141 + 142 + const row = findCommentInSphere(id, sphereId); 143 + if (!row) { 144 + return c.json({ error: "Comment not found" }, 404); 145 + } 146 + const comment = row.kanban_task_comments; 147 + if (comment.authorDid !== did) { 148 + return c.json({ error: "Forbidden" }, 403); 149 + } 150 + 151 + if (comment.pdsUri) { 152 + const session = c.var.session; 153 + const parent = db 154 + .select({ pdsUri: kanbanTasks.pdsUri }) 155 + .from(kanbanTasks) 156 + .where(eq(kanbanTasks.id, comment.taskId)) 157 + .get(); 158 + if (!parent?.pdsUri) { 159 + return c.json({ error: "Parent task not found" }, 404); 160 + } 161 + await putPdsRecord(session, COMMENT_COLLECTION, id, { 162 + subject: parent.pdsUri, 163 + content, 164 + updatedAt: new Date().toISOString(), 165 + }); 166 + } 167 + 168 + updateComment(id, content); 169 + 170 + const updated = db.select().from(kanbanTaskComments).where(eq(kanbanTaskComments.id, id)).get(); 171 + 172 + return c.json({ comment: updated ? { ...updated, createdAt: tidToDate(id) } : updated }); 173 + }); 174 + 175 + // Delete own comment or admin-moderate 176 + app.delete("/comments/:id", requireAuth, async (c) => { 177 + const id = c.req.param("id"); 178 + const db = getDb(); 179 + const did = c.var.did; 180 + const sphereId = c.var.sphereId; 181 + const sphereOwnerDid = c.var.sphereOwnerDid; 182 + const spherePdsUri = c.var.spherePdsUri; 183 + 184 + const row = findCommentInSphere(id, sphereId); 185 + if (!row) { 186 + return c.json({ error: "Comment not found" }, 404); 187 + } 188 + const comment = row.kanban_task_comments; 189 + const isAuthor = comment.authorDid === did; 190 + 191 + if (!isAuthor) { 192 + const role = getActiveMemberRole(sphereId, did); 193 + if (!checkPermission(sphereId, MODULE, "moderate", role)) { 194 + return c.json({ error: "Forbidden" }, 403); 195 + } 196 + 197 + if (comment.hiddenAt) { 198 + return c.json({ ok: true }); 199 + } 200 + 201 + if (comment.pdsUri) { 202 + const sphereUri = spherePdsUri ?? `at://${sphereOwnerDid}/site.exosphere.sphere.profile/self`; 203 + const session = c.var.session; 204 + const pdsUri = await putPdsRecord(session, MODERATION_COLLECTION, id, { 205 + sphere: sphereUri, 206 + subject: comment.pdsUri, 207 + action: "remove", 208 + }); 209 + if (!pdsUri) { 210 + console.warn( 211 + "[kanban] Moderation PDS record failed for comment %s — hiding locally only", 212 + id, 213 + ); 214 + } 215 + } 216 + 217 + hideComment(id, did); 218 + return c.json({ ok: true }); 219 + } 220 + 221 + // Author deleting their own comment 222 + if (comment.pdsUri) { 223 + const session = c.var.session; 224 + await deletePdsRecord(session, COMMENT_COLLECTION, id); 225 + } 226 + 227 + deleteComment(id); 228 + return c.json({ ok: true }); 229 + }); 230 + 231 + // Unhide a moderated comment 232 + app.post("/comments/:id/unhide", requireAuth, requirePermission(MODULE, "moderate"), async (c) => { 233 + const id = c.req.param("id"); 234 + const sphereId = c.var.sphereId; 235 + const did = c.var.did; 236 + 237 + const row = findCommentInSphere(id, sphereId); 238 + if (!row) { 239 + return c.json({ error: "Comment not found" }, 404); 240 + } 241 + const comment = row.kanban_task_comments; 242 + if (!comment.hiddenAt) { 243 + return c.json({ ok: true }); 244 + } 245 + 246 + if (comment.moderatedBy === did) { 247 + const session = c.var.session; 248 + await deletePdsRecord(session, MODERATION_COLLECTION, id); 249 + } 250 + 251 + unhideComment(id); 252 + return c.json({ ok: true }); 253 + }); 254 + 255 + export { app as commentsApi };
+14
packages/kanban/src/api/routes.ts
··· 1 + import { Hono } from "hono"; 2 + import type { AuthEnv } from "@exosphere/core/auth"; 3 + import type { SphereEnv } from "@exosphere/core/types"; 4 + import { tasksApi } from "./tasks.ts"; 5 + import { commentsApi } from "./comments.ts"; 6 + import { columnsApi } from "./columns.ts"; 7 + 8 + const app = new Hono<AuthEnv & SphereEnv>(); 9 + 10 + app.route("/", columnsApi); 11 + app.route("/", tasksApi); 12 + app.route("/", commentsApi); 13 + 14 + export { app as kanbanApi };
+549
packages/kanban/src/api/tasks.ts
··· 1 + import { Hono } from "hono"; 2 + import { z } from "zod"; 3 + import { getDb } from "@exosphere/core/db"; 4 + import { eq, and, sql, asc } from "@exosphere/core/db/drizzle"; 5 + import { requireAuth, type AuthEnv } from "@exosphere/core/auth"; 6 + import { getActiveMemberRole } from "@exosphere/core/sphere"; 7 + import { requirePermission, checkPermission } from "@exosphere/core/permissions"; 8 + import { putPdsRecord, deletePdsRecord, generateRkey, tidToDate } from "@exosphere/core/pds"; 9 + import { resolveDidHandles } from "@exosphere/core/identity"; 10 + import type { SphereEnv } from "@exosphere/core/types"; 11 + import { kanbanTasks, kanbanTaskComments, kanbanTaskStatusChanges } from "../db/schema.ts"; 12 + import { 13 + createTaskSchema, 14 + updateTaskSchema, 15 + updateStatusSchema, 16 + assignTaskSchema, 17 + reorderSchema, 18 + } from "../schemas/task.ts"; 19 + import { 20 + getColumns, 21 + getColumnBySlug, 22 + insertTask, 23 + updateTask, 24 + deleteTaskCascade, 25 + insertStatusChangeAndUpdateTask, 26 + updateTaskPosition, 27 + assignTask, 28 + hideTask, 29 + unhideTask, 30 + } from "../db/operations.ts"; 31 + 32 + const COLLECTION = "site.exosphere.kanban.entry"; 33 + const STATUS_COLLECTION = "site.exosphere.kanban.status"; 34 + const MODERATION_COLLECTION = "site.exosphere.moderation"; 35 + 36 + const MODULE = "kanban"; 37 + 38 + const app = new Hono<AuthEnv & SphereEnv>(); 39 + 40 + // Get single task by number 41 + app.get("/:number{[0-9]+}", async (c) => { 42 + const number = parseInt(c.req.param("number"), 10); 43 + const sphereId = c.var.sphereId; 44 + const db = getDb(); 45 + 46 + const row = db 47 + .select({ 48 + id: kanbanTasks.id, 49 + sphereId: kanbanTasks.sphereId, 50 + number: kanbanTasks.number, 51 + authorDid: kanbanTasks.authorDid, 52 + title: kanbanTasks.title, 53 + description: kanbanTasks.description, 54 + status: kanbanTasks.status, 55 + position: kanbanTasks.position, 56 + assigneeDid: kanbanTasks.assigneeDid, 57 + pdsUri: kanbanTasks.pdsUri, 58 + hiddenAt: kanbanTasks.hiddenAt, 59 + moderatedBy: kanbanTasks.moderatedBy, 60 + updatedAt: kanbanTasks.updatedAt, 61 + commentCount: 62 + sql<number>`(select count(*) from ${kanbanTaskComments} where ${kanbanTaskComments.taskId} = ${kanbanTasks.id} and ${kanbanTaskComments.hiddenAt} is null)`.as( 63 + "comment_count", 64 + ), 65 + }) 66 + .from(kanbanTasks) 67 + .where( 68 + and( 69 + eq(kanbanTasks.number, number), 70 + eq(kanbanTasks.sphereId, sphereId), 71 + sql`${kanbanTasks.hiddenAt} is null`, 72 + ), 73 + ) 74 + .get(); 75 + 76 + if (!row) { 77 + return c.json({ error: "Task not found" }, 404); 78 + } 79 + 80 + const dids = [row.authorDid]; 81 + if (row.assigneeDid) dids.push(row.assigneeDid); 82 + const handleMap = await resolveDidHandles(dids); 83 + 84 + return c.json({ 85 + task: { 86 + ...row, 87 + createdAt: tidToDate(row.id), 88 + authorHandle: handleMap.get(row.authorDid) ?? null, 89 + assigneeHandle: row.assigneeDid ? (handleMap.get(row.assigneeDid) ?? null) : null, 90 + }, 91 + }); 92 + }); 93 + 94 + // List all tasks (grouped by status, ordered by position) 95 + app.get("/", async (c) => { 96 + const db = getDb(); 97 + const sphereId = c.var.sphereId; 98 + 99 + const rows = db 100 + .select({ 101 + id: kanbanTasks.id, 102 + sphereId: kanbanTasks.sphereId, 103 + number: kanbanTasks.number, 104 + authorDid: kanbanTasks.authorDid, 105 + title: kanbanTasks.title, 106 + description: kanbanTasks.description, 107 + status: kanbanTasks.status, 108 + position: kanbanTasks.position, 109 + assigneeDid: kanbanTasks.assigneeDid, 110 + pdsUri: kanbanTasks.pdsUri, 111 + hiddenAt: kanbanTasks.hiddenAt, 112 + moderatedBy: kanbanTasks.moderatedBy, 113 + updatedAt: kanbanTasks.updatedAt, 114 + commentCount: 115 + sql<number>`(select count(*) from ${kanbanTaskComments} where ${kanbanTaskComments.taskId} = ${kanbanTasks.id} and ${kanbanTaskComments.hiddenAt} is null)`.as( 116 + "comment_count", 117 + ), 118 + }) 119 + .from(kanbanTasks) 120 + .where(and(eq(kanbanTasks.sphereId, sphereId), sql`${kanbanTasks.hiddenAt} is null`)) 121 + .orderBy(asc(kanbanTasks.position)) 122 + .all(); 123 + 124 + const allDids = new Set<string>(); 125 + for (const r of rows) { 126 + allDids.add(r.authorDid); 127 + if (r.assigneeDid) allDids.add(r.assigneeDid); 128 + } 129 + const handleMap = await resolveDidHandles([...allDids]); 130 + 131 + const tasks = rows.map((r) => ({ 132 + ...r, 133 + createdAt: tidToDate(r.id), 134 + authorHandle: handleMap.get(r.authorDid) ?? null, 135 + assigneeHandle: r.assigneeDid ? (handleMap.get(r.assigneeDid) ?? null) : null, 136 + })); 137 + 138 + // Group by column 139 + const cols = getColumns(sphereId); 140 + const tasksByColumn: Record<string, typeof tasks> = {}; 141 + for (const col of cols) { 142 + tasksByColumn[col.slug] = []; 143 + } 144 + for (const task of tasks) { 145 + (tasksByColumn[task.status] ??= []).push(task); 146 + } 147 + 148 + return c.json({ 149 + columns: cols.map(({ id, slug, label, position }) => ({ id, slug, label, position })), 150 + tasksByColumn, 151 + tasks, 152 + }); 153 + }); 154 + 155 + // Create task 156 + app.post("/", requireAuth, requirePermission(MODULE, "create"), async (c) => { 157 + const body = await c.req.json(); 158 + const result = createTaskSchema.safeParse(body); 159 + if (!result.success) { 160 + return c.json({ error: z.flattenError(result.error) }, 400); 161 + } 162 + 163 + const { title, description, assigneeDid } = result.data; 164 + let { status } = result.data; 165 + const sphereId = c.var.sphereId; 166 + const sphereOwnerDid = c.var.sphereOwnerDid; 167 + const sphereVisibility = c.var.sphereVisibility; 168 + const id = generateRkey(); 169 + const did = c.var.did; 170 + 171 + // Validate status against this sphere's columns; fall back to first column 172 + const cols = getColumns(sphereId); 173 + if (!cols.some((col) => col.slug === status)) { 174 + status = cols[0].slug; 175 + } 176 + 177 + let pdsUri: string | null = null; 178 + 179 + if (sphereVisibility === "public") { 180 + const session = c.var.session; 181 + pdsUri = await putPdsRecord(session, COLLECTION, id, { 182 + title, 183 + description, 184 + status, 185 + subject: sphereOwnerDid, 186 + assigneeDid: assigneeDid ?? undefined, 187 + }); 188 + } 189 + 190 + const row = insertTask({ 191 + id, 192 + sphereId, 193 + authorDid: did, 194 + title, 195 + description, 196 + status, 197 + assigneeDid: assigneeDid ?? null, 198 + pdsUri, 199 + }); 200 + 201 + return c.json({ task: row ? { ...row, createdAt: tidToDate(id) } : row }, 201); 202 + }); 203 + 204 + // Update task (author or manage permission) 205 + app.put("/:id", requireAuth, async (c) => { 206 + const id = c.req.param("id"); 207 + const body = await c.req.json(); 208 + const result = updateTaskSchema.safeParse(body); 209 + if (!result.success) { 210 + return c.json({ error: z.flattenError(result.error) }, 400); 211 + } 212 + 213 + const db = getDb(); 214 + const did = c.var.did; 215 + const sphereId = c.var.sphereId; 216 + const sphereOwnerDid = c.var.sphereOwnerDid; 217 + 218 + const existing = db 219 + .select({ 220 + id: kanbanTasks.id, 221 + authorDid: kanbanTasks.authorDid, 222 + title: kanbanTasks.title, 223 + description: kanbanTasks.description, 224 + status: kanbanTasks.status, 225 + assigneeDid: kanbanTasks.assigneeDid, 226 + pdsUri: kanbanTasks.pdsUri, 227 + }) 228 + .from(kanbanTasks) 229 + .where(and(eq(kanbanTasks.id, id), eq(kanbanTasks.sphereId, sphereId))) 230 + .get(); 231 + if (!existing) { 232 + return c.json({ error: "Task not found" }, 404); 233 + } 234 + 235 + const isAuthor = existing.authorDid === did; 236 + if (!isAuthor) { 237 + const role = getActiveMemberRole(sphereId, did); 238 + if (!checkPermission(sphereId, MODULE, "manageTasks", role)) { 239 + return c.json({ error: "Forbidden" }, 403); 240 + } 241 + } 242 + 243 + const fields: { title?: string; description?: string } = {}; 244 + if (result.data.title !== undefined) fields.title = result.data.title; 245 + if (result.data.description !== undefined) fields.description = result.data.description; 246 + 247 + if (Object.keys(fields).length > 0) { 248 + updateTask(id, fields); 249 + 250 + if (existing.pdsUri) { 251 + const session = c.var.session; 252 + await putPdsRecord(session, COLLECTION, id, { 253 + title: fields.title ?? existing.title, 254 + description: fields.description ?? existing.description, 255 + status: existing.status, 256 + subject: sphereOwnerDid, 257 + assigneeDid: existing.assigneeDid ?? undefined, 258 + updatedAt: new Date().toISOString(), 259 + }); 260 + } 261 + } 262 + 263 + const updated = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, id)).get(); 264 + return c.json({ task: updated ? { ...updated, createdAt: tidToDate(id) } : updated }); 265 + }); 266 + 267 + // Delete task (author or manage permission) 268 + app.delete("/:id", requireAuth, async (c) => { 269 + const id = c.req.param("id"); 270 + const db = getDb(); 271 + const did = c.var.did; 272 + const sphereId = c.var.sphereId; 273 + 274 + const existing = db 275 + .select({ 276 + id: kanbanTasks.id, 277 + authorDid: kanbanTasks.authorDid, 278 + pdsUri: kanbanTasks.pdsUri, 279 + }) 280 + .from(kanbanTasks) 281 + .where(and(eq(kanbanTasks.id, id), eq(kanbanTasks.sphereId, sphereId))) 282 + .get(); 283 + if (!existing) { 284 + return c.json({ error: "Task not found" }, 404); 285 + } 286 + 287 + const isAuthor = existing.authorDid === did; 288 + if (!isAuthor) { 289 + const role = getActiveMemberRole(sphereId, did); 290 + if (!checkPermission(sphereId, MODULE, "manageTasks", role)) { 291 + return c.json({ error: "Forbidden" }, 403); 292 + } 293 + } 294 + 295 + if (existing.pdsUri) { 296 + const session = c.var.session; 297 + await deletePdsRecord(session, COLLECTION, id); 298 + } 299 + 300 + deleteTaskCascade(id); 301 + return c.json({ ok: true }); 302 + }); 303 + 304 + // Move task to a different column 305 + app.post("/:id/status", requireAuth, requirePermission(MODULE, "changeStatus"), async (c) => { 306 + const id = c.req.param("id"); 307 + const body = await c.req.json(); 308 + const result = updateStatusSchema.safeParse(body); 309 + if (!result.success) { 310 + return c.json({ error: z.flattenError(result.error) }, 400); 311 + } 312 + 313 + const { status, position } = result.data; 314 + const db = getDb(); 315 + const did = c.var.did; 316 + const sphereId = c.var.sphereId; 317 + const sphereVisibility = c.var.sphereVisibility; 318 + 319 + if (!getColumnBySlug(sphereId, status)) { 320 + return c.json({ error: "Unknown column" }, 400); 321 + } 322 + 323 + const existing = db 324 + .select({ 325 + id: kanbanTasks.id, 326 + status: kanbanTasks.status, 327 + pdsUri: kanbanTasks.pdsUri, 328 + }) 329 + .from(kanbanTasks) 330 + .where(and(eq(kanbanTasks.id, id), eq(kanbanTasks.sphereId, sphereId))) 331 + .get(); 332 + if (!existing) { 333 + return c.json({ error: "Task not found" }, 404); 334 + } 335 + 336 + const statusId = generateRkey(); 337 + 338 + let pdsUri: string | null = null; 339 + if (sphereVisibility === "public" && existing.pdsUri) { 340 + const session = c.var.session; 341 + pdsUri = await putPdsRecord(session, STATUS_COLLECTION, statusId, { 342 + subject: existing.pdsUri, 343 + status, 344 + }); 345 + } 346 + insertStatusChangeAndUpdateTask({ 347 + id: statusId, 348 + taskId: id, 349 + authorDid: did, 350 + status, 351 + pdsUri, 352 + position, 353 + }); 354 + 355 + return c.json({ status }); 356 + }); 357 + 358 + // Assign/unassign a member 359 + app.post("/:id/assign", requireAuth, requirePermission(MODULE, "assign"), async (c) => { 360 + const id = c.req.param("id"); 361 + const body = await c.req.json(); 362 + const result = assignTaskSchema.safeParse(body); 363 + if (!result.success) { 364 + return c.json({ error: z.flattenError(result.error) }, 400); 365 + } 366 + 367 + const db = getDb(); 368 + const sphereId = c.var.sphereId; 369 + 370 + const existing = db 371 + .select({ id: kanbanTasks.id }) 372 + .from(kanbanTasks) 373 + .where(and(eq(kanbanTasks.id, id), eq(kanbanTasks.sphereId, sphereId))) 374 + .get(); 375 + if (!existing) { 376 + return c.json({ error: "Task not found" }, 404); 377 + } 378 + 379 + assignTask(id, result.data.assigneeDid); 380 + 381 + return c.json({ ok: true }); 382 + }); 383 + 384 + // Reorder task within its column 385 + app.post("/:id/reorder", requireAuth, requirePermission(MODULE, "changeStatus"), async (c) => { 386 + const id = c.req.param("id"); 387 + const body = await c.req.json(); 388 + const result = reorderSchema.safeParse(body); 389 + if (!result.success) { 390 + return c.json({ error: z.flattenError(result.error) }, 400); 391 + } 392 + 393 + const db = getDb(); 394 + const sphereId = c.var.sphereId; 395 + 396 + const existing = db 397 + .select({ id: kanbanTasks.id }) 398 + .from(kanbanTasks) 399 + .where(and(eq(kanbanTasks.id, id), eq(kanbanTasks.sphereId, sphereId))) 400 + .get(); 401 + if (!existing) { 402 + return c.json({ error: "Task not found" }, 404); 403 + } 404 + 405 + updateTaskPosition(id, result.data.position); 406 + 407 + return c.json({ ok: true }); 408 + }); 409 + 410 + // Hide task (moderation) 411 + app.post("/:id/hide", requireAuth, requirePermission(MODULE, "moderate"), async (c) => { 412 + const id = c.req.param("id"); 413 + const db = getDb(); 414 + const did = c.var.did; 415 + const sphereId = c.var.sphereId; 416 + const sphereOwnerDid = c.var.sphereOwnerDid; 417 + const spherePdsUri = c.var.spherePdsUri; 418 + 419 + const existing = db 420 + .select({ 421 + id: kanbanTasks.id, 422 + pdsUri: kanbanTasks.pdsUri, 423 + hiddenAt: kanbanTasks.hiddenAt, 424 + }) 425 + .from(kanbanTasks) 426 + .where(and(eq(kanbanTasks.id, id), eq(kanbanTasks.sphereId, sphereId))) 427 + .get(); 428 + if (!existing) { 429 + return c.json({ error: "Task not found" }, 404); 430 + } 431 + 432 + if (existing.hiddenAt) { 433 + return c.json({ ok: true }); 434 + } 435 + 436 + if (existing.pdsUri) { 437 + const sphereUri = spherePdsUri ?? `at://${sphereOwnerDid}/site.exosphere.sphere.profile/self`; 438 + const session = c.var.session; 439 + const pdsUri = await putPdsRecord(session, MODERATION_COLLECTION, id, { 440 + sphere: sphereUri, 441 + subject: existing.pdsUri, 442 + action: "remove", 443 + }); 444 + if (!pdsUri) { 445 + console.warn("[kanban] Moderation PDS record failed for task %s — hiding locally only", id); 446 + } 447 + } 448 + 449 + hideTask(id, did); 450 + return c.json({ ok: true }); 451 + }); 452 + 453 + // Unhide task 454 + app.post("/:id/unhide", requireAuth, requirePermission(MODULE, "moderate"), async (c) => { 455 + const id = c.req.param("id"); 456 + const db = getDb(); 457 + const did = c.var.did; 458 + const sphereId = c.var.sphereId; 459 + 460 + const existing = db 461 + .select({ 462 + id: kanbanTasks.id, 463 + hiddenAt: kanbanTasks.hiddenAt, 464 + moderatedBy: kanbanTasks.moderatedBy, 465 + }) 466 + .from(kanbanTasks) 467 + .where(and(eq(kanbanTasks.id, id), eq(kanbanTasks.sphereId, sphereId))) 468 + .get(); 469 + if (!existing) { 470 + return c.json({ error: "Task not found" }, 404); 471 + } 472 + 473 + if (!existing.hiddenAt) { 474 + return c.json({ ok: true }); 475 + } 476 + 477 + if (existing.moderatedBy === did) { 478 + const session = c.var.session; 479 + await deletePdsRecord(session, MODERATION_COLLECTION, id); 480 + } 481 + 482 + unhideTask(id); 483 + return c.json({ ok: true }); 484 + }); 485 + 486 + // Status change history 487 + app.get("/:id/statuses", async (c) => { 488 + const id = c.req.param("id"); 489 + const db = getDb(); 490 + 491 + const rows = db 492 + .select({ 493 + id: kanbanTaskStatusChanges.id, 494 + authorDid: kanbanTaskStatusChanges.authorDid, 495 + status: kanbanTaskStatusChanges.status, 496 + }) 497 + .from(kanbanTaskStatusChanges) 498 + .where(eq(kanbanTaskStatusChanges.taskId, id)) 499 + .orderBy(sql`${kanbanTaskStatusChanges.id} desc`) 500 + .all(); 501 + 502 + const handleMap = await resolveDidHandles(rows.map((r) => r.authorDid)); 503 + const statuses = rows.map((r) => ({ 504 + ...r, 505 + createdAt: tidToDate(r.id), 506 + authorHandle: handleMap.get(r.authorDid) ?? null, 507 + })); 508 + 509 + return c.json({ statuses }); 510 + }); 511 + 512 + // Search tasks 513 + app.get("/search", (c) => { 514 + const q = c.req.query("q")?.trim(); 515 + const sphereId = c.var.sphereId; 516 + if (!q) { 517 + return c.json({ results: [] }); 518 + } 519 + 520 + const db = getDb(); 521 + const conditions = [eq(kanbanTasks.sphereId, sphereId), sql`${kanbanTasks.hiddenAt} is null`]; 522 + 523 + const hashMatch = q.startsWith("#") ? q.slice(1) : null; 524 + const numberVal = parseInt(hashMatch ?? q, 10); 525 + if (hashMatch !== null && !isNaN(numberVal)) { 526 + conditions.push(eq(kanbanTasks.number, numberVal)); 527 + } else if (!isNaN(numberVal) && String(numberVal) === q) { 528 + conditions.push(eq(kanbanTasks.number, numberVal)); 529 + } else { 530 + const escaped = q.replace(/[%_\\]/g, "\\$&"); 531 + conditions.push(sql`${kanbanTasks.title} LIKE ${"%" + escaped + "%"} ESCAPE '\\'`); 532 + } 533 + 534 + const rows = db 535 + .select({ 536 + id: kanbanTasks.id, 537 + number: kanbanTasks.number, 538 + title: kanbanTasks.title, 539 + status: kanbanTasks.status, 540 + }) 541 + .from(kanbanTasks) 542 + .where(and(...conditions)) 543 + .limit(10) 544 + .all(); 545 + 546 + return c.json({ results: rows }); 547 + }); 548 + 549 + export { app as tasksApi };
+13
packages/kanban/src/client.ssr.ts
··· 1 + import type { ClientModule } from "@exosphere/client/types"; 2 + import { BoardPage } from "./ui/pages/board.tsx"; 3 + import { TaskPage } from "./ui/pages/task.tsx"; 4 + import { BoardSettingsPage } from "./ui/pages/settings.tsx"; 5 + 6 + export const kanbanModule: ClientModule = { 7 + name: "board", 8 + routes: [ 9 + { path: "/board", component: BoardPage }, 10 + { path: "/board/settings", component: BoardSettingsPage }, 11 + { path: "/board/:number", component: TaskPage }, 12 + ], 13 + };
+17
packages/kanban/src/client.ts
··· 1 + import { lazy } from "@exosphere/client/router"; 2 + import type { ClientModule } from "@exosphere/client/types"; 3 + 4 + const BoardPage = lazy(() => import("./ui/pages/board.tsx").then((m) => m.BoardPage)); 5 + const TaskPage = lazy(() => import("./ui/pages/task.tsx").then((m) => m.TaskPage)); 6 + const BoardSettingsPage = lazy(() => 7 + import("./ui/pages/settings.tsx").then((m) => m.BoardSettingsPage), 8 + ); 9 + 10 + export const kanbanModule: ClientModule = { 11 + name: "board", 12 + routes: [ 13 + { path: "/board", component: BoardPage }, 14 + { path: "/board/settings", component: BoardSettingsPage }, 15 + { path: "/board/:number", component: TaskPage }, 16 + ], 17 + };
+378
packages/kanban/src/db/operations.ts
··· 1 + import { getDb } from "@exosphere/core/db"; 2 + import { eq, and, max, asc } from "@exosphere/core/db/drizzle"; 3 + import { tidToDate, generateRkey } from "@exosphere/core/pds"; 4 + import type { ModerationHandler } from "@exosphere/core/sphere"; 5 + import { 6 + kanbanColumns, 7 + kanbanTasks, 8 + kanbanTaskComments, 9 + kanbanTaskStatusChanges, 10 + } from "./schema.ts"; 11 + import type { KanbanColumn } from "./schema.ts"; 12 + import { defaultColumns } from "../schemas/task.ts"; 13 + 14 + const POSITION_GAP = 1000; 15 + 16 + // ---- Columns ---- 17 + 18 + export function getColumns(sphereId: string): KanbanColumn[] { 19 + const db = getDb(); 20 + const cols = db 21 + .select() 22 + .from(kanbanColumns) 23 + .where(eq(kanbanColumns.sphereId, sphereId)) 24 + .orderBy(asc(kanbanColumns.position)) 25 + .all(); 26 + 27 + if (cols.length === 0) { 28 + return seedDefaultColumns(sphereId); 29 + } 30 + return cols; 31 + } 32 + 33 + export function seedDefaultColumns(sphereId: string): KanbanColumn[] { 34 + const db = getDb(); 35 + return db.transaction((tx) => { 36 + const existing = tx 37 + .select() 38 + .from(kanbanColumns) 39 + .where(eq(kanbanColumns.sphereId, sphereId)) 40 + .all(); 41 + if (existing.length > 0) return existing; 42 + 43 + for (let i = 0; i < defaultColumns.length; i++) { 44 + tx.insert(kanbanColumns) 45 + .values({ 46 + id: generateRkey(), 47 + sphereId, 48 + slug: defaultColumns[i].slug, 49 + label: defaultColumns[i].label, 50 + position: (i + 1) * POSITION_GAP, 51 + }) 52 + .run(); 53 + } 54 + 55 + return tx 56 + .select() 57 + .from(kanbanColumns) 58 + .where(eq(kanbanColumns.sphereId, sphereId)) 59 + .orderBy(asc(kanbanColumns.position)) 60 + .all(); 61 + }); 62 + } 63 + 64 + function generateSlug(label: string, sphereId: string): string { 65 + const base = 66 + label 67 + .toLowerCase() 68 + .replace(/[^a-z0-9]+/g, "-") 69 + .replace(/^-+|-+$/g, "") 70 + .slice(0, 50) || "column"; 71 + 72 + const db = getDb(); 73 + let slug = base; 74 + let suffix = 2; 75 + for (;;) { 76 + const exists = db 77 + .select({ id: kanbanColumns.id }) 78 + .from(kanbanColumns) 79 + .where(and(eq(kanbanColumns.sphereId, sphereId), eq(kanbanColumns.slug, slug))) 80 + .get(); 81 + if (!exists) return slug; 82 + slug = `${base}-${suffix}`; 83 + suffix++; 84 + } 85 + } 86 + 87 + export function insertColumn(params: { sphereId: string; label: string }): KanbanColumn { 88 + const db = getDb(); 89 + const id = generateRkey(); 90 + const slug = generateSlug(params.label, params.sphereId); 91 + 92 + const lastPosition = db 93 + .select({ maxPosition: max(kanbanColumns.position) }) 94 + .from(kanbanColumns) 95 + .where(eq(kanbanColumns.sphereId, params.sphereId)) 96 + .get(); 97 + const position = (lastPosition?.maxPosition ?? 0) + POSITION_GAP; 98 + 99 + db.insert(kanbanColumns) 100 + .values({ id, sphereId: params.sphereId, slug, label: params.label, position }) 101 + .run(); 102 + 103 + return db.select().from(kanbanColumns).where(eq(kanbanColumns.id, id)).get()!; 104 + } 105 + 106 + export function updateColumnLabel(id: string, label: string): void { 107 + getDb().update(kanbanColumns).set({ label }).where(eq(kanbanColumns.id, id)).run(); 108 + } 109 + 110 + export function deleteColumnAndReassign( 111 + sphereId: string, 112 + columnId: string, 113 + reassignToSlug: string, 114 + ): void { 115 + const db = getDb(); 116 + db.transaction((tx) => { 117 + const col = tx 118 + .select() 119 + .from(kanbanColumns) 120 + .where(and(eq(kanbanColumns.id, columnId), eq(kanbanColumns.sphereId, sphereId))) 121 + .get(); 122 + if (!col) throw new Error("Column not found"); 123 + 124 + const target = tx 125 + .select() 126 + .from(kanbanColumns) 127 + .where(and(eq(kanbanColumns.sphereId, sphereId), eq(kanbanColumns.slug, reassignToSlug))) 128 + .get(); 129 + if (!target) throw new Error("Target column not found"); 130 + 131 + tx.update(kanbanTasks) 132 + .set({ status: reassignToSlug, updatedAt: new Date().toISOString() }) 133 + .where(and(eq(kanbanTasks.sphereId, sphereId), eq(kanbanTasks.status, col.slug))) 134 + .run(); 135 + 136 + tx.delete(kanbanColumns).where(eq(kanbanColumns.id, columnId)).run(); 137 + }); 138 + } 139 + 140 + export function reorderColumns(sphereId: string, columnIds: string[]): void { 141 + const db = getDb(); 142 + db.transaction((tx) => { 143 + for (let i = 0; i < columnIds.length; i++) { 144 + tx.update(kanbanColumns) 145 + .set({ position: (i + 1) * POSITION_GAP }) 146 + .where(and(eq(kanbanColumns.id, columnIds[i]), eq(kanbanColumns.sphereId, sphereId))) 147 + .run(); 148 + } 149 + }); 150 + } 151 + 152 + export function getColumnBySlug(sphereId: string, slug: string): KanbanColumn | undefined { 153 + return getDb() 154 + .select() 155 + .from(kanbanColumns) 156 + .where(and(eq(kanbanColumns.sphereId, sphereId), eq(kanbanColumns.slug, slug))) 157 + .get(); 158 + } 159 + 160 + // ---- Tasks ---- 161 + 162 + export function insertTask(params: { 163 + id: string; 164 + sphereId: string; 165 + authorDid: string; 166 + title: string; 167 + description: string; 168 + status: string; 169 + assigneeDid: string | null; 170 + pdsUri: string | null; 171 + }): typeof kanbanTasks.$inferSelect | undefined { 172 + const db = getDb(); 173 + return db.transaction((tx) => { 174 + const lastNumber = tx 175 + .select({ maxNumber: max(kanbanTasks.number) }) 176 + .from(kanbanTasks) 177 + .where(eq(kanbanTasks.sphereId, params.sphereId)) 178 + .get(); 179 + const number = (lastNumber?.maxNumber ?? 0) + 1; 180 + 181 + const lastPosition = tx 182 + .select({ maxPosition: max(kanbanTasks.position) }) 183 + .from(kanbanTasks) 184 + .where(and(eq(kanbanTasks.sphereId, params.sphereId), eq(kanbanTasks.status, params.status))) 185 + .get(); 186 + const position = (lastPosition?.maxPosition ?? 0) + POSITION_GAP; 187 + 188 + tx.insert(kanbanTasks) 189 + .values({ 190 + id: params.id, 191 + sphereId: params.sphereId, 192 + number, 193 + authorDid: params.authorDid, 194 + title: params.title, 195 + description: params.description, 196 + status: params.status, 197 + position, 198 + assigneeDid: params.assigneeDid, 199 + pdsUri: params.pdsUri, 200 + }) 201 + .onConflictDoNothing() 202 + .run(); 203 + 204 + return tx.select().from(kanbanTasks).where(eq(kanbanTasks.id, params.id)).get(); 205 + }); 206 + } 207 + 208 + export function updateTask(id: string, fields: { title?: string; description?: string }): void { 209 + getDb() 210 + .update(kanbanTasks) 211 + .set({ ...fields, updatedAt: new Date().toISOString() }) 212 + .where(eq(kanbanTasks.id, id)) 213 + .run(); 214 + } 215 + 216 + export function deleteTaskCascade(id: string): void { 217 + const db = getDb(); 218 + db.transaction((tx) => { 219 + tx.delete(kanbanTaskComments).where(eq(kanbanTaskComments.taskId, id)).run(); 220 + tx.delete(kanbanTaskStatusChanges).where(eq(kanbanTaskStatusChanges.taskId, id)).run(); 221 + tx.delete(kanbanTasks).where(eq(kanbanTasks.id, id)).run(); 222 + }); 223 + } 224 + 225 + // ---- Status changes ---- 226 + 227 + export function insertStatusChangeAndUpdateTask(params: { 228 + id: string; 229 + taskId: string; 230 + authorDid: string; 231 + status: string; 232 + pdsUri: string | null; 233 + position?: number; 234 + }): void { 235 + const db = getDb(); 236 + db.transaction((tx) => { 237 + tx.insert(kanbanTaskStatusChanges) 238 + .values({ 239 + id: params.id, 240 + taskId: params.taskId, 241 + authorDid: params.authorDid, 242 + status: params.status, 243 + pdsUri: params.pdsUri, 244 + }) 245 + .onConflictDoNothing() 246 + .run(); 247 + 248 + // If no position provided, place at end of target column 249 + let position = params.position; 250 + if (position === undefined) { 251 + const task = tx.select().from(kanbanTasks).where(eq(kanbanTasks.id, params.taskId)).get(); 252 + if (!task) return; 253 + const sphereId = task.sphereId; 254 + const lastPosition = tx 255 + .select({ maxPosition: max(kanbanTasks.position) }) 256 + .from(kanbanTasks) 257 + .where(and(eq(kanbanTasks.sphereId, sphereId), eq(kanbanTasks.status, params.status))) 258 + .get(); 259 + position = (lastPosition?.maxPosition ?? 0) + POSITION_GAP; 260 + } 261 + 262 + tx.update(kanbanTasks) 263 + .set({ status: params.status, position, updatedAt: new Date().toISOString() }) 264 + .where(eq(kanbanTasks.id, params.taskId)) 265 + .run(); 266 + }); 267 + } 268 + 269 + export function updateTaskPosition(id: string, position: number): void { 270 + getDb() 271 + .update(kanbanTasks) 272 + .set({ position, updatedAt: new Date().toISOString() }) 273 + .where(eq(kanbanTasks.id, id)) 274 + .run(); 275 + } 276 + 277 + // ---- Assignment ---- 278 + 279 + export function assignTask(id: string, assigneeDid: string | null): void { 280 + getDb() 281 + .update(kanbanTasks) 282 + .set({ assigneeDid, updatedAt: new Date().toISOString() }) 283 + .where(eq(kanbanTasks.id, id)) 284 + .run(); 285 + } 286 + 287 + // ---- Comments ---- 288 + 289 + export function insertComment(params: { 290 + id: string; 291 + taskId: string; 292 + authorDid: string; 293 + content: string; 294 + pdsUri: string | null; 295 + }): void { 296 + const updatedAt = tidToDate(params.id); 297 + getDb() 298 + .insert(kanbanTaskComments) 299 + .values({ ...params, updatedAt }) 300 + .onConflictDoUpdate({ 301 + target: kanbanTaskComments.id, 302 + set: { content: params.content, updatedAt: new Date().toISOString() }, 303 + }) 304 + .run(); 305 + } 306 + 307 + export function updateComment(id: string, content: string): void { 308 + getDb() 309 + .update(kanbanTaskComments) 310 + .set({ content, updatedAt: new Date().toISOString() }) 311 + .where(eq(kanbanTaskComments.id, id)) 312 + .run(); 313 + } 314 + 315 + export function deleteComment(id: string): void { 316 + getDb().delete(kanbanTaskComments).where(eq(kanbanTaskComments.id, id)).run(); 317 + } 318 + 319 + // ---- Moderation ---- 320 + 321 + export function hideTask(id: string, moderatorDid: string): void { 322 + getDb() 323 + .update(kanbanTasks) 324 + .set({ hiddenAt: new Date().toISOString(), moderatedBy: moderatorDid }) 325 + .where(eq(kanbanTasks.id, id)) 326 + .run(); 327 + } 328 + 329 + export function unhideTask(id: string): void { 330 + getDb() 331 + .update(kanbanTasks) 332 + .set({ hiddenAt: null, moderatedBy: null }) 333 + .where(eq(kanbanTasks.id, id)) 334 + .run(); 335 + } 336 + 337 + export function hideComment(id: string, moderatorDid: string): void { 338 + getDb() 339 + .update(kanbanTaskComments) 340 + .set({ hiddenAt: new Date().toISOString(), moderatedBy: moderatorDid }) 341 + .where(eq(kanbanTaskComments.id, id)) 342 + .run(); 343 + } 344 + 345 + export function unhideComment(id: string): void { 346 + getDb() 347 + .update(kanbanTaskComments) 348 + .set({ hiddenAt: null, moderatedBy: null }) 349 + .where(eq(kanbanTaskComments.id, id)) 350 + .run(); 351 + } 352 + 353 + /** Moderation handler for kanban tasks and comments. Returns true if it handled the subject. */ 354 + export const handleKanbanModeration: ModerationHandler = (subjectUri, moderatorDid) => { 355 + const db = getDb(); 356 + 357 + const task = db 358 + .select({ id: kanbanTasks.id }) 359 + .from(kanbanTasks) 360 + .where(eq(kanbanTasks.pdsUri, subjectUri)) 361 + .get(); 362 + if (task) { 363 + hideTask(task.id, moderatorDid); 364 + return true; 365 + } 366 + 367 + const comment = db 368 + .select({ id: kanbanTaskComments.id }) 369 + .from(kanbanTaskComments) 370 + .where(eq(kanbanTaskComments.pdsUri, subjectUri)) 371 + .get(); 372 + if (comment) { 373 + hideComment(comment.id, moderatorDid); 374 + return true; 375 + } 376 + 377 + return false; 378 + };
+98
packages/kanban/src/db/schema.ts
··· 1 + import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core"; 2 + import { sql } from "drizzle-orm"; 3 + import type { InferSelectModel } from "drizzle-orm"; 4 + import { spheres } from "@exosphere/core/db/schema"; 5 + 6 + export const kanbanColumns = sqliteTable( 7 + "kanban_columns", 8 + { 9 + id: text("id").primaryKey(), 10 + sphereId: text("sphere_id") 11 + .notNull() 12 + .references(() => spheres.id), 13 + slug: text("slug").notNull(), 14 + label: text("label").notNull(), 15 + position: integer("position").notNull(), 16 + createdAt: text("created_at") 17 + .notNull() 18 + .default(sql`(datetime('now'))`), 19 + }, 20 + (table) => [ 21 + uniqueIndex("idx_kanban_columns_sphere_slug").on(table.sphereId, table.slug), 22 + index("idx_kanban_columns_sphere_position").on(table.sphereId, table.position), 23 + ], 24 + ); 25 + 26 + export const kanbanTasks = sqliteTable( 27 + "kanban_tasks", 28 + { 29 + id: text("id").primaryKey(), 30 + sphereId: text("sphere_id") 31 + .notNull() 32 + .references(() => spheres.id), 33 + number: integer("number").notNull(), 34 + authorDid: text("author_did").notNull(), 35 + title: text("title").notNull(), 36 + description: text("description").notNull().default(""), 37 + status: text("status").notNull().default("backlog"), 38 + position: integer("position").notNull().default(0), 39 + assigneeDid: text("assignee_did"), 40 + pdsUri: text("pds_uri"), 41 + hiddenAt: text("hidden_at"), 42 + moderatedBy: text("moderated_by"), 43 + updatedAt: text("updated_at") 44 + .notNull() 45 + .default(sql`(datetime('now'))`), 46 + }, 47 + (table) => [ 48 + uniqueIndex("idx_kanban_tasks_sphere_number").on(table.sphereId, table.number), 49 + index("idx_kanban_tasks_sphere").on(table.sphereId), 50 + index("idx_kanban_tasks_status").on(table.status), 51 + index("idx_kanban_tasks_sphere_status_position").on( 52 + table.sphereId, 53 + table.status, 54 + table.position, 55 + ), 56 + ], 57 + ); 58 + 59 + export const kanbanTaskComments = sqliteTable( 60 + "kanban_task_comments", 61 + { 62 + id: text("id").primaryKey(), 63 + taskId: text("task_id") 64 + .notNull() 65 + .references(() => kanbanTasks.id), 66 + authorDid: text("author_did").notNull(), 67 + content: text("content").notNull(), 68 + pdsUri: text("pds_uri"), 69 + updatedAt: text("updated_at") 70 + .notNull() 71 + .default(sql`(datetime('now'))`), 72 + hiddenAt: text("hidden_at"), 73 + moderatedBy: text("moderated_by"), 74 + }, 75 + (table) => [ 76 + index("idx_kanban_task_comments_task").on(table.taskId), 77 + index("idx_kanban_task_comments_author_task").on(table.authorDid, table.taskId), 78 + ], 79 + ); 80 + 81 + export const kanbanTaskStatusChanges = sqliteTable( 82 + "kanban_task_status_changes", 83 + { 84 + id: text("id").primaryKey(), 85 + taskId: text("task_id") 86 + .notNull() 87 + .references(() => kanbanTasks.id), 88 + authorDid: text("author_did").notNull(), 89 + status: text("status").notNull(), 90 + pdsUri: text("pds_uri"), 91 + }, 92 + (table) => [index("idx_kanban_task_status_changes_task").on(table.taskId)], 93 + ); 94 + 95 + export type KanbanColumn = InferSelectModel<typeof kanbanColumns>; 96 + export type KanbanTask = InferSelectModel<typeof kanbanTasks>; 97 + export type KanbanTaskComment = InferSelectModel<typeof kanbanTaskComments>; 98 + export type KanbanTaskStatusChange = InferSelectModel<typeof kanbanTaskStatusChanges>;
+19
packages/kanban/src/index.ts
··· 1 + import type { ExosphereModule } from "@exosphere/core/types"; 2 + import { kanbanApi } from "./api/routes.ts"; 3 + import { kanbanIndexer } from "./indexer.ts"; 4 + 5 + export const kanbanModule: ExosphereModule = { 6 + name: "kanban", 7 + api: kanbanApi, 8 + indexer: kanbanIndexer, 9 + permissions: { 10 + create: { label: "Create tasks", defaultRole: "member" }, 11 + comment: { label: "Comment on tasks", defaultRole: "member" }, 12 + changeStatus: { label: "Move tasks between columns", defaultRole: "member" }, 13 + assign: { label: "Assign tasks", defaultRole: "admin" }, 14 + manageTasks: { label: "Edit/delete others' tasks", defaultRole: "admin" }, 15 + manageSettings: { label: "Manage board settings", defaultRole: "admin" }, 16 + moderate: { label: "Hide/unhide content", defaultRole: "admin" }, 17 + }, 18 + permissionsCollection: "site.exosphere.kanban.permissions", 19 + };
+158
packages/kanban/src/indexer.ts
··· 1 + import type { ModuleIndexer, JetstreamCommitEvent } from "@exosphere/core/types"; 2 + import { buildAtUri, parseAtUri } from "@exosphere/core/indexer"; 3 + import { registerModerationHandler, getActiveMemberRole } from "@exosphere/core/sphere"; 4 + import { checkPermission } from "@exosphere/core/permissions"; 5 + import { getDb } from "@exosphere/core/db"; 6 + import { eq, and } from "@exosphere/core/db/drizzle"; 7 + import { spheres } from "@exosphere/core/db/schema"; 8 + import { kanbanTasks, kanbanTaskComments } from "./db/schema.ts"; 9 + import { 10 + getColumns, 11 + getColumnBySlug, 12 + insertTask, 13 + deleteTaskCascade, 14 + insertComment, 15 + deleteComment, 16 + insertStatusChangeAndUpdateTask, 17 + handleKanbanModeration, 18 + } from "./db/operations.ts"; 19 + 20 + // Register this module's moderation handler with core 21 + registerModerationHandler(handleKanbanModeration); 22 + 23 + const MODULE_NAME = "kanban"; 24 + const COLLECTION = "site.exosphere.kanban.entry"; 25 + const COMMENT_COLLECTION = "site.exosphere.kanban.comment"; 26 + const STATUS_COLLECTION = "site.exosphere.kanban.status"; 27 + 28 + function findSphereForAccess( 29 + sphereOwnerDid: string, 30 + did: string, 31 + action: string, 32 + ): { allowed: boolean; sphereId: string | null } { 33 + const db = getDb(); 34 + const sphere = db 35 + .select({ id: spheres.id }) 36 + .from(spheres) 37 + .where(eq(spheres.ownerDid, sphereOwnerDid)) 38 + .get(); 39 + if (!sphere) return { allowed: false, sphereId: null }; 40 + const role = getActiveMemberRole(sphere.id, did); 41 + const allowed = checkPermission(sphere.id, MODULE_NAME, action, role); 42 + return { allowed, sphereId: sphere.id }; 43 + } 44 + 45 + export const kanbanIndexer: ModuleIndexer = { 46 + collections: [COLLECTION, COMMENT_COLLECTION, STATUS_COLLECTION], 47 + 48 + handleCreateOrUpdate(event: JetstreamCommitEvent) { 49 + const { did, commit } = event; 50 + const { collection, rkey, record } = commit; 51 + if (!record) return; 52 + 53 + const pdsUri = buildAtUri(did, collection, rkey); 54 + 55 + if (collection === COLLECTION) { 56 + const subject = record.subject as string; 57 + if (!subject || !subject.startsWith("did:")) return; 58 + 59 + const access = findSphereForAccess(subject, did, "create"); 60 + if (!access.allowed || !access.sphereId) return; 61 + 62 + const rawStatus = record.status as string; 63 + const cols = getColumns(access.sphereId); 64 + const status = cols.some((c) => c.slug === rawStatus) ? rawStatus : cols[0].slug; 65 + 66 + insertTask({ 67 + id: rkey, 68 + sphereId: access.sphereId, 69 + authorDid: did, 70 + title: (record.title as string) ?? "", 71 + description: (record.description as string) ?? "", 72 + status, 73 + assigneeDid: (record.assigneeDid as string) ?? null, 74 + pdsUri, 75 + }); 76 + return; 77 + } 78 + 79 + // Comment and status reference a subject via AT URI 80 + const subjectUri = record.subject as string; 81 + if (!subjectUri) return; 82 + const parsed = parseAtUri(subjectUri); 83 + if (!parsed) return; 84 + 85 + if (collection === COMMENT_COLLECTION) { 86 + const task = getDb() 87 + .select({ id: kanbanTasks.id, sphereId: kanbanTasks.sphereId }) 88 + .from(kanbanTasks) 89 + .where(eq(kanbanTasks.id, parsed.rkey)) 90 + .get(); 91 + if (!task) { 92 + console.warn("[indexer] Kanban comment references unknown task rkey=%s", parsed.rkey); 93 + return; 94 + } 95 + const role = getActiveMemberRole(task.sphereId, did); 96 + if (!checkPermission(task.sphereId, MODULE_NAME, "comment", role)) return; 97 + insertComment({ 98 + id: rkey, 99 + taskId: task.id, 100 + authorDid: did, 101 + content: (record.content as string) ?? "", 102 + pdsUri, 103 + }); 104 + return; 105 + } 106 + 107 + if (collection === STATUS_COLLECTION) { 108 + const task = getDb() 109 + .select({ id: kanbanTasks.id, sphereId: kanbanTasks.sphereId }) 110 + .from(kanbanTasks) 111 + .where(eq(kanbanTasks.id, parsed.rkey)) 112 + .get(); 113 + if (!task) { 114 + console.warn("[indexer] Kanban status references unknown task rkey=%s", parsed.rkey); 115 + return; 116 + } 117 + const role = getActiveMemberRole(task.sphereId, did); 118 + if (!checkPermission(task.sphereId, MODULE_NAME, "changeStatus", role)) return; 119 + 120 + const rawStatus = record.status as string; 121 + if (!rawStatus || !getColumnBySlug(task.sphereId, rawStatus)) return; 122 + 123 + insertStatusChangeAndUpdateTask({ 124 + id: rkey, 125 + taskId: task.id, 126 + authorDid: did, 127 + status: rawStatus, 128 + pdsUri, 129 + }); 130 + } 131 + }, 132 + 133 + handleDelete(event: JetstreamCommitEvent) { 134 + const { did, commit } = event; 135 + const { collection, rkey } = commit; 136 + 137 + if (collection === COLLECTION) { 138 + const task = getDb() 139 + .select({ id: kanbanTasks.id }) 140 + .from(kanbanTasks) 141 + .where(and(eq(kanbanTasks.id, rkey), eq(kanbanTasks.authorDid, did))) 142 + .get(); 143 + if (!task) return; 144 + deleteTaskCascade(rkey); 145 + return; 146 + } 147 + 148 + if (collection === COMMENT_COLLECTION) { 149 + const comment = getDb() 150 + .select({ id: kanbanTaskComments.id }) 151 + .from(kanbanTaskComments) 152 + .where(and(eq(kanbanTaskComments.id, rkey), eq(kanbanTaskComments.authorDid, did))) 153 + .get(); 154 + if (!comment) return; 155 + deleteComment(rkey); 156 + } 157 + }, 158 + };
+113
packages/kanban/src/mcp.ts
··· 1 + import type { McpTool, ApiFetch } from "@exosphere/mcp"; 2 + 3 + /** Resolve a task number to its internal ID via the API. */ 4 + async function resolveTaskId( 5 + number: number, 6 + apiFetch: ApiFetch, 7 + ): Promise<{ id: string } | { error: string }> { 8 + const res = await apiFetch(`/api/kanban/${number}`); 9 + if (!res.ok) return { error: "Task not found" }; 10 + const data = (await res.json()) as { task: { id: string } }; 11 + return { id: data.task.id }; 12 + } 13 + 14 + export const kanbanMcpTools: McpTool[] = [ 15 + { 16 + name: "list_kanban_tasks", 17 + description: 18 + "List all kanban board tasks grouped by the board's configured columns. Returns title, description, status, assignee, and comment count for each.", 19 + inputSchema: { 20 + type: "object", 21 + properties: {}, 22 + }, 23 + handler: async (_args, apiFetch) => { 24 + const res = await apiFetch("/api/kanban"); 25 + const data = await res.json(); 26 + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; 27 + }, 28 + }, 29 + 30 + { 31 + name: "get_kanban_task", 32 + description: 33 + "Get a single kanban task by its number, including description, assignee, status, and comment count", 34 + inputSchema: { 35 + type: "object", 36 + properties: { 37 + number: { type: "integer", description: "The task number" }, 38 + }, 39 + required: ["number"], 40 + }, 41 + handler: async (args, apiFetch) => { 42 + const res = await apiFetch(`/api/kanban/${args.number}`); 43 + if (!res.ok) { 44 + return { content: [{ type: "text", text: "Task not found" }], isError: true }; 45 + } 46 + const data = await res.json(); 47 + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; 48 + }, 49 + }, 50 + 51 + { 52 + name: "search_kanban_tasks", 53 + description: 54 + "Search kanban tasks by title or by number. Use '#42' or just '42' to search by number, or any text to search by title.", 55 + inputSchema: { 56 + type: "object", 57 + properties: { 58 + query: { type: "string", description: "Search query (title text or #number)" }, 59 + }, 60 + required: ["query"], 61 + }, 62 + handler: async (args, apiFetch) => { 63 + const q = encodeURIComponent(String(args.query)); 64 + const res = await apiFetch(`/api/kanban/search?q=${q}`); 65 + const data = await res.json(); 66 + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; 67 + }, 68 + }, 69 + 70 + { 71 + name: "get_kanban_task_comments", 72 + description: "Get comments on a kanban task, identified by its number", 73 + inputSchema: { 74 + type: "object", 75 + properties: { 76 + number: { type: "integer", description: "The task number" }, 77 + }, 78 + required: ["number"], 79 + }, 80 + handler: async (args, apiFetch) => { 81 + const resolved = await resolveTaskId(Number(args.number), apiFetch); 82 + if ("error" in resolved) { 83 + return { content: [{ type: "text", text: resolved.error }], isError: true }; 84 + } 85 + 86 + const res = await apiFetch(`/api/kanban/${resolved.id}/comments`); 87 + const data = await res.json(); 88 + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; 89 + }, 90 + }, 91 + 92 + { 93 + name: "get_kanban_task_statuses", 94 + description: "Get the status change history of a kanban task, identified by its number", 95 + inputSchema: { 96 + type: "object", 97 + properties: { 98 + number: { type: "integer", description: "The task number" }, 99 + }, 100 + required: ["number"], 101 + }, 102 + handler: async (args, apiFetch) => { 103 + const resolved = await resolveTaskId(Number(args.number), apiFetch); 104 + if ("error" in resolved) { 105 + return { content: [{ type: "text", text: resolved.error }], isError: true }; 106 + } 107 + 108 + const res = await apiFetch(`/api/kanban/${resolved.id}/statuses`); 109 + const data = await res.json(); 110 + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; 111 + }, 112 + }, 113 + ];
+17
packages/kanban/src/schemas/column.ts
··· 1 + import { z } from "zod"; 2 + 3 + export const createColumnSchema = z.object({ 4 + label: z.string().min(1).max(50), 5 + }); 6 + 7 + export const updateColumnSchema = z.object({ 8 + label: z.string().min(1).max(50), 9 + }); 10 + 11 + export const reorderColumnsSchema = z.object({ 12 + columnIds: z.array(z.string().min(1)), 13 + }); 14 + 15 + export const deleteColumnSchema = z.object({ 16 + reassignTo: z.string().min(1), 17 + });
+12
packages/kanban/src/schemas/comment.ts
··· 1 + import { z } from "zod"; 2 + 3 + export const createCommentSchema = z.object({ 4 + content: z.string().min(1).max(5000), 5 + }); 6 + 7 + export const updateCommentSchema = z.object({ 8 + content: z.string().min(1).max(5000), 9 + }); 10 + 11 + export type CreateComment = z.infer<typeof createCommentSchema>; 12 + export type UpdateComment = z.infer<typeof updateCommentSchema>;
+37
packages/kanban/src/schemas/task.ts
··· 1 + import { z } from "zod"; 2 + 3 + /** Default columns seeded when a sphere's kanban board is first accessed. */ 4 + export const defaultColumns = [ 5 + { slug: "backlog", label: "Backlog" }, 6 + { slug: "todo", label: "To do" }, 7 + { slug: "in-progress", label: "In progress" }, 8 + { slug: "done", label: "Done" }, 9 + ] as const; 10 + 11 + export const createTaskSchema = z.object({ 12 + title: z.string().min(1).max(200), 13 + description: z.string().max(10000).default(""), 14 + status: z.string().min(1).max(100).default("backlog"), 15 + assigneeDid: z.string().min(1).nullable().optional(), 16 + }); 17 + 18 + export const updateTaskSchema = z.object({ 19 + title: z.string().min(1).max(200).optional(), 20 + description: z.string().max(10000).optional(), 21 + }); 22 + 23 + export const updateStatusSchema = z.object({ 24 + status: z.string().min(1).max(100), 25 + position: z.number().int().optional(), 26 + }); 27 + 28 + export const assignTaskSchema = z.object({ 29 + assigneeDid: z.string().min(1).nullable(), 30 + }); 31 + 32 + export const reorderSchema = z.object({ 33 + position: z.number().int(), 34 + }); 35 + 36 + export type CreateTask = z.infer<typeof createTaskSchema>; 37 + export type UpdateTask = z.infer<typeof updateTaskSchema>;
+33
packages/kanban/src/types.ts
··· 1 + export type { 2 + KanbanColumn, 3 + KanbanTask, 4 + KanbanTaskComment, 5 + KanbanTaskStatusChange, 6 + } from "./db/schema.ts"; 7 + 8 + import type { KanbanTask, KanbanTaskComment } from "./db/schema.ts"; 9 + 10 + /** Column definition as returned by the API. */ 11 + export type KanbanColumnDef = { 12 + id: string; 13 + slug: string; 14 + label: string; 15 + position: number; 16 + }; 17 + 18 + /** Shape returned by the GET /kanban list endpoint. */ 19 + export type KanbanTaskListItem = KanbanTask & { 20 + createdAt: string; 21 + commentCount: number; 22 + authorHandle?: string | null; 23 + assigneeHandle?: string | null; 24 + }; 25 + 26 + /** Shape returned by the GET /kanban/:number detail endpoint. */ 27 + export type KanbanTaskDetail = KanbanTaskListItem; 28 + 29 + /** Shape returned by the GET comments endpoint. */ 30 + export type KanbanTaskCommentListItem = KanbanTaskComment & { 31 + createdAt: string; 32 + authorHandle: string | null; 33 + };
+40
packages/kanban/src/ui/api/columns.ts
··· 1 + import { moduleFetch } from "@exosphere/client/module-api"; 2 + import type { KanbanColumnDef } from "../../types.ts"; 3 + 4 + export type { KanbanColumnDef }; 5 + 6 + export function getColumns() { 7 + return moduleFetch<{ columns: KanbanColumnDef[] }>("/kanban/columns"); 8 + } 9 + 10 + export function createColumn(label: string) { 11 + return moduleFetch<{ column: KanbanColumnDef }>("/kanban/columns", { 12 + method: "POST", 13 + headers: { "Content-Type": "application/json" }, 14 + body: JSON.stringify({ label }), 15 + }); 16 + } 17 + 18 + export function renameColumn(id: string, label: string) { 19 + return moduleFetch<{ column: KanbanColumnDef }>(`/kanban/columns/${encodeURIComponent(id)}`, { 20 + method: "PUT", 21 + headers: { "Content-Type": "application/json" }, 22 + body: JSON.stringify({ label }), 23 + }); 24 + } 25 + 26 + export function deleteColumnApi(id: string, reassignTo: string) { 27 + return moduleFetch<{ ok: true }>(`/kanban/columns/${encodeURIComponent(id)}`, { 28 + method: "DELETE", 29 + headers: { "Content-Type": "application/json" }, 30 + body: JSON.stringify({ reassignTo }), 31 + }); 32 + } 33 + 34 + export function reorderColumnsApi(columnIds: string[]) { 35 + return moduleFetch<{ ok: true }>("/kanban/columns/reorder", { 36 + method: "POST", 37 + headers: { "Content-Type": "application/json" }, 38 + body: JSON.stringify({ columnIds }), 39 + }); 40 + }
+38
packages/kanban/src/ui/api/comments.ts
··· 1 + import { moduleFetch } from "@exosphere/client/module-api"; 2 + import type { KanbanTaskComment, KanbanTaskCommentListItem } from "../../types.ts"; 3 + 4 + export type { KanbanTaskComment, KanbanTaskCommentListItem }; 5 + 6 + export function getComments(taskId: string) { 7 + return moduleFetch<{ comments: KanbanTaskCommentListItem[] }>( 8 + `/kanban/${encodeURIComponent(taskId)}/comments`, 9 + ); 10 + } 11 + 12 + export function createComment(taskId: string, content: string) { 13 + return moduleFetch<{ comment: KanbanTaskComment & { createdAt: string } }>( 14 + `/kanban/${encodeURIComponent(taskId)}/comments`, 15 + { 16 + method: "POST", 17 + headers: { "Content-Type": "application/json" }, 18 + body: JSON.stringify({ content }), 19 + }, 20 + ); 21 + } 22 + 23 + export function updateCommentApi(id: string, content: string) { 24 + return moduleFetch<{ comment: KanbanTaskComment & { createdAt: string } }>( 25 + `/kanban/comments/${encodeURIComponent(id)}`, 26 + { 27 + method: "PUT", 28 + headers: { "Content-Type": "application/json" }, 29 + body: JSON.stringify({ content }), 30 + }, 31 + ); 32 + } 33 + 34 + export function deleteComment(id: string) { 35 + return moduleFetch<{ ok: true }>(`/kanban/comments/${encodeURIComponent(id)}`, { 36 + method: "DELETE", 37 + }); 38 + }
+91
packages/kanban/src/ui/api/tasks.ts
··· 1 + import { moduleFetch } from "@exosphere/client/module-api"; 2 + import type { KanbanColumnDef, KanbanTaskListItem, KanbanTaskDetail } from "../../types.ts"; 3 + 4 + export type { KanbanColumnDef, KanbanTaskListItem, KanbanTaskDetail }; 5 + 6 + export function getTasks() { 7 + return moduleFetch<{ 8 + columns: KanbanColumnDef[]; 9 + tasksByColumn: Record<string, KanbanTaskListItem[]>; 10 + tasks: KanbanTaskListItem[]; 11 + }>("/kanban"); 12 + } 13 + 14 + export function getTask(number: number) { 15 + return moduleFetch<{ task: KanbanTaskDetail }>(`/kanban/${number}`); 16 + } 17 + 18 + export function createTask(body: { 19 + title: string; 20 + description?: string; 21 + status?: string; 22 + assigneeDid?: string | null; 23 + }) { 24 + return moduleFetch<{ task: KanbanTaskListItem }>("/kanban", { 25 + method: "POST", 26 + headers: { "Content-Type": "application/json" }, 27 + body: JSON.stringify(body), 28 + }); 29 + } 30 + 31 + export function updateTask(id: string, body: { title?: string; description?: string }) { 32 + return moduleFetch<{ task: KanbanTaskListItem }>(`/kanban/${encodeURIComponent(id)}`, { 33 + method: "PUT", 34 + headers: { "Content-Type": "application/json" }, 35 + body: JSON.stringify(body), 36 + }); 37 + } 38 + 39 + export function deleteTask(id: string) { 40 + return moduleFetch<{ ok: true }>(`/kanban/${encodeURIComponent(id)}`, { 41 + method: "DELETE", 42 + }); 43 + } 44 + 45 + export function updateTaskStatus(id: string, status: string, position?: number) { 46 + return moduleFetch<{ status: string }>(`/kanban/${encodeURIComponent(id)}/status`, { 47 + method: "POST", 48 + headers: { "Content-Type": "application/json" }, 49 + body: JSON.stringify({ status, position }), 50 + }); 51 + } 52 + 53 + export function assignTaskApi(id: string, assigneeDid: string | null) { 54 + return moduleFetch<{ ok: true }>(`/kanban/${encodeURIComponent(id)}/assign`, { 55 + method: "POST", 56 + headers: { "Content-Type": "application/json" }, 57 + body: JSON.stringify({ assigneeDid }), 58 + }); 59 + } 60 + 61 + export function hideTask(id: string) { 62 + return moduleFetch<{ ok: true }>(`/kanban/${encodeURIComponent(id)}/hide`, { 63 + method: "POST", 64 + }); 65 + } 66 + 67 + export function reorderTask(id: string, position: number) { 68 + return moduleFetch<{ ok: true }>(`/kanban/${encodeURIComponent(id)}/reorder`, { 69 + method: "POST", 70 + headers: { "Content-Type": "application/json" }, 71 + body: JSON.stringify({ position }), 72 + }); 73 + } 74 + 75 + export function unhideTask(id: string) { 76 + return moduleFetch<{ ok: true }>(`/kanban/${encodeURIComponent(id)}/unhide`, { 77 + method: "POST", 78 + }); 79 + } 80 + 81 + export function getStatusHistory(taskId: string) { 82 + return moduleFetch<{ 83 + statuses: Array<{ 84 + id: string; 85 + authorDid: string; 86 + authorHandle: string | null; 87 + status: string; 88 + createdAt: string; 89 + }>; 90 + }>(`/kanban/${encodeURIComponent(taskId)}/statuses`); 91 + }
+216
packages/kanban/src/ui/components/column-manager.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import * as ui from "@exosphere/client/ui.css"; 3 + import * as kbUi from "../ui.css.ts"; 4 + import type { KanbanColumnDef } from "../../types.ts"; 5 + import { createColumn, renameColumn, deleteColumnApi, reorderColumnsApi } from "../api/columns.ts"; 6 + 7 + export function ColumnManager({ 8 + columns, 9 + onChanged, 10 + }: { 11 + columns: KanbanColumnDef[]; 12 + onChanged: () => void; 13 + }) { 14 + const newLabel = useSignal(""); 15 + const adding = useSignal(false); 16 + const editingId = useSignal<string | null>(null); 17 + const editLabel = useSignal(""); 18 + const deletingId = useSignal<string | null>(null); 19 + const reassignSlug = useSignal(""); 20 + const busy = useSignal(false); 21 + 22 + const handleAdd = async () => { 23 + if (!newLabel.value.trim() || adding.value) return; 24 + adding.value = true; 25 + try { 26 + await createColumn(newLabel.value.trim()); 27 + newLabel.value = ""; 28 + onChanged(); 29 + } catch (err) { 30 + console.error("Failed to add column:", err); 31 + } finally { 32 + adding.value = false; 33 + } 34 + }; 35 + 36 + const handleRename = async (id: string) => { 37 + if (!editLabel.value.trim() || busy.value) return; 38 + busy.value = true; 39 + try { 40 + await renameColumn(id, editLabel.value.trim()); 41 + editingId.value = null; 42 + onChanged(); 43 + } catch (err) { 44 + console.error("Failed to rename column:", err); 45 + } finally { 46 + busy.value = false; 47 + } 48 + }; 49 + 50 + const handleDelete = async (id: string) => { 51 + if (!reassignSlug.value || busy.value) return; 52 + busy.value = true; 53 + try { 54 + await deleteColumnApi(id, reassignSlug.value); 55 + deletingId.value = null; 56 + onChanged(); 57 + } catch (err) { 58 + console.error("Failed to delete column:", err); 59 + } finally { 60 + busy.value = false; 61 + } 62 + }; 63 + 64 + const handleMove = async (index: number, direction: -1 | 1) => { 65 + const newIndex = index + direction; 66 + if (newIndex < 0 || newIndex >= columns.length) return; 67 + const reordered = [...columns]; 68 + const [item] = reordered.splice(index, 1); 69 + reordered.splice(newIndex, 0, item); 70 + try { 71 + await reorderColumnsApi(reordered.map((c) => c.id)); 72 + onChanged(); 73 + } catch (err) { 74 + console.error("Failed to reorder columns:", err); 75 + } 76 + }; 77 + 78 + const startEdit = (col: KanbanColumnDef) => { 79 + editingId.value = col.id; 80 + editLabel.value = col.label; 81 + deletingId.value = null; 82 + }; 83 + 84 + const startDelete = (col: KanbanColumnDef) => { 85 + deletingId.value = col.id; 86 + editingId.value = null; 87 + const other = columns.find((c) => c.id !== col.id); 88 + reassignSlug.value = other?.slug ?? ""; 89 + }; 90 + 91 + return ( 92 + <div class={kbUi.columnManager}> 93 + <div class={kbUi.columnManagerList}> 94 + {columns.map((col, i) => ( 95 + <div key={col.id}> 96 + <div class={kbUi.columnManagerItem}> 97 + <div class={kbUi.columnManagerArrows}> 98 + <button 99 + class={kbUi.arrowBtn} 100 + disabled={i === 0} 101 + onClick={() => handleMove(i, -1)} 102 + title="Move left" 103 + > 104 + &#8592; 105 + </button> 106 + <button 107 + class={kbUi.arrowBtn} 108 + disabled={i === columns.length - 1} 109 + onClick={() => handleMove(i, 1)} 110 + title="Move right" 111 + > 112 + &#8594; 113 + </button> 114 + </div> 115 + 116 + {editingId.value === col.id ? ( 117 + <input 118 + class={ui.input} 119 + type="text" 120 + maxLength={50} 121 + value={editLabel.value} 122 + onInput={(e) => (editLabel.value = (e.target as HTMLInputElement).value)} 123 + onKeyDown={(e) => { 124 + if (e.key === "Enter") handleRename(col.id); 125 + if (e.key === "Escape") editingId.value = null; 126 + }} 127 + autoFocus 128 + /> 129 + ) : ( 130 + <span class={kbUi.columnManagerLabel}> 131 + {col.label} <span class={kbUi.columnManagerSlug}>({col.slug})</span> 132 + </span> 133 + )} 134 + 135 + {editingId.value === col.id ? ( 136 + <> 137 + <button 138 + class={ui.buttonCompact} 139 + disabled={busy.value || !editLabel.value.trim()} 140 + onClick={() => handleRename(col.id)} 141 + > 142 + Save 143 + </button> 144 + <button class={ui.buttonInline} onClick={() => (editingId.value = null)}> 145 + Cancel 146 + </button> 147 + </> 148 + ) : ( 149 + <> 150 + <button class={ui.buttonInline} onClick={() => startEdit(col)}> 151 + Rename 152 + </button> 153 + {columns.length > 1 && ( 154 + <button class={ui.buttonDangerInline} onClick={() => startDelete(col)}> 155 + Delete 156 + </button> 157 + )} 158 + </> 159 + )} 160 + </div> 161 + 162 + {deletingId.value === col.id && ( 163 + <div class={kbUi.columnManagerItem}> 164 + <span class={ui.muted}>Move tasks to:</span> 165 + <select 166 + class={kbUi.statusSelect} 167 + value={reassignSlug.value} 168 + onChange={(e) => (reassignSlug.value = (e.target as HTMLSelectElement).value)} 169 + > 170 + {columns 171 + .filter((c) => c.id !== col.id) 172 + .map((c) => ( 173 + <option key={c.slug} value={c.slug}> 174 + {c.label} 175 + </option> 176 + ))} 177 + </select> 178 + <button 179 + class={ui.buttonDanger} 180 + disabled={busy.value || !reassignSlug.value} 181 + onClick={() => handleDelete(col.id)} 182 + > 183 + Confirm delete 184 + </button> 185 + <button class={ui.buttonInline} onClick={() => (deletingId.value = null)}> 186 + Cancel 187 + </button> 188 + </div> 189 + )} 190 + </div> 191 + ))} 192 + </div> 193 + 194 + <div class={kbUi.columnManagerAdd}> 195 + <input 196 + class={ui.input} 197 + type="text" 198 + maxLength={50} 199 + placeholder="New column name" 200 + value={newLabel.value} 201 + onInput={(e) => (newLabel.value = (e.target as HTMLInputElement).value)} 202 + onKeyDown={(e) => { 203 + if (e.key === "Enter") handleAdd(); 204 + }} 205 + /> 206 + <button 207 + class={ui.buttonCompact} 208 + disabled={adding.value || !newLabel.value.trim()} 209 + onClick={handleAdd} 210 + > 211 + {adding.value ? "Adding..." : "Add column"} 212 + </button> 213 + </div> 214 + </div> 215 + ); 216 + }
+66
packages/kanban/src/ui/components/column.tsx
··· 1 + import type { Signal } from "@preact/signals"; 2 + import { Fragment } from "preact"; 3 + import * as kbUi from "../ui.css.ts"; 4 + import type { KanbanTaskListItem } from "../../types.ts"; 5 + import type { DropTarget } from "../hooks/use-board-dnd.ts"; 6 + import { TaskCard } from "./task-card.tsx"; 7 + 8 + export function Column({ 9 + title, 10 + status, 11 + tasks, 12 + canDrag, 13 + draggedTaskId, 14 + dropTarget, 15 + onDragOver, 16 + onDragLeave, 17 + onDrop, 18 + onCardDragStart, 19 + onCardDragEnd, 20 + }: { 21 + title: string; 22 + status: string; 23 + tasks: KanbanTaskListItem[]; 24 + canDrag?: boolean; 25 + draggedTaskId?: string | null; 26 + dropTarget?: Signal<DropTarget | null>; 27 + onDragOver?: (e: DragEvent) => void; 28 + onDragLeave?: (e: DragEvent) => void; 29 + onDrop?: (e: DragEvent) => void; 30 + onCardDragStart?: (task: KanbanTaskListItem, status: string) => (e: DragEvent) => void; 31 + onCardDragEnd?: () => void; 32 + }) { 33 + const dt = dropTarget?.value; 34 + const isOver = draggedTaskId && dt?.status === status; 35 + const indicatorIndex = isOver ? dt.insertIndex : -1; 36 + 37 + const columnClasses = [kbUi.column]; 38 + if (isOver) columnClasses.push(kbUi.columnDragOver); 39 + 40 + return ( 41 + <div 42 + class={columnClasses.join(" ")} 43 + onDragOver={onDragOver} 44 + onDragLeave={onDragLeave} 45 + onDrop={onDrop} 46 + > 47 + <div class={kbUi.columnHeader}> 48 + <span class={kbUi.columnTitle}>{title}</span> 49 + <span class={kbUi.columnCount}>{tasks.length}</span> 50 + </div> 51 + {tasks.map((task, index) => ( 52 + <Fragment key={task.id}> 53 + {indicatorIndex === index && <div class={kbUi.dropIndicator} />} 54 + <TaskCard 55 + task={task} 56 + canDrag={canDrag && task.id !== draggedTaskId} 57 + isDragging={task.id === draggedTaskId} 58 + onDragStart={onCardDragStart?.(task, status)} 59 + onDragEnd={onCardDragEnd} 60 + /> 61 + </Fragment> 62 + ))} 63 + {indicatorIndex === tasks.length && <div class={kbUi.dropIndicator} />} 64 + </div> 65 + ); 66 + }
+47
packages/kanban/src/ui/components/task-card.tsx
··· 1 + import { spherePath } from "@exosphere/client/router"; 2 + import * as kbUi from "../ui.css.ts"; 3 + import type { KanbanTaskListItem } from "../../types.ts"; 4 + 5 + export function TaskCard({ 6 + task, 7 + canDrag, 8 + isDragging, 9 + onDragStart, 10 + onDragEnd, 11 + }: { 12 + task: KanbanTaskListItem; 13 + canDrag?: boolean; 14 + isDragging?: boolean; 15 + onDragStart?: (e: DragEvent) => void; 16 + onDragEnd?: () => void; 17 + }) { 18 + const classes = [kbUi.taskCard]; 19 + if (canDrag) classes.push(kbUi.taskCardDraggable); 20 + if (isDragging) classes.push(kbUi.taskCardDragging); 21 + 22 + return ( 23 + <a 24 + href={spherePath(`/board/${task.number}`)} 25 + class={classes.join(" ")} 26 + data-task-id={task.id} 27 + draggable={canDrag} 28 + onDragStart={onDragStart} 29 + onDragEnd={onDragEnd} 30 + > 31 + <span class={kbUi.taskNumber}>#{task.number}</span> 32 + <div class={kbUi.taskTitle}>{task.title}</div> 33 + <div class={kbUi.taskMeta}> 34 + {task.assigneeHandle ? ( 35 + <span>@{task.assigneeHandle}</span> 36 + ) : task.assigneeDid ? ( 37 + <span>{task.assigneeDid.slice(0, 16)}...</span> 38 + ) : null} 39 + {task.commentCount > 0 && ( 40 + <span> 41 + {task.commentCount} comment{task.commentCount !== 1 ? "s" : ""} 42 + </span> 43 + )} 44 + </div> 45 + </a> 46 + ); 47 + }
+111
packages/kanban/src/ui/components/task-form.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import * as ui from "@exosphere/client/ui.css"; 3 + import type { KanbanColumnDef } from "../../types.ts"; 4 + 5 + export function TaskForm({ 6 + columns, 7 + onCreated, 8 + }: { 9 + columns: KanbanColumnDef[]; 10 + onCreated: () => void; 11 + }) { 12 + const title = useSignal(""); 13 + const description = useSignal(""); 14 + const status = useSignal(columns[0]?.slug ?? "backlog"); 15 + const error = useSignal(""); 16 + const submitting = useSignal(false); 17 + 18 + const handleKeyDown = (e: KeyboardEvent) => { 19 + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { 20 + e.preventDefault(); 21 + submit(); 22 + } 23 + }; 24 + 25 + const submit = async (e?: Event) => { 26 + e?.preventDefault(); 27 + if (!title.value.trim()) { 28 + error.value = "Title is required."; 29 + return; 30 + } 31 + 32 + submitting.value = true; 33 + error.value = ""; 34 + 35 + try { 36 + const { createTask } = await import("../api/tasks.ts"); 37 + await createTask({ 38 + title: title.value.trim(), 39 + description: description.value.trim(), 40 + status: status.value, 41 + }); 42 + title.value = ""; 43 + description.value = ""; 44 + status.value = columns[0]?.slug ?? "backlog"; 45 + onCreated(); 46 + } catch (err) { 47 + error.value = err instanceof Error ? err.message : "Something went wrong."; 48 + } finally { 49 + submitting.value = false; 50 + } 51 + }; 52 + 53 + return ( 54 + <form onSubmit={submit} onKeyDown={handleKeyDown} class={ui.formStack}> 55 + <div> 56 + <label class={ui.label} htmlFor="kb-title"> 57 + Title 58 + </label> 59 + <input 60 + id="kb-title" 61 + class={ui.input} 62 + type="text" 63 + maxLength={200} 64 + placeholder="Brief summary of the task" 65 + value={title.value} 66 + onInput={(e) => (title.value = (e.target as HTMLInputElement).value)} 67 + /> 68 + </div> 69 + 70 + <div> 71 + <label class={ui.label} htmlFor="kb-status"> 72 + Column 73 + </label> 74 + <select 75 + id="kb-status" 76 + class={ui.select} 77 + value={status.value} 78 + onChange={(e) => (status.value = (e.target as HTMLSelectElement).value)} 79 + > 80 + {columns.map((col) => ( 81 + <option key={col.slug} value={col.slug}> 82 + {col.label} 83 + </option> 84 + ))} 85 + </select> 86 + </div> 87 + 88 + <div> 89 + <label class={ui.label} htmlFor="kb-description"> 90 + Description <span class={ui.labelHint}>(optional)</span> 91 + </label> 92 + <textarea 93 + id="kb-description" 94 + class={ui.textarea} 95 + maxLength={10000} 96 + placeholder="Describe the task in detail" 97 + value={description.value} 98 + onInput={(e) => (description.value = (e.target as HTMLTextAreaElement).value)} 99 + /> 100 + </div> 101 + 102 + {error.value && <p class={ui.errorText}>{error.value}</p>} 103 + 104 + <div> 105 + <button type="submit" class={ui.button} disabled={submitting.value}> 106 + {submitting.value ? "Creating..." : "Create task"} 107 + </button> 108 + </div> 109 + </form> 110 + ); 111 + }
+222
packages/kanban/src/ui/hooks/use-board-dnd.ts
··· 1 + import type { Signal } from "@preact/signals"; 2 + import { useSignal } from "@preact/signals"; 3 + import type { KanbanTaskListItem } from "../../types.ts"; 4 + import { updateTaskStatus, reorderTask } from "../api/tasks.ts"; 5 + 6 + const POSITION_GAP = 1000; 7 + 8 + interface DragState { 9 + taskId: string; 10 + sourceStatus: string; 11 + sourceIndex: number; 12 + } 13 + 14 + export interface DropTarget { 15 + status: string; 16 + insertIndex: number; 17 + } 18 + 19 + export function useBoardDnd( 20 + columns: Signal<Record<string, KanbanTaskListItem[]>>, 21 + canDrag: boolean, 22 + ) { 23 + const dragState = useSignal<DragState | null>(null); 24 + const dropTarget = useSignal<DropTarget | null>(null); 25 + 26 + function onDragStart(task: KanbanTaskListItem, status: string) { 27 + return (e: DragEvent) => { 28 + if (!canDrag) { 29 + e.preventDefault(); 30 + return; 31 + } 32 + const tasks = columns.value[status] ?? []; 33 + const index = tasks.findIndex((t) => t.id === task.id); 34 + dragState.value = { taskId: task.id, sourceStatus: status, sourceIndex: index }; 35 + e.dataTransfer!.effectAllowed = "move"; 36 + e.dataTransfer!.setData("text/plain", task.id); 37 + }; 38 + } 39 + 40 + function onDragEnd() { 41 + dragState.value = null; 42 + dropTarget.value = null; 43 + } 44 + 45 + function onDragOver(status: string) { 46 + return (e: DragEvent) => { 47 + if (!dragState.value) return; 48 + e.preventDefault(); 49 + e.dataTransfer!.dropEffect = "move"; 50 + 51 + const columnEl = e.currentTarget as HTMLElement; 52 + const cards = columnEl.querySelectorAll(":scope > [data-task-id]"); 53 + let insertIndex = cards.length; 54 + 55 + for (let i = 0; i < cards.length; i++) { 56 + const rect = cards[i].getBoundingClientRect(); 57 + const midY = rect.top + rect.height / 2; 58 + if (e.clientY < midY) { 59 + insertIndex = i; 60 + break; 61 + } 62 + } 63 + 64 + // Skip if hovering over the dragged card in its original position 65 + const ds = dragState.value; 66 + if ( 67 + status === ds.sourceStatus && 68 + (insertIndex === ds.sourceIndex || insertIndex === ds.sourceIndex + 1) 69 + ) { 70 + // Normalize: treat "right after self" as "at self" 71 + const current = dropTarget.value; 72 + if (current?.status !== status || current.insertIndex !== ds.sourceIndex) { 73 + dropTarget.value = { status, insertIndex: ds.sourceIndex }; 74 + } 75 + return; 76 + } 77 + 78 + const current = dropTarget.value; 79 + if (current?.status !== status || current.insertIndex !== insertIndex) { 80 + dropTarget.value = { status, insertIndex }; 81 + } 82 + }; 83 + } 84 + 85 + function onDragLeave(status: string) { 86 + return (e: DragEvent) => { 87 + const columnEl = e.currentTarget as HTMLElement; 88 + const related = e.relatedTarget as Node | null; 89 + if (related && columnEl.contains(related)) return; 90 + if (dropTarget.value?.status === status) { 91 + dropTarget.value = null; 92 + } 93 + }; 94 + } 95 + 96 + function onDrop(status: string) { 97 + return (e: DragEvent) => { 98 + e.preventDefault(); 99 + const ds = dragState.value; 100 + const dt = dropTarget.value; 101 + if (!ds || !dt) { 102 + onDragEnd(); 103 + return; 104 + } 105 + 106 + const isSameColumn = ds.sourceStatus === dt.status; 107 + 108 + // No-op: dropped back in the same spot 109 + if ( 110 + isSameColumn && 111 + (dt.insertIndex === ds.sourceIndex || dt.insertIndex === ds.sourceIndex + 1) 112 + ) { 113 + onDragEnd(); 114 + return; 115 + } 116 + 117 + // Snapshot for rollback 118 + const snapshot = structuredClone(columns.value); 119 + 120 + // Build the target column tasks without the dragged card 121 + const sourceList = [...(columns.value[ds.sourceStatus] ?? [])]; 122 + const taskIndex = sourceList.findIndex((t) => t.id === ds.taskId); 123 + if (taskIndex === -1) { 124 + onDragEnd(); 125 + return; 126 + } 127 + const [task] = sourceList.splice(taskIndex, 1); 128 + 129 + let targetList: KanbanTaskListItem[]; 130 + let adjustedIndex = dt.insertIndex; 131 + 132 + if (isSameColumn) { 133 + targetList = sourceList; 134 + // Adjust index since we removed the card from the same list 135 + if (ds.sourceIndex < dt.insertIndex) { 136 + adjustedIndex = dt.insertIndex - 1; 137 + } 138 + } else { 139 + targetList = [...(columns.value[dt.status] ?? [])]; 140 + } 141 + 142 + // Compute new position; null means gap exhausted → rebalance 143 + let newPosition = computePosition(targetList, adjustedIndex); 144 + 145 + // Insert at the right spot 146 + targetList.splice(adjustedIndex, 0, { 147 + ...task, 148 + status: dt.status as KanbanTaskListItem["status"], 149 + position: newPosition ?? 0, 150 + }); 151 + 152 + if (newPosition === null) { 153 + rebalancePositions(targetList); 154 + newPosition = targetList[adjustedIndex].position; 155 + } else { 156 + targetList[adjustedIndex] = { ...targetList[adjustedIndex], position: newPosition }; 157 + } 158 + 159 + // Update columns optimistically 160 + const newColumns = { ...columns.value }; 161 + if (isSameColumn) { 162 + newColumns[ds.sourceStatus] = targetList; 163 + } else { 164 + newColumns[ds.sourceStatus] = sourceList; 165 + newColumns[dt.status] = targetList; 166 + } 167 + columns.value = newColumns; 168 + 169 + onDragEnd(); 170 + 171 + // Fire API call 172 + const apiCall = isSameColumn 173 + ? reorderTask(ds.taskId, newPosition) 174 + : updateTaskStatus(ds.taskId, dt.status, newPosition); 175 + 176 + apiCall.catch((err) => { 177 + console.error("[kanban] DnD move failed:", err); 178 + columns.value = snapshot; 179 + }); 180 + }; 181 + } 182 + 183 + return { 184 + dragState, 185 + dropTarget, 186 + onDragStart, 187 + onDragEnd, 188 + onColumnDragOver: onDragOver, 189 + onColumnDragLeave: onDragLeave, 190 + onColumnDrop: onDrop, 191 + }; 192 + } 193 + 194 + /** Compute the position for a card inserted at `insertIndex`. 195 + * Returns `null` when the gap is exhausted and the column needs a rebalance. */ 196 + function computePosition(tasks: KanbanTaskListItem[], insertIndex: number): number | null { 197 + if (tasks.length === 0) return POSITION_GAP; 198 + 199 + const prev = insertIndex > 0 ? tasks[insertIndex - 1] : null; 200 + const next = insertIndex < tasks.length ? tasks[insertIndex] : null; 201 + 202 + if (!prev && next) { 203 + const half = Math.floor(next.position / 2); 204 + return half > 0 ? half : null; 205 + } 206 + if (prev && !next) { 207 + return prev.position + POSITION_GAP; 208 + } 209 + if (prev && next) { 210 + const mid = Math.floor((prev.position + next.position) / 2); 211 + return mid > prev.position ? mid : null; 212 + } 213 + 214 + return POSITION_GAP; 215 + } 216 + 217 + /** Reassign positions with fresh gaps after a gap exhaustion. */ 218 + function rebalancePositions(tasks: KanbanTaskListItem[]): void { 219 + for (let i = 0; i < tasks.length; i++) { 220 + tasks[i] = { ...tasks[i], position: (i + 1) * POSITION_GAP }; 221 + } 222 + }
+117
packages/kanban/src/ui/pages/board.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import { useEffect, useRef } from "preact/hooks"; 3 + import { auth } from "@exosphere/client/auth"; 4 + import { useCanDo } from "@exosphere/client/permissions"; 5 + import { useQuery } from "@exosphere/client/hooks"; 6 + import { ssrPageData } from "@exosphere/client/ssr-data"; 7 + import { spherePath } from "@exosphere/client/router"; 8 + import { Settings } from "lucide-preact"; 9 + import * as ui from "@exosphere/client/ui.css"; 10 + import * as kbUi from "../ui.css.ts"; 11 + import { getTasks } from "../api/tasks.ts"; 12 + import type { KanbanColumnDef, KanbanTaskListItem } from "../api/tasks.ts"; 13 + import { Column } from "../components/column.tsx"; 14 + import { TaskForm } from "../components/task-form.tsx"; 15 + import { useBoardDnd } from "../hooks/use-board-dnd.ts"; 16 + 17 + export function BoardPage() { 18 + const showForm = useSignal(false); 19 + const prefetched = ssrPageData.peek()?.["kanban-tasks"] as 20 + | Awaited<ReturnType<typeof getTasks>> 21 + | undefined; 22 + useEffect(() => { 23 + const pd = ssrPageData.peek(); 24 + if (pd && "kanban-tasks" in pd) delete pd["kanban-tasks"]; 25 + }, []); 26 + 27 + const { data, pending, loading, error, refetch } = useQuery( 28 + () => getTasks(), 29 + [], 30 + prefetched 31 + ? { initialData: prefetched, cacheKey: "kanban-tasks" } 32 + : { cacheKey: "kanban-tasks" }, 33 + ); 34 + 35 + // Column definitions (reactive, updates on refetch) 36 + const columnDefs = useSignal<KanbanColumnDef[]>(data?.columns ?? []); 37 + 38 + // Local mutable columns signal for optimistic DnD updates 39 + const tasksByColumn = useSignal<Record<string, KanbanTaskListItem[]>>(data?.tasksByColumn ?? {}); 40 + const prevData = useRef<typeof data>(null); 41 + if (data && data !== prevData.current) { 42 + prevData.current = data; 43 + columnDefs.value = data.columns; 44 + tasksByColumn.value = data.tasksByColumn; 45 + } 46 + 47 + const isAuthenticated = auth.value.authenticated; 48 + const canCreate = useCanDo("kanban", "create"); 49 + const canChangeStatus = useCanDo("kanban", "changeStatus"); 50 + const canManageSettings = useCanDo("kanban", "manageSettings"); 51 + 52 + const dnd = useBoardDnd(tasksByColumn, isAuthenticated && canChangeStatus.value); 53 + 54 + const onCreated = () => { 55 + showForm.value = false; 56 + refetch(); 57 + }; 58 + 59 + return ( 60 + <> 61 + <div class={ui.container}> 62 + <div class={ui.section}> 63 + <div class={kbUi.titleRow}> 64 + <h1 class={ui.pageTitle}>Board</h1> 65 + <div class={kbUi.titleRowActions}> 66 + {isAuthenticated && canManageSettings.value && ( 67 + <a href={spherePath("/board/settings")} class={kbUi.iconBtn} title="Board settings"> 68 + <Settings size={18} /> 69 + </a> 70 + )} 71 + {isAuthenticated && canCreate.value && !showForm.value && ( 72 + <button class={ui.button} onClick={() => (showForm.value = true)}> 73 + New task 74 + </button> 75 + )} 76 + </div> 77 + </div> 78 + 79 + {showForm.value && ( 80 + <div class={ui.card}> 81 + <TaskForm columns={columnDefs.value} onCreated={onCreated} /> 82 + </div> 83 + )} 84 + </div> 85 + </div> 86 + 87 + <div class={kbUi.boardSection}> 88 + {pending && !data ? ( 89 + loading ? ( 90 + <p class={ui.muted}>Loading...</p> 91 + ) : null 92 + ) : error ? ( 93 + <p class={ui.errorText}>{error}</p> 94 + ) : ( 95 + <div class={kbUi.boardGrid}> 96 + {columnDefs.value.map((col) => ( 97 + <Column 98 + key={col.slug} 99 + status={col.slug} 100 + title={col.label} 101 + tasks={tasksByColumn.value[col.slug] ?? []} 102 + canDrag={isAuthenticated && canChangeStatus.value} 103 + draggedTaskId={dnd.dragState.value?.taskId} 104 + dropTarget={dnd.dropTarget} 105 + onDragOver={dnd.onColumnDragOver(col.slug)} 106 + onDragLeave={dnd.onColumnDragLeave(col.slug)} 107 + onDrop={dnd.onColumnDrop(col.slug)} 108 + onCardDragStart={dnd.onDragStart} 109 + onCardDragEnd={dnd.onDragEnd} 110 + /> 111 + ))} 112 + </div> 113 + )} 114 + </div> 115 + </> 116 + ); 117 + }
+67
packages/kanban/src/ui/pages/settings.tsx
··· 1 + import { useEffect } from "preact/hooks"; 2 + import { auth } from "@exosphere/client/auth"; 3 + import { useCanDo } from "@exosphere/client/permissions"; 4 + import { spherePath } from "@exosphere/client/router"; 5 + import { useQuery } from "@exosphere/client/hooks"; 6 + import { ssrPageData } from "@exosphere/client/ssr-data"; 7 + import * as ui from "@exosphere/client/ui.css"; 8 + import { getColumns } from "../api/columns.ts"; 9 + import { ColumnManager } from "../components/column-manager.tsx"; 10 + 11 + export function BoardSettingsPage() { 12 + const isAuthenticated = auth.value.authenticated; 13 + const canManageSettings = useCanDo("kanban", "manageSettings"); 14 + 15 + const prefetched = ssrPageData.peek()?.["kanban-columns"] as 16 + | Awaited<ReturnType<typeof getColumns>> 17 + | undefined; 18 + useEffect(() => { 19 + const pd = ssrPageData.peek(); 20 + if (pd && "kanban-columns" in pd) delete pd["kanban-columns"]; 21 + }, []); 22 + 23 + const { data, pending, loading, refetch } = useQuery( 24 + () => getColumns(), 25 + [], 26 + prefetched ? { initialData: prefetched } : undefined, 27 + ); 28 + 29 + if (!isAuthenticated || !canManageSettings.value) { 30 + return ( 31 + <div class={ui.container}> 32 + <div class={ui.section}> 33 + <div> 34 + <a href={spherePath("/board")} class={ui.muted}> 35 + &larr; Board 36 + </a> 37 + </div> 38 + <p class={ui.muted}>You don't have permission to manage board settings.</p> 39 + </div> 40 + </div> 41 + ); 42 + } 43 + 44 + return ( 45 + <div class={ui.container}> 46 + <div class={ui.section}> 47 + <div> 48 + <a href={spherePath("/board")} class={ui.muted}> 49 + &larr; Board 50 + </a> 51 + </div> 52 + <h1 class={ui.pageTitle}>Board settings</h1> 53 + </div> 54 + 55 + <div class={ui.section}> 56 + <h2 class={ui.sectionTitle}>Columns</h2> 57 + {pending ? ( 58 + loading ? ( 59 + <p class={ui.muted}>Loading...</p> 60 + ) : null 61 + ) : data ? ( 62 + <ColumnManager columns={data.columns} onChanged={refetch} /> 63 + ) : null} 64 + </div> 65 + </div> 66 + ); 67 + }
+573
packages/kanban/src/ui/pages/task.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import { auth } from "@exosphere/client/auth"; 3 + import { useLocation, useRoute, spherePath } from "@exosphere/client/router"; 4 + import { useCanDo } from "@exosphere/client/permissions"; 5 + import { useQuery } from "@exosphere/client/hooks"; 6 + import { ssrPageData } from "@exosphere/client/ssr-data"; 7 + import { formatDate } from "@exosphere/client/format"; 8 + import { CollapsibleSection } from "@exosphere/client/components/collapsible-section"; 9 + import * as ui from "@exosphere/client/ui.css"; 10 + import * as kbUi from "../ui.css.ts"; 11 + import { getTask, updateTask, deleteTask, updateTaskStatus, hideTask } from "../api/tasks.ts"; 12 + import { getComments, createComment, updateCommentApi, deleteComment } from "../api/comments.ts"; 13 + import { getStatusHistory } from "../api/tasks.ts"; 14 + import { getColumns, type KanbanColumnDef } from "../api/columns.ts"; 15 + import type { KanbanTaskCommentListItem } from "../../types.ts"; 16 + import { useEffect, useRef } from "preact/hooks"; 17 + 18 + const fullDateOpts: Intl.DateTimeFormatOptions = { 19 + year: "numeric", 20 + month: "short", 21 + day: "numeric", 22 + }; 23 + 24 + function statusLabel(value: string, cols: KanbanColumnDef[]): string { 25 + return cols.find((c) => c.slug === value)?.label ?? value; 26 + } 27 + 28 + // ---- Comment components ---- 29 + 30 + function CommentForm({ 31 + taskId, 32 + currentHandle, 33 + onCreated, 34 + }: { 35 + taskId: string; 36 + currentHandle: string | null; 37 + onCreated: (comment: KanbanTaskCommentListItem) => void; 38 + }) { 39 + const content = useSignal(""); 40 + const submitting = useSignal(false); 41 + const error = useSignal(""); 42 + 43 + const handleSubmit = async (e: Event) => { 44 + e.preventDefault(); 45 + if (submitting.value || !content.value.trim()) return; 46 + submitting.value = true; 47 + error.value = ""; 48 + try { 49 + const res = await createComment(taskId, content.value.trim()); 50 + content.value = ""; 51 + onCreated({ ...res.comment, authorHandle: currentHandle }); 52 + } catch { 53 + error.value = "Failed to post comment."; 54 + } finally { 55 + submitting.value = false; 56 + } 57 + }; 58 + 59 + return ( 60 + <form onSubmit={handleSubmit} class={ui.stackSm}> 61 + <textarea 62 + class={ui.textarea} 63 + placeholder="Leave a comment..." 64 + value={content.value} 65 + onInput={(e) => { 66 + content.value = (e.target as HTMLTextAreaElement).value; 67 + }} 68 + onKeyDown={(e) => { 69 + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { 70 + e.preventDefault(); 71 + (e.target as HTMLTextAreaElement).form?.requestSubmit(); 72 + } 73 + }} 74 + rows={3} 75 + /> 76 + {error.value && <p class={ui.errorText}>{error.value}</p>} 77 + <div> 78 + <button 79 + type="submit" 80 + class={ui.button} 81 + disabled={submitting.value || !content.value.trim()} 82 + > 83 + {submitting.value ? "Posting..." : "Comment"} 84 + </button> 85 + </div> 86 + </form> 87 + ); 88 + } 89 + 90 + function CommentItem({ 91 + comment, 92 + isAuthor, 93 + canModerate, 94 + onDelete, 95 + onUpdate, 96 + }: { 97 + comment: KanbanTaskCommentListItem; 98 + isAuthor: boolean; 99 + canModerate: boolean; 100 + onDelete: (id: string) => void; 101 + onUpdate: (id: string, content: string) => Promise<void>; 102 + }) { 103 + const editing = useSignal(false); 104 + const editContent = useSignal(comment.content); 105 + const saving = useSignal(false); 106 + 107 + const handleSave = async () => { 108 + if (saving.value || !editContent.value.trim()) return; 109 + saving.value = true; 110 + try { 111 + await onUpdate(comment.id, editContent.value.trim()); 112 + editing.value = false; 113 + } finally { 114 + saving.value = false; 115 + } 116 + }; 117 + 118 + const handleCancel = () => { 119 + editContent.value = comment.content; 120 + editing.value = false; 121 + }; 122 + 123 + return ( 124 + <div class={ui.cardFlat}> 125 + <div class={ui.metaRow}> 126 + <span class={ui.muted}>{comment.authorHandle ?? comment.authorDid}</span> 127 + <span class={ui.muted}>{formatDate(comment.createdAt, fullDateOpts)}</span> 128 + {comment.createdAt !== comment.updatedAt && <span class={ui.muted}>(edited)</span>} 129 + {isAuthor && !editing.value && ( 130 + <button class={ui.buttonInline} onClick={() => (editing.value = true)}> 131 + Edit 132 + </button> 133 + )} 134 + {(isAuthor || canModerate) && !editing.value && ( 135 + <button class={ui.buttonDangerInline} onClick={() => onDelete(comment.id)}> 136 + Delete 137 + </button> 138 + )} 139 + </div> 140 + {editing.value ? ( 141 + <div class={`${ui.stackSm} ${kbUi.commentEditArea}`}> 142 + <textarea 143 + class={ui.textarea} 144 + value={editContent.value} 145 + onInput={(e) => { 146 + editContent.value = (e.target as HTMLTextAreaElement).value; 147 + }} 148 + onKeyDown={(e) => { 149 + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { 150 + e.preventDefault(); 151 + handleSave(); 152 + } 153 + if (e.key === "Escape") handleCancel(); 154 + }} 155 + rows={3} 156 + /> 157 + <div class={ui.row}> 158 + <button 159 + class={ui.button} 160 + onClick={handleSave} 161 + disabled={saving.value || !editContent.value.trim()} 162 + > 163 + {saving.value ? "Saving..." : "Save"} 164 + </button> 165 + <button class={ui.buttonInline} onClick={handleCancel}> 166 + Cancel 167 + </button> 168 + </div> 169 + </div> 170 + ) : ( 171 + <div class={kbUi.commentBody}>{comment.content}</div> 172 + )} 173 + </div> 174 + ); 175 + } 176 + 177 + function CommentsSection({ 178 + taskId, 179 + canComment, 180 + canModerate, 181 + currentDid, 182 + currentHandle, 183 + }: { 184 + taskId: string; 185 + canComment: boolean; 186 + canModerate: boolean; 187 + currentDid: string | null; 188 + currentHandle: string | null; 189 + }) { 190 + const comments = useSignal<KanbanTaskCommentListItem[]>([]); 191 + const { data, pending, loading } = useQuery(() => getComments(taskId), [taskId]); 192 + 193 + const prevData = useRef(data); 194 + if (data && data !== prevData.current) { 195 + prevData.current = data; 196 + comments.value = data.comments; 197 + } 198 + 199 + const handleCreated = (comment: KanbanTaskCommentListItem) => { 200 + comments.value = [...comments.value, comment]; 201 + }; 202 + 203 + const handleUpdate = async (id: string, content: string) => { 204 + const res = await updateCommentApi(id, content); 205 + comments.value = comments.value.map((c) => 206 + c.id === id ? { ...res.comment, authorHandle: c.authorHandle } : c, 207 + ); 208 + }; 209 + 210 + const handleDelete = async (id: string) => { 211 + try { 212 + await deleteComment(id); 213 + comments.value = comments.value.filter((c) => c.id !== id); 214 + } catch (err) { 215 + console.error("Failed to delete comment:", err); 216 + } 217 + }; 218 + 219 + const commentTitle = `Comments (${comments.value.length})`; 220 + 221 + return ( 222 + <CollapsibleSection title={commentTitle} defaultExpanded> 223 + {pending && comments.value.length === 0 ? ( 224 + loading ? ( 225 + <p class={ui.muted}>Loading comments...</p> 226 + ) : null 227 + ) : comments.value.length === 0 ? ( 228 + <p class={ui.muted}>No comments yet.</p> 229 + ) : ( 230 + <div class={`${ui.stack}${pending ? ` ${kbUi.pendingFade}` : ""}`}> 231 + {comments.value.map((comment) => ( 232 + <CommentItem 233 + key={comment.id} 234 + comment={comment} 235 + isAuthor={currentDid === comment.authorDid} 236 + canModerate={canModerate} 237 + onDelete={handleDelete} 238 + onUpdate={handleUpdate} 239 + /> 240 + ))} 241 + </div> 242 + )} 243 + 244 + {canComment && ( 245 + <CommentForm taskId={taskId} currentHandle={currentHandle} onCreated={handleCreated} /> 246 + )} 247 + </CollapsibleSection> 248 + ); 249 + } 250 + 251 + function StatusHistory({ 252 + taskId, 253 + version, 254 + createdAt, 255 + authorDid, 256 + authorHandle, 257 + columns, 258 + }: { 259 + taskId: string; 260 + version: number; 261 + createdAt: string; 262 + authorDid: string; 263 + authorHandle: string | null; 264 + columns: KanbanColumnDef[]; 265 + }) { 266 + const statuses = useSignal<Array<{ 267 + id: string; 268 + authorDid: string; 269 + authorHandle: string | null; 270 + status: string; 271 + createdAt: string; 272 + }> | null>(null); 273 + const loadingHistory = useSignal(false); 274 + const expandedRef = useRef(false); 275 + 276 + const fetchStatuses = async () => { 277 + loadingHistory.value = true; 278 + try { 279 + const res = await getStatusHistory(taskId); 280 + statuses.value = res.statuses; 281 + } catch { 282 + statuses.value = []; 283 + } finally { 284 + loadingHistory.value = false; 285 + } 286 + }; 287 + 288 + const handleToggle = (expanded: boolean) => { 289 + expandedRef.current = expanded; 290 + if (expanded && statuses.value === null && !loadingHistory.value) { 291 + fetchStatuses(); 292 + } 293 + }; 294 + 295 + const prevVersion = useRef(version); 296 + useEffect(() => { 297 + if (version === prevVersion.current) return; 298 + prevVersion.current = version; 299 + if (expandedRef.current) { 300 + fetchStatuses(); 301 + } else { 302 + statuses.value = null; 303 + } 304 + }, [version]); 305 + 306 + return ( 307 + <CollapsibleSection title="Status history" onToggle={handleToggle}> 308 + {loadingHistory.value ? ( 309 + <p class={ui.muted}>Loading...</p> 310 + ) : ( 311 + <div class={ui.stackSm}> 312 + {statuses.value !== null && 313 + statuses.value.map((entry) => ( 314 + <div key={entry.id} class={ui.metaRow}> 315 + <span class={ui.muted}>{entry.authorHandle ?? entry.authorDid}</span> 316 + <span> 317 + moved to <strong>{statusLabel(entry.status, columns)}</strong> 318 + </span> 319 + <span class={ui.muted}>{formatDate(entry.createdAt, fullDateOpts)}</span> 320 + </div> 321 + ))} 322 + <div class={ui.metaRow}> 323 + <span class={ui.muted}>{authorHandle ?? authorDid}</span> 324 + <span>created</span> 325 + <span class={ui.muted}>{formatDate(createdAt, fullDateOpts)}</span> 326 + </div> 327 + </div> 328 + )} 329 + </CollapsibleSection> 330 + ); 331 + } 332 + 333 + // ---- Task detail page ---- 334 + 335 + export function TaskPage() { 336 + const { params } = useRoute(); 337 + const { route } = useLocation(); 338 + const number = parseInt(params.number, 10); 339 + const statusVersion = useSignal(0); 340 + const localStatus = useSignal<string | null>(null); 341 + const editing = useSignal(false); 342 + const editTitle = useSignal(""); 343 + const editDescription = useSignal(""); 344 + const saving = useSignal(false); 345 + 346 + const prefetched = ssrPageData.peek()?.["kanban-task"] as 347 + | Awaited<ReturnType<typeof getTask>> 348 + | undefined; 349 + useEffect(() => { 350 + const pd = ssrPageData.peek(); 351 + if (pd && "kanban-task" in pd) delete pd["kanban-task"]; 352 + }, []); 353 + 354 + const { data, pending, loading, error, refetch } = useQuery( 355 + () => getTask(number), 356 + [number], 357 + prefetched ? { initialData: prefetched } : undefined, 358 + ); 359 + 360 + const { data: colsData } = useQuery(() => getColumns(), []); 361 + const cols = colsData?.columns ?? []; 362 + 363 + const isAuthenticated = auth.value.authenticated; 364 + const currentDid = isAuthenticated ? auth.value.did : null; 365 + const currentHandle = isAuthenticated ? auth.value.handle : null; 366 + 367 + const canComment = useCanDo("kanban", "comment"); 368 + const canModerate = useCanDo("kanban", "moderate"); 369 + const canChangeStatus = useCanDo("kanban", "changeStatus"); 370 + const canManage = useCanDo("kanban", "manageTasks"); 371 + 372 + const task = data?.task; 373 + const isAuthor = currentDid === task?.authorDid; 374 + const canEdit = isAuthor || canManage.value; 375 + 376 + const handleDelete = async () => { 377 + if (!task) return; 378 + try { 379 + await deleteTask(task.id); 380 + route(spherePath("/board")); 381 + } catch (err) { 382 + console.error("Failed to delete task:", err); 383 + } 384 + }; 385 + 386 + const handleHide = async () => { 387 + if (!task) return; 388 + try { 389 + await hideTask(task.id); 390 + route(spherePath("/board")); 391 + } catch (err) { 392 + console.error("Failed to hide task:", err); 393 + } 394 + }; 395 + 396 + const handleStatusChange = async (newStatus: string) => { 397 + if (!task) return; 398 + const prev = localStatus.value ?? task.status; 399 + localStatus.value = newStatus; 400 + try { 401 + await updateTaskStatus(task.id, newStatus); 402 + statusVersion.value++; 403 + } catch { 404 + localStatus.value = prev; 405 + } 406 + }; 407 + 408 + const startEdit = () => { 409 + if (!task) return; 410 + editTitle.value = task.title; 411 + editDescription.value = task.description; 412 + editing.value = true; 413 + }; 414 + 415 + const handleSave = async () => { 416 + if (!task || saving.value) return; 417 + saving.value = true; 418 + try { 419 + await updateTask(task.id, { 420 + title: editTitle.value.trim(), 421 + description: editDescription.value.trim(), 422 + }); 423 + editing.value = false; 424 + refetch(); 425 + } catch (err) { 426 + console.error("Failed to update task:", err); 427 + } finally { 428 + saving.value = false; 429 + } 430 + }; 431 + 432 + return ( 433 + <div class={ui.container}> 434 + <div class={ui.section}> 435 + <div> 436 + <a href={spherePath("/board")} class={ui.muted}> 437 + &larr; Board 438 + </a> 439 + </div> 440 + 441 + {pending ? ( 442 + loading ? ( 443 + <p class={ui.muted}>Loading...</p> 444 + ) : null 445 + ) : error ? ( 446 + <p class={ui.errorText}>{error}</p> 447 + ) : !task ? ( 448 + <p class={ui.muted}>Task not found.</p> 449 + ) : ( 450 + <div class={kbUi.detailContent}> 451 + <div class={ui.stackLg}> 452 + <div class={ui.card}> 453 + {editing.value ? ( 454 + <div class={ui.formStack}> 455 + <div> 456 + <label class={ui.label} htmlFor="edit-title"> 457 + Title 458 + </label> 459 + <input 460 + id="edit-title" 461 + class={ui.input} 462 + type="text" 463 + maxLength={200} 464 + value={editTitle.value} 465 + onInput={(e) => (editTitle.value = (e.target as HTMLInputElement).value)} 466 + /> 467 + </div> 468 + <div> 469 + <label class={ui.label} htmlFor="edit-description"> 470 + Description 471 + </label> 472 + <textarea 473 + id="edit-description" 474 + class={ui.textarea} 475 + maxLength={10000} 476 + value={editDescription.value} 477 + onInput={(e) => 478 + (editDescription.value = (e.target as HTMLTextAreaElement).value) 479 + } 480 + onKeyDown={(e) => { 481 + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { 482 + e.preventDefault(); 483 + handleSave(); 484 + } 485 + if (e.key === "Escape") editing.value = false; 486 + }} 487 + rows={5} 488 + /> 489 + </div> 490 + <div class={ui.row}> 491 + <button class={ui.button} onClick={handleSave} disabled={saving.value}> 492 + {saving.value ? "Saving..." : "Save"} 493 + </button> 494 + <button class={ui.buttonInline} onClick={() => (editing.value = false)}> 495 + Cancel 496 + </button> 497 + </div> 498 + </div> 499 + ) : ( 500 + <> 501 + <div class={ui.metaRow}> 502 + <span class={ui.muted}>#{task.number}</span> 503 + <span class={ui.badge}>{statusLabel(localStatus.value ?? task.status, cols)}</span> 504 + {task.assigneeHandle && <span class={ui.muted}>@{task.assigneeHandle}</span>} 505 + <span class={ui.muted}>{formatDate(task.createdAt, fullDateOpts)}</span> 506 + </div> 507 + <h2 class={ui.cardTitle}>{task.title}</h2> 508 + <div class={ui.metaRow}> 509 + <span class={ui.muted}>by {task.authorHandle ?? task.authorDid}</span> 510 + {canEdit && ( 511 + <button class={ui.buttonInline} onClick={startEdit}> 512 + Edit 513 + </button> 514 + )} 515 + {(isAuthor || canManage.value) && ( 516 + <button class={ui.buttonDangerInline} onClick={handleDelete}> 517 + Delete 518 + </button> 519 + )} 520 + {canModerate.value && !isAuthor && ( 521 + <button class={ui.buttonDangerInline} onClick={handleHide}> 522 + Hide 523 + </button> 524 + )} 525 + </div> 526 + </> 527 + )} 528 + </div> 529 + 530 + {!editing.value && task.description && ( 531 + <div class={kbUi.descriptionBlock}>{task.description}</div> 532 + )} 533 + 534 + {canChangeStatus.value && !editing.value && ( 535 + <div class={ui.metaRow}> 536 + <span class={ui.muted}>Status:</span> 537 + <select 538 + class={kbUi.statusSelect} 539 + value={localStatus.value ?? task.status} 540 + onChange={(e) => handleStatusChange((e.target as HTMLSelectElement).value)} 541 + > 542 + {cols.map((col) => ( 543 + <option key={col.slug} value={col.slug}> 544 + {col.label} 545 + </option> 546 + ))} 547 + </select> 548 + </div> 549 + )} 550 + </div> 551 + 552 + <CommentsSection 553 + taskId={task.id} 554 + canComment={canComment.value} 555 + canModerate={canModerate.value} 556 + currentDid={currentDid} 557 + currentHandle={currentHandle} 558 + /> 559 + 560 + <StatusHistory 561 + taskId={task.id} 562 + version={statusVersion.value} 563 + createdAt={task.createdAt} 564 + authorDid={task.authorDid} 565 + authorHandle={task.authorHandle ?? null} 566 + columns={cols} 567 + /> 568 + </div> 569 + )} 570 + </div> 571 + </div> 572 + ); 573 + }
+269
packages/kanban/src/ui/ui.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "@exosphere/client/theme.css"; 3 + 4 + // ---- Board layout ---- 5 + 6 + export const boardSection = style({ 7 + paddingInline: vars.space.xl, 8 + paddingBlockEnd: vars.space.xl, 9 + }); 10 + 11 + export const boardGrid = style({ 12 + display: "flex", 13 + gap: vars.space.md, 14 + overflowX: "auto", 15 + paddingBlockEnd: vars.space.sm, 16 + "@media": { 17 + "(max-width: 900px)": { 18 + flexDirection: "column", 19 + }, 20 + }, 21 + }); 22 + 23 + export const column = style({ 24 + display: "flex", 25 + flexDirection: "column", 26 + gap: vars.space.sm, 27 + minBlockSize: "200px", 28 + flex: "1 1 0", 29 + minInlineSize: "220px", 30 + transition: "background-color 0.15s", 31 + }); 32 + 33 + export const columnHeader = style({ 34 + display: "flex", 35 + alignItems: "center", 36 + justifyContent: "space-between", 37 + paddingBlockEnd: vars.space.sm, 38 + borderBlockEnd: `2px solid ${vars.color.border}`, 39 + marginBlockEnd: vars.space.xs, 40 + }); 41 + 42 + export const columnTitle = style({ 43 + fontWeight: 600, 44 + fontSize: "0.9375rem", 45 + }); 46 + 47 + export const columnCount = style({ 48 + fontSize: "0.8125rem", 49 + color: vars.color.textMuted, 50 + }); 51 + 52 + // ---- Task card (compact, in column) ---- 53 + 54 + export const taskCard = style({ 55 + display: "block", 56 + paddingBlock: vars.space.sm, 57 + paddingInline: vars.space.md, 58 + borderRadius: vars.radius.sm, 59 + border: `1px solid ${vars.color.border}`, 60 + backgroundColor: vars.color.surface, 61 + textDecoration: "none", 62 + color: vars.color.text, 63 + transition: "border-color 0.15s", 64 + ":hover": { 65 + borderColor: vars.color.primary, 66 + }, 67 + }); 68 + 69 + export const taskNumber = style({ 70 + fontSize: "0.8125rem", 71 + color: vars.color.textMuted, 72 + fontWeight: 500, 73 + }); 74 + 75 + export const taskTitle = style({ 76 + fontWeight: 500, 77 + fontSize: "0.875rem", 78 + lineHeight: 1.3, 79 + marginBlockStart: "2px", 80 + overflow: "hidden", 81 + textOverflow: "ellipsis", 82 + display: "-webkit-box", 83 + WebkitLineClamp: 2, 84 + WebkitBoxOrient: "vertical", 85 + }); 86 + 87 + export const taskMeta = style({ 88 + display: "flex", 89 + alignItems: "center", 90 + gap: vars.space.sm, 91 + marginBlockStart: vars.space.xs, 92 + fontSize: "0.75rem", 93 + color: vars.color.textMuted, 94 + }); 95 + 96 + // ---- Task detail ---- 97 + 98 + export const detailContent = style({ 99 + display: "flex", 100 + flexDirection: "column", 101 + gap: vars.space.xl, 102 + }); 103 + 104 + export const descriptionBlock = style({ 105 + fontSize: "0.9375rem", 106 + lineHeight: 1.7, 107 + whiteSpace: "pre-wrap", 108 + paddingBlockEnd: vars.space.xl, 109 + borderBlockEnd: `1px solid ${vars.color.border}`, 110 + }); 111 + 112 + export const titleRow = style({ 113 + display: "flex", 114 + alignItems: "center", 115 + justifyContent: "space-between", 116 + minBlockSize: "44px", 117 + }); 118 + 119 + export const titleRowActions = style({ 120 + display: "flex", 121 + alignItems: "center", 122 + gap: vars.space.sm, 123 + }); 124 + 125 + export const iconBtn = style({ 126 + display: "inline-flex", 127 + alignItems: "center", 128 + justifyContent: "center", 129 + inlineSize: "36px", 130 + blockSize: "36px", 131 + borderRadius: vars.radius.sm, 132 + border: `1px solid ${vars.color.border}`, 133 + backgroundColor: vars.color.surface, 134 + color: vars.color.textMuted, 135 + cursor: "pointer", 136 + transition: "border-color 0.15s, color 0.15s", 137 + ":hover": { 138 + borderColor: vars.color.primary, 139 + color: vars.color.text, 140 + textDecoration: "none", 141 + }, 142 + }); 143 + 144 + // ---- Status select (compact) ---- 145 + 146 + export const statusSelect = style({ 147 + boxSizing: "border-box", 148 + paddingBlock: "4px", 149 + paddingInline: vars.space.sm, 150 + borderRadius: vars.radius.sm, 151 + border: `1px solid ${vars.color.border}`, 152 + fontSize: "0.8125rem", 153 + color: vars.color.text, 154 + backgroundColor: vars.color.surface, 155 + fontFamily: vars.font.body, 156 + outline: "none", 157 + transition: "border-color 0.15s", 158 + ":focus": { 159 + borderColor: vars.color.primary, 160 + }, 161 + }); 162 + 163 + // ---- Comment styles ---- 164 + 165 + export const commentBody = style({ 166 + fontSize: "0.9375rem", 167 + lineHeight: 1.6, 168 + whiteSpace: "pre-wrap", 169 + marginBlockStart: vars.space.sm, 170 + }); 171 + 172 + export const commentEditArea = style({ 173 + marginBlockStart: vars.space.sm, 174 + }); 175 + 176 + export const pendingFade = style({ 177 + opacity: 0.6, 178 + }); 179 + 180 + // ---- Drag and drop ---- 181 + 182 + export const columnDragOver = style({ 183 + backgroundColor: vars.color.primaryLight, 184 + borderRadius: vars.radius.sm, 185 + transition: "background-color 0.15s", 186 + }); 187 + 188 + export const taskCardDraggable = style({ 189 + cursor: "grab", 190 + }); 191 + 192 + export const taskCardDragging = style({ 193 + opacity: 0.3, 194 + }); 195 + 196 + export const dropIndicator = style({ 197 + blockSize: "2px", 198 + backgroundColor: vars.color.primary, 199 + borderRadius: "1px", 200 + }); 201 + 202 + // ---- Column manager ---- 203 + 204 + export const columnManager = style({ 205 + border: `1px solid ${vars.color.border}`, 206 + borderRadius: vars.radius.lg, 207 + padding: vars.space.lg, 208 + backgroundColor: vars.color.surface, 209 + }); 210 + 211 + export const columnManagerList = style({ 212 + display: "flex", 213 + flexDirection: "column", 214 + gap: vars.space.sm, 215 + }); 216 + 217 + export const columnManagerItem = style({ 218 + display: "flex", 219 + alignItems: "center", 220 + gap: vars.space.sm, 221 + paddingBlock: vars.space.xs, 222 + }); 223 + 224 + export const columnManagerLabel = style({ 225 + flex: 1, 226 + fontSize: "0.875rem", 227 + fontWeight: 500, 228 + }); 229 + 230 + export const columnManagerSlug = style({ 231 + fontSize: "0.75rem", 232 + color: vars.color.textMuted, 233 + }); 234 + 235 + export const columnManagerArrows = style({ 236 + display: "flex", 237 + gap: "2px", 238 + }); 239 + 240 + export const arrowBtn = style({ 241 + display: "inline-flex", 242 + alignItems: "center", 243 + justifyContent: "center", 244 + inlineSize: "28px", 245 + blockSize: "28px", 246 + border: `1px solid ${vars.color.border}`, 247 + borderRadius: vars.radius.sm, 248 + backgroundColor: vars.color.surface, 249 + cursor: "pointer", 250 + fontSize: "0.75rem", 251 + color: vars.color.text, 252 + padding: 0, 253 + ":hover": { 254 + borderColor: vars.color.primary, 255 + }, 256 + ":disabled": { 257 + opacity: 0.3, 258 + cursor: "not-allowed", 259 + }, 260 + }); 261 + 262 + export const columnManagerAdd = style({ 263 + display: "flex", 264 + alignItems: "center", 265 + gap: vars.space.sm, 266 + marginBlockStart: vars.space.md, 267 + paddingBlockStart: vars.space.md, 268 + borderBlockStart: `1px solid ${vars.color.border}`, 269 + });