@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.
Files changed (163) hide show
  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.cjs +1 -1
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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
  8. 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
  9. 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
  10. 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
  11. package/dist/packages/better-stack/src/plugins/kanban/api/plugin.cjs +846 -0
  12. package/dist/packages/better-stack/src/plugins/kanban/api/plugin.mjs +844 -0
  13. package/dist/packages/better-stack/src/plugins/kanban/client/components/forms/board-form.cjs +85 -0
  14. package/dist/packages/better-stack/src/plugins/kanban/client/components/forms/board-form.mjs +83 -0
  15. package/dist/packages/better-stack/src/plugins/kanban/client/components/forms/column-form.cjs +72 -0
  16. package/dist/packages/better-stack/src/plugins/kanban/client/components/forms/column-form.mjs +70 -0
  17. package/dist/packages/better-stack/src/plugins/kanban/client/components/forms/task-form.cjs +200 -0
  18. package/dist/packages/better-stack/src/plugins/kanban/client/components/forms/task-form.mjs +198 -0
  19. package/dist/packages/better-stack/src/plugins/kanban/client/components/loading/board-skeleton.cjs +47 -0
  20. package/dist/packages/better-stack/src/plugins/kanban/client/components/loading/board-skeleton.mjs +45 -0
  21. package/dist/packages/better-stack/src/plugins/kanban/client/components/loading/boards-list-skeleton.cjs +30 -0
  22. package/dist/packages/better-stack/src/plugins/kanban/client/components/loading/boards-list-skeleton.mjs +28 -0
  23. package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/404-page.cjs +27 -0
  24. package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/404-page.mjs +25 -0
  25. package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/board-page.cjs +31 -0
  26. package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/board-page.internal.cjs +458 -0
  27. package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/board-page.internal.mjs +456 -0
  28. package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/board-page.mjs +29 -0
  29. package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/boards-list-page.cjs +30 -0
  30. package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/boards-list-page.internal.cjs +72 -0
  31. package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/boards-list-page.internal.mjs +70 -0
  32. package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/boards-list-page.mjs +28 -0
  33. package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/new-board-page.cjs +30 -0
  34. package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/new-board-page.internal.cjs +51 -0
  35. package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/new-board-page.internal.mjs +49 -0
  36. package/dist/packages/better-stack/src/plugins/kanban/client/components/pages/new-board-page.mjs +28 -0
  37. package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/column-content.cjs +76 -0
  38. package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/column-content.mjs +74 -0
  39. package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/default-error.cjs +27 -0
  40. package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/default-error.mjs +25 -0
  41. package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/empty-state.cjs +32 -0
  42. package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/empty-state.mjs +30 -0
  43. package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/kanban-board.cjs +78 -0
  44. package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/kanban-board.mjs +76 -0
  45. package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/page-wrapper.cjs +15 -0
  46. package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/page-wrapper.mjs +13 -0
  47. package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/task-card.cjs +68 -0
  48. package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/task-card.mjs +66 -0
  49. package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/user-avatar.cjs +32 -0
  50. package/dist/packages/better-stack/src/plugins/kanban/client/components/shared/user-avatar.mjs +30 -0
  51. package/dist/packages/better-stack/src/plugins/kanban/client/hooks/kanban-hooks.cjs +391 -0
  52. package/dist/packages/better-stack/src/plugins/kanban/client/hooks/kanban-hooks.mjs +381 -0
  53. package/dist/packages/better-stack/src/plugins/kanban/client/plugin.cjs +290 -0
  54. package/dist/packages/better-stack/src/plugins/kanban/client/plugin.mjs +288 -0
  55. package/dist/packages/better-stack/src/plugins/kanban/db.cjs +125 -0
  56. package/dist/packages/better-stack/src/plugins/kanban/db.mjs +123 -0
  57. package/dist/packages/better-stack/src/plugins/kanban/schemas.cjs +117 -0
  58. package/dist/packages/better-stack/src/plugins/kanban/schemas.mjs +102 -0
  59. package/dist/packages/better-stack/src/plugins/kanban/utils.cjs +49 -0
  60. package/dist/packages/better-stack/src/plugins/kanban/utils.mjs +45 -0
  61. package/dist/packages/ui/src/components/avatar.cjs +58 -0
  62. package/dist/packages/ui/src/components/avatar.mjs +54 -0
  63. package/dist/packages/ui/src/components/command.cjs +3 -3
  64. package/dist/packages/ui/src/components/command.mjs +3 -3
  65. package/dist/packages/ui/src/components/form-builder/index.mjs +2 -2
  66. package/dist/packages/ui/src/components/kanban.cjs +835 -0
  67. package/dist/packages/ui/src/components/kanban.mjs +805 -0
  68. package/dist/packages/ui/src/components/popover.cjs +8 -3
  69. package/dist/packages/ui/src/components/popover.mjs +9 -4
  70. package/dist/packages/ui/src/components/search-select.cjs +75 -0
  71. package/dist/packages/ui/src/components/search-select.mjs +73 -0
  72. package/dist/packages/ui/src/lib/compose-refs.cjs +56 -0
  73. package/dist/packages/ui/src/lib/compose-refs.mjs +39 -0
  74. package/dist/plugins/blog/api/index.d.cts +1 -1
  75. package/dist/plugins/blog/api/index.d.mts +1 -1
  76. package/dist/plugins/blog/api/index.d.ts +1 -1
  77. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  78. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  79. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  80. package/dist/plugins/blog/client/index.d.cts +1 -1
  81. package/dist/plugins/blog/client/index.d.mts +1 -1
  82. package/dist/plugins/blog/client/index.d.ts +1 -1
  83. package/dist/plugins/blog/query-keys.d.cts +2 -2
  84. package/dist/plugins/blog/query-keys.d.mts +2 -2
  85. package/dist/plugins/blog/query-keys.d.ts +2 -2
  86. package/dist/plugins/kanban/api/index.cjs +7 -0
  87. package/dist/plugins/kanban/api/index.d.cts +403 -0
  88. package/dist/plugins/kanban/api/index.d.mts +403 -0
  89. package/dist/plugins/kanban/api/index.d.ts +403 -0
  90. package/dist/plugins/kanban/api/index.mjs +1 -0
  91. package/dist/plugins/kanban/client/components/index.cjs +35 -0
  92. package/dist/plugins/kanban/client/components/index.d.cts +102 -0
  93. package/dist/plugins/kanban/client/components/index.d.mts +102 -0
  94. package/dist/plugins/kanban/client/components/index.d.ts +102 -0
  95. package/dist/plugins/kanban/client/components/index.mjs +15 -0
  96. package/dist/plugins/kanban/client/hooks/index.cjs +15 -0
  97. package/dist/plugins/kanban/client/hooks/index.d.cts +143 -0
  98. package/dist/plugins/kanban/client/hooks/index.d.mts +143 -0
  99. package/dist/plugins/kanban/client/hooks/index.d.ts +143 -0
  100. package/dist/plugins/kanban/client/hooks/index.mjs +1 -0
  101. package/dist/plugins/kanban/client/index.cjs +7 -0
  102. package/dist/plugins/kanban/client/index.d.cts +196 -0
  103. package/dist/plugins/kanban/client/index.d.mts +196 -0
  104. package/dist/plugins/kanban/client/index.d.ts +196 -0
  105. package/dist/plugins/kanban/client/index.mjs +1 -0
  106. package/dist/plugins/kanban/client.css +68 -0
  107. package/dist/plugins/kanban/query-keys.cjs +105 -0
  108. package/dist/plugins/kanban/query-keys.d.cts +59 -0
  109. package/dist/plugins/kanban/query-keys.d.mts +59 -0
  110. package/dist/plugins/kanban/query-keys.d.ts +59 -0
  111. package/dist/plugins/kanban/query-keys.mjs +103 -0
  112. package/dist/plugins/kanban/style.css +7 -0
  113. package/dist/plugins/ui-builder/style.css +6 -0
  114. package/dist/shared/stack.DKDMI-QO.d.cts +70 -0
  115. package/dist/shared/stack.DKDMI-QO.d.mts +70 -0
  116. package/dist/shared/stack.DKDMI-QO.d.ts +70 -0
  117. package/dist/shared/stack.FeaWkglm.d.cts +190 -0
  118. package/dist/shared/stack.FeaWkglm.d.mts +190 -0
  119. package/dist/shared/stack.FeaWkglm.d.ts +190 -0
  120. package/package.json +56 -2
  121. package/src/plugins/kanban/api/index.ts +6 -0
  122. package/src/plugins/kanban/api/plugin.ts +1245 -0
  123. package/src/plugins/kanban/client/components/forms/board-form.tsx +108 -0
  124. package/src/plugins/kanban/client/components/forms/column-form.tsx +97 -0
  125. package/src/plugins/kanban/client/components/forms/task-form.tsx +274 -0
  126. package/src/plugins/kanban/client/components/index.tsx +21 -0
  127. package/src/plugins/kanban/client/components/loading/board-skeleton.tsx +49 -0
  128. package/src/plugins/kanban/client/components/loading/boards-list-skeleton.tsx +34 -0
  129. package/src/plugins/kanban/client/components/loading/index.tsx +2 -0
  130. package/src/plugins/kanban/client/components/pages/404-page.tsx +28 -0
  131. package/src/plugins/kanban/client/components/pages/board-page.internal.tsx +575 -0
  132. package/src/plugins/kanban/client/components/pages/board-page.tsx +31 -0
  133. package/src/plugins/kanban/client/components/pages/boards-list-page.internal.tsx +101 -0
  134. package/src/plugins/kanban/client/components/pages/boards-list-page.tsx +26 -0
  135. package/src/plugins/kanban/client/components/pages/new-board-page.internal.tsx +65 -0
  136. package/src/plugins/kanban/client/components/pages/new-board-page.tsx +26 -0
  137. package/src/plugins/kanban/client/components/shared/column-content.tsx +108 -0
  138. package/src/plugins/kanban/client/components/shared/default-error.tsx +32 -0
  139. package/src/plugins/kanban/client/components/shared/empty-state.tsx +37 -0
  140. package/src/plugins/kanban/client/components/shared/kanban-board.tsx +87 -0
  141. package/src/plugins/kanban/client/components/shared/page-wrapper.tsx +20 -0
  142. package/src/plugins/kanban/client/components/shared/task-card.tsx +79 -0
  143. package/src/plugins/kanban/client/components/shared/user-avatar.tsx +63 -0
  144. package/src/plugins/kanban/client/hooks/index.tsx +11 -0
  145. package/src/plugins/kanban/client/hooks/kanban-hooks.tsx +560 -0
  146. package/src/plugins/kanban/client/index.ts +8 -0
  147. package/src/plugins/kanban/client/localization/index.ts +28 -0
  148. package/src/plugins/kanban/client/localization/kanban-common.ts +69 -0
  149. package/src/plugins/kanban/client/localization/kanban-forms.ts +70 -0
  150. package/src/plugins/kanban/client/localization/kanban-list.ts +36 -0
  151. package/src/plugins/kanban/client/overrides.ts +145 -0
  152. package/src/plugins/kanban/client/plugin.tsx +463 -0
  153. package/src/plugins/kanban/client.css +68 -0
  154. package/src/plugins/kanban/db.ts +125 -0
  155. package/src/plugins/kanban/query-keys.ts +154 -0
  156. package/src/plugins/kanban/schemas.ts +143 -0
  157. package/src/plugins/kanban/style.css +7 -0
  158. package/src/plugins/kanban/types.ts +106 -0
  159. package/src/plugins/kanban/utils.ts +107 -0
  160. package/src/plugins/ui-builder/style.css +6 -0
  161. package/dist/shared/{stack.DLhzx1-D.d.cts → stack.CcI4sYJP.d.cts} +1 -1
  162. package/dist/shared/{stack.DLhzx1-D.d.mts → stack.CcI4sYJP.d.mts} +1 -1
  163. 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
+ >;