1- import type { Board , BoardType , BoardWithTasks , Task } from "@agent-kanban/shared" ;
1+ import type { Board , BoardLabel , BoardType , BoardWithTasks , Task } from "@agent-kanban/shared" ;
2+ import { HTTPException } from "hono/http-exception" ;
23import { customAlphabet } from "nanoid" ;
34import { seedBuiltinAgents } from "./agentRepo" ;
45import { type D1 , newId , parseJsonFields } from "./db" ;
@@ -7,6 +8,32 @@ const nanoidSlug = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 10);
78
89import { computeBlocked } from "./taskDeps" ;
910
11+ const HEX_COLOR = / ^ # [ 0 - 9 A - F a - f ] { 6 } $ / ;
12+
13+ function parseBoard < T extends Board | BoardWithTasks > ( board : T ) : T {
14+ return parseJsonFields ( board , [ "labels" ] as ( keyof T ) [ ] ) ;
15+ }
16+
17+ function normalizeLabel ( label : BoardLabel ) : BoardLabel {
18+ if ( ! label || typeof label . name !== "string" || typeof label . color !== "string" ) {
19+ throw new HTTPException ( 400 , { message : "Label name and color are required" } ) ;
20+ }
21+ const name = label . name . trim ( ) ;
22+ const color = label . color . trim ( ) ;
23+ if ( ! name ) throw new HTTPException ( 400 , { message : "Label name is required" } ) ;
24+ if ( ! HEX_COLOR . test ( color ) ) throw new HTTPException ( 400 , { message : "Label color must be a hex color like #22D3EE" } ) ;
25+ return { name, color, description : label . description ?. trim ( ) || "" } ;
26+ }
27+
28+ function normalizeLabels ( labels : BoardLabel [ ] ) : BoardLabel [ ] {
29+ const seen = new Set < string > ( ) ;
30+ return labels . map ( normalizeLabel ) . map ( ( label ) => {
31+ if ( seen . has ( label . name ) ) throw new HTTPException ( 400 , { message : `Duplicate label: ${ label . name } ` } ) ;
32+ seen . add ( label . name ) ;
33+ return label ;
34+ } ) ;
35+ }
36+
1037export async function createBoard ( db : D1 , ownerId : string , name : string , type : BoardType , description ?: string ) : Promise < Board > {
1138 const id = newId ( ) ;
1239 const now = new Date ( ) . toISOString ( ) ;
@@ -17,16 +44,18 @@ export async function createBoard(db: D1, ownerId: string, name: string, type: B
1744
1845 await seedBuiltinAgents ( db , ownerId ) ;
1946
20- return db . prepare ( "SELECT * FROM boards WHERE id = ?" ) . bind ( id ) . first < Board > ( ) as Promise < Board > ;
47+ const board = await db . prepare ( "SELECT * FROM boards WHERE id = ?" ) . bind ( id ) . first < Board > ( ) ;
48+ return parseBoard ( board ! ) ;
2149}
2250
2351export async function listBoards ( db : D1 , ownerId : string ) : Promise < Board [ ] > {
2452 const result = await db . prepare ( "SELECT * FROM boards WHERE owner_id = ? ORDER BY created_at DESC" ) . bind ( ownerId ) . all < Board > ( ) ;
25- return result . results ;
53+ return result . results . map ( parseBoard ) ;
2654}
2755
2856export async function getBoardByName ( db : D1 , ownerId : string , name : string ) : Promise < Board | null > {
29- return db . prepare ( "SELECT * FROM boards WHERE owner_id = ? AND name = ?" ) . bind ( ownerId , name ) . first < Board > ( ) ;
57+ const board = await db . prepare ( "SELECT * FROM boards WHERE owner_id = ? AND name = ?" ) . bind ( ownerId , name ) . first < Board > ( ) ;
58+ return board ? parseBoard ( board ) : null ;
3059}
3160
3261export async function getBoard ( db : D1 , boardId : string ) : Promise < BoardWithTasks | null > {
@@ -41,10 +70,7 @@ export async function getBoard(db: D1, boardId: string): Promise<BoardWithTasks
4170 WHERE t.board_id = ?
4271 ORDER BY
4372 CASE t.status WHEN 'todo' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'in_review' THEN 2 WHEN 'done' THEN 3 ELSE 4 END,
44- CASE WHEN t.status = 'todo' THEN
45- CASE t.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 2 END
46- ELSE 0 END,
47- CASE WHEN t.status = 'todo' THEN t.created_at END DESC,
73+ CASE WHEN t.status = 'todo' THEN t.position END ASC,
4874 CASE WHEN t.status != 'todo' THEN t.updated_at END DESC
4975 ` )
5076 . bind ( boardId )
@@ -58,17 +84,18 @@ export async function getBoard(db: D1, boardId: string): Promise<BoardWithTasks
5884 }
5985 }
6086
61- return { ...board , tasks : tasks . results . map ( ( t ) => parseJsonFields ( t , [ "labels" , "input" ] ) ) } ;
87+ return parseBoard ( { ...board , tasks : tasks . results . map ( ( t ) => parseJsonFields ( t , [ "labels" , "input" ] ) ) } ) ;
6288}
6389
6490export async function getDefaultBoard ( db : D1 , ownerId : string ) : Promise < Board | null > {
65- return db . prepare ( "SELECT * FROM boards WHERE owner_id = ? ORDER BY created_at ASC LIMIT 1" ) . bind ( ownerId ) . first < Board > ( ) ;
91+ const board = await db . prepare ( "SELECT * FROM boards WHERE owner_id = ? ORDER BY created_at ASC LIMIT 1" ) . bind ( ownerId ) . first < Board > ( ) ;
92+ return board ? parseBoard ( board ) : null ;
6693}
6794
6895export async function updateBoard (
6996 db : D1 ,
7097 boardId : string ,
71- updates : { name ?: string ; description ?: string ; visibility ?: "private" | "public" } ,
98+ updates : { name ?: string ; description ?: string ; visibility ?: "private" | "public" ; labels ?: BoardLabel [ ] } ,
7299) : Promise < Board | null > {
73100 const sets : string [ ] = [ ] ;
74101 const values : unknown [ ] = [ ] ;
@@ -91,7 +118,14 @@ export async function updateBoard(
91118 }
92119 }
93120 }
94- if ( sets . length === 0 ) return db . prepare ( "SELECT * FROM boards WHERE id = ?" ) . bind ( boardId ) . first < Board > ( ) ;
121+ if ( updates . labels !== undefined ) {
122+ sets . push ( "labels = ?" ) ;
123+ values . push ( JSON . stringify ( normalizeLabels ( updates . labels ) ) ) ;
124+ }
125+ if ( sets . length === 0 ) {
126+ const board = await db . prepare ( "SELECT * FROM boards WHERE id = ?" ) . bind ( boardId ) . first < Board > ( ) ;
127+ return board ? parseBoard ( board ) : null ;
128+ }
95129
96130 sets . push ( "updated_at = ?" ) ;
97131 values . push ( new Date ( ) . toISOString ( ) ) ;
@@ -101,7 +135,68 @@ export async function updateBoard(
101135 . prepare ( `UPDATE boards SET ${ sets . join ( ", " ) } WHERE id = ?` )
102136 . bind ( ...values )
103137 . run ( ) ;
104- return db . prepare ( "SELECT * FROM boards WHERE id = ?" ) . bind ( boardId ) . first < Board > ( ) ;
138+ const board = await db . prepare ( "SELECT * FROM boards WHERE id = ?" ) . bind ( boardId ) . first < Board > ( ) ;
139+ return board ? parseBoard ( board ) : null ;
140+ }
141+
142+ export async function createBoardLabel ( db : D1 , boardId : string , input : BoardLabel ) : Promise < Board | null > {
143+ const board = await db . prepare ( "SELECT * FROM boards WHERE id = ?" ) . bind ( boardId ) . first < Board > ( ) ;
144+ if ( ! board ) return null ;
145+ const labels = parseBoard ( board ) . labels ;
146+ const label = normalizeLabel ( input ) ;
147+ if ( labels . some ( ( existing ) => existing . name === label . name ) ) throw new HTTPException ( 409 , { message : `Label already exists: ${ label . name } ` } ) ;
148+ return updateBoard ( db , boardId , { labels : [ ...labels , label ] } ) ;
149+ }
150+
151+ export async function updateBoardLabel ( db : D1 , boardId : string , name : string , input : Partial < BoardLabel > ) : Promise < Board | null > {
152+ const board = await db . prepare ( "SELECT * FROM boards WHERE id = ?" ) . bind ( boardId ) . first < Board > ( ) ;
153+ if ( ! board ) return null ;
154+ const labels = parseBoard ( board ) . labels ;
155+ const current = labels . find ( ( label ) => label . name === name ) ;
156+ if ( ! current ) throw new HTTPException ( 404 , { message : `Label not found: ${ name } ` } ) ;
157+ const next = normalizeLabel ( {
158+ name : input . name ?? current . name ,
159+ color : input . color ?? current . color ,
160+ description : input . description ?? current . description ,
161+ } ) ;
162+ if ( next . name !== name && labels . some ( ( label ) => label . name === next . name ) ) {
163+ throw new HTTPException ( 409 , { message : `Label already exists: ${ next . name } ` } ) ;
164+ }
165+
166+ const updatedLabels = labels . map ( ( label ) => ( label . name === name ? next : label ) ) ;
167+ await updateBoard ( db , boardId , { labels : updatedLabels } ) ;
168+
169+ if ( next . name !== name ) {
170+ const tasks = await db
171+ . prepare ( "SELECT id, labels FROM tasks WHERE board_id = ? AND labels IS NOT NULL" )
172+ . bind ( boardId )
173+ . all < { id : string ; labels : string } > ( ) ;
174+ const statements = tasks . results
175+ . map ( ( task ) => ( { id : task . id , labels : JSON . parse ( task . labels ) as string [ ] } ) )
176+ . filter ( ( task ) => task . labels . includes ( name ) )
177+ . map ( ( task ) =>
178+ db
179+ . prepare ( "UPDATE tasks SET labels = ?, updated_at = ? WHERE id = ?" )
180+ . bind ( JSON . stringify ( task . labels . map ( ( label ) => ( label === name ? next . name : label ) ) ) , new Date ( ) . toISOString ( ) , task . id ) ,
181+ ) ;
182+ if ( statements . length > 0 ) await db . batch ( statements ) ;
183+ }
184+
185+ const nextBoard = await db . prepare ( "SELECT * FROM boards WHERE id = ?" ) . bind ( boardId ) . first < Board > ( ) ;
186+ return nextBoard ? parseBoard ( nextBoard ) : null ;
187+ }
188+
189+ export async function deleteBoardLabel ( db : D1 , boardId : string , name : string ) : Promise < Board | null > {
190+ const board = await db . prepare ( "SELECT * FROM boards WHERE id = ?" ) . bind ( boardId ) . first < Board > ( ) ;
191+ if ( ! board ) return null ;
192+ const labels = parseBoard ( board ) . labels ;
193+ if ( ! labels . some ( ( label ) => label . name === name ) ) throw new HTTPException ( 404 , { message : `Label not found: ${ name } ` } ) ;
194+ const inUse = await db
195+ . prepare ( "SELECT 1 FROM tasks WHERE board_id = ? AND EXISTS (SELECT 1 FROM json_each(tasks.labels) WHERE json_each.value = ?) LIMIT 1" )
196+ . bind ( boardId , name )
197+ . first ( ) ;
198+ if ( inUse ) throw new HTTPException ( 409 , { message : `Label is in use: ${ name } ` } ) ;
199+ return updateBoard ( db , boardId , { labels : labels . filter ( ( label ) => label . name !== name ) } ) ;
105200}
106201
107202export async function getBoardBySlug ( db : D1 , slug : string ) : Promise < BoardWithTasks | null > {
@@ -116,16 +211,13 @@ export async function getBoardBySlug(db: D1, slug: string): Promise<BoardWithTas
116211 WHERE t.board_id = ?
117212 ORDER BY
118213 CASE t.status WHEN 'todo' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'in_review' THEN 2 WHEN 'done' THEN 3 ELSE 4 END,
119- CASE WHEN t.status = 'todo' THEN
120- CASE t.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 2 END
121- ELSE 0 END,
122- CASE WHEN t.status = 'todo' THEN t.created_at END DESC,
214+ CASE WHEN t.status = 'todo' THEN t.position END ASC,
123215 CASE WHEN t.status != 'todo' THEN t.updated_at END DESC
124216 ` )
125217 . bind ( board . id )
126218 . all < Task > ( ) ;
127219
128- return { ...board , tasks : tasks . results . map ( ( t ) => parseJsonFields ( t , [ "labels" , "input" ] ) ) } ;
220+ return parseBoard ( { ...board , tasks : tasks . results . map ( ( t ) => parseJsonFields ( t , [ "labels" , "input" ] ) ) } ) ;
129221}
130222
131223export async function deleteBoard ( db : D1 , boardId : string ) : Promise < boolean > {
0 commit comments