superintent 0.0.1
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/LICENSE +21 -0
- package/README.md +226 -0
- package/bin/superintent.js +2 -0
- package/dist/commands/extract.d.ts +2 -0
- package/dist/commands/extract.js +66 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +56 -0
- package/dist/commands/knowledge.d.ts +2 -0
- package/dist/commands/knowledge.js +647 -0
- package/dist/commands/search.d.ts +2 -0
- package/dist/commands/search.js +153 -0
- package/dist/commands/spec.d.ts +2 -0
- package/dist/commands/spec.js +283 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +43 -0
- package/dist/commands/ticket.d.ts +4 -0
- package/dist/commands/ticket.js +942 -0
- package/dist/commands/ui.d.ts +2 -0
- package/dist/commands/ui.js +954 -0
- package/dist/db/client.d.ts +4 -0
- package/dist/db/client.js +26 -0
- package/dist/db/init-schema.d.ts +2 -0
- package/dist/db/init-schema.js +28 -0
- package/dist/db/parsers.d.ts +24 -0
- package/dist/db/parsers.js +79 -0
- package/dist/db/schema.d.ts +7 -0
- package/dist/db/schema.js +64 -0
- package/dist/db/usage.d.ts +8 -0
- package/dist/db/usage.js +24 -0
- package/dist/embed/model.d.ts +5 -0
- package/dist/embed/model.js +34 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +31 -0
- package/dist/types.d.ts +120 -0
- package/dist/types.js +1 -0
- package/dist/ui/components/index.d.ts +6 -0
- package/dist/ui/components/index.js +13 -0
- package/dist/ui/components/knowledge.d.ts +33 -0
- package/dist/ui/components/knowledge.js +238 -0
- package/dist/ui/components/layout.d.ts +1 -0
- package/dist/ui/components/layout.js +323 -0
- package/dist/ui/components/search.d.ts +15 -0
- package/dist/ui/components/search.js +114 -0
- package/dist/ui/components/spec.d.ts +11 -0
- package/dist/ui/components/spec.js +253 -0
- package/dist/ui/components/ticket.d.ts +90 -0
- package/dist/ui/components/ticket.js +604 -0
- package/dist/ui/components/utils.d.ts +26 -0
- package/dist/ui/components/utils.js +34 -0
- package/dist/ui/styles.css +2 -0
- package/dist/utils/cli.d.ts +21 -0
- package/dist/utils/cli.js +31 -0
- package/dist/utils/config.d.ts +12 -0
- package/dist/utils/config.js +116 -0
- package/dist/utils/id.d.ts +6 -0
- package/dist/utils/id.js +13 -0
- package/dist/utils/io.d.ts +8 -0
- package/dist/utils/io.js +15 -0
- package/package.json +60 -0
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { serve } from '@hono/node-server';
|
|
4
|
+
import open from 'open';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { getClient, closeClient } from '../db/client.js';
|
|
9
|
+
import { parseTicketRow, parseKnowledgeRow, parseSearchRow, parseSpecRow } from '../db/parsers.js';
|
|
10
|
+
import { trackUsage } from '../db/usage.js';
|
|
11
|
+
import { loadConfig, getProjectNamespace } from '../utils/config.js';
|
|
12
|
+
import { embed } from '../embed/model.js';
|
|
13
|
+
import { getHtml, renderKanbanView, renderKanbanColumns, renderColumnMore, renderSearchView, renderSearchResults, renderKnowledgeView, renderKnowledgeList, renderTicketModal, renderKnowledgeModal, renderNewTicketModal, renderEditTicketModal, renderSpecView, renderSpecList, renderSpecModal, renderNewSpecModal, renderEditSpecModal, } from '../ui/components/index.js';
|
|
14
|
+
export const uiCommand = new Command('ui')
|
|
15
|
+
.description('Start web UI for ticket and knowledge management')
|
|
16
|
+
.option('-p, --port <port>', 'Server port', '3456')
|
|
17
|
+
.option('-o, --open', 'Auto-open browser')
|
|
18
|
+
.action(async (options) => {
|
|
19
|
+
const port = parseInt(options.port, 10);
|
|
20
|
+
const namespace = getProjectNamespace();
|
|
21
|
+
const app = new Hono();
|
|
22
|
+
// ============ STATIC ASSETS ============
|
|
23
|
+
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
24
|
+
const cssPath = join(packageRoot, 'dist', 'ui', 'styles.css');
|
|
25
|
+
let cssContent;
|
|
26
|
+
try {
|
|
27
|
+
cssContent = readFileSync(cssPath, 'utf-8');
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
cssContent = '/* Tailwind CSS not built. Run: npm run build:css */';
|
|
31
|
+
}
|
|
32
|
+
app.get('/styles.css', (c) => {
|
|
33
|
+
c.header('Content-Type', 'text/css');
|
|
34
|
+
c.header('Cache-Control', 'public, max-age=3600');
|
|
35
|
+
return c.body(cssContent);
|
|
36
|
+
});
|
|
37
|
+
// ============ MAIN HTML ============
|
|
38
|
+
app.get('/', (c) => c.html(getHtml(namespace)));
|
|
39
|
+
// ============ API ROUTES (JSON) ============
|
|
40
|
+
// List all tickets
|
|
41
|
+
app.get('/api/tickets', async (c) => {
|
|
42
|
+
try {
|
|
43
|
+
const client = await getClient();
|
|
44
|
+
const result = await client.execute({
|
|
45
|
+
sql: 'SELECT * FROM tickets ORDER BY created_at DESC',
|
|
46
|
+
args: [],
|
|
47
|
+
});
|
|
48
|
+
const tickets = result.rows.map((row) => parseTicketRow(row));
|
|
49
|
+
return c.json({ success: true, data: tickets });
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
return c.json({ success: false, error: error.message }, 500);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
// Get single ticket
|
|
56
|
+
app.get('/api/tickets/:id', async (c) => {
|
|
57
|
+
try {
|
|
58
|
+
const id = c.req.param('id');
|
|
59
|
+
const client = await getClient();
|
|
60
|
+
const result = await client.execute({
|
|
61
|
+
sql: 'SELECT * FROM tickets WHERE id = ?',
|
|
62
|
+
args: [id],
|
|
63
|
+
});
|
|
64
|
+
if (result.rows.length === 0) {
|
|
65
|
+
return c.json({ success: false, error: 'Ticket not found' }, 404);
|
|
66
|
+
}
|
|
67
|
+
const ticket = parseTicketRow(result.rows[0]);
|
|
68
|
+
return c.json({ success: true, data: ticket });
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
return c.json({ success: false, error: error.message }, 500);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
// Update ticket status
|
|
75
|
+
app.patch('/api/tickets/:id/status', async (c) => {
|
|
76
|
+
try {
|
|
77
|
+
const id = c.req.param('id');
|
|
78
|
+
const body = await c.req.parseBody();
|
|
79
|
+
const newStatus = body.status;
|
|
80
|
+
const validStatuses = ['Backlog', 'In Progress', 'In Review', 'Done', 'Blocked', 'Paused', 'Abandoned', 'Superseded'];
|
|
81
|
+
if (!validStatuses.includes(newStatus)) {
|
|
82
|
+
return c.json({ success: false, error: 'Invalid status' }, 400);
|
|
83
|
+
}
|
|
84
|
+
const client = await getClient();
|
|
85
|
+
// If setting to Done, auto-complete all tasks and DoD
|
|
86
|
+
if (newStatus === 'Done') {
|
|
87
|
+
const current = await client.execute({
|
|
88
|
+
sql: 'SELECT tasks, definition_of_done FROM tickets WHERE id = ?',
|
|
89
|
+
args: [id],
|
|
90
|
+
});
|
|
91
|
+
if (current.rows.length > 0) {
|
|
92
|
+
const row = current.rows[0];
|
|
93
|
+
const tasks = row.tasks ? JSON.parse(row.tasks) : [];
|
|
94
|
+
const dod = row.definition_of_done ? JSON.parse(row.definition_of_done) : [];
|
|
95
|
+
const completedTasks = tasks.map((t) => ({ ...t, done: true }));
|
|
96
|
+
const completedDod = dod.map((d) => ({ ...d, done: true }));
|
|
97
|
+
await client.execute({
|
|
98
|
+
sql: `UPDATE tickets SET status = ?, tasks = ?, definition_of_done = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
99
|
+
args: [newStatus, JSON.stringify(completedTasks), JSON.stringify(completedDod), id],
|
|
100
|
+
});
|
|
101
|
+
return c.json({ success: true, data: { id, status: newStatus } });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
await client.execute({
|
|
105
|
+
sql: `UPDATE tickets SET status = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
106
|
+
args: [newStatus, id],
|
|
107
|
+
});
|
|
108
|
+
return c.json({ success: true, data: { id, status: newStatus } });
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
return c.json({ success: false, error: error.message }, 500);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
// Toggle task completion
|
|
115
|
+
app.patch('/api/tickets/:id/task/:index', async (c) => {
|
|
116
|
+
try {
|
|
117
|
+
const id = c.req.param('id');
|
|
118
|
+
const index = parseInt(c.req.param('index'), 10);
|
|
119
|
+
const client = await getClient();
|
|
120
|
+
const result = await client.execute({
|
|
121
|
+
sql: 'SELECT tasks FROM tickets WHERE id = ?',
|
|
122
|
+
args: [id],
|
|
123
|
+
});
|
|
124
|
+
if (result.rows.length === 0) {
|
|
125
|
+
return c.json({ success: false, error: 'Ticket not found' }, 404);
|
|
126
|
+
}
|
|
127
|
+
const row = result.rows[0];
|
|
128
|
+
const tasks = row.tasks ? JSON.parse(row.tasks) : [];
|
|
129
|
+
if (index >= 0 && index < tasks.length) {
|
|
130
|
+
tasks[index].done = !tasks[index].done;
|
|
131
|
+
await client.execute({
|
|
132
|
+
sql: `UPDATE tickets SET tasks = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
133
|
+
args: [JSON.stringify(tasks), id],
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return c.json({ success: true });
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
return c.json({ success: false, error: error.message }, 500);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
// Toggle DoD completion
|
|
143
|
+
app.patch('/api/tickets/:id/dod/:index', async (c) => {
|
|
144
|
+
try {
|
|
145
|
+
const id = c.req.param('id');
|
|
146
|
+
const index = parseInt(c.req.param('index'), 10);
|
|
147
|
+
const client = await getClient();
|
|
148
|
+
const result = await client.execute({
|
|
149
|
+
sql: 'SELECT definition_of_done FROM tickets WHERE id = ?',
|
|
150
|
+
args: [id],
|
|
151
|
+
});
|
|
152
|
+
if (result.rows.length === 0) {
|
|
153
|
+
return c.json({ success: false, error: 'Ticket not found' }, 404);
|
|
154
|
+
}
|
|
155
|
+
const row = result.rows[0];
|
|
156
|
+
const dod = row.definition_of_done ? JSON.parse(row.definition_of_done) : [];
|
|
157
|
+
if (index >= 0 && index < dod.length) {
|
|
158
|
+
dod[index].done = !dod[index].done;
|
|
159
|
+
await client.execute({
|
|
160
|
+
sql: `UPDATE tickets SET definition_of_done = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
161
|
+
args: [JSON.stringify(dod), id],
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return c.json({ success: true });
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
return c.json({ success: false, error: error.message }, 500);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
// Quick create ticket (from UI form)
|
|
171
|
+
app.post('/api/tickets/quick', async (c) => {
|
|
172
|
+
try {
|
|
173
|
+
const formData = await c.req.parseBody();
|
|
174
|
+
const title = formData.title?.trim();
|
|
175
|
+
const type = formData.type || 'feature';
|
|
176
|
+
const intent = formData.intent?.trim();
|
|
177
|
+
if (!title) {
|
|
178
|
+
return c.html('<div class="text-red-500 p-2">Title is required</div>', 400);
|
|
179
|
+
}
|
|
180
|
+
if (!intent) {
|
|
181
|
+
return c.html('<div class="text-red-500 p-2">Intent is required</div>', 400);
|
|
182
|
+
}
|
|
183
|
+
// Generate ID: TICKET-YYYYMMDD-HHMMSS-manual (manual ticket format)
|
|
184
|
+
// The -manual suffix signals AI to enrich ticket before execution
|
|
185
|
+
const now = new Date();
|
|
186
|
+
const date = now.toISOString().slice(0, 10).replace(/-/g, '');
|
|
187
|
+
const time = now.toISOString().slice(11, 19).replace(/:/g, '');
|
|
188
|
+
const id = `TICKET-${date}-${time}-manual`;
|
|
189
|
+
const client = await getClient();
|
|
190
|
+
await client.execute({
|
|
191
|
+
sql: `INSERT INTO tickets (id, type, title, intent, status) VALUES (?, ?, ?, ?, 'Backlog')`,
|
|
192
|
+
args: [id, type, title, intent],
|
|
193
|
+
});
|
|
194
|
+
// Return refreshed kanban columns
|
|
195
|
+
const statuses = ['Backlog', 'In Progress', 'In Review', 'Done'];
|
|
196
|
+
const archiveStatuses = ['Blocked', 'Paused', 'Abandoned', 'Superseded'];
|
|
197
|
+
const limit = 20;
|
|
198
|
+
const columnData = await Promise.all(statuses.map(async (status) => {
|
|
199
|
+
const result = await client.execute({
|
|
200
|
+
sql: `SELECT id, type, title, status, intent, change_class, change_class_reason, tasks
|
|
201
|
+
FROM tickets WHERE status = ? ORDER BY created_at DESC LIMIT ?`,
|
|
202
|
+
args: [status, limit + 1],
|
|
203
|
+
});
|
|
204
|
+
const hasMore = result.rows.length > limit;
|
|
205
|
+
const tickets = result.rows.slice(0, limit).map((row) => parseTicketRow(row));
|
|
206
|
+
return { status, tickets, hasMore };
|
|
207
|
+
}));
|
|
208
|
+
// Add Archive column
|
|
209
|
+
const archiveResult = await client.execute({
|
|
210
|
+
sql: `SELECT id, type, title, status, intent, change_class, change_class_reason, tasks
|
|
211
|
+
FROM tickets WHERE status IN (?, ?, ?, ?) ORDER BY created_at DESC LIMIT ?`,
|
|
212
|
+
args: [...archiveStatuses, limit + 1],
|
|
213
|
+
});
|
|
214
|
+
const archiveHasMore = archiveResult.rows.length > limit;
|
|
215
|
+
const archiveTickets = archiveResult.rows.slice(0, limit).map((row) => parseTicketRow(row));
|
|
216
|
+
columnData.push({ status: 'Archive', tickets: archiveTickets, hasMore: archiveHasMore });
|
|
217
|
+
return c.html(renderKanbanColumns(columnData));
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
return c.html(`<div class="text-red-500 p-2">Error: ${error.message}</div>`, 500);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
// Delete ticket
|
|
224
|
+
app.delete('/api/tickets/:id', async (c) => {
|
|
225
|
+
try {
|
|
226
|
+
const id = c.req.param('id');
|
|
227
|
+
const client = await getClient();
|
|
228
|
+
// First, orphan any derived knowledge (set origin_ticket_id to NULL)
|
|
229
|
+
await client.execute({
|
|
230
|
+
sql: `UPDATE knowledge SET origin_ticket_id = NULL WHERE origin_ticket_id = ?`,
|
|
231
|
+
args: [id],
|
|
232
|
+
});
|
|
233
|
+
// Delete the ticket
|
|
234
|
+
const result = await client.execute({
|
|
235
|
+
sql: `DELETE FROM tickets WHERE id = ?`,
|
|
236
|
+
args: [id],
|
|
237
|
+
});
|
|
238
|
+
if (result.rowsAffected === 0) {
|
|
239
|
+
return c.json({ success: false, error: 'Ticket not found' }, 404);
|
|
240
|
+
}
|
|
241
|
+
return c.json({ success: true });
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
return c.json({ success: false, error: error.message }, 500);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
// Edit ticket (title, type, intent)
|
|
248
|
+
app.patch('/api/tickets/:id', async (c) => {
|
|
249
|
+
try {
|
|
250
|
+
const id = c.req.param('id');
|
|
251
|
+
const formData = await c.req.parseBody();
|
|
252
|
+
const title = formData.title?.trim();
|
|
253
|
+
const type = formData.type || 'feature';
|
|
254
|
+
const intent = formData.intent?.trim();
|
|
255
|
+
if (!title) {
|
|
256
|
+
return c.html('<div class="text-red-500 p-2">Title is required</div>', 400);
|
|
257
|
+
}
|
|
258
|
+
if (!intent) {
|
|
259
|
+
return c.html('<div class="text-red-500 p-2">Intent is required</div>', 400);
|
|
260
|
+
}
|
|
261
|
+
const client = await getClient();
|
|
262
|
+
await client.execute({
|
|
263
|
+
sql: `UPDATE tickets SET title = ?, type = ?, intent = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
264
|
+
args: [title, type, intent, id],
|
|
265
|
+
});
|
|
266
|
+
// Fetch updated ticket and return detail modal
|
|
267
|
+
const result = await client.execute({
|
|
268
|
+
sql: `SELECT id, type, title, status, intent, context, constraints_use, constraints_avoid,
|
|
269
|
+
assumptions, tasks, definition_of_done, change_class, change_class_reason,
|
|
270
|
+
origin_spec_id, plan, derived_knowledge, comments, created_at, updated_at FROM tickets WHERE id = ?`,
|
|
271
|
+
args: [id],
|
|
272
|
+
});
|
|
273
|
+
if (result.rows.length === 0) {
|
|
274
|
+
return c.html('<div class="p-6 text-red-500">Ticket not found</div>', 404);
|
|
275
|
+
}
|
|
276
|
+
const ticket = parseTicketRow(result.rows[0]);
|
|
277
|
+
// Trigger kanban refresh in the background
|
|
278
|
+
c.header('HX-Trigger', 'refresh');
|
|
279
|
+
return c.html(renderTicketModal(ticket));
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
return c.html(`<div class="text-red-500 p-2">Error: ${error.message}</div>`, 500);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
// Toggle knowledge active status
|
|
286
|
+
app.patch('/api/knowledge/:id/active', async (c) => {
|
|
287
|
+
try {
|
|
288
|
+
const id = c.req.param('id');
|
|
289
|
+
const body = await c.req.parseBody();
|
|
290
|
+
// hx-vals sends string "true"/"false"
|
|
291
|
+
const active = body.active === 'true' ? 1 : 0;
|
|
292
|
+
const client = await getClient();
|
|
293
|
+
await client.execute({
|
|
294
|
+
sql: 'UPDATE knowledge SET active = ? WHERE id = ?',
|
|
295
|
+
args: [active, id],
|
|
296
|
+
});
|
|
297
|
+
// Fetch updated knowledge and return modal HTML
|
|
298
|
+
const result = await client.execute({
|
|
299
|
+
sql: `SELECT id, namespace, chunk_index, title, content,
|
|
300
|
+
category, tags, source, origin_ticket_id, origin_ticket_type, confidence, active, decision_scope,
|
|
301
|
+
usage_count, last_used_at, created_at, updated_at
|
|
302
|
+
FROM knowledge WHERE id = ?`,
|
|
303
|
+
args: [id],
|
|
304
|
+
});
|
|
305
|
+
if (result.rows.length === 0) {
|
|
306
|
+
return c.html('<div class="p-6 text-red-500">Knowledge not found</div>', 404);
|
|
307
|
+
}
|
|
308
|
+
const knowledge = parseKnowledgeRow(result.rows[0]);
|
|
309
|
+
return c.html(renderKnowledgeModal(knowledge));
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
return c.html(`<div class="p-6 text-red-500">Error: ${error.message}</div>`, 500);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
// List knowledge
|
|
316
|
+
app.get('/api/knowledge', async (c) => {
|
|
317
|
+
try {
|
|
318
|
+
const client = await getClient();
|
|
319
|
+
const category = c.req.query('category');
|
|
320
|
+
const namespace = c.req.query('namespace');
|
|
321
|
+
const scope = c.req.query('scope');
|
|
322
|
+
const status = c.req.query('status') || 'active';
|
|
323
|
+
const conditions = [];
|
|
324
|
+
const args = [];
|
|
325
|
+
// Status filter
|
|
326
|
+
if (status === 'active') {
|
|
327
|
+
conditions.push('active = 1');
|
|
328
|
+
}
|
|
329
|
+
else if (status === 'inactive') {
|
|
330
|
+
conditions.push('active = 0');
|
|
331
|
+
}
|
|
332
|
+
// 'all' = no filter
|
|
333
|
+
if (category) {
|
|
334
|
+
conditions.push('category = ?');
|
|
335
|
+
args.push(category);
|
|
336
|
+
}
|
|
337
|
+
if (namespace) {
|
|
338
|
+
conditions.push('namespace = ?');
|
|
339
|
+
args.push(namespace);
|
|
340
|
+
}
|
|
341
|
+
if (scope) {
|
|
342
|
+
conditions.push('decision_scope = ?');
|
|
343
|
+
args.push(scope);
|
|
344
|
+
}
|
|
345
|
+
const sql = `SELECT id, namespace, chunk_index, title, content,
|
|
346
|
+
category, tags, source, origin_ticket_id, origin_ticket_type, confidence, active, decision_scope,
|
|
347
|
+
usage_count, last_used_at, created_at, updated_at
|
|
348
|
+
FROM knowledge WHERE ${conditions.join(' AND ')} ORDER BY created_at DESC LIMIT 50`;
|
|
349
|
+
const result = await client.execute({ sql, args });
|
|
350
|
+
const knowledge = result.rows.map((row) => parseKnowledgeRow(row));
|
|
351
|
+
return c.json({ success: true, data: knowledge });
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
return c.json({ success: false, error: error.message }, 500);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
// Semantic search
|
|
358
|
+
app.post('/api/search', async (c) => {
|
|
359
|
+
try {
|
|
360
|
+
const body = await c.req.json();
|
|
361
|
+
const query = body.query;
|
|
362
|
+
const limit = body.limit || 5;
|
|
363
|
+
const namespace = body.namespace;
|
|
364
|
+
const category = body.category;
|
|
365
|
+
if (!query || query.trim().length < 2) {
|
|
366
|
+
return c.json({ success: true, data: { query: '', results: [] } });
|
|
367
|
+
}
|
|
368
|
+
const client = await getClient();
|
|
369
|
+
const queryEmbedding = await embed(query);
|
|
370
|
+
const topK = limit * 2;
|
|
371
|
+
const conditions = ['k.active = 1'];
|
|
372
|
+
const args = [
|
|
373
|
+
JSON.stringify(queryEmbedding),
|
|
374
|
+
JSON.stringify(queryEmbedding),
|
|
375
|
+
];
|
|
376
|
+
if (namespace) {
|
|
377
|
+
conditions.push('k.namespace = ?');
|
|
378
|
+
args.push(namespace);
|
|
379
|
+
}
|
|
380
|
+
if (category) {
|
|
381
|
+
conditions.push('k.category = ?');
|
|
382
|
+
args.push(category);
|
|
383
|
+
}
|
|
384
|
+
// Try indexed search first (k must be literal, not bound parameter)
|
|
385
|
+
try {
|
|
386
|
+
const sql = `
|
|
387
|
+
SELECT
|
|
388
|
+
k.id, k.namespace, k.chunk_index, k.title, k.content,
|
|
389
|
+
k.category, k.tags, k.source, k.origin_ticket_id, k.origin_ticket_type, k.confidence, k.active, k.decision_scope,
|
|
390
|
+
k.usage_count, k.last_used_at, k.created_at,
|
|
391
|
+
vector_distance_cos(k.embedding, vector32(?)) as distance
|
|
392
|
+
FROM vector_top_k('knowledge_embedding_idx', vector32(?), ${topK}) AS v
|
|
393
|
+
JOIN knowledge k ON k.rowid = v.id
|
|
394
|
+
WHERE ${conditions.join(' AND ')}
|
|
395
|
+
ORDER BY distance ASC
|
|
396
|
+
LIMIT ${limit}
|
|
397
|
+
`;
|
|
398
|
+
const result = await client.execute({ sql, args });
|
|
399
|
+
const results = result.rows.map((row) => parseSearchRow(row));
|
|
400
|
+
// Track usage for returned results
|
|
401
|
+
await trackUsage(results.map(r => r.id));
|
|
402
|
+
return c.json({ success: true, data: { query, results } });
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// Fallback to non-indexed search
|
|
406
|
+
const fallbackConditions = ['active = 1'];
|
|
407
|
+
const fallbackArgs = [JSON.stringify(queryEmbedding)];
|
|
408
|
+
if (namespace) {
|
|
409
|
+
fallbackConditions.push('namespace = ?');
|
|
410
|
+
fallbackArgs.push(namespace);
|
|
411
|
+
}
|
|
412
|
+
if (category) {
|
|
413
|
+
fallbackConditions.push('category = ?');
|
|
414
|
+
fallbackArgs.push(category);
|
|
415
|
+
}
|
|
416
|
+
const fallbackSql = `
|
|
417
|
+
SELECT
|
|
418
|
+
id, namespace, chunk_index, title, content,
|
|
419
|
+
category, tags, source, origin_ticket_id, origin_ticket_type, confidence, active, decision_scope,
|
|
420
|
+
usage_count, last_used_at, created_at,
|
|
421
|
+
vector_distance_cos(embedding, vector32(?)) as distance
|
|
422
|
+
FROM knowledge
|
|
423
|
+
WHERE ${fallbackConditions.join(' AND ')}
|
|
424
|
+
ORDER BY distance ASC
|
|
425
|
+
LIMIT ?
|
|
426
|
+
`;
|
|
427
|
+
fallbackArgs.push(limit);
|
|
428
|
+
const result = await client.execute({ sql: fallbackSql, args: fallbackArgs });
|
|
429
|
+
const results = result.rows.map((row) => parseSearchRow(row));
|
|
430
|
+
// Track usage for returned results
|
|
431
|
+
await trackUsage(results.map(r => r.id));
|
|
432
|
+
return c.json({ success: true, data: { query, results } });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
return c.json({ success: false, error: error.message }, 500);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
// ============ HTMX PARTIAL ROUTES (HTML) ============
|
|
440
|
+
// Kanban view
|
|
441
|
+
app.get('/partials/kanban-view', (c) => {
|
|
442
|
+
return c.html(renderKanbanView());
|
|
443
|
+
});
|
|
444
|
+
// Kanban columns - paginated per status (20 tickets per column initially)
|
|
445
|
+
app.get('/partials/kanban-columns', async (c) => {
|
|
446
|
+
try {
|
|
447
|
+
const client = await getClient();
|
|
448
|
+
const statuses = ['Backlog', 'In Progress', 'In Review', 'Done'];
|
|
449
|
+
const archiveStatuses = ['Blocked', 'Paused', 'Abandoned', 'Superseded'];
|
|
450
|
+
const limit = 20;
|
|
451
|
+
const columnData = await Promise.all(statuses.map(async (status) => {
|
|
452
|
+
const result = await client.execute({
|
|
453
|
+
sql: `SELECT id, type, title, status, intent, change_class, change_class_reason, tasks
|
|
454
|
+
FROM tickets WHERE status = ? ORDER BY created_at DESC LIMIT ?`,
|
|
455
|
+
args: [status, limit + 1],
|
|
456
|
+
});
|
|
457
|
+
const hasMore = result.rows.length > limit;
|
|
458
|
+
const tickets = result.rows.slice(0, limit).map((row) => parseTicketRow(row));
|
|
459
|
+
return { status, tickets, hasMore };
|
|
460
|
+
}));
|
|
461
|
+
// Add Archive column (Blocked, Paused, Abandoned, Superseded)
|
|
462
|
+
const archiveResult = await client.execute({
|
|
463
|
+
sql: `SELECT id, type, title, status, intent, change_class, change_class_reason, tasks
|
|
464
|
+
FROM tickets WHERE status IN (?, ?, ?, ?) ORDER BY created_at DESC LIMIT ?`,
|
|
465
|
+
args: [...archiveStatuses, limit + 1],
|
|
466
|
+
});
|
|
467
|
+
const archiveHasMore = archiveResult.rows.length > limit;
|
|
468
|
+
const archiveTickets = archiveResult.rows.slice(0, limit).map((row) => parseTicketRow(row));
|
|
469
|
+
columnData.push({ status: 'Archive', tickets: archiveTickets, hasMore: archiveHasMore });
|
|
470
|
+
return c.html(renderKanbanColumns(columnData));
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
return c.html(`<div class="text-red-500 p-4">Error: ${error.message}</div>`);
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
// Load more tickets for a specific column
|
|
477
|
+
app.get('/partials/kanban-column/:status', async (c) => {
|
|
478
|
+
try {
|
|
479
|
+
const status = decodeURIComponent(c.req.param('status'));
|
|
480
|
+
const offset = parseInt(c.req.query('offset') || '0', 10);
|
|
481
|
+
const limit = 20;
|
|
482
|
+
const client = await getClient();
|
|
483
|
+
let result;
|
|
484
|
+
// Handle Archive column specially (multiple statuses)
|
|
485
|
+
if (status === 'Archive') {
|
|
486
|
+
const archiveStatuses = ['Blocked', 'Paused', 'Abandoned', 'Superseded'];
|
|
487
|
+
result = await client.execute({
|
|
488
|
+
sql: `SELECT id, type, title, status, intent, change_class, change_class_reason, tasks
|
|
489
|
+
FROM tickets WHERE status IN (?, ?, ?, ?) ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
|
490
|
+
args: [...archiveStatuses, limit + 1, offset],
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
result = await client.execute({
|
|
495
|
+
sql: `SELECT id, type, title, status, intent, change_class, change_class_reason, tasks
|
|
496
|
+
FROM tickets WHERE status = ? ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
|
497
|
+
args: [status, limit + 1, offset],
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
const hasMore = result.rows.length > limit;
|
|
501
|
+
const tickets = result.rows.slice(0, limit).map((row) => parseTicketRow(row));
|
|
502
|
+
const nextOffset = offset + limit;
|
|
503
|
+
return c.html(renderColumnMore(tickets, status, nextOffset, hasMore));
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
return c.html(`<div class="text-red-500 p-4">Error: ${error.message}</div>`);
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
// Ticket modal - optimized query selecting only needed fields
|
|
510
|
+
app.get('/partials/ticket-modal/:id', async (c) => {
|
|
511
|
+
try {
|
|
512
|
+
const id = c.req.param('id');
|
|
513
|
+
const client = await getClient();
|
|
514
|
+
const result = await client.execute({
|
|
515
|
+
sql: `SELECT id, type, title, status, intent, context, constraints_use, constraints_avoid,
|
|
516
|
+
assumptions, tasks, definition_of_done, change_class, change_class_reason,
|
|
517
|
+
origin_spec_id, plan, derived_knowledge, comments, created_at, updated_at FROM tickets WHERE id = ?`,
|
|
518
|
+
args: [id],
|
|
519
|
+
});
|
|
520
|
+
if (result.rows.length === 0) {
|
|
521
|
+
return c.html('<div class="p-6 text-red-500">Ticket not found</div>');
|
|
522
|
+
}
|
|
523
|
+
const ticket = parseTicketRow(result.rows[0]);
|
|
524
|
+
return c.html(renderTicketModal(ticket));
|
|
525
|
+
}
|
|
526
|
+
catch (error) {
|
|
527
|
+
return c.html(`<div class="p-6 text-red-500">Error: ${error.message}</div>`);
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
// New ticket modal
|
|
531
|
+
app.get('/partials/new-ticket-modal', (c) => {
|
|
532
|
+
return c.html(renderNewTicketModal());
|
|
533
|
+
});
|
|
534
|
+
// Edit ticket modal
|
|
535
|
+
app.get('/partials/edit-ticket-modal/:id', async (c) => {
|
|
536
|
+
try {
|
|
537
|
+
const id = c.req.param('id');
|
|
538
|
+
const client = await getClient();
|
|
539
|
+
const result = await client.execute({
|
|
540
|
+
sql: `SELECT id, type, title, intent FROM tickets WHERE id = ?`,
|
|
541
|
+
args: [id],
|
|
542
|
+
});
|
|
543
|
+
if (result.rows.length === 0) {
|
|
544
|
+
return c.html('<div class="p-6 text-red-500">Ticket not found</div>');
|
|
545
|
+
}
|
|
546
|
+
const ticket = parseTicketRow(result.rows[0]);
|
|
547
|
+
return c.html(renderEditTicketModal(ticket));
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
return c.html(`<div class="p-6 text-red-500">Error: ${error.message}</div>`);
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
// Search view
|
|
554
|
+
app.get('/partials/search-view', (c) => {
|
|
555
|
+
return c.html(renderSearchView());
|
|
556
|
+
});
|
|
557
|
+
// Search results
|
|
558
|
+
app.get('/partials/search-results', async (c) => {
|
|
559
|
+
try {
|
|
560
|
+
const query = c.req.query('query');
|
|
561
|
+
const namespace = c.req.query('namespace');
|
|
562
|
+
const category = c.req.query('category');
|
|
563
|
+
const limit = parseInt(c.req.query('limit') || '5', 10);
|
|
564
|
+
if (!query || query.trim().length < 2) {
|
|
565
|
+
return c.html('<p class="text-gray-500 text-center py-8">Enter at least 2 characters to search</p>');
|
|
566
|
+
}
|
|
567
|
+
const client = await getClient();
|
|
568
|
+
const queryEmbedding = await embed(query);
|
|
569
|
+
const topK = limit * 2;
|
|
570
|
+
const conditions = ['k.active = 1'];
|
|
571
|
+
const args = [
|
|
572
|
+
JSON.stringify(queryEmbedding),
|
|
573
|
+
JSON.stringify(queryEmbedding),
|
|
574
|
+
];
|
|
575
|
+
if (namespace) {
|
|
576
|
+
conditions.push('k.namespace = ?');
|
|
577
|
+
args.push(namespace);
|
|
578
|
+
}
|
|
579
|
+
if (category) {
|
|
580
|
+
conditions.push('k.category = ?');
|
|
581
|
+
args.push(category);
|
|
582
|
+
}
|
|
583
|
+
// Try indexed search (k must be literal, not bound parameter)
|
|
584
|
+
try {
|
|
585
|
+
const sql = `
|
|
586
|
+
SELECT
|
|
587
|
+
k.id, k.namespace, k.chunk_index, k.title, k.content,
|
|
588
|
+
k.category, k.tags, k.source, k.origin_ticket_id, k.origin_ticket_type, k.confidence, k.active, k.decision_scope,
|
|
589
|
+
k.usage_count, k.last_used_at, k.created_at,
|
|
590
|
+
vector_distance_cos(k.embedding, vector32(?)) as distance
|
|
591
|
+
FROM vector_top_k('knowledge_embedding_idx', vector32(?), ${topK}) AS v
|
|
592
|
+
JOIN knowledge k ON k.rowid = v.id
|
|
593
|
+
WHERE ${conditions.join(' AND ')}
|
|
594
|
+
ORDER BY distance ASC
|
|
595
|
+
LIMIT ${limit}
|
|
596
|
+
`;
|
|
597
|
+
const result = await client.execute({ sql, args });
|
|
598
|
+
const results = result.rows.map((row) => parseSearchRow(row));
|
|
599
|
+
// Track usage for returned results
|
|
600
|
+
await trackUsage(results.map(r => r.id));
|
|
601
|
+
return c.html(renderSearchResults(results));
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
// Fallback to non-indexed search
|
|
605
|
+
const fallbackConditions = ['active = 1'];
|
|
606
|
+
const fallbackArgs = [JSON.stringify(queryEmbedding)];
|
|
607
|
+
if (namespace) {
|
|
608
|
+
fallbackConditions.push('namespace = ?');
|
|
609
|
+
fallbackArgs.push(namespace);
|
|
610
|
+
}
|
|
611
|
+
if (category) {
|
|
612
|
+
fallbackConditions.push('category = ?');
|
|
613
|
+
fallbackArgs.push(category);
|
|
614
|
+
}
|
|
615
|
+
const fallbackSql = `
|
|
616
|
+
SELECT
|
|
617
|
+
id, namespace, chunk_index, title, content,
|
|
618
|
+
category, tags, source, origin_ticket_id, origin_ticket_type, confidence, active, decision_scope,
|
|
619
|
+
usage_count, last_used_at, created_at,
|
|
620
|
+
vector_distance_cos(embedding, vector32(?)) as distance
|
|
621
|
+
FROM knowledge
|
|
622
|
+
WHERE ${fallbackConditions.join(' AND ')}
|
|
623
|
+
ORDER BY distance ASC
|
|
624
|
+
LIMIT ?
|
|
625
|
+
`;
|
|
626
|
+
fallbackArgs.push(limit);
|
|
627
|
+
const result = await client.execute({ sql: fallbackSql, args: fallbackArgs });
|
|
628
|
+
const results = result.rows.map((row) => parseSearchRow(row));
|
|
629
|
+
// Track usage for returned results
|
|
630
|
+
await trackUsage(results.map(r => r.id));
|
|
631
|
+
return c.html(renderSearchResults(results));
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
catch (error) {
|
|
635
|
+
return c.html(`<div class="text-red-500 p-4">Search error: ${error.message}</div>`);
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
// Knowledge view
|
|
639
|
+
app.get('/partials/knowledge-view', (c) => {
|
|
640
|
+
return c.html(renderKnowledgeView());
|
|
641
|
+
});
|
|
642
|
+
// Knowledge list
|
|
643
|
+
app.get('/partials/knowledge-list', async (c) => {
|
|
644
|
+
try {
|
|
645
|
+
const client = await getClient();
|
|
646
|
+
const category = c.req.query('k-category');
|
|
647
|
+
const namespace = c.req.query('k-namespace');
|
|
648
|
+
const scope = c.req.query('k-scope');
|
|
649
|
+
const sourceFilter = c.req.query('k-origin');
|
|
650
|
+
const status = c.req.query('k-status') || 'active';
|
|
651
|
+
const conditions = [];
|
|
652
|
+
const args = [];
|
|
653
|
+
// Status filter
|
|
654
|
+
if (status === 'active') {
|
|
655
|
+
conditions.push('active = 1');
|
|
656
|
+
}
|
|
657
|
+
else if (status === 'inactive') {
|
|
658
|
+
conditions.push('active = 0');
|
|
659
|
+
}
|
|
660
|
+
// 'all' = no filter
|
|
661
|
+
if (category) {
|
|
662
|
+
conditions.push('category = ?');
|
|
663
|
+
args.push(category);
|
|
664
|
+
}
|
|
665
|
+
if (namespace) {
|
|
666
|
+
conditions.push('namespace = ?');
|
|
667
|
+
args.push(namespace);
|
|
668
|
+
}
|
|
669
|
+
if (scope) {
|
|
670
|
+
conditions.push('decision_scope = ?');
|
|
671
|
+
args.push(scope);
|
|
672
|
+
}
|
|
673
|
+
if (sourceFilter) {
|
|
674
|
+
conditions.push('source = ?');
|
|
675
|
+
args.push(sourceFilter);
|
|
676
|
+
}
|
|
677
|
+
let sql = `SELECT id, namespace, chunk_index, title, content,
|
|
678
|
+
category, tags, source, origin_ticket_id, origin_ticket_type, confidence, active, decision_scope,
|
|
679
|
+
usage_count, last_used_at, created_at, updated_at
|
|
680
|
+
FROM knowledge`;
|
|
681
|
+
if (conditions.length > 0) {
|
|
682
|
+
sql += ` WHERE ${conditions.join(' AND ')}`;
|
|
683
|
+
}
|
|
684
|
+
sql += ' ORDER BY created_at DESC LIMIT 50';
|
|
685
|
+
const result = await client.execute({ sql, args });
|
|
686
|
+
const knowledge = result.rows.map((row) => parseKnowledgeRow(row));
|
|
687
|
+
return c.html(renderKnowledgeList(knowledge));
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
return c.html(`<div class="text-red-500 p-4">Error: ${error.message}</div>`);
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
// Knowledge modal
|
|
694
|
+
app.get('/partials/knowledge-modal/:id', async (c) => {
|
|
695
|
+
try {
|
|
696
|
+
const id = c.req.param('id');
|
|
697
|
+
const client = await getClient();
|
|
698
|
+
const result = await client.execute({
|
|
699
|
+
sql: `SELECT id, namespace, chunk_index, title, content,
|
|
700
|
+
category, tags, source, origin_ticket_id, origin_ticket_type, confidence, active, decision_scope,
|
|
701
|
+
usage_count, last_used_at, created_at, updated_at
|
|
702
|
+
FROM knowledge WHERE id = ?`,
|
|
703
|
+
args: [id],
|
|
704
|
+
});
|
|
705
|
+
if (result.rows.length === 0) {
|
|
706
|
+
return c.html('<div class="p-6 text-red-500">Knowledge entry not found</div>');
|
|
707
|
+
}
|
|
708
|
+
const knowledge = parseKnowledgeRow(result.rows[0]);
|
|
709
|
+
return c.html(renderKnowledgeModal(knowledge));
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
return c.html(`<div class="p-6 text-red-500">Error: ${error.message}</div>`);
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
// ============ SPEC PARTIALS ============
|
|
716
|
+
// Spec view
|
|
717
|
+
app.get('/partials/spec-view', (c) => {
|
|
718
|
+
return c.html(renderSpecView());
|
|
719
|
+
});
|
|
720
|
+
// New spec modal
|
|
721
|
+
app.get('/partials/new-spec-modal', (c) => {
|
|
722
|
+
return c.html(renderNewSpecModal());
|
|
723
|
+
});
|
|
724
|
+
// Spec list
|
|
725
|
+
app.get('/partials/spec-list', async (c) => {
|
|
726
|
+
try {
|
|
727
|
+
const client = await getClient();
|
|
728
|
+
const result = await client.execute({
|
|
729
|
+
sql: 'SELECT id, title, content, created_at, updated_at FROM specs ORDER BY created_at DESC LIMIT 50',
|
|
730
|
+
args: [],
|
|
731
|
+
});
|
|
732
|
+
const specs = result.rows.map((row) => parseSpecRow(row));
|
|
733
|
+
// Get ticket counts per spec
|
|
734
|
+
const countResult = await client.execute({
|
|
735
|
+
sql: 'SELECT origin_spec_id, COUNT(*) as cnt FROM tickets WHERE origin_spec_id IS NOT NULL GROUP BY origin_spec_id',
|
|
736
|
+
args: [],
|
|
737
|
+
});
|
|
738
|
+
const ticketCounts = {};
|
|
739
|
+
for (const row of countResult.rows) {
|
|
740
|
+
ticketCounts[row.origin_spec_id] = Number(row.cnt);
|
|
741
|
+
}
|
|
742
|
+
return c.html(renderSpecList(specs, ticketCounts));
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
return c.html(`<div class="text-red-500 p-4">Error: ${error.message}</div>`);
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
// Spec modal
|
|
749
|
+
app.get('/partials/spec-modal/:id', async (c) => {
|
|
750
|
+
try {
|
|
751
|
+
const id = c.req.param('id');
|
|
752
|
+
const client = await getClient();
|
|
753
|
+
const result = await client.execute({
|
|
754
|
+
sql: 'SELECT id, title, content, created_at, updated_at FROM specs WHERE id = ?',
|
|
755
|
+
args: [id],
|
|
756
|
+
});
|
|
757
|
+
if (result.rows.length === 0) {
|
|
758
|
+
return c.html('<div class="p-6 text-red-500">Spec not found</div>');
|
|
759
|
+
}
|
|
760
|
+
const spec = parseSpecRow(result.rows[0]);
|
|
761
|
+
// Get related tickets
|
|
762
|
+
const ticketResult = await client.execute({
|
|
763
|
+
sql: 'SELECT id, title, status FROM tickets WHERE origin_spec_id = ? ORDER BY created_at DESC',
|
|
764
|
+
args: [id],
|
|
765
|
+
});
|
|
766
|
+
const relatedTickets = ticketResult.rows.map(row => ({
|
|
767
|
+
id: row.id,
|
|
768
|
+
title: row.title,
|
|
769
|
+
status: row.status,
|
|
770
|
+
}));
|
|
771
|
+
return c.html(renderSpecModal(spec, relatedTickets));
|
|
772
|
+
}
|
|
773
|
+
catch (error) {
|
|
774
|
+
return c.html(`<div class="p-6 text-red-500">Error: ${error.message}</div>`);
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
// Edit spec modal
|
|
778
|
+
app.get('/partials/edit-spec-modal/:id', async (c) => {
|
|
779
|
+
try {
|
|
780
|
+
const id = c.req.param('id');
|
|
781
|
+
const client = await getClient();
|
|
782
|
+
const result = await client.execute({
|
|
783
|
+
sql: 'SELECT id, title, content, created_at, updated_at FROM specs WHERE id = ?',
|
|
784
|
+
args: [id],
|
|
785
|
+
});
|
|
786
|
+
if (result.rows.length === 0) {
|
|
787
|
+
return c.html('<div class="p-6 text-red-500">Spec not found</div>');
|
|
788
|
+
}
|
|
789
|
+
const spec = parseSpecRow(result.rows[0]);
|
|
790
|
+
return c.html(renderEditSpecModal(spec));
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
793
|
+
return c.html(`<div class="p-6 text-red-500">Error: ${error.message}</div>`);
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
// Update spec
|
|
797
|
+
app.patch('/api/specs/:id', async (c) => {
|
|
798
|
+
try {
|
|
799
|
+
const id = c.req.param('id');
|
|
800
|
+
const formData = await c.req.parseBody();
|
|
801
|
+
const title = formData.title?.trim();
|
|
802
|
+
const content = formData.content?.trim();
|
|
803
|
+
if (!title) {
|
|
804
|
+
return c.html('<div class="text-red-500 p-2">Title is required</div>', 400);
|
|
805
|
+
}
|
|
806
|
+
if (!content) {
|
|
807
|
+
return c.html('<div class="text-red-500 p-2">Content is required</div>', 400);
|
|
808
|
+
}
|
|
809
|
+
const client = await getClient();
|
|
810
|
+
await client.execute({
|
|
811
|
+
sql: `UPDATE specs SET title = ?, content = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
812
|
+
args: [title, content, id],
|
|
813
|
+
});
|
|
814
|
+
// Fetch updated spec and return detail modal
|
|
815
|
+
const result = await client.execute({
|
|
816
|
+
sql: 'SELECT id, title, content, created_at, updated_at FROM specs WHERE id = ?',
|
|
817
|
+
args: [id],
|
|
818
|
+
});
|
|
819
|
+
if (result.rows.length === 0) {
|
|
820
|
+
return c.html('<div class="p-6 text-red-500">Spec not found</div>', 404);
|
|
821
|
+
}
|
|
822
|
+
const spec = parseSpecRow(result.rows[0]);
|
|
823
|
+
// Get related tickets
|
|
824
|
+
const ticketResult = await client.execute({
|
|
825
|
+
sql: 'SELECT id, title, status FROM tickets WHERE origin_spec_id = ? ORDER BY created_at DESC',
|
|
826
|
+
args: [id],
|
|
827
|
+
});
|
|
828
|
+
const relatedTickets = ticketResult.rows.map(row => ({
|
|
829
|
+
id: row.id,
|
|
830
|
+
title: row.title,
|
|
831
|
+
status: row.status,
|
|
832
|
+
}));
|
|
833
|
+
c.header('HX-Trigger', 'refresh');
|
|
834
|
+
return c.html(renderSpecModal(spec, relatedTickets));
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
return c.html(`<div class="text-red-500 p-2">Error: ${error.message}</div>`, 500);
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
// Quick-create spec (manual)
|
|
841
|
+
app.post('/api/specs/quick', async (c) => {
|
|
842
|
+
try {
|
|
843
|
+
const formData = await c.req.parseBody();
|
|
844
|
+
const title = formData.title?.trim();
|
|
845
|
+
const content = formData.content?.trim();
|
|
846
|
+
if (!title) {
|
|
847
|
+
return c.html('<div class="text-red-500 p-2">Title is required</div>', 400);
|
|
848
|
+
}
|
|
849
|
+
if (!content) {
|
|
850
|
+
return c.html('<div class="text-red-500 p-2">Content is required</div>', 400);
|
|
851
|
+
}
|
|
852
|
+
// Generate ID: SPEC-YYYYMMDD-HHMMSS
|
|
853
|
+
const now = new Date();
|
|
854
|
+
const date = now.toISOString().slice(0, 10).replace(/-/g, '');
|
|
855
|
+
const time = now.toISOString().slice(11, 19).replace(/:/g, '');
|
|
856
|
+
const id = `SPEC-${date}-${time}`;
|
|
857
|
+
const client = await getClient();
|
|
858
|
+
await client.execute({
|
|
859
|
+
sql: `INSERT INTO specs (id, title, content) VALUES (?, ?, ?)`,
|
|
860
|
+
args: [id, title, content],
|
|
861
|
+
});
|
|
862
|
+
// Return refreshed spec list
|
|
863
|
+
const result = await client.execute({
|
|
864
|
+
sql: 'SELECT id, title, content, created_at, updated_at FROM specs ORDER BY created_at DESC LIMIT 50',
|
|
865
|
+
args: [],
|
|
866
|
+
});
|
|
867
|
+
const specs = result.rows.map((row) => parseSpecRow(row));
|
|
868
|
+
const countResult = await client.execute({
|
|
869
|
+
sql: 'SELECT origin_spec_id, COUNT(*) as cnt FROM tickets WHERE origin_spec_id IS NOT NULL GROUP BY origin_spec_id',
|
|
870
|
+
args: [],
|
|
871
|
+
});
|
|
872
|
+
const ticketCounts = {};
|
|
873
|
+
for (const row of countResult.rows) {
|
|
874
|
+
ticketCounts[row.origin_spec_id] = Number(row.cnt);
|
|
875
|
+
}
|
|
876
|
+
return c.html(renderSpecList(specs, ticketCounts));
|
|
877
|
+
}
|
|
878
|
+
catch (error) {
|
|
879
|
+
return c.html(`<div class="text-red-500 p-2">Error: ${error.message}</div>`, 500);
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
// Delete spec
|
|
883
|
+
app.delete('/api/specs/:id', async (c) => {
|
|
884
|
+
try {
|
|
885
|
+
const id = c.req.param('id');
|
|
886
|
+
const client = await getClient();
|
|
887
|
+
await client.execute({
|
|
888
|
+
sql: 'DELETE FROM specs WHERE id = ?',
|
|
889
|
+
args: [id],
|
|
890
|
+
});
|
|
891
|
+
// Return updated spec list
|
|
892
|
+
const result = await client.execute({
|
|
893
|
+
sql: 'SELECT id, title, content, created_at, updated_at FROM specs ORDER BY created_at DESC LIMIT 50',
|
|
894
|
+
args: [],
|
|
895
|
+
});
|
|
896
|
+
const specs = result.rows.map((row) => parseSpecRow(row));
|
|
897
|
+
const countResult = await client.execute({
|
|
898
|
+
sql: 'SELECT origin_spec_id, COUNT(*) as cnt FROM tickets WHERE origin_spec_id IS NOT NULL GROUP BY origin_spec_id',
|
|
899
|
+
args: [],
|
|
900
|
+
});
|
|
901
|
+
const ticketCounts = {};
|
|
902
|
+
for (const row of countResult.rows) {
|
|
903
|
+
ticketCounts[row.origin_spec_id] = Number(row.cnt);
|
|
904
|
+
}
|
|
905
|
+
return c.html(renderSpecList(specs, ticketCounts));
|
|
906
|
+
}
|
|
907
|
+
catch (error) {
|
|
908
|
+
return c.html(`<div class="text-red-500 p-4">Error: ${error.message}</div>`);
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
// ============ START SERVER ============
|
|
912
|
+
const config = loadConfig();
|
|
913
|
+
const isLocal = config.url.startsWith('file:');
|
|
914
|
+
const dbMode = isLocal ? 'Local' : 'Cloud';
|
|
915
|
+
const banner = `
|
|
916
|
+
\x1b[36m ███████╗███╗ ███╗ █████╗ ██████╗ ████████╗
|
|
917
|
+
██╔════╝████╗ ████║██╔══██╗██╔══██╗╚══██╔══╝
|
|
918
|
+
███████╗██╔████╔██║███████║██████╔╝ ██║
|
|
919
|
+
╚════██║██║╚██╔╝██║██╔══██║██╔══██╗ ██║
|
|
920
|
+
███████║██║ ╚═╝ ██║██║ ██║██║ ██║ ██║
|
|
921
|
+
╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝
|
|
922
|
+
██╗███╗ ██╗████████╗███████╗███╗ ██╗████████╗
|
|
923
|
+
██║████╗ ██║╚══██╔══╝██╔════╝████╗ ██║╚══██╔══╝
|
|
924
|
+
██║██╔██╗ ██║ ██║ █████╗ ██╔██╗ ██║ ██║
|
|
925
|
+
██║██║╚██╗██║ ██║ ██╔══╝ ██║╚██╗██║ ██║
|
|
926
|
+
██║██║ ╚████║ ██║ ███████╗██║ ╚████║ ██║
|
|
927
|
+
╚═╝╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝\x1b[0m
|
|
928
|
+
|
|
929
|
+
\x1b[32m*\x1b[0m Ready at \x1b[1mhttp://localhost:${port}\x1b[0m
|
|
930
|
+
\x1b[90m>\x1b[0m Using \x1b[33mTurso ${dbMode}\x1b[0m
|
|
931
|
+
|
|
932
|
+
\x1b[90mPress Ctrl+C to stop\x1b[0m
|
|
933
|
+
`;
|
|
934
|
+
console.log(banner);
|
|
935
|
+
const server = serve({
|
|
936
|
+
fetch: app.fetch,
|
|
937
|
+
port,
|
|
938
|
+
});
|
|
939
|
+
if (options.open) {
|
|
940
|
+
setTimeout(() => {
|
|
941
|
+
open(`http://localhost:${port}`);
|
|
942
|
+
}, 500);
|
|
943
|
+
}
|
|
944
|
+
// Handle graceful shutdown
|
|
945
|
+
const shutdown = () => {
|
|
946
|
+
console.log('\n\x1b[90m Goodbye!\x1b[0m\n');
|
|
947
|
+
server.close(() => {
|
|
948
|
+
closeClient();
|
|
949
|
+
process.exit(0);
|
|
950
|
+
});
|
|
951
|
+
};
|
|
952
|
+
process.on('SIGINT', shutdown);
|
|
953
|
+
process.on('SIGTERM', shutdown);
|
|
954
|
+
});
|