react-embed-docs 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.
Files changed (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +422 -0
  3. package/dist/client/components/Breadcrumbs.d.ts +21 -0
  4. package/dist/client/components/Breadcrumbs.d.ts.map +1 -0
  5. package/dist/client/components/Breadcrumbs.js +123 -0
  6. package/dist/client/components/DocsLayout.d.ts +20 -0
  7. package/dist/client/components/DocsLayout.d.ts.map +1 -0
  8. package/dist/client/components/DocsLayout.js +387 -0
  9. package/dist/client/components/DocumentContent.d.ts +5 -0
  10. package/dist/client/components/DocumentContent.d.ts.map +1 -0
  11. package/dist/client/components/DocumentContent.js +15 -0
  12. package/dist/client/components/DocumentEdit.d.ts +6 -0
  13. package/dist/client/components/DocumentEdit.d.ts.map +1 -0
  14. package/dist/client/components/DocumentEdit.js +153 -0
  15. package/dist/client/components/DocumentList.d.ts +5 -0
  16. package/dist/client/components/DocumentList.d.ts.map +1 -0
  17. package/dist/client/components/DocumentList.js +39 -0
  18. package/dist/client/components/DocumentProvider.d.ts +42 -0
  19. package/dist/client/components/DocumentProvider.d.ts.map +1 -0
  20. package/dist/client/components/DocumentProvider.js +47 -0
  21. package/dist/client/components/DocumentView.d.ts +6 -0
  22. package/dist/client/components/DocumentView.d.ts.map +1 -0
  23. package/dist/client/components/DocumentView.js +58 -0
  24. package/dist/client/components/DragOverlayItem.d.ts +5 -0
  25. package/dist/client/components/DragOverlayItem.d.ts.map +1 -0
  26. package/dist/client/components/DragOverlayItem.js +9 -0
  27. package/dist/client/components/EmojiPicker.d.ts +8 -0
  28. package/dist/client/components/EmojiPicker.d.ts.map +1 -0
  29. package/dist/client/components/EmojiPicker.js +48 -0
  30. package/dist/client/components/ExportButton.d.ts +22 -0
  31. package/dist/client/components/ExportButton.d.ts.map +1 -0
  32. package/dist/client/components/ExportButton.js +97 -0
  33. package/dist/client/components/Layout.d.ts +7 -0
  34. package/dist/client/components/Layout.d.ts.map +1 -0
  35. package/dist/client/components/Layout.js +172 -0
  36. package/dist/client/components/ReactEmbedDocs.d.ts +8 -0
  37. package/dist/client/components/ReactEmbedDocs.d.ts.map +1 -0
  38. package/dist/client/components/ReactEmbedDocs.js +8 -0
  39. package/dist/client/components/SearchInput.d.ts +2 -0
  40. package/dist/client/components/SearchInput.d.ts.map +1 -0
  41. package/dist/client/components/SearchInput.js +7 -0
  42. package/dist/client/components/Sidebar.d.ts +10 -0
  43. package/dist/client/components/Sidebar.d.ts.map +1 -0
  44. package/dist/client/components/Sidebar.js +176 -0
  45. package/dist/client/components/SortableTreeItem.d.ts +13 -0
  46. package/dist/client/components/SortableTreeItem.d.ts.map +1 -0
  47. package/dist/client/components/SortableTreeItem.js +24 -0
  48. package/dist/client/components/VersionHistory.d.ts +14 -0
  49. package/dist/client/components/VersionHistory.d.ts.map +1 -0
  50. package/dist/client/components/VersionHistory.js +102 -0
  51. package/dist/client/hooks/useCollaboration.d.ts +99 -0
  52. package/dist/client/hooks/useCollaboration.d.ts.map +1 -0
  53. package/dist/client/hooks/useCollaboration.js +180 -0
  54. package/dist/client/hooks/useDocsQuery.d.ts +84 -0
  55. package/dist/client/hooks/useDocsQuery.d.ts.map +1 -0
  56. package/dist/client/hooks/useDocsQuery.js +241 -0
  57. package/dist/client/hooks/useExport.d.ts +31 -0
  58. package/dist/client/hooks/useExport.d.ts.map +1 -0
  59. package/dist/client/hooks/useExport.js +66 -0
  60. package/dist/client/hooks/useFileUpload.d.ts +44 -0
  61. package/dist/client/hooks/useFileUpload.d.ts.map +1 -0
  62. package/dist/client/hooks/useFileUpload.js +193 -0
  63. package/dist/client/hooks/useSystemTheme.d.ts +2 -0
  64. package/dist/client/hooks/useSystemTheme.d.ts.map +1 -0
  65. package/dist/client/hooks/useSystemTheme.js +19 -0
  66. package/dist/client/hooks/useVersions.d.ts +105 -0
  67. package/dist/client/hooks/useVersions.d.ts.map +1 -0
  68. package/dist/client/hooks/useVersions.js +129 -0
  69. package/dist/client/index.d.ts +23 -0
  70. package/dist/client/index.d.ts.map +1 -0
  71. package/dist/client/index.js +18 -0
  72. package/dist/client/lib/blocknoteTheme.d.ts +13 -0
  73. package/dist/client/lib/blocknoteTheme.d.ts.map +1 -0
  74. package/dist/client/lib/blocknoteTheme.js +76 -0
  75. package/dist/client/lib/path.d.ts +8 -0
  76. package/dist/client/lib/path.d.ts.map +1 -0
  77. package/dist/client/lib/path.js +30 -0
  78. package/dist/client/providers/DocumentProvider.d.ts +1 -0
  79. package/dist/client/providers/DocumentProvider.d.ts.map +1 -0
  80. package/dist/client/providers/DocumentProvider.js +1 -0
  81. package/dist/server/CollaborationService.d.ts +134 -0
  82. package/dist/server/CollaborationService.d.ts.map +1 -0
  83. package/dist/server/CollaborationService.js +307 -0
  84. package/dist/server/DocsService.d.ts +115 -0
  85. package/dist/server/DocsService.d.ts.map +1 -0
  86. package/dist/server/DocsService.js +512 -0
  87. package/dist/server/ExportService.d.ts +106 -0
  88. package/dist/server/ExportService.d.ts.map +1 -0
  89. package/dist/server/ExportService.js +501 -0
  90. package/dist/server/FilesService.d.ts +44 -0
  91. package/dist/server/FilesService.d.ts.map +1 -0
  92. package/dist/server/FilesService.js +78 -0
  93. package/dist/server/VersioningService.d.ts +112 -0
  94. package/dist/server/VersioningService.d.ts.map +1 -0
  95. package/dist/server/VersioningService.js +264 -0
  96. package/dist/server/db.d.ts +7 -0
  97. package/dist/server/db.d.ts.map +1 -0
  98. package/dist/server/db.js +22 -0
  99. package/dist/server/index.d.ts +55 -0
  100. package/dist/server/index.d.ts.map +1 -0
  101. package/dist/server/index.js +36 -0
  102. package/dist/server/routes.d.ts +9 -0
  103. package/dist/server/routes.d.ts.map +1 -0
  104. package/dist/server/routes.js +483 -0
  105. package/dist/server/schema.d.ts +587 -0
  106. package/dist/server/schema.d.ts.map +1 -0
  107. package/dist/server/schema.js +126 -0
  108. package/dist/shared/types.d.ts +314 -0
  109. package/dist/shared/types.d.ts.map +1 -0
  110. package/dist/shared/types.js +48 -0
  111. package/drizzle/migrations/0000_gray_monster_badoon.sql +88 -0
  112. package/drizzle/migrations/meta/0000_snapshot.json +574 -0
  113. package/drizzle/migrations/meta/_journal.json +13 -0
  114. package/package.json +109 -0
  115. package/styles/docs.css +981 -0
@@ -0,0 +1,483 @@
1
+ import { Hono } from 'hono';
2
+ import { z } from 'zod';
3
+ import { createDocumentSchema, updateDocumentSchema, uploadFileSchema } from '../shared/types.js';
4
+ import { CollaborationService } from './CollaborationService.js';
5
+ import { ExportService } from './ExportService.js';
6
+ import { VersioningService } from './VersioningService.js';
7
+ /**
8
+ * Document routes
9
+ * All routes are prefixed with /docs
10
+ */
11
+ export function createRouter(docsService, filesService, db) {
12
+ const router = new Hono();
13
+ const exportService = new ExportService();
14
+ const collaborationService = new CollaborationService(db);
15
+ const versioningService = new VersioningService(db);
16
+ /**
17
+ * GET /docs
18
+ * List all documents with optional filtering
19
+ */
20
+ router.get('/', async (c) => {
21
+ const query = c.req.query();
22
+ const filters = {
23
+ search: query.search,
24
+ parentId: query.parentId === 'null' ? null : query.parentId,
25
+ isPublished: query.isPublished === 'true' ? true : query.isPublished === 'false' ? false : undefined,
26
+ };
27
+ console.log('filters', filters);
28
+ const options = {
29
+ offset: query.offset ? parseInt(query.offset, 10) : 0,
30
+ limit: query.limit ? parseInt(query.limit, 10) : 10,
31
+ sortBy: query.sortBy,
32
+ sortDir: query.sortDir,
33
+ };
34
+ const { documents, total } = await docsService.list(filters, options);
35
+ console.log('documents', documents);
36
+ return c.json({ items: documents, total });
37
+ });
38
+ /**
39
+ * GET /docs/search
40
+ * Search documents using text search
41
+ */
42
+ router.get('/search', async (c) => {
43
+ const query = c.req.query();
44
+ const searchQuery = query.q || query.query || '';
45
+ if (!searchQuery) {
46
+ return c.json({ results: [], total: 0 });
47
+ }
48
+ const options = {
49
+ limit: query.limit ? parseInt(query.limit, 10) : 20,
50
+ offset: query.offset ? parseInt(query.offset, 10) : 0,
51
+ };
52
+ const { results, total } = await docsService.searchText(searchQuery, options);
53
+ return c.json({ results, total });
54
+ });
55
+ /**
56
+ * GET /docs/tree
57
+ * Get all documents in tree structure
58
+ */
59
+ router.get('/tree', async (c) => {
60
+ const documents = await docsService.getTree();
61
+ return c.json({ items: documents });
62
+ });
63
+ /**
64
+ * GET /docs/:id
65
+ * Get a single document by ID or slug
66
+ * Tries ID first, then falls back to slug lookup
67
+ */
68
+ router.get('/:id', async (c) => {
69
+ const id = c.req.param('id');
70
+ // Try to find by ID first
71
+ let doc = await docsService.getById(id);
72
+ // If not found by ID, try by slug
73
+ if (!doc) {
74
+ doc = await docsService.getBySlug(id);
75
+ }
76
+ if (!doc) {
77
+ return c.json({ message: 'Document not found' }, 404);
78
+ }
79
+ return c.json(doc);
80
+ });
81
+ /**
82
+ * GET /docs/slug/:slug
83
+ * Get a single document by slug
84
+ */
85
+ router.get('/slug/:slug', async (c) => {
86
+ const slug = c.req.param('slug');
87
+ const doc = await docsService.getBySlug(slug);
88
+ if (!doc) {
89
+ return c.json({ message: 'Document not found' }, 404);
90
+ }
91
+ return c.json(doc);
92
+ });
93
+ /**
94
+ * POST /docs
95
+ * Create a new document
96
+ */
97
+ router.post('/', async (c) => {
98
+ const body = await c.req.json();
99
+ // Validate input
100
+ const result = createDocumentSchema.safeParse(body);
101
+ if (!result.success) {
102
+ return c.json({ message: 'Invalid input', errors: result.error.errors }, 400);
103
+ }
104
+ try {
105
+ const doc = await docsService.create(result.data);
106
+ return c.json(doc, 201);
107
+ }
108
+ catch (error) {
109
+ return c.json({ message: 'Failed to create document', error: error.message }, 500);
110
+ }
111
+ });
112
+ /**
113
+ * PUT /docs/:id
114
+ * Update an existing document
115
+ */
116
+ router.put('/:id', async (c) => {
117
+ const id = c.req.param('id');
118
+ const body = await c.req.json();
119
+ // Validate input
120
+ const result = updateDocumentSchema.safeParse(body);
121
+ if (!result.success) {
122
+ return c.json({ message: 'Invalid input', errors: result.error.errors }, 400);
123
+ }
124
+ const doc = await docsService.update(id, result.data);
125
+ if (!doc) {
126
+ return c.json({ message: 'Document not found' }, 404);
127
+ }
128
+ return c.json(doc);
129
+ });
130
+ /**
131
+ * DELETE /docs/:id
132
+ * Soft delete a document
133
+ */
134
+ router.delete('/:id', async (c) => {
135
+ const id = c.req.param('id');
136
+ const deleted = await docsService.delete(id);
137
+ if (!deleted) {
138
+ return c.json({ message: 'Document not found' }, 404);
139
+ }
140
+ return c.body(null, 204);
141
+ });
142
+ /**
143
+ * GET /docs/:id/children
144
+ * Get children of a document
145
+ */
146
+ router.get('/:id/children', async (c) => {
147
+ const id = c.req.param('id');
148
+ const children = await docsService.getChildren(id);
149
+ return c.json({ items: children });
150
+ });
151
+ /**
152
+ * GET /docs/:id/breadcrumbs
153
+ * Get breadcrumb path for a document (from root to document)
154
+ */
155
+ router.get('/:id/breadcrumbs', async (c) => {
156
+ const id = c.req.param('id');
157
+ const breadcrumbs = await docsService.getBreadcrumbs(id);
158
+ return c.json({ items: breadcrumbs });
159
+ });
160
+ /**
161
+ * POST /docs/:id/reorder
162
+ * Reorder a document within its parent or move to new parent
163
+ */
164
+ router.post('/:id/reorder', async (c) => {
165
+ const id = c.req.param('id');
166
+ const body = await c.req.json();
167
+ const reorderSchema = z.object({
168
+ parentId: z.string().nullable().optional(),
169
+ order: z.number(),
170
+ });
171
+ const result = reorderSchema.safeParse(body);
172
+ if (!result.success) {
173
+ return c.json({ message: 'Invalid input', errors: result.error.errors }, 400);
174
+ }
175
+ const { parentId, order } = result.data;
176
+ const doc = await docsService.reorder(id, parentId ?? null, order);
177
+ if (!doc) {
178
+ return c.json({ message: 'Document not found' }, 404);
179
+ }
180
+ return c.json(doc);
181
+ });
182
+ /**
183
+ * PATCH /docs/:id/order
184
+ * Update just the order field
185
+ */
186
+ router.patch('/:id/order', async (c) => {
187
+ const id = c.req.param('id');
188
+ const body = await c.req.json();
189
+ const orderSchema = z.object({
190
+ order: z.number(),
191
+ });
192
+ const result = orderSchema.safeParse(body);
193
+ if (!result.success) {
194
+ return c.json({ message: 'Invalid input', errors: result.error.errors }, 400);
195
+ }
196
+ const doc = await docsService.updateOrder(id, result.data.order);
197
+ if (!doc) {
198
+ return c.json({ message: 'Document not found' }, 404);
199
+ }
200
+ return c.json(doc);
201
+ });
202
+ /**
203
+ * POST /files
204
+ * Upload a file
205
+ */
206
+ router.post('/files', async (c) => {
207
+ console.log('uploading files');
208
+ const body = await c.req.json();
209
+ console.log('body', body);
210
+ // Validate input
211
+ const result = uploadFileSchema.safeParse(body);
212
+ if (!result.success) {
213
+ return c.json({ message: 'Invalid input', errors: result.error.errors }, 400);
214
+ }
215
+ const { filename, mimeType, content } = result.data;
216
+ // Validate base64 content
217
+ try {
218
+ // Check if content is empty
219
+ if (!content || content.length === 0) {
220
+ return c.json({ message: 'Empty file content' }, 400);
221
+ }
222
+ const decodedSize = Math.ceil(content.length * 0.75); // Approximate decoded size
223
+ const maxSize = 5 * 1024 * 1024; // 5MB limit
224
+ if (decodedSize > maxSize) {
225
+ return c.json({ message: 'File too large (max 5MB)' }, 413);
226
+ }
227
+ // Clean up base64 string - remove any whitespace or newlines
228
+ const cleanedContent = content.replace(/\s/g, '');
229
+ // Verify it is valid base64
230
+ try {
231
+ Buffer.from(cleanedContent, 'base64');
232
+ }
233
+ catch (e) {
234
+ return c.json({ message: 'Invalid base64 encoding' }, 400);
235
+ }
236
+ const file = await filesService.upload({
237
+ filename,
238
+ mimeType,
239
+ size: decodedSize,
240
+ content: cleanedContent,
241
+ });
242
+ return c.json({
243
+ id: file.id,
244
+ filename: file.filename,
245
+ mimeType: file.mimeType,
246
+ size: file.size,
247
+ url: `/api/docs/files/${file.id}`,
248
+ }, 201);
249
+ }
250
+ catch (error) {
251
+ console.error('File upload error:', error);
252
+ return c.json({ message: 'Failed to upload file' }, 500);
253
+ }
254
+ });
255
+ /**
256
+ * GET /files/:id
257
+ * Get a file by ID (returns binary content)
258
+ */
259
+ router.get('/files/:id', async (c) => {
260
+ const id = c.req.param('id');
261
+ const file = await filesService.getById(id);
262
+ if (!file) {
263
+ return c.json({ message: 'File not found' }, 404);
264
+ }
265
+ // Decode base64 and return binary
266
+ const buffer = Buffer.from(file.content, 'base64');
267
+ c.header('Content-Type', file.mimeType);
268
+ c.header('Content-Disposition', `inline; filename="${file.filename}"`);
269
+ return c.body(buffer);
270
+ });
271
+ /**
272
+ * GET /files/:id/meta
273
+ * Get file metadata without content
274
+ */
275
+ router.get('/files/:id/meta', async (c) => {
276
+ const id = c.req.param('id');
277
+ const file = await filesService.getById(id);
278
+ if (!file) {
279
+ return c.json({ message: 'File not found' }, 404);
280
+ }
281
+ return c.json({
282
+ id: file.id,
283
+ filename: file.filename,
284
+ mimeType: file.mimeType,
285
+ size: file.size,
286
+ createdAt: file.createdAt,
287
+ });
288
+ });
289
+ /**
290
+ * DELETE /files/:id
291
+ * Delete a file
292
+ */
293
+ router.delete('/files/:id', async (c) => {
294
+ const id = c.req.param('id');
295
+ try {
296
+ await filesService.delete(id);
297
+ return c.body(null, 204);
298
+ }
299
+ catch (error) {
300
+ return c.json({ message: 'File not found' }, 404);
301
+ }
302
+ });
303
+ /**
304
+ * GET /docs/:id/export
305
+ * Export a document to various formats (docx, pdf, html, md)
306
+ */
307
+ router.get('/:id/export', async (c) => {
308
+ const id = c.req.param('id');
309
+ const format = c.req.query('format') || 'docx';
310
+ // Try ID first, then slug
311
+ let doc = await docsService.getById(id);
312
+ if (!doc) {
313
+ doc = await docsService.getBySlug(id);
314
+ }
315
+ if (!doc) {
316
+ return c.json({ message: 'Document not found' }, 404);
317
+ }
318
+ try {
319
+ let result;
320
+ switch (format.toLowerCase()) {
321
+ case 'docx':
322
+ result = await exportService.exportToDocx(doc);
323
+ break;
324
+ case 'pdf':
325
+ result = await exportService.exportToPdf(doc);
326
+ break;
327
+ case 'html':
328
+ result = await exportService.exportToHtml(doc);
329
+ break;
330
+ case 'md':
331
+ case 'markdown':
332
+ result = await exportService.exportToMarkdown(doc);
333
+ break;
334
+ default:
335
+ return c.json({ message: `Unsupported format: ${format}` }, 400);
336
+ }
337
+ c.header('Content-Type', result.mimeType);
338
+ c.header('Content-Disposition', `attachment; filename="${result.filename}"`);
339
+ // Convert Node Buffer to Uint8Array for proper binary response
340
+ const uint8Array = new Uint8Array(result.buffer);
341
+ return c.body(uint8Array);
342
+ }
343
+ catch (error) {
344
+ console.error('Export error:', error);
345
+ return c.json({
346
+ message: 'Export failed',
347
+ error: error.message
348
+ }, 500);
349
+ }
350
+ });
351
+ /**
352
+ * ============================================================================
353
+ * VERSIONING ROUTES
354
+ * ============================================================================
355
+ */
356
+ /**
357
+ * GET /docs/:id/versions
358
+ * List all versions of a document
359
+ */
360
+ router.get('/:id/versions', async (c) => {
361
+ const id = c.req.param('id');
362
+ const doc = await docsService.getById(id);
363
+ if (!doc) {
364
+ return c.json({ message: 'Document not found' }, 404);
365
+ }
366
+ const versions = await versioningService.listVersions(id);
367
+ return c.json({ items: versions, total: versions.length });
368
+ });
369
+ /**
370
+ * POST /docs/:id/versions
371
+ * Create a new version manually
372
+ */
373
+ router.post('/:id/versions', async (c) => {
374
+ const id = c.req.param('id');
375
+ const body = await c.req.json().catch(() => ({}));
376
+ const doc = await docsService.getById(id);
377
+ if (!doc) {
378
+ return c.json({ message: 'Document not found' }, 404);
379
+ }
380
+ try {
381
+ const version = await versioningService.createVersion(id, {
382
+ description: body.description,
383
+ createdBy: body.createdBy,
384
+ createdByName: body.createdByName,
385
+ });
386
+ return c.json(version, 201);
387
+ }
388
+ catch (error) {
389
+ return c.json({ message: 'Failed to create version' }, 500);
390
+ }
391
+ });
392
+ /**
393
+ * GET /docs/versions/:versionId
394
+ * Get a specific version
395
+ */
396
+ router.get('/versions/:versionId', async (c) => {
397
+ const versionId = c.req.param('versionId');
398
+ const version = await versioningService.getVersion(versionId);
399
+ if (!version) {
400
+ return c.json({ message: 'Version not found' }, 404);
401
+ }
402
+ return c.json(version);
403
+ });
404
+ /**
405
+ * POST /docs/:id/restore/:versionId
406
+ * Restore document to a specific version
407
+ */
408
+ router.post('/:id/restore/:versionId', async (c) => {
409
+ const id = c.req.param('id');
410
+ const versionId = c.req.param('versionId');
411
+ const body = await c.req.json().catch(() => ({}));
412
+ try {
413
+ const result = await versioningService.restoreVersion(id, versionId, {
414
+ restoredBy: body.restoredBy,
415
+ restoredByName: body.restoredByName,
416
+ });
417
+ return c.json(result);
418
+ }
419
+ catch (error) {
420
+ return c.json({ message: error.message }, 400);
421
+ }
422
+ });
423
+ /**
424
+ * GET /docs/:id/compare
425
+ * Compare two versions
426
+ */
427
+ router.get('/:id/compare', async (c) => {
428
+ const id = c.req.param('id');
429
+ const v1 = c.req.query('v1');
430
+ const v2 = c.req.query('v2');
431
+ if (!v1 || !v2) {
432
+ return c.json({ message: 'Missing version IDs (v1 and v2 required)' }, 400);
433
+ }
434
+ try {
435
+ const diff = await versioningService.compareVersions(v1, v2);
436
+ return c.json(diff);
437
+ }
438
+ catch (error) {
439
+ return c.json({ message: error.message }, 400);
440
+ }
441
+ });
442
+ /**
443
+ * DELETE /docs/versions/:versionId
444
+ * Delete a version
445
+ */
446
+ router.delete('/versions/:versionId', async (c) => {
447
+ const versionId = c.req.param('versionId');
448
+ try {
449
+ await versioningService.deleteVersion(versionId);
450
+ return c.body(null, 204);
451
+ }
452
+ catch (error) {
453
+ return c.json({ message: 'Failed to delete version' }, 500);
454
+ }
455
+ });
456
+ /**
457
+ * ============================================================================
458
+ * COLLABORATION ROUTES
459
+ * ============================================================================
460
+ */
461
+ /**
462
+ * GET /docs/:id/collaborators
463
+ * Get active collaborators for a document
464
+ */
465
+ router.get('/:id/collaborators', async (c) => {
466
+ const id = c.req.param('id');
467
+ const doc = await docsService.getById(id);
468
+ if (!doc) {
469
+ return c.json({ message: 'Document not found' }, 404);
470
+ }
471
+ const users = await collaborationService.getActiveUsers(id);
472
+ return c.json({ items: users });
473
+ });
474
+ /**
475
+ * POST /docs/:id/cleanup-presence
476
+ * Clean up stale presence records (admin/maintenance)
477
+ */
478
+ router.post('/cleanup-presence', async (c) => {
479
+ const count = await collaborationService.cleanupStalePresence();
480
+ return c.json({ removed: count });
481
+ });
482
+ return router;
483
+ }