@stoneforge/shared-routes 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +13 -0
- package/README.md +69 -0
- package/dist/channels.d.ts +9 -0
- package/dist/channels.d.ts.map +1 -0
- package/dist/channels.js +413 -0
- package/dist/channels.js.map +1 -0
- package/dist/documents.d.ts +9 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +1205 -0
- package/dist/documents.js.map +1 -0
- package/dist/elements.d.ts +9 -0
- package/dist/elements.d.ts.map +1 -0
- package/dist/elements.js +87 -0
- package/dist/elements.js.map +1 -0
- package/dist/entities.d.ts +9 -0
- package/dist/entities.d.ts.map +1 -0
- package/dist/entities.js +92 -0
- package/dist/entities.js.map +1 -0
- package/dist/inbox.d.ts +10 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +538 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/libraries.d.ts +9 -0
- package/dist/libraries.d.ts.map +1 -0
- package/dist/libraries.js +467 -0
- package/dist/libraries.js.map +1 -0
- package/dist/messages.d.ts +9 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +338 -0
- package/dist/messages.js.map +1 -0
- package/dist/plans.d.ts +10 -0
- package/dist/plans.d.ts.map +1 -0
- package/dist/plans.js +495 -0
- package/dist/plans.js.map +1 -0
- package/dist/types.d.ts +128 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/ws/broadcaster.d.ts +62 -0
- package/dist/ws/broadcaster.d.ts.map +1 -0
- package/dist/ws/broadcaster.js +146 -0
- package/dist/ws/broadcaster.js.map +1 -0
- package/dist/ws/handler.d.ts +11 -0
- package/dist/ws/handler.d.ts.map +1 -0
- package/dist/ws/handler.js +38 -0
- package/dist/ws/handler.js.map +1 -0
- package/dist/ws/index.d.ts +11 -0
- package/dist/ws/index.d.ts.map +1 -0
- package/dist/ws/index.js +11 -0
- package/dist/ws/index.js.map +1 -0
- package/dist/ws/types.d.ts +85 -0
- package/dist/ws/types.d.ts.map +1 -0
- package/dist/ws/types.js +63 -0
- package/dist/ws/types.js.map +1 -0
- package/package.json +40 -0
|
@@ -0,0 +1,1205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document Routes Factory
|
|
3
|
+
*
|
|
4
|
+
* CRUD operations for documents, versioning, links, and comments.
|
|
5
|
+
*/
|
|
6
|
+
import { Hono } from 'hono';
|
|
7
|
+
import { createDocument, isValidDocumentCategory, isValidDocumentStatus, DocumentStatus as DocumentStatusEnum, ContentType, validateContent, createEvent, createTimestamp } from '@stoneforge/core';
|
|
8
|
+
export function createDocumentRoutes(services) {
|
|
9
|
+
const { api, storageBackend } = services;
|
|
10
|
+
const app = new Hono();
|
|
11
|
+
// GET /api/documents - List documents
|
|
12
|
+
app.get('/api/documents', async (c) => {
|
|
13
|
+
try {
|
|
14
|
+
const url = new URL(c.req.url);
|
|
15
|
+
// Parse pagination and filter parameters
|
|
16
|
+
const limitParam = url.searchParams.get('limit');
|
|
17
|
+
const offsetParam = url.searchParams.get('offset');
|
|
18
|
+
const orderByParam = url.searchParams.get('orderBy');
|
|
19
|
+
const orderDirParam = url.searchParams.get('orderDir');
|
|
20
|
+
const searchParam = url.searchParams.get('search');
|
|
21
|
+
const categoryParam = url.searchParams.get('category');
|
|
22
|
+
const statusParam = url.searchParams.get('status');
|
|
23
|
+
// Build filter
|
|
24
|
+
const filter = {
|
|
25
|
+
type: 'document',
|
|
26
|
+
};
|
|
27
|
+
// Category filter
|
|
28
|
+
if (categoryParam) {
|
|
29
|
+
const categories = categoryParam.split(',');
|
|
30
|
+
for (const cat of categories) {
|
|
31
|
+
if (!isValidDocumentCategory(cat)) {
|
|
32
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid category: ${cat}` } }, 400);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
filter.category = categories.length === 1 ? categories[0] : categories;
|
|
36
|
+
}
|
|
37
|
+
// Status filter (default: active only, unless explicitly specified)
|
|
38
|
+
if (statusParam) {
|
|
39
|
+
const statuses = statusParam.split(',');
|
|
40
|
+
for (const s of statuses) {
|
|
41
|
+
if (!isValidDocumentStatus(s)) {
|
|
42
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid status: ${s}` } }, 400);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
filter.status = statuses.length === 1 ? statuses[0] : statuses;
|
|
46
|
+
}
|
|
47
|
+
// Validate limit and offset
|
|
48
|
+
const MAX_LIMIT = 500;
|
|
49
|
+
let requestedLimit = 50;
|
|
50
|
+
let requestedOffset = 0;
|
|
51
|
+
if (limitParam) {
|
|
52
|
+
requestedLimit = parseInt(limitParam, 10);
|
|
53
|
+
if (isNaN(requestedLimit) || requestedLimit < 1 || requestedLimit > MAX_LIMIT) {
|
|
54
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid limit: must be 1-${MAX_LIMIT}` } }, 400);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (offsetParam) {
|
|
58
|
+
requestedOffset = parseInt(offsetParam, 10);
|
|
59
|
+
if (isNaN(requestedOffset) || requestedOffset < 0) {
|
|
60
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid offset: must be >= 0' } }, 400);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Validate orderBy — whitelist allowed columns
|
|
64
|
+
const ALLOWED_ORDER_COLUMNS = ['updated_at', 'created_at', 'title', 'type'];
|
|
65
|
+
if (orderByParam) {
|
|
66
|
+
if (!ALLOWED_ORDER_COLUMNS.includes(orderByParam)) {
|
|
67
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid orderBy: ${orderByParam}. Must be one of: ${ALLOWED_ORDER_COLUMNS.join(', ')}` } }, 400);
|
|
68
|
+
}
|
|
69
|
+
filter.orderBy = orderByParam;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
filter.orderBy = 'updated_at';
|
|
73
|
+
}
|
|
74
|
+
// Validate orderDir
|
|
75
|
+
if (orderDirParam) {
|
|
76
|
+
if (orderDirParam !== 'asc' && orderDirParam !== 'desc') {
|
|
77
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid orderDir: ${orderDirParam}. Must be 'asc' or 'desc'` } }, 400);
|
|
78
|
+
}
|
|
79
|
+
filter.orderDir = orderDirParam;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
filter.orderDir = 'desc';
|
|
83
|
+
}
|
|
84
|
+
// If search param is provided, use the search API for better results
|
|
85
|
+
if (searchParam && searchParam.trim()) {
|
|
86
|
+
const searchResults = await api.search(searchParam.trim(), filter);
|
|
87
|
+
const slicedResults = searchResults.slice(requestedOffset, requestedOffset + requestedLimit);
|
|
88
|
+
return c.json({
|
|
89
|
+
items: slicedResults,
|
|
90
|
+
total: searchResults.length,
|
|
91
|
+
offset: requestedOffset,
|
|
92
|
+
limit: requestedLimit,
|
|
93
|
+
hasMore: requestedOffset + requestedLimit < searchResults.length,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// Standard paginated query when no search
|
|
97
|
+
filter.limit = requestedLimit;
|
|
98
|
+
filter.offset = requestedOffset;
|
|
99
|
+
const result = await api.listPaginated(filter);
|
|
100
|
+
// Return paginated response format
|
|
101
|
+
return c.json({
|
|
102
|
+
items: result.items,
|
|
103
|
+
total: result.total,
|
|
104
|
+
offset: result.offset,
|
|
105
|
+
limit: result.limit,
|
|
106
|
+
hasMore: result.hasMore,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
console.error('[stoneforge] Failed to get documents:', error);
|
|
111
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get documents' } }, 500);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
/**
|
|
115
|
+
* GET /api/documents/search
|
|
116
|
+
* Search documents using FTS5 full-text search with BM25 ranking.
|
|
117
|
+
*
|
|
118
|
+
* Query params:
|
|
119
|
+
* - q: Search query (required)
|
|
120
|
+
* - limit: Hard cap on results (default: 50)
|
|
121
|
+
* - category: Filter by category
|
|
122
|
+
* - status: Filter by status (default: active)
|
|
123
|
+
* - sensitivity: Elbow detection sensitivity (default: 1.5)
|
|
124
|
+
* - mode: Search mode — 'relevance' (FTS5 only, default), 'semantic' (vector), 'hybrid' (RRF fusion)
|
|
125
|
+
*/
|
|
126
|
+
app.get('/api/documents/search', async (c) => {
|
|
127
|
+
try {
|
|
128
|
+
const url = new URL(c.req.url);
|
|
129
|
+
const query = url.searchParams.get('q');
|
|
130
|
+
const limitParam = url.searchParams.get('limit');
|
|
131
|
+
const categoryParam = url.searchParams.get('category');
|
|
132
|
+
const statusParam = url.searchParams.get('status');
|
|
133
|
+
const sensitivityParam = url.searchParams.get('sensitivity');
|
|
134
|
+
const mode = url.searchParams.get('mode') ?? 'relevance';
|
|
135
|
+
if (!query || query.trim().length === 0) {
|
|
136
|
+
return c.json({ results: [] });
|
|
137
|
+
}
|
|
138
|
+
// Validate mode
|
|
139
|
+
if (!['relevance', 'semantic', 'hybrid'].includes(mode)) {
|
|
140
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid mode: ${mode}. Must be relevance, semantic, or hybrid` } }, 400);
|
|
141
|
+
}
|
|
142
|
+
// Semantic and hybrid modes require a registered EmbeddingService (currently CLI-only)
|
|
143
|
+
if (mode === 'semantic' || mode === 'hybrid') {
|
|
144
|
+
return c.json({ error: { code: 'NOT_IMPLEMENTED', message: `${mode} search requires an embedding provider to be configured. Use mode=relevance for FTS5 keyword search. See documentation for embedding setup instructions.` } }, 501);
|
|
145
|
+
}
|
|
146
|
+
// Validate category if provided
|
|
147
|
+
if (categoryParam && !isValidDocumentCategory(categoryParam)) {
|
|
148
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid category: ${categoryParam}` } }, 400);
|
|
149
|
+
}
|
|
150
|
+
// Validate status if provided
|
|
151
|
+
if (statusParam && !isValidDocumentStatus(statusParam)) {
|
|
152
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid status: ${statusParam}` } }, 400);
|
|
153
|
+
}
|
|
154
|
+
// Validate limit
|
|
155
|
+
let searchLimit = 50;
|
|
156
|
+
if (limitParam) {
|
|
157
|
+
searchLimit = parseInt(limitParam, 10);
|
|
158
|
+
if (isNaN(searchLimit) || searchLimit < 1 || searchLimit > 500) {
|
|
159
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid limit: must be 1-500' } }, 400);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Validate sensitivity
|
|
163
|
+
let sensitivity = 1.5;
|
|
164
|
+
if (sensitivityParam) {
|
|
165
|
+
sensitivity = parseFloat(sensitivityParam);
|
|
166
|
+
if (isNaN(sensitivity) || sensitivity <= 0 || sensitivity > 10) {
|
|
167
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid sensitivity: must be between 0 and 10' } }, 400);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const results = await api.searchDocumentsFTS(query.trim(), {
|
|
171
|
+
hardCap: searchLimit,
|
|
172
|
+
...(categoryParam && { category: categoryParam }),
|
|
173
|
+
...(statusParam && { status: statusParam }),
|
|
174
|
+
elbowSensitivity: sensitivity,
|
|
175
|
+
});
|
|
176
|
+
return c.json({
|
|
177
|
+
results: results.map((r) => ({
|
|
178
|
+
id: r.document.id,
|
|
179
|
+
title: r.document.title,
|
|
180
|
+
contentType: r.document.contentType,
|
|
181
|
+
category: r.document.category,
|
|
182
|
+
status: r.document.status,
|
|
183
|
+
score: r.score,
|
|
184
|
+
snippet: r.snippet,
|
|
185
|
+
updatedAt: r.document.updatedAt,
|
|
186
|
+
})),
|
|
187
|
+
query: query.trim(),
|
|
188
|
+
mode,
|
|
189
|
+
total: results.length,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
console.error('[stoneforge] Failed to search documents:', error);
|
|
194
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to search documents' } }, 500);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
// GET /api/documents/:id - Get single document
|
|
198
|
+
app.get('/api/documents/:id', async (c) => {
|
|
199
|
+
try {
|
|
200
|
+
const id = c.req.param('id');
|
|
201
|
+
const document = await api.get(id);
|
|
202
|
+
if (!document) {
|
|
203
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
204
|
+
}
|
|
205
|
+
if (document.type !== 'document') {
|
|
206
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
207
|
+
}
|
|
208
|
+
return c.json(document);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
console.error('[stoneforge] Failed to get document:', error);
|
|
212
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get document' } }, 500);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
// POST /api/documents - Create document
|
|
216
|
+
app.post('/api/documents', async (c) => {
|
|
217
|
+
try {
|
|
218
|
+
const body = await c.req.json();
|
|
219
|
+
// Validate required fields
|
|
220
|
+
if (!body.createdBy || typeof body.createdBy !== 'string') {
|
|
221
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'createdBy is required' } }, 400);
|
|
222
|
+
}
|
|
223
|
+
// Default content type to 'text' if not provided
|
|
224
|
+
const contentType = body.contentType || 'text';
|
|
225
|
+
// Validate contentType
|
|
226
|
+
const validContentTypes = Object.values(ContentType);
|
|
227
|
+
if (!validContentTypes.includes(contentType)) {
|
|
228
|
+
return c.json({
|
|
229
|
+
error: {
|
|
230
|
+
code: 'VALIDATION_ERROR',
|
|
231
|
+
message: `Invalid contentType. Must be one of: ${validContentTypes.join(', ')}`,
|
|
232
|
+
},
|
|
233
|
+
}, 400);
|
|
234
|
+
}
|
|
235
|
+
// Default content to empty string if not provided
|
|
236
|
+
const content = body.content || '';
|
|
237
|
+
// Validate JSON content if contentType is json
|
|
238
|
+
if (contentType === 'json' && content) {
|
|
239
|
+
try {
|
|
240
|
+
JSON.parse(content);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return c.json({
|
|
244
|
+
error: {
|
|
245
|
+
code: 'VALIDATION_ERROR',
|
|
246
|
+
message: 'Invalid JSON content',
|
|
247
|
+
},
|
|
248
|
+
}, 400);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Validate category if provided
|
|
252
|
+
if (body.category !== undefined && !isValidDocumentCategory(body.category)) {
|
|
253
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid category: ${body.category}` } }, 400);
|
|
254
|
+
}
|
|
255
|
+
// Validate status if provided
|
|
256
|
+
if (body.status !== undefined && !isValidDocumentStatus(body.status)) {
|
|
257
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid status: ${body.status}` } }, 400);
|
|
258
|
+
}
|
|
259
|
+
// Build CreateDocumentInput
|
|
260
|
+
const docInput = {
|
|
261
|
+
contentType,
|
|
262
|
+
content,
|
|
263
|
+
createdBy: body.createdBy,
|
|
264
|
+
...(body.title !== undefined && { title: body.title }),
|
|
265
|
+
...(body.tags !== undefined && { tags: body.tags }),
|
|
266
|
+
...(body.metadata !== undefined && { metadata: body.metadata }),
|
|
267
|
+
...(body.category !== undefined && { category: body.category }),
|
|
268
|
+
...(body.status !== undefined && { status: body.status }),
|
|
269
|
+
};
|
|
270
|
+
// Validate libraryId before creating the document
|
|
271
|
+
if (body.libraryId) {
|
|
272
|
+
const library = await api.get(body.libraryId);
|
|
273
|
+
if (!library || library.type !== 'library') {
|
|
274
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid libraryId: library not found' } }, 400);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Create the document using the factory function
|
|
278
|
+
const document = await createDocument(docInput);
|
|
279
|
+
// Create in database
|
|
280
|
+
const created = await api.create(document);
|
|
281
|
+
// Add to library (already validated above)
|
|
282
|
+
if (body.libraryId) {
|
|
283
|
+
await api.addDependency({
|
|
284
|
+
blockedId: created.id,
|
|
285
|
+
blockerId: body.libraryId,
|
|
286
|
+
type: 'parent-child',
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return c.json(created, 201);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
if (error.code === 'VALIDATION_ERROR') {
|
|
293
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: error.message } }, 400);
|
|
294
|
+
}
|
|
295
|
+
console.error('[stoneforge] Failed to create document:', error);
|
|
296
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to create document' } }, 500);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
// PATCH /api/documents/:id - Update document
|
|
300
|
+
app.patch('/api/documents/:id', async (c) => {
|
|
301
|
+
try {
|
|
302
|
+
const id = c.req.param('id');
|
|
303
|
+
const body = await c.req.json();
|
|
304
|
+
// First verify it's a document
|
|
305
|
+
const existing = await api.get(id);
|
|
306
|
+
if (!existing) {
|
|
307
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
308
|
+
}
|
|
309
|
+
if (existing.type !== 'document') {
|
|
310
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
311
|
+
}
|
|
312
|
+
// Reject content updates on immutable documents
|
|
313
|
+
const existingDoc = existing;
|
|
314
|
+
if (existingDoc.immutable === true && body.content !== undefined) {
|
|
315
|
+
return c.json({ error: { code: 'IMMUTABLE', message: 'Cannot update content of immutable document' } }, 403);
|
|
316
|
+
}
|
|
317
|
+
// Extract allowed updates (prevent changing immutable fields)
|
|
318
|
+
const updates = {};
|
|
319
|
+
const allowedFields = ['title', 'content', 'contentType', 'tags', 'metadata', 'category', 'status'];
|
|
320
|
+
for (const field of allowedFields) {
|
|
321
|
+
if (body[field] !== undefined) {
|
|
322
|
+
updates[field] = body[field];
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Validate contentType if provided
|
|
326
|
+
if (updates.contentType) {
|
|
327
|
+
const validTypes = Object.values(ContentType);
|
|
328
|
+
if (!validTypes.includes(updates.contentType)) {
|
|
329
|
+
return c.json({
|
|
330
|
+
error: {
|
|
331
|
+
code: 'VALIDATION_ERROR',
|
|
332
|
+
message: `Invalid contentType. Must be one of: ${validTypes.join(', ')}`,
|
|
333
|
+
},
|
|
334
|
+
}, 400);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Validate category if provided
|
|
338
|
+
if (updates.category !== undefined && !isValidDocumentCategory(updates.category)) {
|
|
339
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid category: ${updates.category}` } }, 400);
|
|
340
|
+
}
|
|
341
|
+
// Validate status if provided
|
|
342
|
+
if (updates.status !== undefined && !isValidDocumentStatus(updates.status)) {
|
|
343
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid status: ${updates.status}` } }, 400);
|
|
344
|
+
}
|
|
345
|
+
// Validate content (size limit + JSON validation if applicable)
|
|
346
|
+
if (updates.content !== undefined) {
|
|
347
|
+
const contentTypeVal = (updates.contentType || existing.contentType);
|
|
348
|
+
try {
|
|
349
|
+
validateContent(updates.content, contentTypeVal);
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: err.message } }, 400);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// Update the document
|
|
356
|
+
const updated = await api.update(id, updates);
|
|
357
|
+
return c.json(updated);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
if (error.code === 'NOT_FOUND') {
|
|
361
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
362
|
+
}
|
|
363
|
+
if (error.code === 'CONCURRENT_MODIFICATION') {
|
|
364
|
+
return c.json({ error: { code: 'CONFLICT', message: 'Document was modified by another process' } }, 409);
|
|
365
|
+
}
|
|
366
|
+
if (error.code === 'VALIDATION_ERROR') {
|
|
367
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: error.message } }, 400);
|
|
368
|
+
}
|
|
369
|
+
console.error('[stoneforge] Failed to update document:', error);
|
|
370
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to update document' } }, 500);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
// POST /api/documents/:id/archive - Archive a document
|
|
374
|
+
app.post('/api/documents/:id/archive', async (c) => {
|
|
375
|
+
try {
|
|
376
|
+
const id = c.req.param('id');
|
|
377
|
+
const existing = await api.get(id);
|
|
378
|
+
if (!existing) {
|
|
379
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
380
|
+
}
|
|
381
|
+
if (existing.type !== 'document') {
|
|
382
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
383
|
+
}
|
|
384
|
+
const updated = await api.update(id, { status: DocumentStatusEnum.ARCHIVED });
|
|
385
|
+
return c.json(updated);
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
if (error.code === 'NOT_FOUND') {
|
|
389
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
390
|
+
}
|
|
391
|
+
console.error('[stoneforge] Failed to archive document:', error);
|
|
392
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to archive document' } }, 500);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
// POST /api/documents/:id/unarchive - Unarchive a document
|
|
396
|
+
app.post('/api/documents/:id/unarchive', async (c) => {
|
|
397
|
+
try {
|
|
398
|
+
const id = c.req.param('id');
|
|
399
|
+
const existing = await api.get(id);
|
|
400
|
+
if (!existing) {
|
|
401
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
402
|
+
}
|
|
403
|
+
if (existing.type !== 'document') {
|
|
404
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
405
|
+
}
|
|
406
|
+
const updated = await api.update(id, { status: DocumentStatusEnum.ACTIVE });
|
|
407
|
+
return c.json(updated);
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
if (error.code === 'NOT_FOUND') {
|
|
411
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
412
|
+
}
|
|
413
|
+
console.error('[stoneforge] Failed to unarchive document:', error);
|
|
414
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to unarchive document' } }, 500);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
// DELETE /api/documents/:id - Delete a document (soft-delete via tombstone)
|
|
418
|
+
app.delete('/api/documents/:id', async (c) => {
|
|
419
|
+
try {
|
|
420
|
+
const id = c.req.param('id');
|
|
421
|
+
const existing = await api.get(id);
|
|
422
|
+
if (!existing) {
|
|
423
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
424
|
+
}
|
|
425
|
+
if (existing.type !== 'document') {
|
|
426
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
427
|
+
}
|
|
428
|
+
await api.delete(id);
|
|
429
|
+
return c.json({ success: true, id }, 200);
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
if (error.code === 'NOT_FOUND') {
|
|
433
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
434
|
+
}
|
|
435
|
+
console.error('[stoneforge] Failed to delete document:', error);
|
|
436
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to delete document' } }, 500);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
// GET /api/documents/:id/versions - Get document version history
|
|
440
|
+
app.get('/api/documents/:id/versions', async (c) => {
|
|
441
|
+
try {
|
|
442
|
+
const id = c.req.param('id');
|
|
443
|
+
// First verify it's a document
|
|
444
|
+
const existing = await api.get(id);
|
|
445
|
+
if (!existing) {
|
|
446
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
447
|
+
}
|
|
448
|
+
if (existing.type !== 'document') {
|
|
449
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
450
|
+
}
|
|
451
|
+
// Get version history using the API method
|
|
452
|
+
const versions = await api.getDocumentHistory(id);
|
|
453
|
+
return c.json(versions);
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
console.error('[stoneforge] Failed to get document versions:', error);
|
|
457
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get document versions' } }, 500);
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
// GET /api/documents/:id/versions/:version - Get specific version
|
|
461
|
+
app.get('/api/documents/:id/versions/:version', async (c) => {
|
|
462
|
+
try {
|
|
463
|
+
const id = c.req.param('id');
|
|
464
|
+
const versionParam = c.req.param('version');
|
|
465
|
+
const version = parseInt(versionParam, 10);
|
|
466
|
+
if (isNaN(version) || version < 1) {
|
|
467
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid version number' } }, 400);
|
|
468
|
+
}
|
|
469
|
+
// Get the specific version
|
|
470
|
+
const document = await api.getDocumentVersion(id, version);
|
|
471
|
+
if (!document) {
|
|
472
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document version not found' } }, 404);
|
|
473
|
+
}
|
|
474
|
+
return c.json(document);
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
console.error('[stoneforge] Failed to get document version:', error);
|
|
478
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get document version' } }, 500);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
// POST /api/documents/:id/restore - Restore document to a specific version
|
|
482
|
+
app.post('/api/documents/:id/restore', async (c) => {
|
|
483
|
+
try {
|
|
484
|
+
const id = c.req.param('id');
|
|
485
|
+
const body = await c.req.json();
|
|
486
|
+
const version = body.version;
|
|
487
|
+
if (typeof version !== 'number' || version < 1) {
|
|
488
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid version number' } }, 400);
|
|
489
|
+
}
|
|
490
|
+
// First verify it's a document
|
|
491
|
+
const existing = await api.get(id);
|
|
492
|
+
if (!existing) {
|
|
493
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
494
|
+
}
|
|
495
|
+
if (existing.type !== 'document') {
|
|
496
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
497
|
+
}
|
|
498
|
+
// Get the version to restore
|
|
499
|
+
const versionToRestore = await api.getDocumentVersion(id, version);
|
|
500
|
+
if (!versionToRestore) {
|
|
501
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document version not found' } }, 404);
|
|
502
|
+
}
|
|
503
|
+
// Update the document with the restored content (including optional fields if present in snapshot)
|
|
504
|
+
const restorePayload = {
|
|
505
|
+
content: versionToRestore.content,
|
|
506
|
+
contentType: versionToRestore.contentType,
|
|
507
|
+
};
|
|
508
|
+
if (versionToRestore.tags !== undefined)
|
|
509
|
+
restorePayload.tags = versionToRestore.tags;
|
|
510
|
+
if (versionToRestore.metadata !== undefined)
|
|
511
|
+
restorePayload.metadata = versionToRestore.metadata;
|
|
512
|
+
if (versionToRestore.title !== undefined)
|
|
513
|
+
restorePayload.title = versionToRestore.title;
|
|
514
|
+
if (versionToRestore.category !== undefined)
|
|
515
|
+
restorePayload.category = versionToRestore.category;
|
|
516
|
+
const restored = await api.update(id, restorePayload);
|
|
517
|
+
return c.json(restored);
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
if (error.code === 'NOT_FOUND') {
|
|
521
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
522
|
+
}
|
|
523
|
+
console.error('[stoneforge] Failed to restore document version:', error);
|
|
524
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to restore document version' } }, 500);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
// POST /api/documents/:id/clone - Clone a document
|
|
528
|
+
app.post('/api/documents/:id/clone', async (c) => {
|
|
529
|
+
try {
|
|
530
|
+
const id = c.req.param('id');
|
|
531
|
+
const body = await c.req.json();
|
|
532
|
+
// Get the source document
|
|
533
|
+
const sourceDoc = await api.get(id);
|
|
534
|
+
if (!sourceDoc) {
|
|
535
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
536
|
+
}
|
|
537
|
+
if (sourceDoc.type !== 'document') {
|
|
538
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
539
|
+
}
|
|
540
|
+
const sourceDocument = sourceDoc;
|
|
541
|
+
// Validate createdBy
|
|
542
|
+
if (!body.createdBy || typeof body.createdBy !== 'string') {
|
|
543
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'createdBy is required' } }, 400);
|
|
544
|
+
}
|
|
545
|
+
// Use the new title or generate one from the original
|
|
546
|
+
const originalTitle = sourceDocument.title || `Document ${sourceDocument.id}`;
|
|
547
|
+
const newTitle = body.title || `${originalTitle} (Copy)`;
|
|
548
|
+
// Create a new document with the same content
|
|
549
|
+
const docInput = {
|
|
550
|
+
contentType: sourceDocument.contentType,
|
|
551
|
+
content: sourceDocument.content || '',
|
|
552
|
+
createdBy: body.createdBy,
|
|
553
|
+
title: newTitle,
|
|
554
|
+
tags: sourceDocument.tags || [],
|
|
555
|
+
metadata: sourceDocument.metadata || {},
|
|
556
|
+
category: sourceDocument.category,
|
|
557
|
+
};
|
|
558
|
+
// Validate libraryId before creating the document
|
|
559
|
+
if (body.libraryId) {
|
|
560
|
+
const library = await api.get(body.libraryId);
|
|
561
|
+
if (!library || library.type !== 'library') {
|
|
562
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid libraryId: library not found' } }, 400);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const newDoc = await createDocument(docInput);
|
|
566
|
+
// Create in database
|
|
567
|
+
const created = await api.create(newDoc);
|
|
568
|
+
// Add to library (already validated above)
|
|
569
|
+
if (body.libraryId) {
|
|
570
|
+
await api.addDependency({
|
|
571
|
+
blockedId: created.id,
|
|
572
|
+
blockerId: body.libraryId,
|
|
573
|
+
type: 'parent-child',
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
return c.json(created, 201);
|
|
577
|
+
}
|
|
578
|
+
catch (error) {
|
|
579
|
+
if (error.code === 'NOT_FOUND') {
|
|
580
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
581
|
+
}
|
|
582
|
+
console.error('[stoneforge] Failed to clone document:', error);
|
|
583
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to clone document' } }, 500);
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
/**
|
|
587
|
+
* GET /api/documents/:id/links
|
|
588
|
+
* Returns documents linked from this document (outgoing) and documents linking to it (incoming)
|
|
589
|
+
* Query params:
|
|
590
|
+
* - direction: 'outgoing' | 'incoming' | 'both' (default: 'both')
|
|
591
|
+
*/
|
|
592
|
+
app.get('/api/documents/:id/links', async (c) => {
|
|
593
|
+
try {
|
|
594
|
+
const documentId = c.req.param('id');
|
|
595
|
+
const url = new URL(c.req.url);
|
|
596
|
+
const direction = url.searchParams.get('direction') || 'both';
|
|
597
|
+
// Verify document exists
|
|
598
|
+
const doc = await api.get(documentId);
|
|
599
|
+
if (!doc) {
|
|
600
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
601
|
+
}
|
|
602
|
+
if (doc.type !== 'document') {
|
|
603
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
604
|
+
}
|
|
605
|
+
// Fetch document details based on direction
|
|
606
|
+
let outgoing = [];
|
|
607
|
+
let incoming = [];
|
|
608
|
+
if (direction === 'outgoing' || direction === 'both') {
|
|
609
|
+
// Outgoing links: documents this document references (blockedId = this document)
|
|
610
|
+
const outgoingDeps = await api.getDependencies(documentId, ['references']);
|
|
611
|
+
const outgoingDocs = await Promise.all(outgoingDeps.map(async (dep) => {
|
|
612
|
+
const linkedDoc = await api.get(dep.blockerId);
|
|
613
|
+
if (linkedDoc && linkedDoc.type === 'document') {
|
|
614
|
+
return linkedDoc;
|
|
615
|
+
}
|
|
616
|
+
return null;
|
|
617
|
+
}));
|
|
618
|
+
outgoing = outgoingDocs.filter(Boolean);
|
|
619
|
+
}
|
|
620
|
+
if (direction === 'incoming' || direction === 'both') {
|
|
621
|
+
// Incoming links: documents that reference this document (blockerId = this document)
|
|
622
|
+
const incomingDeps = await api.getDependents(documentId, ['references']);
|
|
623
|
+
const incomingDocs = await Promise.all(incomingDeps.map(async (dep) => {
|
|
624
|
+
const linkedDoc = await api.get(dep.blockedId);
|
|
625
|
+
if (linkedDoc && linkedDoc.type === 'document') {
|
|
626
|
+
return linkedDoc;
|
|
627
|
+
}
|
|
628
|
+
return null;
|
|
629
|
+
}));
|
|
630
|
+
incoming = incomingDocs.filter(Boolean);
|
|
631
|
+
}
|
|
632
|
+
return c.json({ outgoing, incoming });
|
|
633
|
+
}
|
|
634
|
+
catch (error) {
|
|
635
|
+
console.error('[stoneforge] Failed to get document links:', error);
|
|
636
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get document links' } }, 500);
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
/**
|
|
640
|
+
* POST /api/documents/:id/links
|
|
641
|
+
* Creates a link from this document to another document
|
|
642
|
+
* Body: { targetDocumentId: string, actor?: string }
|
|
643
|
+
*/
|
|
644
|
+
app.post('/api/documents/:id/links', async (c) => {
|
|
645
|
+
try {
|
|
646
|
+
const sourceId = c.req.param('id');
|
|
647
|
+
const body = await c.req.json();
|
|
648
|
+
// Validate target document ID
|
|
649
|
+
if (!body.targetDocumentId || typeof body.targetDocumentId !== 'string') {
|
|
650
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'targetDocumentId is required' } }, 400);
|
|
651
|
+
}
|
|
652
|
+
const targetId = body.targetDocumentId;
|
|
653
|
+
// Prevent self-reference
|
|
654
|
+
if (sourceId === targetId) {
|
|
655
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Cannot link a document to itself' } }, 400);
|
|
656
|
+
}
|
|
657
|
+
// Verify source document exists
|
|
658
|
+
const sourceDoc = await api.get(sourceId);
|
|
659
|
+
if (!sourceDoc) {
|
|
660
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Source document not found' } }, 404);
|
|
661
|
+
}
|
|
662
|
+
if (sourceDoc.type !== 'document') {
|
|
663
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Source document not found' } }, 404);
|
|
664
|
+
}
|
|
665
|
+
// Verify target document exists
|
|
666
|
+
const targetDoc = await api.get(targetId);
|
|
667
|
+
if (!targetDoc) {
|
|
668
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Target document not found' } }, 404);
|
|
669
|
+
}
|
|
670
|
+
if (targetDoc.type !== 'document') {
|
|
671
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Target document not found' } }, 404);
|
|
672
|
+
}
|
|
673
|
+
// Check if link already exists
|
|
674
|
+
const existingDeps = await api.getDependencies(sourceId);
|
|
675
|
+
const alreadyLinked = existingDeps.some((dep) => dep.blockedId === sourceId && dep.blockerId === targetId && dep.type === 'references');
|
|
676
|
+
if (alreadyLinked) {
|
|
677
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Link already exists between these documents' } }, 400);
|
|
678
|
+
}
|
|
679
|
+
// Create the references dependency (source document references target document)
|
|
680
|
+
await api.addDependency({
|
|
681
|
+
blockedId: sourceId,
|
|
682
|
+
blockerId: targetId,
|
|
683
|
+
type: 'references',
|
|
684
|
+
actor: body.actor || 'el-0000',
|
|
685
|
+
});
|
|
686
|
+
return c.json({ sourceId, targetId, targetDocument: targetDoc }, 201);
|
|
687
|
+
}
|
|
688
|
+
catch (error) {
|
|
689
|
+
console.error('[stoneforge] Failed to link documents:', error);
|
|
690
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to link documents' } }, 500);
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
/**
|
|
694
|
+
* DELETE /api/documents/:sourceId/links/:targetId
|
|
695
|
+
* Removes a link between two documents
|
|
696
|
+
*/
|
|
697
|
+
app.delete('/api/documents/:sourceId/links/:targetId', async (c) => {
|
|
698
|
+
try {
|
|
699
|
+
const sourceId = c.req.param('sourceId');
|
|
700
|
+
const targetId = c.req.param('targetId');
|
|
701
|
+
// Verify source document exists
|
|
702
|
+
const sourceDoc = await api.get(sourceId);
|
|
703
|
+
if (!sourceDoc) {
|
|
704
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Source document not found' } }, 404);
|
|
705
|
+
}
|
|
706
|
+
if (sourceDoc.type !== 'document') {
|
|
707
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Source document not found' } }, 404);
|
|
708
|
+
}
|
|
709
|
+
// Find the link dependency
|
|
710
|
+
const dependencies = await api.getDependencies(sourceId);
|
|
711
|
+
const linkDep = dependencies.find((dep) => dep.blockedId === sourceId && dep.blockerId === targetId && dep.type === 'references');
|
|
712
|
+
if (!linkDep) {
|
|
713
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Link not found between these documents' } }, 404);
|
|
714
|
+
}
|
|
715
|
+
// Remove the dependency
|
|
716
|
+
await api.removeDependency(sourceId, targetId, 'references');
|
|
717
|
+
return c.json({ success: true, sourceId, targetId });
|
|
718
|
+
}
|
|
719
|
+
catch (error) {
|
|
720
|
+
console.error('[stoneforge] Failed to remove document link:', error);
|
|
721
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to remove document link' } }, 500);
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
/**
|
|
725
|
+
* PUT /api/documents/:id/library
|
|
726
|
+
* Move a document to a library (or between libraries)
|
|
727
|
+
* Body: { libraryId: string, actor?: string }
|
|
728
|
+
*/
|
|
729
|
+
app.put('/api/documents/:id/library', async (c) => {
|
|
730
|
+
try {
|
|
731
|
+
const documentId = c.req.param('id');
|
|
732
|
+
const body = await c.req.json();
|
|
733
|
+
// Verify document exists and is not tombstoned
|
|
734
|
+
const doc = await api.get(documentId);
|
|
735
|
+
if (!doc) {
|
|
736
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
737
|
+
}
|
|
738
|
+
if (doc.type !== 'document') {
|
|
739
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
740
|
+
}
|
|
741
|
+
const docData = doc;
|
|
742
|
+
if (docData.status === 'tombstone' || docData.deletedAt) {
|
|
743
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
744
|
+
}
|
|
745
|
+
// Validate libraryId
|
|
746
|
+
if (!body.libraryId || typeof body.libraryId !== 'string') {
|
|
747
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'libraryId is required' } }, 400);
|
|
748
|
+
}
|
|
749
|
+
const libraryId = body.libraryId;
|
|
750
|
+
// Verify library exists
|
|
751
|
+
const library = await api.get(libraryId);
|
|
752
|
+
if (!library || library.type !== 'library') {
|
|
753
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid libraryId: library not found' } }, 400);
|
|
754
|
+
}
|
|
755
|
+
const actor = body.actor || 'el-0000';
|
|
756
|
+
// Find current library
|
|
757
|
+
const deps = await api.getDependencies(documentId, ['parent-child']);
|
|
758
|
+
let previousLibraryId = null;
|
|
759
|
+
for (const dep of deps) {
|
|
760
|
+
// The blocker is the parent (library)
|
|
761
|
+
const parent = await api.get(dep.blockerId);
|
|
762
|
+
if (parent && parent.type === 'library') {
|
|
763
|
+
previousLibraryId = dep.blockerId;
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
// Already in requested library — no-op
|
|
768
|
+
if (previousLibraryId === libraryId) {
|
|
769
|
+
return c.json({ documentId, libraryId, previousLibraryId });
|
|
770
|
+
}
|
|
771
|
+
// Remove from old library if present
|
|
772
|
+
if (previousLibraryId) {
|
|
773
|
+
await api.removeDependency(documentId, previousLibraryId, 'parent-child');
|
|
774
|
+
}
|
|
775
|
+
// Add to new library
|
|
776
|
+
await api.addDependency({
|
|
777
|
+
blockedId: documentId,
|
|
778
|
+
blockerId: libraryId,
|
|
779
|
+
type: 'parent-child',
|
|
780
|
+
actor,
|
|
781
|
+
});
|
|
782
|
+
return c.json({ documentId, libraryId, previousLibraryId });
|
|
783
|
+
}
|
|
784
|
+
catch (error) {
|
|
785
|
+
console.error('[stoneforge] Failed to move document to library:', error);
|
|
786
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to move document to library' } }, 500);
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
/**
|
|
790
|
+
* DELETE /api/documents/:id/library
|
|
791
|
+
* Remove a document from its library
|
|
792
|
+
*/
|
|
793
|
+
app.delete('/api/documents/:id/library', async (c) => {
|
|
794
|
+
try {
|
|
795
|
+
const documentId = c.req.param('id');
|
|
796
|
+
// Verify document exists and is not tombstoned
|
|
797
|
+
const doc = await api.get(documentId);
|
|
798
|
+
if (!doc) {
|
|
799
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
800
|
+
}
|
|
801
|
+
if (doc.type !== 'document') {
|
|
802
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
803
|
+
}
|
|
804
|
+
const docData = doc;
|
|
805
|
+
if (docData.status === 'tombstone' || docData.deletedAt) {
|
|
806
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
807
|
+
}
|
|
808
|
+
// Find current library
|
|
809
|
+
const deps = await api.getDependencies(documentId, ['parent-child']);
|
|
810
|
+
let libraryDep = null;
|
|
811
|
+
for (const dep of deps) {
|
|
812
|
+
const parent = await api.get(dep.blockerId);
|
|
813
|
+
if (parent && parent.type === 'library') {
|
|
814
|
+
libraryDep = dep;
|
|
815
|
+
break;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
if (!libraryDep) {
|
|
819
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document is not in any library' } }, 404);
|
|
820
|
+
}
|
|
821
|
+
await api.removeDependency(documentId, libraryDep.blockerId, 'parent-child');
|
|
822
|
+
return c.json({ success: true, documentId, removedFromLibrary: libraryDep.blockerId });
|
|
823
|
+
}
|
|
824
|
+
catch (error) {
|
|
825
|
+
console.error('[stoneforge] Failed to remove document from library:', error);
|
|
826
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to remove document from library' } }, 500);
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
/**
|
|
830
|
+
* GET /api/documents/:id/comments
|
|
831
|
+
* Returns all comments for a document
|
|
832
|
+
* Query params:
|
|
833
|
+
* - includeResolved: 'true' to include resolved comments (default: false)
|
|
834
|
+
*/
|
|
835
|
+
app.get('/api/documents/:id/comments', async (c) => {
|
|
836
|
+
try {
|
|
837
|
+
const documentId = c.req.param('id');
|
|
838
|
+
const url = new URL(c.req.url);
|
|
839
|
+
const includeResolved = url.searchParams.get('includeResolved') === 'true';
|
|
840
|
+
// Pagination params
|
|
841
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 200);
|
|
842
|
+
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
|
843
|
+
// Verify document exists
|
|
844
|
+
const doc = await api.get(documentId);
|
|
845
|
+
if (!doc) {
|
|
846
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
847
|
+
}
|
|
848
|
+
if (doc.type !== 'document') {
|
|
849
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
850
|
+
}
|
|
851
|
+
const docData = doc;
|
|
852
|
+
if (docData.status === 'tombstone' || docData.deletedAt) {
|
|
853
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
854
|
+
}
|
|
855
|
+
// Count total comments
|
|
856
|
+
let countQuery = 'SELECT COUNT(*) as total FROM comments WHERE document_id = ? AND deleted_at IS NULL';
|
|
857
|
+
if (!includeResolved)
|
|
858
|
+
countQuery += ' AND resolved = 0';
|
|
859
|
+
const total = storageBackend.queryOne(countQuery, [documentId])?.total ?? 0;
|
|
860
|
+
// Query comments from the database with pagination
|
|
861
|
+
let query = `
|
|
862
|
+
SELECT * FROM comments
|
|
863
|
+
WHERE document_id = ? AND deleted_at IS NULL
|
|
864
|
+
`;
|
|
865
|
+
if (!includeResolved) {
|
|
866
|
+
query += ' AND resolved = 0';
|
|
867
|
+
}
|
|
868
|
+
query += ' ORDER BY created_at ASC LIMIT ? OFFSET ?';
|
|
869
|
+
const comments = storageBackend.query(query, [documentId, limit, offset]);
|
|
870
|
+
// Batch-fetch entities (Fix 3: N+1 → 1 query)
|
|
871
|
+
const entityIds = new Set();
|
|
872
|
+
for (const comment of comments) {
|
|
873
|
+
entityIds.add(comment.author_id);
|
|
874
|
+
if (comment.resolved_by)
|
|
875
|
+
entityIds.add(comment.resolved_by);
|
|
876
|
+
}
|
|
877
|
+
const entityMap = new Map();
|
|
878
|
+
if (entityIds.size > 0) {
|
|
879
|
+
const ids = [...entityIds];
|
|
880
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
881
|
+
const rows = storageBackend.query(`SELECT id, data FROM elements WHERE id IN (${placeholders})`, ids);
|
|
882
|
+
for (const row of rows) {
|
|
883
|
+
try {
|
|
884
|
+
const data = JSON.parse(row.data);
|
|
885
|
+
entityMap.set(row.id, { id: row.id, name: data.name ?? 'Unknown', entityType: data.entityType ?? 'unknown' });
|
|
886
|
+
}
|
|
887
|
+
catch { /* skip corrupt */ }
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
// Hydrate comments synchronously using entityMap
|
|
891
|
+
const hydratedComments = comments.map((comment) => {
|
|
892
|
+
const author = entityMap.get(comment.author_id);
|
|
893
|
+
const resolvedByEntity = comment.resolved_by ? entityMap.get(comment.resolved_by) : null;
|
|
894
|
+
return {
|
|
895
|
+
id: comment.id,
|
|
896
|
+
documentId: comment.document_id,
|
|
897
|
+
author: author
|
|
898
|
+
? { id: author.id, name: author.name, entityType: author.entityType }
|
|
899
|
+
: { id: comment.author_id, name: 'Unknown', entityType: 'unknown' },
|
|
900
|
+
content: comment.content,
|
|
901
|
+
anchor: (() => {
|
|
902
|
+
try {
|
|
903
|
+
return JSON.parse(comment.anchor);
|
|
904
|
+
}
|
|
905
|
+
catch {
|
|
906
|
+
console.warn(`[stoneforge] Malformed anchor JSON for comment ${comment.id}`);
|
|
907
|
+
return { hash: '', prefix: '', text: comment.anchor, suffix: '' };
|
|
908
|
+
}
|
|
909
|
+
})(),
|
|
910
|
+
startOffset: comment.start_offset,
|
|
911
|
+
endOffset: comment.end_offset,
|
|
912
|
+
resolved: comment.resolved === 1,
|
|
913
|
+
resolvedBy: resolvedByEntity
|
|
914
|
+
? { id: resolvedByEntity.id, name: resolvedByEntity.name }
|
|
915
|
+
: null,
|
|
916
|
+
resolvedAt: comment.resolved_at,
|
|
917
|
+
createdAt: comment.created_at,
|
|
918
|
+
updatedAt: comment.updated_at,
|
|
919
|
+
};
|
|
920
|
+
});
|
|
921
|
+
return c.json({
|
|
922
|
+
comments: hydratedComments,
|
|
923
|
+
total,
|
|
924
|
+
limit,
|
|
925
|
+
offset,
|
|
926
|
+
hasMore: offset + comments.length < total,
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
catch (error) {
|
|
930
|
+
console.error('[stoneforge] Failed to get document comments:', error);
|
|
931
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get document comments' } }, 500);
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
/**
|
|
935
|
+
* POST /api/documents/:id/comments
|
|
936
|
+
* Creates a new comment on a document
|
|
937
|
+
* Body: {
|
|
938
|
+
* authorId: string,
|
|
939
|
+
* content: string,
|
|
940
|
+
* anchor: { hash: string, prefix: string, text: string, suffix: string },
|
|
941
|
+
* startOffset?: number,
|
|
942
|
+
* endOffset?: number
|
|
943
|
+
* }
|
|
944
|
+
*/
|
|
945
|
+
app.post('/api/documents/:id/comments', async (c) => {
|
|
946
|
+
try {
|
|
947
|
+
const documentId = c.req.param('id');
|
|
948
|
+
const body = await c.req.json();
|
|
949
|
+
// Validate required fields
|
|
950
|
+
if (!body.authorId || typeof body.authorId !== 'string') {
|
|
951
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'authorId is required' } }, 400);
|
|
952
|
+
}
|
|
953
|
+
if (!body.content || typeof body.content !== 'string' || body.content.trim().length === 0) {
|
|
954
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'content is required' } }, 400);
|
|
955
|
+
}
|
|
956
|
+
if (!body.anchor || typeof body.anchor !== 'object') {
|
|
957
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'anchor is required' } }, 400);
|
|
958
|
+
}
|
|
959
|
+
if (!body.anchor.hash || !body.anchor.text) {
|
|
960
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'anchor must include hash and text' } }, 400);
|
|
961
|
+
}
|
|
962
|
+
// Verify document exists
|
|
963
|
+
const doc = await api.get(documentId);
|
|
964
|
+
if (!doc) {
|
|
965
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
966
|
+
}
|
|
967
|
+
if (doc.type !== 'document') {
|
|
968
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
969
|
+
}
|
|
970
|
+
const docData = doc;
|
|
971
|
+
if (docData.status === 'tombstone' || docData.deletedAt) {
|
|
972
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
973
|
+
}
|
|
974
|
+
// Verify author exists
|
|
975
|
+
const author = await api.get(body.authorId);
|
|
976
|
+
if (!author) {
|
|
977
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Author not found' } }, 404);
|
|
978
|
+
}
|
|
979
|
+
if (author.type !== 'entity') {
|
|
980
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'authorId must be an entity' } }, 400);
|
|
981
|
+
}
|
|
982
|
+
// Generate comment ID
|
|
983
|
+
const commentId = `cmt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
984
|
+
const now = new Date().toISOString();
|
|
985
|
+
// Insert comment
|
|
986
|
+
storageBackend.run(`
|
|
987
|
+
INSERT INTO comments (id, document_id, author_id, content, anchor, start_offset, end_offset, resolved, created_at, updated_at)
|
|
988
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
|
989
|
+
`, [
|
|
990
|
+
commentId,
|
|
991
|
+
documentId,
|
|
992
|
+
body.authorId,
|
|
993
|
+
body.content.trim(),
|
|
994
|
+
JSON.stringify(body.anchor),
|
|
995
|
+
body.startOffset ?? null,
|
|
996
|
+
body.endOffset ?? null,
|
|
997
|
+
now,
|
|
998
|
+
now,
|
|
999
|
+
]);
|
|
1000
|
+
// Record comment_added event
|
|
1001
|
+
const event = createEvent({
|
|
1002
|
+
elementId: documentId,
|
|
1003
|
+
eventType: 'comment_added',
|
|
1004
|
+
actor: body.authorId,
|
|
1005
|
+
oldValue: null,
|
|
1006
|
+
newValue: { commentId, content: body.content.trim(), anchor: body.anchor },
|
|
1007
|
+
});
|
|
1008
|
+
storageBackend.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at) VALUES (?, ?, ?, ?, ?, ?)`, [event.elementId, event.eventType, event.actor, null, JSON.stringify(event.newValue), event.createdAt]);
|
|
1009
|
+
return c.json({
|
|
1010
|
+
id: commentId,
|
|
1011
|
+
documentId,
|
|
1012
|
+
author: {
|
|
1013
|
+
id: author.id,
|
|
1014
|
+
name: author.name,
|
|
1015
|
+
entityType: author.entityType,
|
|
1016
|
+
},
|
|
1017
|
+
content: body.content.trim(),
|
|
1018
|
+
anchor: body.anchor,
|
|
1019
|
+
startOffset: body.startOffset ?? null,
|
|
1020
|
+
endOffset: body.endOffset ?? null,
|
|
1021
|
+
resolved: false,
|
|
1022
|
+
resolvedBy: null,
|
|
1023
|
+
resolvedAt: null,
|
|
1024
|
+
createdAt: now,
|
|
1025
|
+
updatedAt: now,
|
|
1026
|
+
}, 201);
|
|
1027
|
+
}
|
|
1028
|
+
catch (error) {
|
|
1029
|
+
console.error('[stoneforge] Failed to create comment:', error);
|
|
1030
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to create comment' } }, 500);
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
/**
|
|
1034
|
+
* PATCH /api/documents/:id/comments/:commentId
|
|
1035
|
+
* Update a comment's content
|
|
1036
|
+
* Body: { content: string }
|
|
1037
|
+
*/
|
|
1038
|
+
app.patch('/api/documents/:id/comments/:commentId', async (c) => {
|
|
1039
|
+
try {
|
|
1040
|
+
const documentId = c.req.param('id');
|
|
1041
|
+
const commentId = c.req.param('commentId');
|
|
1042
|
+
const body = await c.req.json();
|
|
1043
|
+
// Verify document exists and is not tombstoned
|
|
1044
|
+
const doc = await api.get(documentId);
|
|
1045
|
+
if (!doc) {
|
|
1046
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
1047
|
+
}
|
|
1048
|
+
if (doc.type !== 'document') {
|
|
1049
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
1050
|
+
}
|
|
1051
|
+
const docData = doc;
|
|
1052
|
+
if (docData.status === 'tombstone' || docData.deletedAt) {
|
|
1053
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
1054
|
+
}
|
|
1055
|
+
// Validate content
|
|
1056
|
+
if (!body.content || typeof body.content !== 'string' || body.content.trim().length === 0) {
|
|
1057
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'content is required' } }, 400);
|
|
1058
|
+
}
|
|
1059
|
+
// Verify comment exists
|
|
1060
|
+
const existing = storageBackend.queryOne(`SELECT * FROM comments WHERE id = ? AND document_id = ? AND deleted_at IS NULL`, [commentId, documentId]);
|
|
1061
|
+
if (!existing) {
|
|
1062
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Comment not found' } }, 404);
|
|
1063
|
+
}
|
|
1064
|
+
const now = createTimestamp();
|
|
1065
|
+
storageBackend.run(`UPDATE comments SET content = ?, updated_at = ? WHERE id = ?`, [body.content.trim(), now, commentId]);
|
|
1066
|
+
// Record comment_updated event
|
|
1067
|
+
const event = createEvent({
|
|
1068
|
+
elementId: documentId,
|
|
1069
|
+
eventType: 'comment_updated',
|
|
1070
|
+
actor: existing.author_id,
|
|
1071
|
+
oldValue: { commentId, content: existing.content },
|
|
1072
|
+
newValue: { commentId, content: body.content.trim() },
|
|
1073
|
+
});
|
|
1074
|
+
storageBackend.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at) VALUES (?, ?, ?, ?, ?, ?)`, [event.elementId, event.eventType, event.actor, JSON.stringify(event.oldValue), JSON.stringify(event.newValue), event.createdAt]);
|
|
1075
|
+
return c.json({
|
|
1076
|
+
id: commentId,
|
|
1077
|
+
documentId,
|
|
1078
|
+
content: body.content.trim(),
|
|
1079
|
+
updatedAt: now,
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
catch (error) {
|
|
1083
|
+
console.error('[stoneforge] Failed to update comment:', error);
|
|
1084
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to update comment' } }, 500);
|
|
1085
|
+
}
|
|
1086
|
+
});
|
|
1087
|
+
/**
|
|
1088
|
+
* DELETE /api/documents/:id/comments/:commentId
|
|
1089
|
+
* Soft-delete a comment
|
|
1090
|
+
*/
|
|
1091
|
+
app.delete('/api/documents/:id/comments/:commentId', async (c) => {
|
|
1092
|
+
try {
|
|
1093
|
+
const documentId = c.req.param('id');
|
|
1094
|
+
const commentId = c.req.param('commentId');
|
|
1095
|
+
// Verify document exists and is not tombstoned
|
|
1096
|
+
const doc = await api.get(documentId);
|
|
1097
|
+
if (!doc) {
|
|
1098
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
1099
|
+
}
|
|
1100
|
+
if (doc.type !== 'document') {
|
|
1101
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
1102
|
+
}
|
|
1103
|
+
const docData = doc;
|
|
1104
|
+
if (docData.status === 'tombstone' || docData.deletedAt) {
|
|
1105
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
1106
|
+
}
|
|
1107
|
+
// Verify comment exists and not already deleted
|
|
1108
|
+
const existing = storageBackend.queryOne(`SELECT * FROM comments WHERE id = ? AND document_id = ? AND deleted_at IS NULL`, [commentId, documentId]);
|
|
1109
|
+
if (!existing) {
|
|
1110
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Comment not found' } }, 404);
|
|
1111
|
+
}
|
|
1112
|
+
const now = createTimestamp();
|
|
1113
|
+
storageBackend.run(`UPDATE comments SET deleted_at = ?, updated_at = ? WHERE id = ?`, [now, now, commentId]);
|
|
1114
|
+
// Record comment_deleted event
|
|
1115
|
+
const event = createEvent({
|
|
1116
|
+
elementId: documentId,
|
|
1117
|
+
eventType: 'comment_deleted',
|
|
1118
|
+
actor: existing.author_id,
|
|
1119
|
+
oldValue: { commentId, content: existing.content },
|
|
1120
|
+
newValue: null,
|
|
1121
|
+
});
|
|
1122
|
+
storageBackend.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at) VALUES (?, ?, ?, ?, ?, ?)`, [event.elementId, event.eventType, event.actor, JSON.stringify(event.oldValue), null, event.createdAt]);
|
|
1123
|
+
return c.json({ success: true, id: commentId });
|
|
1124
|
+
}
|
|
1125
|
+
catch (error) {
|
|
1126
|
+
console.error('[stoneforge] Failed to delete comment:', error);
|
|
1127
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to delete comment' } }, 500);
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
/**
|
|
1131
|
+
* POST /api/documents/:id/comments/:commentId/resolve
|
|
1132
|
+
* Resolve or unresolve a comment
|
|
1133
|
+
* Body: { resolved: boolean, actor: string }
|
|
1134
|
+
*/
|
|
1135
|
+
app.post('/api/documents/:id/comments/:commentId/resolve', async (c) => {
|
|
1136
|
+
try {
|
|
1137
|
+
const documentId = c.req.param('id');
|
|
1138
|
+
const commentId = c.req.param('commentId');
|
|
1139
|
+
const body = await c.req.json();
|
|
1140
|
+
// Verify document exists and is not tombstoned
|
|
1141
|
+
const doc = await api.get(documentId);
|
|
1142
|
+
if (!doc) {
|
|
1143
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
1144
|
+
}
|
|
1145
|
+
if (doc.type !== 'document') {
|
|
1146
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
1147
|
+
}
|
|
1148
|
+
const docData = doc;
|
|
1149
|
+
if (docData.status === 'tombstone' || docData.deletedAt) {
|
|
1150
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
|
|
1151
|
+
}
|
|
1152
|
+
// Validate body
|
|
1153
|
+
if (typeof body.resolved !== 'boolean') {
|
|
1154
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'resolved (boolean) is required' } }, 400);
|
|
1155
|
+
}
|
|
1156
|
+
if (!body.actor || typeof body.actor !== 'string') {
|
|
1157
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'actor is required' } }, 400);
|
|
1158
|
+
}
|
|
1159
|
+
// Verify actor is an entity
|
|
1160
|
+
const actorEntity = await api.get(body.actor);
|
|
1161
|
+
if (!actorEntity) {
|
|
1162
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Actor not found' } }, 404);
|
|
1163
|
+
}
|
|
1164
|
+
if (actorEntity.type !== 'entity') {
|
|
1165
|
+
return c.json({ error: { code: 'VALIDATION_ERROR', message: 'actor must be an entity' } }, 400);
|
|
1166
|
+
}
|
|
1167
|
+
// Verify comment exists and not deleted
|
|
1168
|
+
const existing = storageBackend.queryOne(`SELECT * FROM comments WHERE id = ? AND document_id = ? AND deleted_at IS NULL`, [commentId, documentId]);
|
|
1169
|
+
if (!existing) {
|
|
1170
|
+
return c.json({ error: { code: 'NOT_FOUND', message: 'Comment not found' } }, 404);
|
|
1171
|
+
}
|
|
1172
|
+
const now = createTimestamp();
|
|
1173
|
+
if (body.resolved) {
|
|
1174
|
+
storageBackend.run(`UPDATE comments SET resolved = 1, resolved_by = ?, resolved_at = ?, updated_at = ? WHERE id = ?`, [body.actor, now, now, commentId]);
|
|
1175
|
+
}
|
|
1176
|
+
else {
|
|
1177
|
+
storageBackend.run(`UPDATE comments SET resolved = 0, resolved_by = NULL, resolved_at = NULL, updated_at = ? WHERE id = ?`, [now, commentId]);
|
|
1178
|
+
}
|
|
1179
|
+
// Record resolve/unresolve event
|
|
1180
|
+
const eventType = body.resolved ? 'comment_resolved' : 'comment_unresolved';
|
|
1181
|
+
const event = createEvent({
|
|
1182
|
+
elementId: documentId,
|
|
1183
|
+
eventType: eventType,
|
|
1184
|
+
actor: body.actor,
|
|
1185
|
+
oldValue: { commentId, resolved: existing.resolved === 1 },
|
|
1186
|
+
newValue: { commentId, resolved: body.resolved },
|
|
1187
|
+
});
|
|
1188
|
+
storageBackend.run(`INSERT INTO events (element_id, event_type, actor, old_value, new_value, created_at) VALUES (?, ?, ?, ?, ?, ?)`, [event.elementId, event.eventType, event.actor, JSON.stringify(event.oldValue), JSON.stringify(event.newValue), event.createdAt]);
|
|
1189
|
+
return c.json({
|
|
1190
|
+
id: commentId,
|
|
1191
|
+
documentId,
|
|
1192
|
+
resolved: body.resolved,
|
|
1193
|
+
resolvedBy: body.resolved ? body.actor : null,
|
|
1194
|
+
resolvedAt: body.resolved ? now : null,
|
|
1195
|
+
updatedAt: now,
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
catch (error) {
|
|
1199
|
+
console.error('[stoneforge] Failed to resolve comment:', error);
|
|
1200
|
+
return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to resolve comment' } }, 500);
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
return app;
|
|
1204
|
+
}
|
|
1205
|
+
//# sourceMappingURL=documents.js.map
|