3 * @module routes/documents
4 * @description Knowledge base and document library endpoints with attachment support.
5 * Provides CRUD operations for technical documentation with multi-tenant isolation,
6 * file attachments (base64-encoded), and category organization.
8 * @requires services/db
9 * @requires middleware/auth
10 * @requires middleware/adminOnly
11 * @requires middleware/tenant
12 * @requires middleware/fileValidation
13 * @author RMM-PSA Development Team
14 * @copyright 2026 RMM-PSA Platform
15 * @license Proprietary
19 * @apiDefine Documents Knowledge Base
20 * Document library and technical documentation management
23const express = require('express');
24const router = express.Router();
25const pool = require('../services/db');
26const authenticateToken = require('../middleware/auth');
27const requireAdmin = require('../middleware/adminOnly');
28const { getTenantFilter, setTenantContext } = require('../middleware/tenant');
29const { validateAttachments } = require('../middleware/fileValidation');
31// Apply authentication and tenant context to all routes
32router.use(authenticateToken, setTenantContext);
35 * @api {get} /api/documents List documents
36 * @apiName ListDocuments
38 * @apiDescription Retrieve paginated list of documents with tenant filtering.
39 * Returns document metadata with author and tenant info, plus total count for pagination.
40 * Attachments not included in list (use GET /:id for full document).
41 * @apiParam {number} [page=1] Page number (query parameter)
42 * @apiParam {number} [limit=10] Results per page (query parameter, max: 100)
43 * @apiSuccess {object[]} documents Array of document summaries
44 * @apiSuccess {number} documents.doc_id Document ID
45 * @apiSuccess {string} documents.title Document title
46 * @apiSuccess {string} documents.category Document category/type
47 * @apiSuccess {string} documents.content Document content (full text/markdown)
48 * @apiSuccess {DateTime} documents.created_at Creation timestamp
49 * @apiSuccess {DateTime} documents.updated_at Last modified timestamp
50 * @apiSuccess {number} documents.tenant_id Tenant ID
51 * @apiSuccess {string} documents.author_name Author display name
52 * @apiSuccess {string} documents.tenant_name Tenant name
53 * @apiSuccess {number} total Total document count (for pagination)
54 * @apiSuccess {number} page Current page number
55 * @apiSuccess {number} limit Results per page
56 * @apiSuccess {number} pages Total pages available
57 * @apiError (500) {String} error="Server error" Database query failed
58 * @apiExample {curl} Example:
59 * curl -X GET "http://localhost:3000/api/documents?page=1&limit=10" \\
60 * -H "Authorization: Bearer YOUR_TOKEN"
61 * @apiSuccessExample {json} Success-Response:
67 * "title": "How to Reset Passwords",
68 * "category": "procedure",
69 * "content": "# Password Reset...\n",
70 * "created_at": "2026-03-10T08:00:00.000Z",
71 * "updated_at": "2026-03-12T10:30:00.000Z",
73 * "author_name": "John Admin",
74 * "tenant_name": "Acme Corp"
83router.get('/', async (req, res) => {
84 const page = parseInt(req.query.page) || 1;
85 const limit = parseInt(req.query.limit) || 10;
86 const offset = (page - 1) * limit;
89 // ā
enforce tenant filter (now that tenant_id exists)
90 const { clause: tenantClause, params: tenantParams, nextParamIndex } = getTenantFilter(req, 'd', true);
91 const params = [...tenantParams];
92 const whereClause = tenantClause ? `WHERE ${tenantClause}` : '';
94 params.push(limit, offset);
96 const [docsResult, countResult] = await Promise.all([
106 u.name AS author_name,
107 t.name AS tenant_name
109 LEFT JOIN users u ON d.author_id = u.user_id
110 LEFT JOIN tenants t ON d.tenant_id = t.tenant_id
112 ORDER BY d.doc_id DESC
113 LIMIT $${nextParamIndex} OFFSET $${nextParamIndex + 1}
115 // ā
ensure alias ādā is defined here too
124 documents: docsResult.rows,
125 total: parseInt(countResult.rows[0].count, 10)
128 console.error('Error fetching documents:', err);
129 res.status(500).json({ error: 'Server error', details: err.message });
134 * @api {get} /api/documents/:id Get document by ID
135 * @apiName GetDocument
136 * @apiGroup Documents
137 * @apiDescription Retrieve single document with attachment metadata (file data excluded).
138 * Includes author, tenant info, and list of attachments with size/filename.
139 * Enforces tenant isolation.
140 * @apiParam {number} id Document ID
141 * @apiSuccess {number} doc_id Document ID
142 * @apiSuccess {string} title Document title
143 * @apiSuccess {string} category Document category/type
144 * @apiSuccess {string} content Full document content
145 * @apiSuccess {DateTime} created_at Creation timestamp
146 * @apiSuccess {DateTime} updated_at Last modified timestamp
147 * @apiSuccess {number} tenant_id Tenant ID
148 * @apiSuccess {string} author_name Author display name
149 * @apiSuccess {string} tenant_name Tenant name
150 * @apiSuccess {Object[]} attachments Attachment metadata (data payload excluded)
151 * @apiSuccess {number} attachments.id Attachment ID
152 * @apiSuccess {string} attachments.filename Stored filename
153 * @apiSuccess {string} attachments.original_filename Original upload filename
154 * @apiSuccess {string} attachments.file_size_mb File size (e.g., "2.5 MB")
155 * @apiSuccess {DateTime} attachments.created_at Upload timestamp
156 * @apiError (404) {String} error="Document not found" Document ID not found or tenant mismatch
157 * @apiError (500) {String} error="Server error" Database query failed
158 * @apiExample {curl} Example:
159 * curl -X GET http://localhost:3000/api/documents/42 \\
160 * -H "Authorization: Bearer YOUR_TOKEN"
161 * @apiSuccessExample {json} Success-Response:
165 * "title": "Firewall Configuration Guide",
166 * "category": "network",
167 * "content": "# Firewall Setup\n...markdown content...",
168 * "created_at": "2026-03-10T08:00:00.000Z",
169 * "updated_at": "2026-03-12T10:30:00.000Z",
171 * "author_name": "John Admin",
172 * "tenant_name": "Acme Corp",
176 * "filename": "firewall-diagram.png",
177 * "original_filename": "Firewall Diagram.png",
178 * "file_size_mb": "0.8 MB",
179 * "created_at": "2026-03-10T08:30:00.000Z"
184router.get('/:id', async (req, res) => {
185 const { id } = req.params;
188 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'd', false);
190 SELECT d.doc_id, d.title, d.category, d.content, d.created_at, d.updated_at, d.tenant_id,
191 u.name as author_name, t.name as tenant_name
193 LEFT JOIN users u ON d.author_id = u.user_id
194 LEFT JOIN tenants t ON d.tenant_id = t.tenant_id
200 // Shift tenant param placeholders to start at $2 because $1 is the doc_id
201 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (m, n) => `$${parseInt(n, 10) + 1}`);
202 query += ` AND ${adjustedClause}`;
203 params.push(...tenantParams);
206 const result = await pool.query(query, params);
208 if (result.rows.length === 0) {
209 return res.status(404).json({ error: 'Document not found' });
212 const doc = result.rows[0];
214 // Fetch attachment metadata (exclude data payload)
215 let attachments = [];
217 const aRes = await pool.query(
218 `SELECT id, filename, original_filename, file_size_mb, created_at
219 FROM document_attachments
220 WHERE document_id = $1
224 attachments = aRes.rows;
226 // Table might not exist yet; ignore
227 console.warn('No attachment metadata available:', e.message);
230 res.json({ ...doc, attachments });
232 console.error('Error fetching document:', err);
233 res.status(500).json({ error: 'Server error', details: err.message });
238 * @api {post} /api/documents Create document
239 * @apiName CreateDocument
240 * @apiGroup Documents
241 * @apiDescription Create new document with optional attachments (base64-encoded).
242 * Attachments validated by middleware (file type, size). Document assigned to user's tenant.
243 * Creates document_attachments table if not exists.
244 * @apiParam {string} title Document title
245 * @apiParam {string} content Document content (markdown/text)
246 * @apiParam {string} [category] Document category (e.g., "procedure", "network", "troubleshooting")
247 * @apiParam {string} [type] Legacy category field (use category instead)
248 * @apiParam {object[]} [validatedAttachments] Validated attachments from middleware
249 * @apiParam {string} validatedAttachments.filename Stored filename
250 * @apiParam {string} validatedAttachments.originalFilename Original upload filename
251 * @apiParam {string} validatedAttachments.data Base64-encoded file content
252 * @apiParam {string} validatedAttachments.size File size (e.g., "1.2 MB")
253 * @apiSuccess (201) {Number} doc_id Created document ID
254 * @apiSuccess (201) {String} title Document title
255 * @apiSuccess (201) {String} content Document content
256 * @apiSuccess (201) {String} category Document category
257 * @apiSuccess (201) {Number} tenant_id Tenant ID
258 * @apiSuccess (201) {DateTime} created_at Creation timestamp
259 * @apiSuccess (201) {DateTime} updated_at Last modified timestamp
260 * @apiError (403) {String} error="No tenant context" Tenant not set (authentication issue)
261 * @apiError (500) {String} error="Failed to create document" Database insert failed
262 * @apiExample {curl} Example:
263 * curl -X POST http://localhost:3000/api/documents \\
264 * -H "Authorization: Bearer YOUR_TOKEN" \\
265 * -H "Content-Type: application/json" \\
267 * "title": "Windows Update Procedure",
268 * "content": "# Procedure\n1. Open Windows Update...",
269 * "category": "procedure"
271 * @apiSuccessExample {json} Success-Response:
272 * HTTP/1.1 201 Created
275 * "title": "Windows Update Procedure",
276 * "content": "# Procedure\n1. Open Windows Update...",
277 * "category": "procedure",
279 * "created_at": "2026-03-12T11:00:00.000Z",
280 * "updated_at": "2026-03-12T11:00:00.000Z"
283router.post('/', validateAttachments, async (req, res) => {
284 const { title, content, category: categoryFromBody, type, validatedAttachments } = req.body;
286 // Prefer explicit category; fall back to legacy 'type'
287 const category = categoryFromBody ?? type ?? null;
289 if (!req.tenant?.id) {
290 return res.status(403).json({ error: 'No tenant context' });
294 console.log('POST /documents - body:', { title, hasContent: !!content, category, attachmentsCount: Array.isArray(validatedAttachments) ? validatedAttachments.length : 0 });
295 console.log('POST /documents - tenant:', req.tenant);
296 const result = await pool.query(
297 `INSERT INTO documents (title, content, category, tenant_id)
298 VALUES ($1, $2, $3, $4) RETURNING *`,
299 [title, content, category, req.tenant.id]
302 const doc = result.rows[0];
304 // If attachments are provided and validated, store them
305 if (Array.isArray(validatedAttachments) && validatedAttachments.length > 0) {
306 // ensure attachments table exists
308 CREATE TABLE IF NOT EXISTS document_attachments (
309 id SERIAL PRIMARY KEY,
310 document_id INTEGER REFERENCES documents(doc_id) ON DELETE CASCADE,
311 filename VARCHAR(255),
312 original_filename VARCHAR(255),
314 file_size_mb VARCHAR(10),
315 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
319 for (const a of validatedAttachments) {
322 `INSERT INTO document_attachments (document_id, filename, original_filename, data_base64, file_size_mb)
323 VALUES ($1, $2, $3, $4, $5)`,
324 [doc.doc_id, a.filename, a.originalFilename, a.data, a.size]
327 console.error('Failed to save attachment', innerErr);
332 res.status(201).json(doc);
334 console.error('Error creating document:', err);
335 res.status(500).json({ error: 'Failed to create document', details: err.message });
340 * @api {put} /api/documents/:id Update document
341 * @apiName UpdateDocument
342 * @apiGroup Documents
343 * @apiDescription Update existing document (title, content, category).
344 * Enforces tenant isolation - user can only update documents in their tenant.
345 * Does not affect attachments (use POST /:id/attachments for that).
346 * @apiParam {number} id Document ID
347 * @apiParam {string} title Updated document title
348 * @apiParam {string} content Updated document content
349 * @apiParam {string} [category] Updated document category
350 * @apiSuccess {number} doc_id Document ID
351 * @apiSuccess {string} title Updated title
352 * @apiSuccess {string} content Updated content
353 * @apiSuccess {string} category Updated category
354 * @apiSuccess {number} tenant_id Tenant ID
355 * @apiSuccess {DateTime} created_at Original creation timestamp
356 * @apiSuccess {DateTime} updated_at New update timestamp
357 * @apiError (404) {String} error="Document not found" Document ID not found or tenant mismatch
358 * @apiError (500) {String} error="Failed to update document" Database update failed
359 * @apiExample {curl} Example:
360 * curl -X PUT http://localhost:3000/api/documents/42 \\
361 * -H "Authorization: Bearer YOUR_TOKEN" \\
362 * -H "Content-Type: application/json" \\
364 * "title": "Firewall Configuration Guide (Updated)",
365 * "content": "# Updated Firewall Setup\n...",
366 * "category": "network"
368 * @apiSuccessExample {json} Success-Response:
372 * "title": "Firewall Configuration Guide (Updated)",
373 * "content": "# Updated Firewall Setup\n...",
374 * "category": "network",
376 * "created_at": "2026-03-10T08:00:00.000Z",
377 * "updated_at": "2026-03-12T11:15:00.000Z"
380router.put('/:id', async (req, res) => {
381 const { id } = req.params;
382 const { title, content, category } = req.body;
385 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'd', false);
387 // First check if document exists and user has access
388 let checkQuery = 'SELECT doc_id FROM documents d WHERE doc_id = $1';
389 const checkParams = [id];
392 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (m, n) => `$${parseInt(n, 10) + 1}`);
393 checkQuery += ` AND ${adjustedClause}`;
394 checkParams.push(...tenantParams);
397 const checkResult = await pool.query(checkQuery, checkParams);
399 if (checkResult.rows.length === 0) {
400 return res.status(404).json({ error: 'Document not found' });
403 // Update the document
404 const result = await pool.query(
406 SET title = $1, content = $2, category = $3, updated_at = NOW()
409 [title, content, category, id]
412 res.json(result.rows[0]);
414 console.error('Error updating document:', err);
415 res.status(500).json({ error: 'Failed to update document', details: err.message });
420 * @api {post} /api/documents/:id/attachments Upload attachments
421 * @apiName UploadDocumentAttachments
422 * @apiGroup Documents
423 * @apiDescription Add attachments to existing document (base64-encoded files).
424 * Attachments validated by middleware for file type and size. Creates attachments table if not exists.
425 * Enforces tenant isolation.
426 * @apiParam {number} id Document ID
427 * @apiParam {object[]} validatedAttachments Validated attachments from middleware
428 * @apiParam {string} validatedAttachments.filename Stored filename
429 * @apiParam {string} validatedAttachments.originalFilename Original upload filename
430 * @apiParam {string} validatedAttachments.data Base64-encoded file content
431 * @apiParam {string} validatedAttachments.size File size (e.g., "1.2 MB")
432 * @apiSuccess (201) {String} message="Attachments uploaded successfully"
433 * @apiSuccess (201) {Number} count Number of attachments uploaded
434 * @apiError (400) {String} error="No valid attachments provided" No attachments in request
435 * @apiError (404) {String} error="Document not found" Document ID not found or tenant mismatch
436 * @apiError (500) {String} error="Failed to upload attachments" Database insert failed
437 * @apiExample {curl} Example:
438 * curl -X POST http://localhost:3000/api/documents/42/attachments \\
439 * -H "Authorization: Bearer YOUR_TOKEN" \\
440 * -H "Content-Type: application/json" \\
442 * "validatedAttachments": [
444 * "filename": "network-diagram.png",
445 * "originalFilename": "Network Diagram.png",
446 * "data": "iVBORw0KGgoAAAANS...",
451 * @apiSuccessExample {json} Success-Response:
452 * HTTP/1.1 201 Created
454 * "message": "Attachments uploaded successfully",
458router.post('/:id/attachments', validateAttachments, async (req, res) => {
459 const { id } = req.params;
460 const { validatedAttachments } = req.body;
462 if (!validatedAttachments || validatedAttachments.length === 0) {
463 return res.status(400).json({ error: 'No valid attachments provided' });
467 // Verify document exists and user has tenant access
468 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'd', false);
469 let checkQuery = 'SELECT doc_id FROM documents d WHERE doc_id = $1';
470 const checkParams = [id];
472 const adjusted = tenantClause.replace(/\$(\d+)/g, (m, n) => `$${parseInt(n, 10) + 1}`);
473 checkQuery += ` AND ${adjusted}`;
474 checkParams.push(...tenantParams);
476 const check = await pool.query(checkQuery, checkParams);
477 if (check.rows.length === 0) {
478 return res.status(404).json({ error: 'Document not found' });
481 // Ensure attachments table exists
483 CREATE TABLE IF NOT EXISTS document_attachments (
484 id SERIAL PRIMARY KEY,
485 document_id INTEGER REFERENCES documents(doc_id) ON DELETE CASCADE,
486 filename VARCHAR(255),
487 original_filename VARCHAR(255),
489 file_size_mb VARCHAR(10),
490 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
494 // Insert attachments
495 for (const a of validatedAttachments) {
498 `INSERT INTO document_attachments (document_id, filename, original_filename, data_base64, file_size_mb)
499 VALUES ($1, $2, $3, $4, $5)`,
500 [id, a.filename, a.originalFilename, a.data, a.size]
503 console.error('Failed to save attachment', innerErr);
507 res.status(201).json({ message: 'Attachments uploaded successfully', count: validatedAttachments.length });
509 console.error('Error uploading attachments:', err);
510 res.status(500).json({ error: 'Failed to upload attachments', details: err.message });
515 * @api {get} /api/documents/:id/attachments/:attachmentId/download Download attachment
516 * @apiName DownloadDocumentAttachment
517 * @apiGroup Documents
518 * @apiDescription Download attachment file from document. Returns binary file with original filename.
519 * Decodes base64-encoded attachment data and streams to client. Enforces tenant isolation.
520 * @apiParam {number} id Document ID
521 * @apiParam {number} attachmentId Attachment ID
522 * @apiSuccess {File} file Binary file download with original filename
523 * @apiError (404) {String} error="Document not found" Document ID not found or tenant mismatch
524 * @apiError (404) {String} error="Attachment not found" Attachment ID not found for document
525 * @apiError (500) {String} error="Failed to download attachment" Database query or decoding failed
526 * @apiExample {curl} Example:
527 * curl -X GET http://localhost:3000/api/documents/42/attachments/15/download \\
528 * -H "Authorization: Bearer YOUR_TOKEN" \\
529 * -o firewall-diagram.png
530 * @apiSuccessExample {binary} Success-Response:
532 * Content-Type: application/octet-stream
533 * Content-Disposition: attachment; filename="Firewall Diagram.png"
535 * [binary file content]
537router.get('/:id/attachments/:attachmentId/download', async (req, res) => {
538 const { id, attachmentId } = req.params;
540 console.log(`[Documents] Download request - doc_id: ${id}, attachment_id: ${attachmentId}, user: ${req.user?.user_id}, tenant: ${req.tenant?.id}`);
543 // Ensure document belongs to tenant via join
544 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'd', false);
545 let checkQuery = 'SELECT 1 FROM documents d WHERE d.doc_id = $1';
546 const checkParams = [id];
548 const adjusted = tenantClause.replace(/\$(\d+)/g, (m, n) => `$${parseInt(n, 10) + 1}`);
549 checkQuery += ` AND ${adjusted}`;
550 checkParams.push(...tenantParams);
553 console.log('[Documents] Download check query:', checkQuery, checkParams);
555 const check = await pool.query(checkQuery, checkParams);
556 if (check.rows.length === 0) {
557 console.log('[Documents] Document not found or access denied for tenant');
558 return res.status(404).json({ error: 'Document not found' });
561 const aRes = await pool.query(
562 `SELECT original_filename, filename, data_base64
563 FROM document_attachments
564 WHERE id = $1 AND document_id = $2`,
568 if (aRes.rows.length === 0) {
569 console.log('[Documents] Attachment not found');
570 return res.status(404).json({ error: 'Attachment not found' });
573 const att = aRes.rows[0];
574 console.log('[Documents] Attachment found:', att.original_filename || att.filename);
576 // Decode base64 and send as file
577 let base64 = att.data_base64;
578 if (base64.includes(',')) base64 = base64.split(',')[1];
579 const buf = Buffer.from(base64, 'base64');
581 res.setHeader('Content-Type', 'application/octet-stream');
582 res.setHeader('Content-Disposition', `attachment; filename="${att.original_filename || att.filename}"`);
585 console.error('Error downloading attachment:', err);
586 res.status(500).json({ error: 'Failed to download attachment', details: err.message });
591 * @api {delete} /api/documents/:id Delete document
592 * @apiName DeleteDocument
593 * @apiGroup Documents
594 * @apiDescription Delete document and associated attachments. Admin only.
595 * Enforces tenant isolation - admin can only delete documents in their tenant.
596 * Cascades to delete attachments first due to foreign key constraints.
597 * @apiParam {number} id Document ID
598 * @apiSuccess {string} message="Document deleted successfully"
599 * @apiSuccess {object} deleted_document Deleted document object
600 * @apiSuccess {number} deleted_document.doc_id Deleted document ID
601 * @apiSuccess {string} deleted_document.title Deleted document title
602 * @apiSuccess {number} deleted_document.tenant_id Tenant ID
603 * @apiError (403) {String} error="Forbidden" User not admin
604 * @apiError (404) {String} error="Document not found" Document ID not found or tenant mismatch
605 * @apiError (500) {String} error="Failed to delete document" Database delete failed
606 * @apiExample {curl} Example:
607 * curl -X DELETE http://localhost:3000/api/documents/42 \\
608 * -H "Authorization: Bearer YOUR_TOKEN"
609 * @apiSuccessExample {json} Success-Response:
612 * "message": "Document deleted successfully",
613 * "deleted_document": {
615 * "title": "Firewall Configuration Guide",
616 * "category": "network",
618 * "created_at": "2026-03-10T08:00:00.000Z",
619 * "updated_at": "2026-03-12T10:30:00.000Z"
623router.delete('/:id', requireAdmin, async (req, res) => {
624 const { id } = req.params;
627 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'd', false);
629 // First check if document exists and user has access
630 let checkQuery = 'SELECT doc_id, title FROM documents d WHERE doc_id = $1';
631 const checkParams = [id];
634 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (m, n) => `$${parseInt(n, 10) + 1}`);
635 checkQuery += ` AND ${adjustedClause}`;
636 checkParams.push(...tenantParams);
639 const checkResult = await pool.query(checkQuery, checkParams);
641 if (checkResult.rows.length === 0) {
642 return res.status(404).json({ error: 'Document not found' });
645 const document = checkResult.rows[0];
647 // Delete associated attachments first (due to foreign key constraints)
649 await pool.query('DELETE FROM document_attachments WHERE document_id = $1', [id]);
650 } catch (attachErr) {
651 // Table might not exist, that's ok
652 console.log('No attachments to delete or attachment table does not exist');
655 // Delete the document
656 const result = await pool.query(
657 'DELETE FROM documents WHERE doc_id = $1 RETURNING *',
661 console.log(`[Documents] Document deleted: ID ${id}, Title: "${document.title}", User: ${req.user?.user_id}, Tenant: ${req.tenant?.id}`);
664 message: 'Document deleted successfully',
665 deleted_document: result.rows[0]
668 console.error('Error deleting document:', err);
669 res.status(500).json({ error: 'Failed to delete document', details: err.message });
673module.exports = router;