@openboard/start 1.0.2

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 (30) hide show
  1. package/README.md +110 -0
  2. package/bin/openboard.js +116 -0
  3. package/package.json +35 -0
  4. package/packages/client/dist/assets/index-CIdk7SfN.css +1 -0
  5. package/packages/client/dist/assets/index-HJtFAWwr.js +203 -0
  6. package/packages/client/dist/index.html +15 -0
  7. package/packages/server/dist/agents/agent-queue.js +175 -0
  8. package/packages/server/dist/agents/agent-runner.js +18 -0
  9. package/packages/server/dist/agents/agent.interface.js +1 -0
  10. package/packages/server/dist/agents/codereview.agent.js +113 -0
  11. package/packages/server/dist/agents/dummy.agent.js +49 -0
  12. package/packages/server/dist/agents/opencode.agent.js +184 -0
  13. package/packages/server/dist/agents/opencode.events.js +348 -0
  14. package/packages/server/dist/db/database.js +176 -0
  15. package/packages/server/dist/gh-worker.js +82 -0
  16. package/packages/server/dist/index.js +81 -0
  17. package/packages/server/dist/repositories/board.repository.js +72 -0
  18. package/packages/server/dist/repositories/column-config.repository.js +30 -0
  19. package/packages/server/dist/repositories/column.repository.js +36 -0
  20. package/packages/server/dist/repositories/comment.repository.js +35 -0
  21. package/packages/server/dist/repositories/ticket.repository.js +171 -0
  22. package/packages/server/dist/routes/boards.router.js +33 -0
  23. package/packages/server/dist/routes/column-config.router.js +44 -0
  24. package/packages/server/dist/routes/columns.router.js +45 -0
  25. package/packages/server/dist/routes/system.router.js +173 -0
  26. package/packages/server/dist/routes/tickets.router.js +88 -0
  27. package/packages/server/dist/sse.js +43 -0
  28. package/packages/server/dist/types.js +2 -0
  29. package/packages/server/dist/utils/opencode.js +11 -0
  30. package/packages/server/dist/utils/os.js +73 -0
