@btst/stack 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/node_modules/.pnpm/@dnd-kit_core@6.3.1_react-dom@19.2.0_react@19.2.0__react@19.2.0/node_modules/@dnd-kit/core/dist/core.esm.cjs +1 -1
- package/dist/node_modules/.pnpm/@dnd-kit_core@6.3.1_react-dom@19.2.0_react@19.2.0__react@19.2.0/node_modules/@dnd-kit/core/dist/core.esm.mjs +1 -1
- package/dist/node_modules/.pnpm/@dnd-kit_sortable@10.0.0_@dnd-kit_core@6.3.1_react-dom@19.2.0_react@19.2.0__react@19.2.0__react@19.2.0/node_modules/@dnd-kit/sortable/dist/sortable.esm.cjs +77 -0
- package/dist/node_modules/.pnpm/@dnd-kit_sortable@10.0.0_@dnd-kit_core@6.3.1_react-dom@19.2.0_react@19.2.0__react@19.2.0__react@19.2.0/node_modules/@dnd-kit/sortable/dist/sortable.esm.mjs +79 -3
- package/dist/node_modules/.pnpm/@radix-ui_react-avatar@1.1.11_@types_react-dom@19.2.3_@types_react@19.2.6__@types_react_850cfbef1935a6e49a6ad6c93c7ca70d/node_modules/@radix-ui/react-avatar/dist/index.cjs +140 -0
- package/dist/node_modules/.pnpm/@radix-ui_react-avatar@1.1.11_@types_react-dom@19.2.3_@types_react@19.2.6__@types_react_850cfbef1935a6e49a6ad6c93c7ca70d/node_modules/@radix-ui/react-avatar/dist/index.mjs +119 -0
- package/dist/node_modules/.pnpm/@radix-ui_react-context@1.1.3_@types_react@19.2.6_react@19.2.0/node_modules/@radix-ui/react-context/dist/index.cjs +80 -0
- package/dist/node_modules/.pnpm/@radix-ui_react-context@1.1.3_@types_react@19.2.6_react@19.2.0/node_modules/@radix-ui/react-context/dist/index.mjs +64 -0
- package/dist/node_modules/.pnpm/@radix-ui_react-use-is-hydrated@0.1.0_@types_react@19.2.6_react@19.2.0/node_modules/@radix-ui/react-use-is-hydrated/dist/index.cjs +18 -0
- package/dist/node_modules/.pnpm/@radix-ui_react-use-is-hydrated@0.1.0_@types_react@19.2.6_react@19.2.0/node_modules/@radix-ui/react-use-is-hydrated/dist/index.mjs +16 -0
- package/dist/packages/better-stack/src/plugins/kanban/api/plugin.cjs +846 -0
- package/dist/packages/better-stack/src/plugins/kanban/api/plugin.mjs +844 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/forms/board-form.cjs +85 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/forms/board-form.mjs +83 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/forms/column-form.cjs +72 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/forms/column-form.mjs +70 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/forms/task-form.cjs +200 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/forms/task-form.mjs +198 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/loading/board-skeleton.cjs +47 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/loading/board-skeleton.mjs +45 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/loading/boards-list-skeleton.cjs +30 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/loading/boards-list-skeleton.mjs +28 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/404-page.cjs +27 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/404-page.mjs +25 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/board-page.cjs +31 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/board-page.internal.cjs +458 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/board-page.internal.mjs +456 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/board-page.mjs +29 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/boards-list-page.cjs +30 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/boards-list-page.internal.cjs +72 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/boards-list-page.internal.mjs +70 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/boards-list-page.mjs +28 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/new-board-page.cjs +30 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/new-board-page.internal.cjs +51 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/new-board-page.internal.mjs +49 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/new-board-page.mjs +28 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/column-content.cjs +76 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/column-content.mjs +74 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/default-error.cjs +27 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/default-error.mjs +25 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/empty-state.cjs +32 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/empty-state.mjs +30 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/kanban-board.cjs +78 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/kanban-board.mjs +76 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/page-wrapper.cjs +15 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/page-wrapper.mjs +13 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/task-card.cjs +68 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/task-card.mjs +66 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/user-avatar.cjs +32 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/user-avatar.mjs +30 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/hooks/kanban-hooks.cjs +391 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/hooks/kanban-hooks.mjs +381 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/plugin.cjs +290 -0
- package/dist/packages/better-stack/src/plugins/kanban/client/plugin.mjs +288 -0
- package/dist/packages/better-stack/src/plugins/kanban/db.cjs +125 -0
- package/dist/packages/better-stack/src/plugins/kanban/db.mjs +123 -0
- package/dist/packages/better-stack/src/plugins/kanban/schemas.cjs +117 -0
- package/dist/packages/better-stack/src/plugins/kanban/schemas.mjs +102 -0
- package/dist/packages/better-stack/src/plugins/kanban/utils.cjs +49 -0
- package/dist/packages/better-stack/src/plugins/kanban/utils.mjs +45 -0
- package/dist/packages/ui/src/components/avatar.cjs +58 -0
- package/dist/packages/ui/src/components/avatar.mjs +54 -0
- package/dist/packages/ui/src/components/command.cjs +3 -3
- package/dist/packages/ui/src/components/command.mjs +3 -3
- package/dist/packages/ui/src/components/form-builder/index.mjs +2 -2
- package/dist/packages/ui/src/components/kanban.cjs +835 -0
- package/dist/packages/ui/src/components/kanban.mjs +805 -0
- package/dist/packages/ui/src/components/popover.cjs +8 -3
- package/dist/packages/ui/src/components/popover.mjs +9 -4
- package/dist/packages/ui/src/components/search-select.cjs +75 -0
- package/dist/packages/ui/src/components/search-select.mjs +73 -0
- package/dist/packages/ui/src/lib/compose-refs.cjs +56 -0
- package/dist/packages/ui/src/lib/compose-refs.mjs +39 -0
- package/dist/plugins/blog/api/index.d.cts +1 -1
- package/dist/plugins/blog/api/index.d.mts +1 -1
- package/dist/plugins/blog/api/index.d.ts +1 -1
- package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
- package/dist/plugins/blog/client/index.d.cts +1 -1
- package/dist/plugins/blog/client/index.d.mts +1 -1
- package/dist/plugins/blog/client/index.d.ts +1 -1
- package/dist/plugins/blog/query-keys.d.cts +2 -2
- package/dist/plugins/blog/query-keys.d.mts +2 -2
- package/dist/plugins/blog/query-keys.d.ts +2 -2
- package/dist/plugins/kanban/api/index.cjs +7 -0
- package/dist/plugins/kanban/api/index.d.cts +403 -0
- package/dist/plugins/kanban/api/index.d.mts +403 -0
- package/dist/plugins/kanban/api/index.d.ts +403 -0
- package/dist/plugins/kanban/api/index.mjs +1 -0
- package/dist/plugins/kanban/client/components/index.cjs +35 -0
- package/dist/plugins/kanban/client/components/index.d.cts +102 -0
- package/dist/plugins/kanban/client/components/index.d.mts +102 -0
- package/dist/plugins/kanban/client/components/index.d.ts +102 -0
- package/dist/plugins/kanban/client/components/index.mjs +15 -0
- package/dist/plugins/kanban/client/hooks/index.cjs +15 -0
- package/dist/plugins/kanban/client/hooks/index.d.cts +143 -0
- package/dist/plugins/kanban/client/hooks/index.d.mts +143 -0
- package/dist/plugins/kanban/client/hooks/index.d.ts +143 -0
- package/dist/plugins/kanban/client/hooks/index.mjs +1 -0
- package/dist/plugins/kanban/client/index.cjs +7 -0
- package/dist/plugins/kanban/client/index.d.cts +196 -0
- package/dist/plugins/kanban/client/index.d.mts +196 -0
- package/dist/plugins/kanban/client/index.d.ts +196 -0
- package/dist/plugins/kanban/client/index.mjs +1 -0
- package/dist/plugins/kanban/client.css +68 -0
- package/dist/plugins/kanban/query-keys.cjs +105 -0
- package/dist/plugins/kanban/query-keys.d.cts +59 -0
- package/dist/plugins/kanban/query-keys.d.mts +59 -0
- package/dist/plugins/kanban/query-keys.d.ts +59 -0
- package/dist/plugins/kanban/query-keys.mjs +103 -0
- package/dist/plugins/kanban/style.css +7 -0
- package/dist/plugins/ui-builder/style.css +6 -0
- package/dist/shared/stack.DKDMI-QO.d.cts +70 -0
- package/dist/shared/stack.DKDMI-QO.d.mts +70 -0
- package/dist/shared/stack.DKDMI-QO.d.ts +70 -0
- package/dist/shared/stack.FeaWkglm.d.cts +190 -0
- package/dist/shared/stack.FeaWkglm.d.mts +190 -0
- package/dist/shared/stack.FeaWkglm.d.ts +190 -0
- package/package.json +56 -2
- package/src/plugins/kanban/api/index.ts +6 -0
- package/src/plugins/kanban/api/plugin.ts +1245 -0
- package/src/plugins/kanban/client/components/forms/board-form.tsx +108 -0
- package/src/plugins/kanban/client/components/forms/column-form.tsx +97 -0
- package/src/plugins/kanban/client/components/forms/task-form.tsx +274 -0
- package/src/plugins/kanban/client/components/index.tsx +21 -0
- package/src/plugins/kanban/client/components/loading/board-skeleton.tsx +49 -0
- package/src/plugins/kanban/client/components/loading/boards-list-skeleton.tsx +34 -0
- package/src/plugins/kanban/client/components/loading/index.tsx +2 -0
- package/src/plugins/kanban/client/components/pages/404-page.tsx +28 -0
- package/src/plugins/kanban/client/components/pages/board-page.internal.tsx +575 -0
- package/src/plugins/kanban/client/components/pages/board-page.tsx +31 -0
- package/src/plugins/kanban/client/components/pages/boards-list-page.internal.tsx +101 -0
- package/src/plugins/kanban/client/components/pages/boards-list-page.tsx +26 -0
- package/src/plugins/kanban/client/components/pages/new-board-page.internal.tsx +65 -0
- package/src/plugins/kanban/client/components/pages/new-board-page.tsx +26 -0
- package/src/plugins/kanban/client/components/shared/column-content.tsx +108 -0
- package/src/plugins/kanban/client/components/shared/default-error.tsx +32 -0
- package/src/plugins/kanban/client/components/shared/empty-state.tsx +37 -0
- package/src/plugins/kanban/client/components/shared/kanban-board.tsx +87 -0
- package/src/plugins/kanban/client/components/shared/page-wrapper.tsx +20 -0
- package/src/plugins/kanban/client/components/shared/task-card.tsx +79 -0
- package/src/plugins/kanban/client/components/shared/user-avatar.tsx +63 -0
- package/src/plugins/kanban/client/hooks/index.tsx +11 -0
- package/src/plugins/kanban/client/hooks/kanban-hooks.tsx +560 -0
- package/src/plugins/kanban/client/index.ts +8 -0
- package/src/plugins/kanban/client/localization/index.ts +28 -0
- package/src/plugins/kanban/client/localization/kanban-common.ts +69 -0
- package/src/plugins/kanban/client/localization/kanban-forms.ts +70 -0
- package/src/plugins/kanban/client/localization/kanban-list.ts +36 -0
- package/src/plugins/kanban/client/overrides.ts +145 -0
- package/src/plugins/kanban/client/plugin.tsx +463 -0
- package/src/plugins/kanban/client.css +68 -0
- package/src/plugins/kanban/db.ts +125 -0
- package/src/plugins/kanban/query-keys.ts +154 -0
- package/src/plugins/kanban/schemas.ts +143 -0
- package/src/plugins/kanban/style.css +7 -0
- package/src/plugins/kanban/types.ts +106 -0
- package/src/plugins/kanban/utils.ts +107 -0
- package/src/plugins/ui-builder/style.css +6 -0
- package/dist/shared/{stack.DLhzx1-D.d.cts → stack.CcI4sYJP.d.cts} +1 -1
- package/dist/shared/{stack.DLhzx1-D.d.mts → stack.CcI4sYJP.d.mts} +1 -1
- package/dist/shared/{stack.DLhzx1-D.d.ts → stack.CcI4sYJP.d.ts} +1 -1
|
@@ -0,0 +1,1245 @@
|
|
|
1
|
+
import type { Adapter } from "@btst/db";
|
|
2
|
+
import { defineBackendPlugin } from "@btst/stack/plugins/api";
|
|
3
|
+
import { createEndpoint } from "@btst/stack/plugins/api";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { kanbanSchema as dbSchema } from "../db";
|
|
6
|
+
import type {
|
|
7
|
+
Board,
|
|
8
|
+
BoardWithKanbanColumn,
|
|
9
|
+
Column,
|
|
10
|
+
ColumnWithTasks,
|
|
11
|
+
Task,
|
|
12
|
+
} from "../types";
|
|
13
|
+
import { slugify } from "../utils";
|
|
14
|
+
import {
|
|
15
|
+
BoardListQuerySchema,
|
|
16
|
+
createBoardSchema,
|
|
17
|
+
createColumnSchema,
|
|
18
|
+
createTaskSchema,
|
|
19
|
+
moveTaskSchema,
|
|
20
|
+
reorderColumnsSchema,
|
|
21
|
+
reorderTasksSchema,
|
|
22
|
+
updateBoardSchema,
|
|
23
|
+
updateColumnSchema,
|
|
24
|
+
updateTaskSchema,
|
|
25
|
+
} from "../schemas";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Context passed to kanban API hooks
|
|
29
|
+
*/
|
|
30
|
+
export interface KanbanApiContext<
|
|
31
|
+
TBody = unknown,
|
|
32
|
+
TParams = unknown,
|
|
33
|
+
TQuery = unknown,
|
|
34
|
+
> {
|
|
35
|
+
body?: TBody;
|
|
36
|
+
params?: TParams;
|
|
37
|
+
query?: TQuery;
|
|
38
|
+
request?: Request;
|
|
39
|
+
headers?: Headers;
|
|
40
|
+
[key: string]: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Configuration hooks for kanban backend plugin
|
|
45
|
+
* All hooks are optional and allow consumers to customize behavior
|
|
46
|
+
*/
|
|
47
|
+
export interface KanbanBackendHooks {
|
|
48
|
+
// ============ Board Hooks ============
|
|
49
|
+
/**
|
|
50
|
+
* Called before listing boards. Return false to deny access.
|
|
51
|
+
*/
|
|
52
|
+
onBeforeListBoards?: (
|
|
53
|
+
filter: z.infer<typeof BoardListQuerySchema>,
|
|
54
|
+
context: KanbanApiContext,
|
|
55
|
+
) => Promise<boolean> | boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Called before creating a board. Return false to deny access.
|
|
58
|
+
*/
|
|
59
|
+
onBeforeCreateBoard?: (
|
|
60
|
+
data: z.infer<typeof createBoardSchema>,
|
|
61
|
+
context: KanbanApiContext,
|
|
62
|
+
) => Promise<boolean> | boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Called before reading a single board. Return false to deny access.
|
|
65
|
+
*/
|
|
66
|
+
onBeforeReadBoard?: (
|
|
67
|
+
boardId: string,
|
|
68
|
+
context: KanbanApiContext,
|
|
69
|
+
) => Promise<boolean> | boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Called before updating a board. Return false to deny access.
|
|
72
|
+
*/
|
|
73
|
+
onBeforeUpdateBoard?: (
|
|
74
|
+
boardId: string,
|
|
75
|
+
data: z.infer<typeof updateBoardSchema>,
|
|
76
|
+
context: KanbanApiContext,
|
|
77
|
+
) => Promise<boolean> | boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Called before deleting a board. Return false to deny access.
|
|
80
|
+
*/
|
|
81
|
+
onBeforeDeleteBoard?: (
|
|
82
|
+
boardId: string,
|
|
83
|
+
context: KanbanApiContext,
|
|
84
|
+
) => Promise<boolean> | boolean;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Called after boards are listed successfully
|
|
88
|
+
*/
|
|
89
|
+
onBoardsRead?: (
|
|
90
|
+
boards: Board[],
|
|
91
|
+
filter: z.infer<typeof BoardListQuerySchema>,
|
|
92
|
+
context: KanbanApiContext,
|
|
93
|
+
) => Promise<void> | void;
|
|
94
|
+
/**
|
|
95
|
+
* Called after a single board is read successfully
|
|
96
|
+
*/
|
|
97
|
+
onBoardRead?: (
|
|
98
|
+
board: Board,
|
|
99
|
+
context: KanbanApiContext,
|
|
100
|
+
) => Promise<void> | void;
|
|
101
|
+
/**
|
|
102
|
+
* Called after a board is created successfully
|
|
103
|
+
*/
|
|
104
|
+
onBoardCreated?: (
|
|
105
|
+
board: Board,
|
|
106
|
+
context: KanbanApiContext,
|
|
107
|
+
) => Promise<void> | void;
|
|
108
|
+
/**
|
|
109
|
+
* Called after a board is updated successfully
|
|
110
|
+
*/
|
|
111
|
+
onBoardUpdated?: (
|
|
112
|
+
board: Board,
|
|
113
|
+
context: KanbanApiContext,
|
|
114
|
+
) => Promise<void> | void;
|
|
115
|
+
/**
|
|
116
|
+
* Called after a board is deleted successfully
|
|
117
|
+
*/
|
|
118
|
+
onBoardDeleted?: (
|
|
119
|
+
boardId: string,
|
|
120
|
+
context: KanbanApiContext,
|
|
121
|
+
) => Promise<void> | void;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Called when listing boards fails
|
|
125
|
+
*/
|
|
126
|
+
onListBoardsError?: (
|
|
127
|
+
error: Error,
|
|
128
|
+
context: KanbanApiContext,
|
|
129
|
+
) => Promise<void> | void;
|
|
130
|
+
/**
|
|
131
|
+
* Called when reading a single board fails
|
|
132
|
+
*/
|
|
133
|
+
onReadBoardError?: (
|
|
134
|
+
error: Error,
|
|
135
|
+
context: KanbanApiContext,
|
|
136
|
+
) => Promise<void> | void;
|
|
137
|
+
/**
|
|
138
|
+
* Called when creating a board fails
|
|
139
|
+
*/
|
|
140
|
+
onCreateBoardError?: (
|
|
141
|
+
error: Error,
|
|
142
|
+
context: KanbanApiContext,
|
|
143
|
+
) => Promise<void> | void;
|
|
144
|
+
/**
|
|
145
|
+
* Called when updating a board fails
|
|
146
|
+
*/
|
|
147
|
+
onUpdateBoardError?: (
|
|
148
|
+
error: Error,
|
|
149
|
+
context: KanbanApiContext,
|
|
150
|
+
) => Promise<void> | void;
|
|
151
|
+
/**
|
|
152
|
+
* Called when deleting a board fails
|
|
153
|
+
*/
|
|
154
|
+
onDeleteBoardError?: (
|
|
155
|
+
error: Error,
|
|
156
|
+
context: KanbanApiContext,
|
|
157
|
+
) => Promise<void> | void;
|
|
158
|
+
|
|
159
|
+
// ============ Column Hooks ============
|
|
160
|
+
/**
|
|
161
|
+
* Called before creating a column. Return false to deny access.
|
|
162
|
+
*/
|
|
163
|
+
onBeforeCreateColumn?: (
|
|
164
|
+
data: z.infer<typeof createColumnSchema>,
|
|
165
|
+
context: KanbanApiContext,
|
|
166
|
+
) => Promise<boolean> | boolean;
|
|
167
|
+
/**
|
|
168
|
+
* Called before updating a column. Return false to deny access.
|
|
169
|
+
*/
|
|
170
|
+
onBeforeUpdateColumn?: (
|
|
171
|
+
columnId: string,
|
|
172
|
+
data: z.infer<typeof updateColumnSchema>,
|
|
173
|
+
context: KanbanApiContext,
|
|
174
|
+
) => Promise<boolean> | boolean;
|
|
175
|
+
/**
|
|
176
|
+
* Called before deleting a column. Return false to deny access.
|
|
177
|
+
*/
|
|
178
|
+
onBeforeDeleteColumn?: (
|
|
179
|
+
columnId: string,
|
|
180
|
+
context: KanbanApiContext,
|
|
181
|
+
) => Promise<boolean> | boolean;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Called after a column is created successfully
|
|
185
|
+
*/
|
|
186
|
+
onColumnCreated?: (
|
|
187
|
+
column: Column,
|
|
188
|
+
context: KanbanApiContext,
|
|
189
|
+
) => Promise<void> | void;
|
|
190
|
+
/**
|
|
191
|
+
* Called after a column is updated successfully
|
|
192
|
+
*/
|
|
193
|
+
onColumnUpdated?: (
|
|
194
|
+
column: Column,
|
|
195
|
+
context: KanbanApiContext,
|
|
196
|
+
) => Promise<void> | void;
|
|
197
|
+
/**
|
|
198
|
+
* Called after a column is deleted successfully
|
|
199
|
+
*/
|
|
200
|
+
onColumnDeleted?: (
|
|
201
|
+
columnId: string,
|
|
202
|
+
context: KanbanApiContext,
|
|
203
|
+
) => Promise<void> | void;
|
|
204
|
+
|
|
205
|
+
// ============ Task Hooks ============
|
|
206
|
+
/**
|
|
207
|
+
* Called before creating a task. Return false to deny access.
|
|
208
|
+
*/
|
|
209
|
+
onBeforeCreateTask?: (
|
|
210
|
+
data: z.infer<typeof createTaskSchema>,
|
|
211
|
+
context: KanbanApiContext,
|
|
212
|
+
) => Promise<boolean> | boolean;
|
|
213
|
+
/**
|
|
214
|
+
* Called before updating a task. Return false to deny access.
|
|
215
|
+
*/
|
|
216
|
+
onBeforeUpdateTask?: (
|
|
217
|
+
taskId: string,
|
|
218
|
+
data: z.infer<typeof updateTaskSchema>,
|
|
219
|
+
context: KanbanApiContext,
|
|
220
|
+
) => Promise<boolean> | boolean;
|
|
221
|
+
/**
|
|
222
|
+
* Called before deleting a task. Return false to deny access.
|
|
223
|
+
*/
|
|
224
|
+
onBeforeDeleteTask?: (
|
|
225
|
+
taskId: string,
|
|
226
|
+
context: KanbanApiContext,
|
|
227
|
+
) => Promise<boolean> | boolean;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Called after a task is created successfully
|
|
231
|
+
*/
|
|
232
|
+
onTaskCreated?: (
|
|
233
|
+
task: Task,
|
|
234
|
+
context: KanbanApiContext,
|
|
235
|
+
) => Promise<void> | void;
|
|
236
|
+
/**
|
|
237
|
+
* Called after a task is updated successfully
|
|
238
|
+
*/
|
|
239
|
+
onTaskUpdated?: (
|
|
240
|
+
task: Task,
|
|
241
|
+
context: KanbanApiContext,
|
|
242
|
+
) => Promise<void> | void;
|
|
243
|
+
/**
|
|
244
|
+
* Called after a task is deleted successfully
|
|
245
|
+
*/
|
|
246
|
+
onTaskDeleted?: (
|
|
247
|
+
taskId: string,
|
|
248
|
+
context: KanbanApiContext,
|
|
249
|
+
) => Promise<void> | void;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Kanban backend plugin
|
|
254
|
+
* Provides API endpoints for managing kanban boards, columns, and tasks
|
|
255
|
+
*
|
|
256
|
+
* @param hooks - Optional configuration hooks for customizing plugin behavior
|
|
257
|
+
*/
|
|
258
|
+
export const kanbanBackendPlugin = (hooks?: KanbanBackendHooks) =>
|
|
259
|
+
defineBackendPlugin({
|
|
260
|
+
name: "kanban",
|
|
261
|
+
|
|
262
|
+
dbPlugin: dbSchema,
|
|
263
|
+
|
|
264
|
+
routes: (adapter: Adapter) => {
|
|
265
|
+
// ============ Board Endpoints ============
|
|
266
|
+
|
|
267
|
+
const listBoards = createEndpoint(
|
|
268
|
+
"/boards",
|
|
269
|
+
{
|
|
270
|
+
method: "GET",
|
|
271
|
+
query: BoardListQuerySchema,
|
|
272
|
+
},
|
|
273
|
+
async (ctx) => {
|
|
274
|
+
const { query, headers } = ctx;
|
|
275
|
+
const context: KanbanApiContext = { query, headers };
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
if (hooks?.onBeforeListBoards) {
|
|
279
|
+
const canList = await hooks.onBeforeListBoards(query, context);
|
|
280
|
+
if (!canList) {
|
|
281
|
+
throw ctx.error(403, {
|
|
282
|
+
message: "Unauthorized: Cannot list boards",
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const whereConditions = [];
|
|
288
|
+
|
|
289
|
+
if (query.slug) {
|
|
290
|
+
whereConditions.push({
|
|
291
|
+
field: "slug",
|
|
292
|
+
value: query.slug,
|
|
293
|
+
operator: "eq" as const,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (query.ownerId) {
|
|
298
|
+
whereConditions.push({
|
|
299
|
+
field: "ownerId",
|
|
300
|
+
value: query.ownerId,
|
|
301
|
+
operator: "eq" as const,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (query.organizationId) {
|
|
306
|
+
whereConditions.push({
|
|
307
|
+
field: "organizationId",
|
|
308
|
+
value: query.organizationId,
|
|
309
|
+
operator: "eq" as const,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const boards = await adapter.findMany<BoardWithKanbanColumn>({
|
|
314
|
+
model: "kanbanBoard",
|
|
315
|
+
limit: query.limit ?? 50,
|
|
316
|
+
offset: query.offset ?? 0,
|
|
317
|
+
where: whereConditions,
|
|
318
|
+
sortBy: {
|
|
319
|
+
field: "createdAt",
|
|
320
|
+
direction: "desc",
|
|
321
|
+
},
|
|
322
|
+
join: {
|
|
323
|
+
kanbanColumn: true,
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Get all column IDs to fetch tasks
|
|
328
|
+
// Note: adapter returns joined data under schema key name ("column"), not model name
|
|
329
|
+
const columnIds: string[] = [];
|
|
330
|
+
for (const board of boards) {
|
|
331
|
+
if (board.column) {
|
|
332
|
+
for (const col of board.column) {
|
|
333
|
+
columnIds.push(col.id);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Fetch tasks for each column in parallel (avoids loading all tasks from DB)
|
|
339
|
+
const tasksByColumn = new Map<string, Task[]>();
|
|
340
|
+
if (columnIds.length > 0) {
|
|
341
|
+
const taskQueries = columnIds.map((columnId) =>
|
|
342
|
+
adapter.findMany<Task>({
|
|
343
|
+
model: "kanbanTask",
|
|
344
|
+
where: [
|
|
345
|
+
{
|
|
346
|
+
field: "columnId",
|
|
347
|
+
value: columnId,
|
|
348
|
+
operator: "eq" as const,
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
sortBy: { field: "order", direction: "asc" },
|
|
352
|
+
}),
|
|
353
|
+
);
|
|
354
|
+
const taskResults = await Promise.all(taskQueries);
|
|
355
|
+
for (let i = 0; i < columnIds.length; i++) {
|
|
356
|
+
const columnId = columnIds[i];
|
|
357
|
+
const tasks = taskResults[i];
|
|
358
|
+
if (columnId && tasks) {
|
|
359
|
+
tasksByColumn.set(columnId, tasks);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Map boards with columns and tasks
|
|
365
|
+
const result = boards.map((board) => {
|
|
366
|
+
const columns = (board.column || [])
|
|
367
|
+
.sort((a, b) => a.order - b.order)
|
|
368
|
+
.map((col) => ({
|
|
369
|
+
...col,
|
|
370
|
+
tasks: tasksByColumn.get(col.id) || [],
|
|
371
|
+
}));
|
|
372
|
+
const { column: _, ...boardWithoutJoin } = board;
|
|
373
|
+
return {
|
|
374
|
+
...boardWithoutJoin,
|
|
375
|
+
columns,
|
|
376
|
+
};
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
if (hooks?.onBoardsRead) {
|
|
380
|
+
await hooks.onBoardsRead(result, query, context);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return result;
|
|
384
|
+
} catch (error) {
|
|
385
|
+
if (hooks?.onListBoardsError) {
|
|
386
|
+
await hooks.onListBoardsError(error as Error, context);
|
|
387
|
+
}
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
const getBoard = createEndpoint(
|
|
394
|
+
"/boards/:id",
|
|
395
|
+
{
|
|
396
|
+
method: "GET",
|
|
397
|
+
},
|
|
398
|
+
async (ctx) => {
|
|
399
|
+
const { params, headers } = ctx;
|
|
400
|
+
const context: KanbanApiContext = { params, headers };
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
if (hooks?.onBeforeReadBoard) {
|
|
404
|
+
const canRead = await hooks.onBeforeReadBoard(params.id, context);
|
|
405
|
+
if (!canRead) {
|
|
406
|
+
throw ctx.error(403, {
|
|
407
|
+
message: "Unauthorized: Cannot read board",
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const board = await adapter.findOne<BoardWithKanbanColumn>({
|
|
413
|
+
model: "kanbanBoard",
|
|
414
|
+
where: [
|
|
415
|
+
{ field: "id", value: params.id, operator: "eq" as const },
|
|
416
|
+
],
|
|
417
|
+
join: {
|
|
418
|
+
kanbanColumn: true,
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
if (!board) {
|
|
423
|
+
throw ctx.error(404, { message: "Board not found" });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Fetch tasks for each column in parallel (avoids loading all tasks from DB)
|
|
427
|
+
// Note: adapter returns joined data under schema key name ("column"), not model name
|
|
428
|
+
const columnIds = (board.column || []).map((c) => c.id);
|
|
429
|
+
const tasksByColumn = new Map<string, Task[]>();
|
|
430
|
+
if (columnIds.length > 0) {
|
|
431
|
+
const taskQueries = columnIds.map((columnId) =>
|
|
432
|
+
adapter.findMany<Task>({
|
|
433
|
+
model: "kanbanTask",
|
|
434
|
+
where: [
|
|
435
|
+
{
|
|
436
|
+
field: "columnId",
|
|
437
|
+
value: columnId,
|
|
438
|
+
operator: "eq" as const,
|
|
439
|
+
},
|
|
440
|
+
],
|
|
441
|
+
sortBy: { field: "order", direction: "asc" },
|
|
442
|
+
}),
|
|
443
|
+
);
|
|
444
|
+
const taskResults = await Promise.all(taskQueries);
|
|
445
|
+
for (let i = 0; i < columnIds.length; i++) {
|
|
446
|
+
const columnId = columnIds[i];
|
|
447
|
+
const tasks = taskResults[i];
|
|
448
|
+
if (columnId && tasks) {
|
|
449
|
+
tasksByColumn.set(columnId, tasks);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const columns = (board.column || [])
|
|
455
|
+
.sort((a, b) => a.order - b.order)
|
|
456
|
+
.map((col) => ({
|
|
457
|
+
...col,
|
|
458
|
+
tasks: tasksByColumn.get(col.id) || [],
|
|
459
|
+
}));
|
|
460
|
+
|
|
461
|
+
const { column: _, ...boardWithoutJoin } = board;
|
|
462
|
+
const result = {
|
|
463
|
+
...boardWithoutJoin,
|
|
464
|
+
columns,
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
if (hooks?.onBoardRead) {
|
|
468
|
+
await hooks.onBoardRead(result, context);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return result;
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (hooks?.onReadBoardError) {
|
|
474
|
+
await hooks.onReadBoardError(error as Error, context);
|
|
475
|
+
}
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const createBoard = createEndpoint(
|
|
482
|
+
"/boards",
|
|
483
|
+
{
|
|
484
|
+
method: "POST",
|
|
485
|
+
body: createBoardSchema,
|
|
486
|
+
},
|
|
487
|
+
async (ctx) => {
|
|
488
|
+
const context: KanbanApiContext = {
|
|
489
|
+
body: ctx.body,
|
|
490
|
+
headers: ctx.headers,
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
if (hooks?.onBeforeCreateBoard) {
|
|
495
|
+
const canCreate = await hooks.onBeforeCreateBoard(
|
|
496
|
+
ctx.body,
|
|
497
|
+
context,
|
|
498
|
+
);
|
|
499
|
+
if (!canCreate) {
|
|
500
|
+
throw ctx.error(403, {
|
|
501
|
+
message: "Unauthorized: Cannot create board",
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const { ...boardData } = ctx.body;
|
|
507
|
+
const slug = slugify(boardData.slug || boardData.name);
|
|
508
|
+
|
|
509
|
+
if (!slug) {
|
|
510
|
+
throw ctx.error(400, {
|
|
511
|
+
message:
|
|
512
|
+
"Invalid slug: must contain at least one alphanumeric character",
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Use transaction to ensure board and default columns are created atomically
|
|
517
|
+
let newBoard: Board | undefined;
|
|
518
|
+
const createdColumns: ColumnWithTasks[] = [];
|
|
519
|
+
|
|
520
|
+
await adapter.transaction(async (tx) => {
|
|
521
|
+
newBoard = await tx.create<Board>({
|
|
522
|
+
model: "kanbanBoard",
|
|
523
|
+
data: {
|
|
524
|
+
...boardData,
|
|
525
|
+
slug,
|
|
526
|
+
createdAt: new Date(),
|
|
527
|
+
updatedAt: new Date(),
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// Create default columns
|
|
532
|
+
const defaultColumns = [
|
|
533
|
+
{ title: "To Do", order: 0, boardId: newBoard.id },
|
|
534
|
+
{ title: "In Progress", order: 1, boardId: newBoard.id },
|
|
535
|
+
{ title: "Done", order: 2, boardId: newBoard.id },
|
|
536
|
+
];
|
|
537
|
+
|
|
538
|
+
for (const colData of defaultColumns) {
|
|
539
|
+
const col = await tx.create<Column>({
|
|
540
|
+
model: "kanbanColumn",
|
|
541
|
+
data: {
|
|
542
|
+
...colData,
|
|
543
|
+
createdAt: new Date(),
|
|
544
|
+
updatedAt: new Date(),
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
createdColumns.push({ ...col, tasks: [] });
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
if (!newBoard) {
|
|
552
|
+
throw ctx.error(500, {
|
|
553
|
+
message: "Failed to create board",
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const result = { ...newBoard, columns: createdColumns };
|
|
558
|
+
|
|
559
|
+
if (hooks?.onBoardCreated) {
|
|
560
|
+
await hooks.onBoardCreated(result, context);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return result;
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (hooks?.onCreateBoardError) {
|
|
566
|
+
await hooks.onCreateBoardError(error as Error, context);
|
|
567
|
+
}
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
const updateBoard = createEndpoint(
|
|
574
|
+
"/boards/:id",
|
|
575
|
+
{
|
|
576
|
+
method: "PUT",
|
|
577
|
+
body: updateBoardSchema.omit({ id: true }),
|
|
578
|
+
},
|
|
579
|
+
async (ctx) => {
|
|
580
|
+
const context: KanbanApiContext = {
|
|
581
|
+
body: ctx.body,
|
|
582
|
+
params: ctx.params,
|
|
583
|
+
headers: ctx.headers,
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
try {
|
|
587
|
+
if (hooks?.onBeforeUpdateBoard) {
|
|
588
|
+
const canUpdate = await hooks.onBeforeUpdateBoard(
|
|
589
|
+
ctx.params.id,
|
|
590
|
+
{ ...ctx.body, id: ctx.params.id },
|
|
591
|
+
context,
|
|
592
|
+
);
|
|
593
|
+
if (!canUpdate) {
|
|
594
|
+
throw ctx.error(403, {
|
|
595
|
+
message: "Unauthorized: Cannot update board",
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const { slug: rawSlug, ...restBoardData } = ctx.body;
|
|
601
|
+
const slugified = rawSlug ? slugify(rawSlug) : undefined;
|
|
602
|
+
|
|
603
|
+
if (rawSlug && !slugified) {
|
|
604
|
+
throw ctx.error(400, {
|
|
605
|
+
message:
|
|
606
|
+
"Invalid slug: must contain at least one alphanumeric character",
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const boardData = {
|
|
611
|
+
...restBoardData,
|
|
612
|
+
...(slugified ? { slug: slugified } : {}),
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const updated = await adapter.update<Board>({
|
|
616
|
+
model: "kanbanBoard",
|
|
617
|
+
where: [{ field: "id", value: ctx.params.id }],
|
|
618
|
+
update: {
|
|
619
|
+
...boardData,
|
|
620
|
+
updatedAt: new Date(),
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
if (!updated) {
|
|
625
|
+
throw ctx.error(404, { message: "Board not found" });
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (hooks?.onBoardUpdated) {
|
|
629
|
+
await hooks.onBoardUpdated(updated, context);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return updated;
|
|
633
|
+
} catch (error) {
|
|
634
|
+
if (hooks?.onUpdateBoardError) {
|
|
635
|
+
await hooks.onUpdateBoardError(error as Error, context);
|
|
636
|
+
}
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
},
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
const deleteBoard = createEndpoint(
|
|
643
|
+
"/boards/:id",
|
|
644
|
+
{
|
|
645
|
+
method: "DELETE",
|
|
646
|
+
},
|
|
647
|
+
async (ctx) => {
|
|
648
|
+
const context: KanbanApiContext = {
|
|
649
|
+
params: ctx.params,
|
|
650
|
+
headers: ctx.headers,
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
try {
|
|
654
|
+
// Verify the board exists before attempting deletion
|
|
655
|
+
const existingBoard = await adapter.findOne<Board>({
|
|
656
|
+
model: "kanbanBoard",
|
|
657
|
+
where: [
|
|
658
|
+
{ field: "id", value: ctx.params.id, operator: "eq" as const },
|
|
659
|
+
],
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
if (!existingBoard) {
|
|
663
|
+
throw ctx.error(404, { message: "Board not found" });
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (hooks?.onBeforeDeleteBoard) {
|
|
667
|
+
const canDelete = await hooks.onBeforeDeleteBoard(
|
|
668
|
+
ctx.params.id,
|
|
669
|
+
context,
|
|
670
|
+
);
|
|
671
|
+
if (!canDelete) {
|
|
672
|
+
throw ctx.error(403, {
|
|
673
|
+
message: "Unauthorized: Cannot delete board",
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
await adapter.delete<Board>({
|
|
679
|
+
model: "kanbanBoard",
|
|
680
|
+
where: [{ field: "id", value: ctx.params.id }],
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
if (hooks?.onBoardDeleted) {
|
|
684
|
+
await hooks.onBoardDeleted(ctx.params.id, context);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return { success: true };
|
|
688
|
+
} catch (error) {
|
|
689
|
+
if (hooks?.onDeleteBoardError) {
|
|
690
|
+
await hooks.onDeleteBoardError(error as Error, context);
|
|
691
|
+
}
|
|
692
|
+
throw error;
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
// ============ Column Endpoints ============
|
|
698
|
+
|
|
699
|
+
const createColumn = createEndpoint(
|
|
700
|
+
"/columns",
|
|
701
|
+
{
|
|
702
|
+
method: "POST",
|
|
703
|
+
body: createColumnSchema,
|
|
704
|
+
},
|
|
705
|
+
async (ctx) => {
|
|
706
|
+
const context: KanbanApiContext = {
|
|
707
|
+
body: ctx.body,
|
|
708
|
+
headers: ctx.headers,
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
try {
|
|
712
|
+
if (hooks?.onBeforeCreateColumn) {
|
|
713
|
+
const canCreate = await hooks.onBeforeCreateColumn(
|
|
714
|
+
ctx.body,
|
|
715
|
+
context,
|
|
716
|
+
);
|
|
717
|
+
if (!canCreate) {
|
|
718
|
+
throw ctx.error(403, {
|
|
719
|
+
message: "Unauthorized: Cannot create column",
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Get existing columns to determine order
|
|
725
|
+
const existingColumns = await adapter.findMany<Column>({
|
|
726
|
+
model: "kanbanColumn",
|
|
727
|
+
where: [
|
|
728
|
+
{
|
|
729
|
+
field: "boardId",
|
|
730
|
+
value: ctx.body.boardId,
|
|
731
|
+
operator: "eq" as const,
|
|
732
|
+
},
|
|
733
|
+
],
|
|
734
|
+
});
|
|
735
|
+
const nextOrder =
|
|
736
|
+
existingColumns.length > 0
|
|
737
|
+
? Math.max(...existingColumns.map((c) => c.order)) + 1
|
|
738
|
+
: 0;
|
|
739
|
+
|
|
740
|
+
const newColumn = await adapter.create<Column>({
|
|
741
|
+
model: "kanbanColumn",
|
|
742
|
+
data: {
|
|
743
|
+
...ctx.body,
|
|
744
|
+
order: ctx.body.order ?? nextOrder,
|
|
745
|
+
createdAt: new Date(),
|
|
746
|
+
updatedAt: new Date(),
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
if (hooks?.onColumnCreated) {
|
|
751
|
+
await hooks.onColumnCreated(newColumn, context);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return newColumn;
|
|
755
|
+
} catch (error) {
|
|
756
|
+
throw error;
|
|
757
|
+
}
|
|
758
|
+
},
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
const updateColumn = createEndpoint(
|
|
762
|
+
"/columns/:id",
|
|
763
|
+
{
|
|
764
|
+
method: "PUT",
|
|
765
|
+
body: updateColumnSchema.omit({ id: true }),
|
|
766
|
+
},
|
|
767
|
+
async (ctx) => {
|
|
768
|
+
const context: KanbanApiContext = {
|
|
769
|
+
body: ctx.body,
|
|
770
|
+
params: ctx.params,
|
|
771
|
+
headers: ctx.headers,
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
try {
|
|
775
|
+
if (hooks?.onBeforeUpdateColumn) {
|
|
776
|
+
const canUpdate = await hooks.onBeforeUpdateColumn(
|
|
777
|
+
ctx.params.id,
|
|
778
|
+
{ ...ctx.body, id: ctx.params.id },
|
|
779
|
+
context,
|
|
780
|
+
);
|
|
781
|
+
if (!canUpdate) {
|
|
782
|
+
throw ctx.error(403, {
|
|
783
|
+
message: "Unauthorized: Cannot update column",
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const updated = await adapter.update<Column>({
|
|
789
|
+
model: "kanbanColumn",
|
|
790
|
+
where: [{ field: "id", value: ctx.params.id }],
|
|
791
|
+
update: {
|
|
792
|
+
...ctx.body,
|
|
793
|
+
updatedAt: new Date(),
|
|
794
|
+
},
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
if (!updated) {
|
|
798
|
+
throw ctx.error(404, { message: "Column not found" });
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (hooks?.onColumnUpdated) {
|
|
802
|
+
await hooks.onColumnUpdated(updated, context);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
return updated;
|
|
806
|
+
} catch (error) {
|
|
807
|
+
throw error;
|
|
808
|
+
}
|
|
809
|
+
},
|
|
810
|
+
);
|
|
811
|
+
|
|
812
|
+
const deleteColumn = createEndpoint(
|
|
813
|
+
"/columns/:id",
|
|
814
|
+
{
|
|
815
|
+
method: "DELETE",
|
|
816
|
+
},
|
|
817
|
+
async (ctx) => {
|
|
818
|
+
const context: KanbanApiContext = {
|
|
819
|
+
params: ctx.params,
|
|
820
|
+
headers: ctx.headers,
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
try {
|
|
824
|
+
// Verify the column exists before attempting deletion
|
|
825
|
+
const existingColumn = await adapter.findOne<Column>({
|
|
826
|
+
model: "kanbanColumn",
|
|
827
|
+
where: [
|
|
828
|
+
{ field: "id", value: ctx.params.id, operator: "eq" as const },
|
|
829
|
+
],
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
if (!existingColumn) {
|
|
833
|
+
throw ctx.error(404, { message: "Column not found" });
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (hooks?.onBeforeDeleteColumn) {
|
|
837
|
+
const canDelete = await hooks.onBeforeDeleteColumn(
|
|
838
|
+
ctx.params.id,
|
|
839
|
+
context,
|
|
840
|
+
);
|
|
841
|
+
if (!canDelete) {
|
|
842
|
+
throw ctx.error(403, {
|
|
843
|
+
message: "Unauthorized: Cannot delete column",
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
await adapter.delete<Column>({
|
|
849
|
+
model: "kanbanColumn",
|
|
850
|
+
where: [{ field: "id", value: ctx.params.id }],
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
if (hooks?.onColumnDeleted) {
|
|
854
|
+
await hooks.onColumnDeleted(ctx.params.id, context);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
return { success: true };
|
|
858
|
+
} catch (error) {
|
|
859
|
+
throw error;
|
|
860
|
+
}
|
|
861
|
+
},
|
|
862
|
+
);
|
|
863
|
+
|
|
864
|
+
const reorderColumns = createEndpoint(
|
|
865
|
+
"/columns/reorder",
|
|
866
|
+
{
|
|
867
|
+
method: "POST",
|
|
868
|
+
body: reorderColumnsSchema,
|
|
869
|
+
},
|
|
870
|
+
async (ctx) => {
|
|
871
|
+
const { boardId, columnIds } = ctx.body;
|
|
872
|
+
const context: KanbanApiContext = {
|
|
873
|
+
body: ctx.body,
|
|
874
|
+
headers: ctx.headers,
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
// Check authorization for each column being reordered
|
|
878
|
+
if (hooks?.onBeforeUpdateColumn) {
|
|
879
|
+
for (let i = 0; i < columnIds.length; i++) {
|
|
880
|
+
const columnId = columnIds[i];
|
|
881
|
+
if (!columnId) continue;
|
|
882
|
+
const canUpdate = await hooks.onBeforeUpdateColumn(
|
|
883
|
+
columnId,
|
|
884
|
+
{ id: columnId, order: i },
|
|
885
|
+
context,
|
|
886
|
+
);
|
|
887
|
+
if (!canUpdate) {
|
|
888
|
+
throw ctx.error(403, {
|
|
889
|
+
message: "Unauthorized: Cannot reorder columns",
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const updatedColumns: Column[] = [];
|
|
896
|
+
await adapter.transaction(async (tx) => {
|
|
897
|
+
for (let i = 0; i < columnIds.length; i++) {
|
|
898
|
+
const columnId = columnIds[i];
|
|
899
|
+
if (!columnId) continue;
|
|
900
|
+
const updated = await tx.update<Column>({
|
|
901
|
+
model: "kanbanColumn",
|
|
902
|
+
where: [
|
|
903
|
+
{ field: "id", value: columnId },
|
|
904
|
+
{ field: "boardId", value: boardId, operator: "eq" as const },
|
|
905
|
+
],
|
|
906
|
+
update: { order: i, updatedAt: new Date() },
|
|
907
|
+
});
|
|
908
|
+
if (updated) {
|
|
909
|
+
updatedColumns.push(updated);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
// Call onColumnUpdated for each reordered column
|
|
915
|
+
if (hooks?.onColumnUpdated) {
|
|
916
|
+
for (const column of updatedColumns) {
|
|
917
|
+
await hooks.onColumnUpdated(column, context);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
return { success: true };
|
|
922
|
+
},
|
|
923
|
+
);
|
|
924
|
+
|
|
925
|
+
// ============ Task Endpoints ============
|
|
926
|
+
|
|
927
|
+
const createTask = createEndpoint(
|
|
928
|
+
"/tasks",
|
|
929
|
+
{
|
|
930
|
+
method: "POST",
|
|
931
|
+
body: createTaskSchema,
|
|
932
|
+
},
|
|
933
|
+
async (ctx) => {
|
|
934
|
+
const context: KanbanApiContext = {
|
|
935
|
+
body: ctx.body,
|
|
936
|
+
headers: ctx.headers,
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
try {
|
|
940
|
+
if (hooks?.onBeforeCreateTask) {
|
|
941
|
+
const canCreate = await hooks.onBeforeCreateTask(
|
|
942
|
+
ctx.body,
|
|
943
|
+
context,
|
|
944
|
+
);
|
|
945
|
+
if (!canCreate) {
|
|
946
|
+
throw ctx.error(403, {
|
|
947
|
+
message: "Unauthorized: Cannot create task",
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Get existing tasks in column to determine order
|
|
953
|
+
const existingTasks = await adapter.findMany<Task>({
|
|
954
|
+
model: "kanbanTask",
|
|
955
|
+
where: [
|
|
956
|
+
{
|
|
957
|
+
field: "columnId",
|
|
958
|
+
value: ctx.body.columnId,
|
|
959
|
+
operator: "eq" as const,
|
|
960
|
+
},
|
|
961
|
+
],
|
|
962
|
+
});
|
|
963
|
+
const nextOrder =
|
|
964
|
+
existingTasks.length > 0
|
|
965
|
+
? Math.max(...existingTasks.map((t) => t.order)) + 1
|
|
966
|
+
: 0;
|
|
967
|
+
|
|
968
|
+
const taskData: Omit<Task, "id"> = {
|
|
969
|
+
title: ctx.body.title,
|
|
970
|
+
columnId: ctx.body.columnId,
|
|
971
|
+
description: ctx.body.description,
|
|
972
|
+
priority: ctx.body.priority || "MEDIUM",
|
|
973
|
+
order: ctx.body.order ?? nextOrder,
|
|
974
|
+
assigneeId: ctx.body.assigneeId ?? undefined,
|
|
975
|
+
isArchived: ctx.body.isArchived ?? false,
|
|
976
|
+
createdAt: new Date(),
|
|
977
|
+
updatedAt: new Date(),
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
const newTask = await adapter.create<Task>({
|
|
981
|
+
model: "kanbanTask",
|
|
982
|
+
data: taskData,
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
if (hooks?.onTaskCreated) {
|
|
986
|
+
await hooks.onTaskCreated(newTask, context);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
return newTask;
|
|
990
|
+
} catch (error) {
|
|
991
|
+
throw error;
|
|
992
|
+
}
|
|
993
|
+
},
|
|
994
|
+
);
|
|
995
|
+
|
|
996
|
+
const updateTask = createEndpoint(
|
|
997
|
+
"/tasks/:id",
|
|
998
|
+
{
|
|
999
|
+
method: "PUT",
|
|
1000
|
+
body: updateTaskSchema.omit({ id: true }),
|
|
1001
|
+
},
|
|
1002
|
+
async (ctx) => {
|
|
1003
|
+
const context: KanbanApiContext = {
|
|
1004
|
+
body: ctx.body,
|
|
1005
|
+
params: ctx.params,
|
|
1006
|
+
headers: ctx.headers,
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
try {
|
|
1010
|
+
if (hooks?.onBeforeUpdateTask) {
|
|
1011
|
+
const canUpdate = await hooks.onBeforeUpdateTask(
|
|
1012
|
+
ctx.params.id,
|
|
1013
|
+
{ ...ctx.body, id: ctx.params.id },
|
|
1014
|
+
context,
|
|
1015
|
+
);
|
|
1016
|
+
if (!canUpdate) {
|
|
1017
|
+
throw ctx.error(403, {
|
|
1018
|
+
message: "Unauthorized: Cannot update task",
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const updated = await adapter.update<Task>({
|
|
1024
|
+
model: "kanbanTask",
|
|
1025
|
+
where: [{ field: "id", value: ctx.params.id }],
|
|
1026
|
+
update: {
|
|
1027
|
+
...ctx.body,
|
|
1028
|
+
updatedAt: new Date(),
|
|
1029
|
+
},
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
if (!updated) {
|
|
1033
|
+
throw ctx.error(404, { message: "Task not found" });
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (hooks?.onTaskUpdated) {
|
|
1037
|
+
await hooks.onTaskUpdated(updated, context);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return updated;
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
throw error;
|
|
1043
|
+
}
|
|
1044
|
+
},
|
|
1045
|
+
);
|
|
1046
|
+
|
|
1047
|
+
const deleteTask = createEndpoint(
|
|
1048
|
+
"/tasks/:id",
|
|
1049
|
+
{
|
|
1050
|
+
method: "DELETE",
|
|
1051
|
+
},
|
|
1052
|
+
async (ctx) => {
|
|
1053
|
+
const context: KanbanApiContext = {
|
|
1054
|
+
params: ctx.params,
|
|
1055
|
+
headers: ctx.headers,
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
try {
|
|
1059
|
+
// Verify the task exists before attempting deletion
|
|
1060
|
+
const existingTask = await adapter.findOne<Task>({
|
|
1061
|
+
model: "kanbanTask",
|
|
1062
|
+
where: [
|
|
1063
|
+
{ field: "id", value: ctx.params.id, operator: "eq" as const },
|
|
1064
|
+
],
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
if (!existingTask) {
|
|
1068
|
+
throw ctx.error(404, { message: "Task not found" });
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (hooks?.onBeforeDeleteTask) {
|
|
1072
|
+
const canDelete = await hooks.onBeforeDeleteTask(
|
|
1073
|
+
ctx.params.id,
|
|
1074
|
+
context,
|
|
1075
|
+
);
|
|
1076
|
+
if (!canDelete) {
|
|
1077
|
+
throw ctx.error(403, {
|
|
1078
|
+
message: "Unauthorized: Cannot delete task",
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
await adapter.delete<Task>({
|
|
1084
|
+
model: "kanbanTask",
|
|
1085
|
+
where: [{ field: "id", value: ctx.params.id }],
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
if (hooks?.onTaskDeleted) {
|
|
1089
|
+
await hooks.onTaskDeleted(ctx.params.id, context);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
return { success: true };
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
throw error;
|
|
1095
|
+
}
|
|
1096
|
+
},
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
const moveTask = createEndpoint(
|
|
1100
|
+
"/tasks/move",
|
|
1101
|
+
{
|
|
1102
|
+
method: "POST",
|
|
1103
|
+
body: moveTaskSchema,
|
|
1104
|
+
},
|
|
1105
|
+
async (ctx) => {
|
|
1106
|
+
const { taskId, targetColumnId, targetOrder } = ctx.body;
|
|
1107
|
+
const context: KanbanApiContext = {
|
|
1108
|
+
body: ctx.body,
|
|
1109
|
+
headers: ctx.headers,
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
// Get current task
|
|
1113
|
+
const task = await adapter.findOne<Task>({
|
|
1114
|
+
model: "kanbanTask",
|
|
1115
|
+
where: [{ field: "id", value: taskId, operator: "eq" as const }],
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
if (!task) {
|
|
1119
|
+
throw ctx.error(404, { message: "Task not found" });
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Check authorization before moving task
|
|
1123
|
+
if (hooks?.onBeforeUpdateTask) {
|
|
1124
|
+
const canUpdate = await hooks.onBeforeUpdateTask(
|
|
1125
|
+
taskId,
|
|
1126
|
+
{ id: taskId, columnId: targetColumnId, order: targetOrder },
|
|
1127
|
+
context,
|
|
1128
|
+
);
|
|
1129
|
+
if (!canUpdate) {
|
|
1130
|
+
throw ctx.error(403, {
|
|
1131
|
+
message: "Unauthorized: Cannot move task",
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Update task with new column and order
|
|
1137
|
+
const updated = await adapter.update<Task>({
|
|
1138
|
+
model: "kanbanTask",
|
|
1139
|
+
where: [{ field: "id", value: taskId }],
|
|
1140
|
+
update: {
|
|
1141
|
+
columnId: targetColumnId,
|
|
1142
|
+
order: targetOrder,
|
|
1143
|
+
updatedAt: new Date(),
|
|
1144
|
+
},
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
if (!updated) {
|
|
1148
|
+
throw ctx.error(404, { message: "Task not found" });
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (hooks?.onTaskUpdated) {
|
|
1152
|
+
await hooks.onTaskUpdated(updated, context);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
return updated;
|
|
1156
|
+
},
|
|
1157
|
+
);
|
|
1158
|
+
|
|
1159
|
+
const reorderTasks = createEndpoint(
|
|
1160
|
+
"/tasks/reorder",
|
|
1161
|
+
{
|
|
1162
|
+
method: "POST",
|
|
1163
|
+
body: reorderTasksSchema,
|
|
1164
|
+
},
|
|
1165
|
+
async (ctx) => {
|
|
1166
|
+
const { columnId, taskIds } = ctx.body;
|
|
1167
|
+
const context: KanbanApiContext = {
|
|
1168
|
+
body: ctx.body,
|
|
1169
|
+
headers: ctx.headers,
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
// Check authorization for each task being reordered
|
|
1173
|
+
if (hooks?.onBeforeUpdateTask) {
|
|
1174
|
+
for (let i = 0; i < taskIds.length; i++) {
|
|
1175
|
+
const taskId = taskIds[i];
|
|
1176
|
+
if (!taskId) continue;
|
|
1177
|
+
const canUpdate = await hooks.onBeforeUpdateTask(
|
|
1178
|
+
taskId,
|
|
1179
|
+
{ id: taskId, order: i },
|
|
1180
|
+
context,
|
|
1181
|
+
);
|
|
1182
|
+
if (!canUpdate) {
|
|
1183
|
+
throw ctx.error(403, {
|
|
1184
|
+
message: "Unauthorized: Cannot reorder tasks",
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const updatedTasks: Task[] = [];
|
|
1191
|
+
await adapter.transaction(async (tx) => {
|
|
1192
|
+
for (let i = 0; i < taskIds.length; i++) {
|
|
1193
|
+
const taskId = taskIds[i];
|
|
1194
|
+
if (!taskId) continue;
|
|
1195
|
+
const updated = await tx.update<Task>({
|
|
1196
|
+
model: "kanbanTask",
|
|
1197
|
+
where: [
|
|
1198
|
+
{ field: "id", value: taskId },
|
|
1199
|
+
{
|
|
1200
|
+
field: "columnId",
|
|
1201
|
+
value: columnId,
|
|
1202
|
+
operator: "eq" as const,
|
|
1203
|
+
},
|
|
1204
|
+
],
|
|
1205
|
+
update: { order: i, updatedAt: new Date() },
|
|
1206
|
+
});
|
|
1207
|
+
if (updated) {
|
|
1208
|
+
updatedTasks.push(updated);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
// Call onTaskUpdated for each reordered task
|
|
1214
|
+
if (hooks?.onTaskUpdated) {
|
|
1215
|
+
for (const task of updatedTasks) {
|
|
1216
|
+
await hooks.onTaskUpdated(task, context);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
return { success: true };
|
|
1221
|
+
},
|
|
1222
|
+
);
|
|
1223
|
+
|
|
1224
|
+
return {
|
|
1225
|
+
listBoards,
|
|
1226
|
+
getBoard,
|
|
1227
|
+
createBoard,
|
|
1228
|
+
updateBoard,
|
|
1229
|
+
deleteBoard,
|
|
1230
|
+
createColumn,
|
|
1231
|
+
updateColumn,
|
|
1232
|
+
deleteColumn,
|
|
1233
|
+
reorderColumns,
|
|
1234
|
+
createTask,
|
|
1235
|
+
updateTask,
|
|
1236
|
+
deleteTask,
|
|
1237
|
+
moveTask,
|
|
1238
|
+
reorderTasks,
|
|
1239
|
+
} as const;
|
|
1240
|
+
},
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
export type KanbanApiRouter = ReturnType<
|
|
1244
|
+
ReturnType<typeof kanbanBackendPlugin>["routes"]
|
|
1245
|
+
>;
|