@@ -0,0 +1,81 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import { initDb } from './db/database.js';
4
+ import { boardsRouter } from './routes/boards.router.js';
5
+ import { columnsRouter } from './routes/columns.router.js';
6
+ import { ticketsRouter } from './routes/tickets.router.js';
7
+ import { columnConfigRouter } from './routes/column-config.router.js';
8
+ import { systemRouter } from './routes/system.router.js';
9
+ import { sseManager } from './sse.js';
10
+ import path from 'path';
11
+ import { fileURLToPath } from 'url';
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.dirname(__filename);
14
+ const PORT = process.env.PORT ?? 3001;
15
+ async function start() {
16
+ await initDb();
17
+ console.log('[openboard] Database ready');
18
+ const app = express();
19
+ app.use(cors({ origin: process.env.CLIENT_ORIGIN ? process.env.CLIENT_ORIGIN.split(',') : ['http://localhost:5173', 'http://localhost:4173'] }));
20
+ app.use(express.json());
21
+ // SSE subscription endpoint
22
+ // GET /api/events?boardId=<id>
23
+ // Clients subscribe here; the server pushes events whenever data changes.
24
+ app.get('/api/events', (req, res) => {
25
+ const boardId = req.query.boardId || '*';
26
+ res.setHeader('Content-Type', 'text/event-stream');
27
+ res.setHeader('Cache-Control', 'no-cache');
28
+ res.setHeader('Connection', 'keep-alive');
29
+ res.setHeader('X-Accel-Buffering', 'no'); // disable nginx buffering if present
30
+ res.flushHeaders();
31
+ // Register this connection
32
+ sseManager.subscribe(boardId, res);
33
+ // Send a connected confirmation event
34
+ res.write(`event: connected\ndata: ${JSON.stringify({ boardId })}\n\n`);
35
+ // Heartbeat every 25 s to keep the connection alive through proxies/firewalls
36
+ const heartbeat = setInterval(() => {
37
+ try {
38
+ res.write(': heartbeat\n\n');
39
+ }
40
+ catch {
41
+ clearInterval(heartbeat);
42
+ }
43
+ }, 25_000);
44
+ req.on('close', () => {
45
+ clearInterval(heartbeat);
46
+ sseManager.unsubscribe(boardId, res);
47
+ });
48
+ });
49
+ app.use('/api/boards', boardsRouter);
50
+ app.use('/api/boards/:boardId/columns', columnsRouter);
51
+ app.use('/api/boards/:boardId/tickets', ticketsRouter);
52
+ app.use('/api/boards/:boardId/columns', columnConfigRouter);
53
+ app.use('/api/system', systemRouter);
54
+ app.get('/api/health', (_req, res) => res.json({ status: 'ok' }));
55
+ // Serve the built client
56
+ const clientExtPath = path.join(__dirname, '../../client/dist');
57
+ app.use(express.static(clientExtPath));
58
+ app.get('*', (req, res) => {
59
+ res.sendFile(path.join(clientExtPath, 'index.html'));
60
+ });
61
+ const server = app.listen(PORT);
62
+ server.on('listening', () => {
63
+ const address = server.address();
64
+ const actualPort = typeof address === 'string' ? address : address?.port;
65
+ console.log(`[openboard] Server running at http://localhost:${actualPort}`);
66
+ });
67
+ server.on('error', (err) => {
68
+ if (err.code === 'EADDRINUSE') {
69
+ console.log(`[openboard] Port ${PORT} is in use, trying a random available port...`);
70
+ server.close();
71
+ server.listen(0);
72
+ }
73
+ else {
74
+ console.error('[openboard] Server error:', err);
75
+ }
76
+ });
77
+ }
78
+ start().catch(err => {
79
+ console.error('[openboard] Failed to start:', err);
80
+ process.exit(1);
81
+ });
@@ -0,0 +1,72 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { getDb } from '../db/database.js';
3
+ export const boardRepository = {
4
+ findAll() {
5
+ const db = getDb();
6
+ const boards = db.prepare('SELECT * FROM boards ORDER BY created_at ASC').all();
7
+ return boards.map(board => {
8
+ const workspaces = db.prepare('SELECT * FROM board_workspaces WHERE board_id = ?').all(board.id);
9
+ return {
10
+ ...board,
11
+ workspaces
12
+ };
13
+ });
14
+ },
15
+ findById(id) {
16
+ const db = getDb();
17
+ const board = db.prepare('SELECT * FROM boards WHERE id = ?').get(id);
18
+ if (!board)
19
+ return undefined;
20
+ const workspaces = db.prepare('SELECT * FROM board_workspaces WHERE board_id = ?').all(id);
21
+ return {
22
+ ...board,
23
+ workspaces
24
+ };
25
+ },
26
+ findByPath(path) {
27
+ const db = getDb();
28
+ const board = db.prepare('SELECT * FROM boards WHERE path = ?').get(path);
29
+ if (!board)
30
+ return undefined;
31
+ const workspaces = db.prepare('SELECT * FROM board_workspaces WHERE board_id = ?').all(board.id);
32
+ return {
33
+ ...board,
34
+ workspaces
35
+ };
36
+ },
37
+ create(name, path, workspaces = []) {
38
+ const boardId = randomUUID();
39
+ const db = getDb();
40
+ db.transaction(() => {
41
+ db.prepare('INSERT INTO boards (id, name, path) VALUES (?, ?, ?)').run(boardId, name, path || null);
42
+ for (const ws of workspaces) {
43
+ db.prepare('INSERT INTO board_workspaces (id, board_id, type, path) VALUES (?, ?, ?, ?)')
44
+ .run(randomUUID(), boardId, ws.type, ws.path);
45
+ }
46
+ })();
47
+ return this.findById(boardId);
48
+ },
49
+ update(id, name, path, workspaces) {
50
+ const db = getDb();
51
+ db.transaction(() => {
52
+ if (path !== undefined) {
53
+ db.prepare('UPDATE boards SET name = ?, path = ? WHERE id = ?').run(name, path, id);
54
+ }
55
+ else {
56
+ db.prepare('UPDATE boards SET name = ? WHERE id = ?').run(name, id);
57
+ }
58
+ if (workspaces) {
59
+ // Simple sync: delete all and re-create
60
+ db.prepare('DELETE FROM board_workspaces WHERE board_id = ?').run(id);
61
+ for (const ws of workspaces) {
62
+ db.prepare('INSERT INTO board_workspaces (id, board_id, type, path) VALUES (?, ?, ?, ?)')
63
+ .run(randomUUID(), id, ws.type, ws.path);
64
+ }
65
+ }
66
+ })();
67
+ return this.findById(id);
68
+ },
69
+ delete(id) {
70
+ getDb().prepare('DELETE FROM boards WHERE id = ?').run(id);
71
+ },
72
+ };
@@ -0,0 +1,30 @@
1
+ import { getDb } from '../db/database.js';
2
+ export const columnConfigRepository = {
3
+ findByColumnId(columnId) {
4
+ return getDb()
5
+ .prepare('SELECT * FROM column_configs WHERE column_id = ?')
6
+ .get(columnId);
7
+ },
8
+ findByBoardId(boardId) {
9
+ // Since board_id is not in column_configs, we join with columns
10
+ return getDb()
11
+ .prepare(`
12
+ SELECT cf.* FROM column_configs cf
13
+ JOIN columns c ON cf.column_id = c.id
14
+ WHERE c.board_id = ?
15
+ `)
16
+ .all(boardId);
17
+ },
18
+ upsert(data) {
19
+ getDb()
20
+ .prepare(`INSERT OR REPLACE INTO column_configs (column_id, agent_type, agent_model, max_agents, on_finish_column_id, on_reject_column_id)
21
+ VALUES (?, ?, ?, ?, ?, ?)`)
22
+ .run(data.columnId, data.agentType, data.agentModel ?? null, data.maxAgents ?? 1, data.onFinishColumnId ?? null, data.onRejectColumnId ?? null);
23
+ return this.findByColumnId(data.columnId);
24
+ },
25
+ delete(columnId) {
26
+ getDb()
27
+ .prepare('DELETE FROM column_configs WHERE column_id = ?')
28
+ .run(columnId);
29
+ },
30
+ };
@@ -0,0 +1,36 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { getDb } from '../db/database.js';
3
+ export const columnRepository = {
4
+ findByBoardId(boardId) {
5
+ return getDb()
6
+ .prepare('SELECT * FROM columns WHERE board_id = ? ORDER BY position ASC')
7
+ .all(boardId);
8
+ },
9
+ findById(id) {
10
+ return getDb().prepare('SELECT * FROM columns WHERE id = ?').get(id);
11
+ },
12
+ create(boardId, name) {
13
+ const id = randomUUID();
14
+ const maxPos = getDb()
15
+ .prepare('SELECT COALESCE(MAX(position), -1) as m FROM columns WHERE board_id = ?')
16
+ .get(boardId).m;
17
+ getDb()
18
+ .prepare('INSERT INTO columns (id, board_id, name, position) VALUES (?, ?, ?, ?)')
19
+ .run(id, boardId, name, maxPos + 1);
20
+ return this.findById(id);
21
+ },
22
+ update(id, name) {
23
+ getDb().prepare('UPDATE columns SET name = ? WHERE id = ?').run(name, id);
24
+ return this.findById(id);
25
+ },
26
+ reorder(boardId, orderedIds) {
27
+ const update = getDb().prepare('UPDATE columns SET position = ? WHERE id = ? AND board_id = ?');
28
+ const transaction = getDb().transaction(() => {
29
+ orderedIds.forEach((id, index) => update.run(index, id, boardId));
30
+ });
31
+ transaction();
32
+ },
33
+ delete(id) {
34
+ getDb().prepare('DELETE FROM columns WHERE id = ?').run(id);
35
+ },
36
+ };
@@ -0,0 +1,35 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { getDb } from '../db/database.js';
3
+ import { sseManager } from '../sse.js';
4
+ export const commentRepository = {
5
+ findByTicketId(ticketId) {
6
+ const db = getDb();
7
+ return db.prepare('SELECT * FROM comments WHERE ticket_id = ? ORDER BY created_at ASC').all(ticketId);
8
+ },
9
+ create(data) {
10
+ const id = randomUUID();
11
+ const db = getDb();
12
+ db.prepare('INSERT INTO comments (id, ticket_id, author, content) VALUES (?, ?, ?, ?)')
13
+ .run(id, data.ticketId, data.author ?? 'agent', data.content);
14
+ const comment = db.prepare('SELECT * FROM comments WHERE id = ?').get(id);
15
+ if (comment) {
16
+ sseManager.emit(data.ticketId, 'comment:added', {
17
+ ticketId: data.ticketId,
18
+ comment,
19
+ });
20
+ }
21
+ return comment;
22
+ },
23
+ update(id, content) {
24
+ const db = getDb();
25
+ db.prepare('UPDATE comments SET content = ? WHERE id = ?').run(content, id);
26
+ const comment = db.prepare('SELECT * FROM comments WHERE id = ?').get(id);
27
+ if (comment) {
28
+ sseManager.emit(comment.ticket_id, 'comment:updated', {
29
+ ticketId: comment.ticket_id,
30
+ comment,
31
+ });
32
+ }
33
+ return comment;
34
+ },
35
+ };
@@ -0,0 +1,171 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { getDb } from '../db/database.js';
3
+ import { sseManager } from '../sse.js';
4
+ function parseTicket(row) {
5
+ let agent_sessions = [];
6
+ if (row.agent_sessions) {
7
+ try {
8
+ agent_sessions = JSON.parse(row.agent_sessions);
9
+ // Patch opencode sessions with the current port if it changed on restart
10
+ const currentPort = process.env.OPENCODE_PORT || '4096';
11
+ agent_sessions = agent_sessions.map((session) => {
12
+ if (session.agent_type === 'opencode') {
13
+ if (session.port && String(session.port) !== String(currentPort)) {
14
+ session.port = Number(currentPort);
15
+ }
16
+ if (session.url) {
17
+ session.url = session.url.replace(/:\d+\//, `:${currentPort}/`);
18
+ }
19
+ }
20
+ return session;
21
+ });
22
+ }
23
+ catch { }
24
+ }
25
+ return {
26
+ ...row,
27
+ agent_sessions
28
+ };
29
+ }
30
+ export const ticketRepository = {
31
+ findByColumnId(columnId) {
32
+ const rows = getDb()
33
+ .prepare('SELECT * FROM tickets WHERE column_id = ? ORDER BY position ASC')
34
+ .all(columnId);
35
+ return rows.map(parseTicket);
36
+ },
37
+ findByBoardId(boardId) {
38
+ const rows = getDb()
39
+ .prepare('SELECT * FROM tickets WHERE board_id = ? ORDER BY position ASC')
40
+ .all(boardId);
41
+ return rows.map(parseTicket);
42
+ },
43
+ findById(id) {
44
+ const row = getDb().prepare('SELECT * FROM tickets WHERE id = ?').get(id);
45
+ if (!row)
46
+ return undefined;
47
+ return parseTicket(row);
48
+ },
49
+ create(data) {
50
+ const id = randomUUID();
51
+ const maxPos = getDb()
52
+ .prepare('SELECT COALESCE(MAX(position), -1) as m FROM tickets WHERE column_id = ?')
53
+ .get(data.columnId).m;
54
+ getDb()
55
+ .prepare(`INSERT INTO tickets (id, column_id, board_id, title, description, priority, position)
56
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
57
+ .run(id, data.columnId, data.boardId, data.title, data.description ?? '', data.priority ?? 'medium', maxPos + 1);
58
+ const savedTicket = this.findById(id);
59
+ sseManager.emit(savedTicket.board_id, 'ticket:updated', savedTicket);
60
+ return savedTicket;
61
+ },
62
+ update(id, data) {
63
+ const ticket = this.findById(id);
64
+ if (!ticket)
65
+ return undefined;
66
+ const updated = { ...ticket, ...data, updated_at: new Date().toISOString() };
67
+ getDb()
68
+ .prepare(`UPDATE tickets SET title = ?, description = ?, priority = ?, updated_at = ? WHERE id = ?`)
69
+ .run(updated.title, updated.description, updated.priority, updated.updated_at, id);
70
+ const savedTicket = this.findById(id);
71
+ if (savedTicket) {
72
+ sseManager.emit(savedTicket.board_id, 'ticket:updated', savedTicket);
73
+ }
74
+ return savedTicket;
75
+ },
76
+ move(id, toColumnId, position) {
77
+ const db = getDb();
78
+ const existing = db.prepare('SELECT column_id, position FROM tickets WHERE id = ?').get(id);
79
+ if (!existing)
80
+ return undefined;
81
+ const fromColumnId = existing.column_id;
82
+ const fromPosition = existing.position;
83
+ db.transaction(() => {
84
+ if (fromColumnId === toColumnId) {
85
+ // Moving within same column
86
+ if (fromPosition === position)
87
+ return; // No-op
88
+ if (fromPosition < position) {
89
+ // Moving down: shift items between old and new position up
90
+ db.prepare('UPDATE tickets SET position = position - 1 WHERE column_id = ? AND position > ? AND position <= ?').run(toColumnId, fromPosition, position);
91
+ }
92
+ else {
93
+ // Moving up: shift items between new and old position down
94
+ db.prepare('UPDATE tickets SET position = position + 1 WHERE column_id = ? AND position >= ? AND position < ?').run(toColumnId, position, fromPosition);
95
+ }
96
+ }
97
+ else {
98
+ // Moving to different column
99
+ // 1. Shift items in destination column down to make room
100
+ db.prepare('UPDATE tickets SET position = position + 1 WHERE column_id = ? AND position >= ?').run(toColumnId, position);
101
+ // 2. Clear gaps in source column
102
+ db.prepare('UPDATE tickets SET position = position - 1 WHERE column_id = ? AND position > ?').run(fromColumnId, fromPosition);
103
+ }
104
+ // Finally, update the target ticket (agent history remains intact)
105
+ db.prepare(`UPDATE tickets SET column_id = ?, position = ?, updated_at = datetime('now') WHERE id = ?`).run(toColumnId, position, id);
106
+ })();
107
+ const updatedTicket = this.findById(id);
108
+ if (updatedTicket) {
109
+ sseManager.emit(updatedTicket.board_id, 'ticket:updated', updatedTicket);
110
+ }
111
+ return updatedTicket;
112
+ },
113
+ updateAgentSession(id, sessionData) {
114
+ const ticket = this.findById(id);
115
+ if (!ticket)
116
+ return undefined;
117
+ const sessions = [...ticket.agent_sessions];
118
+ // Find the index of the most recent session for this column
119
+ let existingIdx = -1;
120
+ for (let i = sessions.length - 1; i >= 0; i--) {
121
+ if (sessions[i].column_id === sessionData.column_id) {
122
+ existingIdx = i;
123
+ break;
124
+ }
125
+ }
126
+ const isTerminalStatus = (s) => s === 'done' || s === 'blocked';
127
+ let isNewSession = false;
128
+ if (existingIdx === -1) {
129
+ // No session ever Existed for this column
130
+ isNewSession = true;
131
+ }
132
+ else {
133
+ const existingSession = sessions[existingIdx];
134
+ // If the existing session is already in a terminal state (done or blocked)
135
+ // AND we are trying to start a new 'processing' session, then we append.
136
+ if (isTerminalStatus(existingSession.status) && sessionData.status === 'processing') {
137
+ isNewSession = true;
138
+ }
139
+ }
140
+ if (isNewSession) {
141
+ const newSession = {
142
+ ...sessionData,
143
+ started_at: new Date().toISOString(),
144
+ // If it starts terminal (which would be weird but possible), set finished_at
145
+ finished_at: isTerminalStatus(sessionData.status) ? new Date().toISOString() : undefined
146
+ };
147
+ sessions.push(newSession);
148
+ }
149
+ else {
150
+ // Update the existing (active) session
151
+ const newSession = {
152
+ ...sessions[existingIdx], // Preserve started_at and any missing fields
153
+ ...sessionData,
154
+ finished_at: isTerminalStatus(sessionData.status) ? new Date().toISOString() : undefined
155
+ };
156
+ sessions[existingIdx] = newSession;
157
+ }
158
+ const updated = { ...ticket, agent_sessions: sessions, updated_at: new Date().toISOString() };
159
+ getDb()
160
+ .prepare('UPDATE tickets SET agent_sessions = ?, updated_at = ? WHERE id = ?')
161
+ .run(JSON.stringify(sessions), updated.updated_at, id);
162
+ const savedTicket = this.findById(id);
163
+ if (savedTicket) {
164
+ sseManager.emit(savedTicket.board_id, 'ticket:updated', savedTicket);
165
+ }
166
+ return savedTicket;
167
+ },
168
+ delete(id) {
169
+ getDb().prepare('DELETE FROM tickets WHERE id = ?').run(id);
170
+ },
171
+ };
@@ -0,0 +1,33 @@
1
+ import { Router } from 'express';
2
+ import { boardRepository } from '../repositories/board.repository.js';
3
+ import { sseManager } from '../sse.js';
4
+ const router = Router();
5
+ router.get('/', (_req, res) => {
6
+ res.json(boardRepository.findAll());
7
+ });
8
+ router.post('/', (req, res) => {
9
+ const { name, path, workspaces } = req.body;
10
+ if (!name?.trim()) {
11
+ res.status(400).json({ error: 'Name is required' });
12
+ return;
13
+ }
14
+ const board = boardRepository.create(name.trim(), path, workspaces);
15
+ sseManager.emitGlobal('board:created', board);
16
+ res.status(201).json(board);
17
+ });
18
+ router.patch('/:id', (req, res) => {
19
+ const { name, path, workspaces } = req.body;
20
+ const board = boardRepository.update(req.params.id, name?.trim(), path, workspaces);
21
+ if (!board) {
22
+ res.status(404).json({ error: 'Board not found' });
23
+ return;
24
+ }
25
+ sseManager.emitGlobal('board:updated', board);
26
+ res.json(board);
27
+ });
28
+ router.delete('/:id', (req, res) => {
29
+ boardRepository.delete(req.params.id);
30
+ sseManager.emitGlobal('board:deleted', { id: req.params.id });
31
+ res.status(204).end();
32
+ });
33
+ export { router as boardsRouter };
@@ -0,0 +1,44 @@
1
+ import { Router } from 'express';
2
+ import { columnConfigRepository } from '../repositories/column-config.repository.js';
3
+ import { sseManager } from '../sse.js';
4
+ const router = Router({ mergeParams: true });
5
+ // GET /api/boards/:boardId/columns/configs
6
+ // Fetches all configs for all columns in this board
7
+ router.get('/configs', (req, res) => {
8
+ const configs = columnConfigRepository.findByBoardId(req.params.boardId);
9
+ res.json(configs);
10
+ });
11
+ // GET /api/boards/:boardId/columns/:id/config
12
+ router.get('/:id/config', (req, res) => {
13
+ const config = columnConfigRepository.findByColumnId(req.params.id);
14
+ if (!config) {
15
+ res.json({ column_id: req.params.id, agent_type: 'none', on_finish_column_id: null });
16
+ return;
17
+ }
18
+ res.json(config);
19
+ });
20
+ // PUT /api/boards/:boardId/columns/:id/config
21
+ router.put('/:id/config', (req, res) => {
22
+ const { agentType, agentModel, maxAgents, onFinishColumnId, onRejectColumnId } = req.body;
23
+ if (!agentType) {
24
+ res.status(400).json({ error: 'agentType is required' });
25
+ return;
26
+ }
27
+ const config = columnConfigRepository.upsert({
28
+ columnId: req.params.id,
29
+ agentType,
30
+ agentModel,
31
+ maxAgents,
32
+ onFinishColumnId,
33
+ onRejectColumnId,
34
+ });
35
+ sseManager.emit(req.params.boardId, 'column:config:updated', config);
36
+ res.json(config);
37
+ });
38
+ // DELETE /api/boards/:boardId/columns/:id/config
39
+ router.delete('/:id/config', (req, res) => {
40
+ columnConfigRepository.delete(req.params.id);
41
+ sseManager.emit(req.params.boardId, 'column:config:deleted', { columnId: req.params.id });
42
+ res.status(204).end();
43
+ });
44
+ export { router as columnConfigRouter };
@@ -0,0 +1,45 @@
1
+ import { Router } from 'express';
2
+ import { columnRepository } from '../repositories/column.repository.js';
3
+ import { sseManager } from '../sse.js';
4
+ const router = Router({ mergeParams: true });
5
+ // GET /api/boards/:boardId/columns
6
+ router.get('/', (req, res) => {
7
+ res.json(columnRepository.findByBoardId(req.params.boardId));
8
+ });
9
+ // POST /api/boards/:boardId/columns
10
+ router.post('/', (req, res) => {
11
+ const { name } = req.body;
12
+ if (!name?.trim()) {
13
+ res.status(400).json({ error: 'Name is required' });
14
+ return;
15
+ }
16
+ const column = columnRepository.create(req.params.boardId, name.trim());
17
+ sseManager.emit(req.params.boardId, 'column:created', column);
18
+ res.status(201).json(column);
19
+ });
20
+ // PATCH /api/boards/:boardId/columns/:id
21
+ router.patch('/:id', (req, res) => {
22
+ const { name } = req.body;
23
+ const column = columnRepository.update(req.params.id, name.trim());
24
+ if (!column) {
25
+ res.status(404).json({ error: 'Column not found' });
26
+ return;
27
+ }
28
+ sseManager.emit(req.params.boardId, 'column:updated', column);
29
+ res.json(column);
30
+ });
31
+ // PUT /api/boards/:boardId/columns/reorder
32
+ router.put('/reorder', (req, res) => {
33
+ const { orderedIds } = req.body;
34
+ columnRepository.reorder(req.params.boardId, orderedIds);
35
+ const columns = columnRepository.findByBoardId(req.params.boardId);
36
+ sseManager.emit(req.params.boardId, 'columns:reordered', columns);
37
+ res.status(204).end();
38
+ });
39
+ // DELETE /api/boards/:boardId/columns/:id
40
+ router.delete('/:id', (req, res) => {
41
+ columnRepository.delete(req.params.id);
42
+ sseManager.emit(req.params.boardId, 'column:deleted', { id: req.params.id });
43
+ res.status(204).end();
44
+ });
45
+ export { router as columnsRouter };