const express = require('express'); const multer = require('multer'); const mammoth = require('mammoth'); const { diffWords, diffWordsWithSpace, diffLines, diffArrays } = require('diff'); // Helpers: tokenization and robust token diff (handles CJK without spaces) function tokenizeForDiff(s = '') { const tokens = []; const len = s.length; for (let i = 0; i < len; i++) { const ch = s[i]; const code = ch.codePointAt(0); if (ch === '\n') { tokens.push('\n'); continue; } if (ch === '\r') continue; if ((code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3400 && code <= 0x4DBF) || (code >= 0xF900 && code <= 0xFAFF)) { tokens.push(ch); continue; } if (/[A-Za-z0-9]/.test(ch)) { let j = i + 1; while (j < len && /[A-Za-z0-9]/.test(s[j])) j++; tokens.push(s.slice(i, j)); i = j - 1; continue; } if (ch === ' ') { let j = i + 1; while (j < len && s[j] === ' ') j++; tokens.push(s.slice(i, j)); i = j - 1; continue; } tokens.push(ch); } return tokens; } function safeDiffTokens(a, b) { try { return diffArrays(tokenizeForDiff(a), tokenizeForDiff(b)); } catch { try { return diffWordsWithSpace(a, b); } // fallback catch { return [{ value: String(b || '') }]; } } } const { Document, Packer, Paragraph, TextRun, CommentRangeStart, CommentRangeEnd, CommentReference, Comments } = require('docx'); const router = express.Router(); const TutorialRefinityTask = require('../models/TutorialRefinityTask'); const TutorialRefinityVersion = require('../models/TutorialRefinityVersion'); const TutorialRefinityAnnotation = require('../models/TutorialRefinityAnnotation'); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024 } }); // Helper to set download headers function setDownloadHeaders(res, filename) { res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`); } // Helper to check if user is admin function isAdminUser(req) { const role = req.headers['x-user-role'] || req.headers['user-role']; return role === 'admin'; } // Helper to get current user identifier function getCurrentUser(req) { return String(req.headers['x-user-name'] || req.headers['x-user-email'] || '').toLowerCase(); } // Router-level logging router.use((req, res, next) => { console.log('[tutorial-refinity router] Request:', req.method, req.path, req.url); next(); }); // ----- Document Parsing and Diff APIs ----- router.post('/parse', upload.single('file'), async (req, res) => { try { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); const result = await mammoth.extractRawText({ buffer: req.file.buffer }); res.json({ text: result.value }); } catch (e) { res.status(500).json({ error: 'Parse failed' }); } }); router.post('/diff', async (req, res) => { try { const prev = String(req.body?.prev || ''); const current = String(req.body?.current || ''); const parts = safeDiffTokens(prev, current); const toHtml = (s) => String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/\r?\n/g, '
'); const html = parts.map(p => { const text = Array.isArray(p.value) ? p.value.join('') : String(p.value || ''); if (p.added) return `${toHtml(text)}`; if (p.removed) return `${toHtml(text)}`; return `${toHtml(text)}`; }).join(''); res.json({ html }); } catch (e) { res.status(500).json({ error: 'Diff failed' }); } }); router.post('/track-changes', async (req, res) => { try { const prev = String(req.body?.prev || ''); const current = String(req.body?.current || ''); const outName = String(req.body?.filename || 'tutorial-tracked.docx').replace(/\/+|\\+/g,'_'); const parts = safeDiffTokens(prev, current); const addedColor = '065F46'; const removedColor = '991B1B'; const paragraphs = []; let children = []; const pushParagraph = () => { paragraphs.push(new Paragraph({ children: children.length ? children : [ new TextRun('') ] })); children = []; }; parts.forEach(p => { const arr = Array.isArray(p.value) ? p.value : String(p.value || '').split(/(?<=)/); arr.forEach(tok => { if (tok === '\n') { pushParagraph(); return; } if (!tok) return; if (p.added) { const opt = { text: tok, color: addedColor }; try { Object.assign(opt, { shading: { fill: 'DCFCE7', color: 'auto', type: 'clear' } }); } catch {} children.push(new TextRun(opt)); } else if (p.removed) { const opt = { text: tok, color: removedColor, strike: true }; try { Object.assign(opt, { shading: { fill: 'FEE2E2', color: 'auto', type: 'clear' } }); } catch {} children.push(new TextRun(opt)); } else { children.push(new TextRun({ text: tok })); } }); }); if (children.length) pushParagraph(); if (!paragraphs.length) paragraphs.push(new Paragraph({ children: [ new TextRun('') ] })); const doc = new Document({ sections: [ { properties: {}, children: paragraphs } ] }); const buffer = await Packer.toBuffer(doc); setDownloadHeaders(res, outName); res.send(Buffer.from(buffer)); } catch (e) { res.status(500).json({ error: 'Track changes export failed' }); } }); router.post('/compare-comments-with-corrections', async (req, res) => { try { const prev = String(req.body?.prev || ''); const current = String(req.body?.current || ''); const outName = String(req.body?.filename || 'tutorial-compare-comments.docx').replace(/[\\/]+/g,'_'); const annotationVersionId = req.body?.annotationVersionId; const authorName = String(req.body?.authorName || 'Refinity'); const authorInitials = String(req.body?.authorInitials || (authorName.split(/\s+/).map(s=>s[0]||'').join('').slice(0,3).toUpperCase()) || 'RF'); if (!annotationVersionId) { return res.status(400).json({ error: 'annotationVersionId is required' }); } // Get the older version to find taskId and versionNumber const olderVersion = await TutorialRefinityVersion.findById(annotationVersionId).lean(); if (!olderVersion) { return res.status(404).json({ error: 'Version not found' }); } // Find all versions in the same task that come after the older version const laterVersions = await TutorialRefinityVersion.find({ taskId: olderVersion.taskId, versionNumber: { $gt: olderVersion.versionNumber } }).sort({ versionNumber: 1 }).lean(); // Get all annotations from the older version and all later versions const versionIds = [annotationVersionId, ...laterVersions.map(v => v._id)]; const allAnns = await TutorialRefinityAnnotation.find({ versionId: { $in: versionIds } }).sort({ start: 1, end: 1 }).lean(); // Build a map of annotations by their text content for matching const annByText = new Map(); const annByPosition = []; for (const ann of allAnns) { const version = ann.versionId.toString() === annotationVersionId.toString() ? olderVersion : laterVersions.find(v => v._id.toString() === ann.versionId.toString()); if (!version) continue; const annText = prev.slice(ann.start || 0, ann.end || 0); if (annText) { const normalized = annText.trim().replace(/\s+/g, ' '); if (!annByText.has(normalized)) { annByText.set(normalized, []); } annByText.get(normalized).push({ category: ann.category || 'other', start: ann.start, end: ann.end, correction: ann.correction || '', originalAnn: ann, }); annByPosition.push({ start: ann.start || 0, end: ann.end || 0, category: ann.category || 'other', correction: ann.correction || '', selectedText: annText, normalized, originalAnn: ann, }); } } annByPosition.sort((a, b) => a.start - b.start || a.end - b.end); function calculateTextSimilarity(str1, str2) { if (!str1 || !str2) return 0; if (str1 === str2) return 1; const longer = str1.length > str2.length ? str1 : str2; const shorter = str1.length > str2.length ? str2 : str1; if (longer.includes(shorter)) { return shorter.length / longer.length; } const set1 = new Set(str1.split('')); const set2 = new Set(str2.split('')); const intersection = new Set([...set1].filter(x => set2.has(x))); const union = new Set([...set1, ...set2]); return union.size > 0 ? intersection.size / union.size : 0; } // ANNOTATION-FIRST APPROACH: Only process annotations from the database // Skip all diff-based processing - only mark what has been annotated const annotationBasedItems = []; // Process each annotation directly from the database for (const ann of annByPosition) { const annSelectedText = ann.selectedText; const annCorrection = ann.correction || ''; const annCategory = ann.category || 'other'; // Determine if this is a deletion, insertion, or replacement // Check if correction is empty or same as selected text (deletion) const isDeletion = !annCorrection || annCorrection.trim() === '' || annCorrection.trim() === annSelectedText.trim(); // For insertions, check if the selected text is very short (anchor text) and correction is longer const isInsertion = annSelectedText.trim().length <= 2 && annCorrection.trim().length > annSelectedText.trim().length; annotationBasedItems.push({ start: ann.start, end: ann.end, removedText: annSelectedText, replacementText: annCorrection, category: annCategory, isDeleted: isDeletion, isInsertion: isInsertion, isMove: false, }); } // Sort by position annotationBasedItems.sort((a, b) => a.start - b.start || a.end - b.end); // De-dupe identical annotations (same span/category/correction/flags). // Without this, duplicated DB rows (or re-annotated identical spans) can cause the // generator to literally duplicate the span text in the document body. const mergedAnnotations = (() => { const out = []; const seen = new Set(); for (const it of annotationBasedItems) { const key = [ it.start ?? '', it.end ?? '', String(it.category || ''), String(it.replacementText || ''), it.isDeleted ? '1' : '0', it.isInsertion ? '1' : '0', it.isMove ? '1' : '0', ].join('|'); if (seen.has(key)) continue; seen.add(key); out.push(it); } return out; })(); const lines = []; let pos = 0; while (pos <= prev.length) { const nlIdx = prev.indexOf('\n', pos); if (nlIdx === -1) { if (pos <= prev.length) { const end = prev.length; const text = prev.slice(pos, end).replace(/\r$/, ''); lines.push({ text, start: pos, end }); } break; } let lineEnd = nlIdx; if (lineEnd > pos && prev[lineEnd - 1] === '\r') lineEnd -= 1; const text = prev.slice(pos, lineEnd); lines.push({ text, start: pos, end: lineEnd }); pos = nlIdx + 1; } if (!lines.length) { lines.push({ text: '', start: 0, end: 0 }); } const commentItems = []; let nextCommentId = 0; const labelMap = { distortion: 'Distortion', omission: 'Unjustified omission', register: 'Inappropriate register', unidiomatic: 'Unidiomatic expression', grammar: 'Error of grammar, syntax', spelling: 'Error of spelling', punctuation: 'Error of punctuation', addition: 'Unjustified addition', other: 'Other', }; for (const a of mergedAnnotations) { const start = a.start; const end = a.end; if (end <= start && !a.isInsertion) continue; const category = String(a.category || 'unidiomatic'); const isInsertion = a.isInsertion || false; lines.forEach((line, lineIndex) => { const segStart = line.start; const segEnd = line.end; if (isInsertion) { // Insertion: attach comment to the selected text from annotation (not just a single character) if (start >= segStart && start <= segEnd) { // Use the actual annotation span (start to end) for insertions const ovStart = Math.max(start, segStart); const ovEnd = Math.min(end, segEnd); if (ovEnd <= ovStart) { // If span is zero-length, use at least one character const insertionPoint = Math.max(start, segStart); const localStart = insertionPoint - segStart; let localEnd = localStart; if (insertionPoint < segEnd) { localEnd = Math.min(localStart + 1, segEnd - segStart); } else if (insertionPoint === segEnd && segEnd > segStart) { localEnd = segEnd - segStart; localStart = Math.max(0, localEnd - 1); } else { localEnd = Math.max(1, segEnd - segStart); } const originalSpan = prev.slice(segStart + localStart, segStart + localEnd) || ' '; const newerText = a.replacementText || ''; const cid = nextCommentId++; commentItems.push({ id: cid, lineIndex, localStart, localEnd, originalSpan, newerText, isDeleted: false, isInsertion: true, isMove: false, category, globalStart: segStart + localStart, globalEnd: segStart + localEnd, }); } else { // Use the full annotation span const originalSpan = prev.slice(ovStart, ovEnd); const newerText = a.replacementText || ''; const localStart = ovStart - segStart; const localEnd = ovEnd - segStart; const cid = nextCommentId++; commentItems.push({ id: cid, lineIndex, localStart, localEnd, originalSpan, newerText, isDeleted: false, isInsertion: true, isMove: false, category, globalStart: ovStart, globalEnd: ovEnd, }); } } return; } if (end <= segStart || start >= segEnd) return; const ovStart = Math.max(start, segStart); const ovEnd = Math.min(end, segEnd); if (ovEnd <= ovStart) return; const originalSpan = prev.slice(ovStart, ovEnd); const newerText = a.isDeleted ? '' : (a.replacementText || originalSpan); const isDeleted = a.isDeleted; const localStart = ovStart - segStart; const localEnd = ovEnd - segStart; const cid = nextCommentId++; commentItems.push({ id: cid, lineIndex, localStart, localEnd, originalSpan, newerText, isDeleted, isInsertion: false, isMove: a.isMove || false, category, globalStart: ovStart, globalEnd: ovEnd, }); }); } const paragraphs = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const lineText = line.text || ''; const lineLen = lineText.length; const items = commentItems .filter(ci => ci.lineIndex === i) .sort((a, b) => a.localStart - b.localStart || a.localEnd - b.localEnd); if (!items.length) { paragraphs.push(new Paragraph({ children: [ new TextRun({ text: lineText }) ] })); continue; } const children = []; let cursor = 0; for (const item of items) { const id = item.id; const localStart = item.localStart; const localEnd = item.localEnd; const originalSpan = item.originalSpan || ' '; if (localStart > cursor) { children.push(new TextRun({ text: lineText.slice(cursor, localStart) })); } if (localEnd <= localStart && !item.isInsertion) continue; // For insertions, use the originalSpan from the annotation (not from lineText) // This ensures we don't duplicate text - we only highlight what was in the original text const spanStart = localStart; const spanEnd = item.isInsertion && localEnd <= localStart ? Math.min(localStart + 1, lineLen) : localEnd; // Use originalSpan for insertions to avoid duplicating text const spanText = item.isInsertion ? originalSpan : (spanEnd > spanStart ? lineText.slice(spanStart, spanEnd) : originalSpan); children.push(new CommentRangeStart(id)); children.push(new TextRun({ text: spanText })); children.push(new CommentRangeEnd(id)); children.push(new CommentReference(id)); // For insertions, don't advance cursor past the original text - we're just attaching a comment cursor = item.isInsertion ? Math.max(cursor, spanEnd) : Math.max(spanEnd, localEnd); } if (cursor < lineLen) { children.push(new TextRun({ text: lineText.slice(cursor) })); } paragraphs.push(new Paragraph({ children })); } const commentDefs = commentItems.map((item) => { const categoryLabel = labelMap[item.category] || item.category; const original = item.originalSpan || ''; const newer = item.newerText || original; let commentChildren; if (item.isDeleted) { commentChildren = [ new TextRun({ text: `[${categoryLabel}] ` }), new TextRun({ text: original, strike: true }), ]; } else if (item.isInsertion) { const commentText = `[${categoryLabel}] → ${newer}`; commentChildren = [ new TextRun({ text: commentText }) ]; } else { const commentText = `[${categoryLabel}] ${original} → ${newer}`; commentChildren = [ new TextRun({ text: commentText }) ]; } return { id: Number(item.id), initials: authorInitials || 'RF', author: authorName || 'Refinity', date: new Date(), children: [ new Paragraph({ children: commentChildren, spacing: { after: 0 }, }), ], }; }); let sampleText = 'N/A'; if (commentItems.length > 0 && commentDefs.length > 0) { const sampleItem = commentItems[0]; const categoryLabel = labelMap[sampleItem.category] || sampleItem.category; const original = sampleItem.originalSpan || ''; if (sampleItem.isDeleted) { sampleText = `[${categoryLabel}] ${original} (deleted with strikethrough)`; } else if (sampleItem.isInsertion) { const newer = sampleItem.newerText || ''; sampleText = `[${categoryLabel}] → ${newer} (insertion)`; } else { const newer = sampleItem.newerText || original; sampleText = `[${categoryLabel}] ${original} → ${newer}`; } } console.log('[tutorial-refinity compare-comments-with-corrections]', { annotationBasedItems: mergedAnnotations.length, totalAnnotations: allAnns.length, commentItems: commentItems.length, commentDefs: commentDefs.length, paragraphs: paragraphs.length, sampleCommentId: commentItems[0]?.id, sampleCommentText: sampleText, hasCommentsInDoc: !!commentDefs.length, }); const doc = new Document({ sections: [ { properties: {}, children: paragraphs, }, ], comments: { children: commentDefs }, }); const buffer = await Packer.toBuffer(doc); setDownloadHeaders(res, outName); res.send(Buffer.from(buffer)); } catch (e) { console.error('[tutorial-refinity compare-comments-with-corrections] error:', e.message, e.stack); try { const current = String(req.body?.prev || ''); const paragraphs = String(current).split(/\r?\n/).map(line => new Paragraph({ children: [ new TextRun({ text: line }) ] })); const doc = new Document({ sections: [ { properties: {}, children: paragraphs.length ? paragraphs : [ new Paragraph('') ] } ] }); const buffer = await Packer.toBuffer(doc); const outName = String(req.body?.filename || 'tutorial-compare-fallback.docx').replace(/[\\/]+/g,'_'); setDownloadHeaders(res, outName); res.send(Buffer.from(buffer)); } catch (e2) { console.error('[tutorial-refinity compare-comments-with-corrections] fallback error:', e2.message); res.status(500).json({ error: 'Failed to generate comparison comments document' }); } } }); router.post('/export-plain', async (req, res) => { try { const current = String(req.body?.current || ''); const outName = String(req.body?.filename || 'tutorial.docx').replace(/[\/]+/g,'_'); const paragraphs = String(current).split(/\r?\n/).map(line => new Paragraph({ children: [new TextRun({ text: line })] })); const doc = new Document({ sections: [ { properties: {}, children: paragraphs.length ? paragraphs : [ new Paragraph('') ] } ] }); const buffer = await Packer.toBuffer(doc); setDownloadHeaders(res, outName); res.send(Buffer.from(buffer)); } catch (e) { res.status(500).json({ error: 'Export failed' }); } }); // ----- Task APIs ----- router.get('/test', (req, res) => { res.json({ message: 'tutorial-refinity route is working', timestamp: new Date().toISOString() }); }); // Diagnostic endpoint to list all tasks (for debugging) router.get('/tasks/all', async (req, res) => { try { const allTasks = await TutorialRefinityTask.find({}).sort({ createdAt: -1 }).lean(); console.log('[tutorial-refinity] GET /tasks/all - All tasks in database:', { count: allTasks.length, tasks: allTasks.map(t => ({ _id: t._id, title: t.title, weekNumber: t.weekNumber, createdBy: t.createdBy, createdAt: t.createdAt })) }); res.json({ count: allTasks.length, tasks: allTasks }); } catch (e) { console.error('[tutorial-refinity] GET /tasks/all error:', e); res.status(500).json({ error: `Failed to load all tasks: ${e.message || String(e)}` }); } }); router.get('/tasks', async (req, res) => { try { // Parse weekNumber more robustly - handle both string and number let weekNumber = null; if (req.query.weekNumber !== undefined && req.query.weekNumber !== null && req.query.weekNumber !== '') { const parsed = parseInt(req.query.weekNumber); if (!isNaN(parsed) && parsed > 0) { weekNumber = parsed; } } const isAdmin = isAdminUser(req); const currentUser = getCurrentUser(req); console.log('[tutorial-refinity] GET /tasks - Request received', { weekNumber, isAdmin, currentUser, rawQuery: req.query, weekNumberType: typeof req.query.weekNumber }); let query = {}; if (weekNumber !== null) { // Query by exact weekNumber match query.weekNumber = weekNumber; } // Find all tasks matching the query - no filtering by user since all tasks are admin-created let tasks = await TutorialRefinityTask.find(query).sort({ createdAt: -1 }).lean(); console.log('[tutorial-refinity] GET /tasks - Found tasks:', { count: tasks.length, query: query, taskIds: tasks.map(t => t._id), taskTitles: tasks.map(t => t.title), weekNumbers: tasks.map(t => t.weekNumber), createdBy: tasks.map(t => t.createdBy) }); // For non-admin users, only show admin-created tasks // Admin-created tasks are those where createdBy matches an admin user // Since only admins can create tasks (enforced in POST), all tasks are admin-created // So we show all tasks to all users - they can all access the same admin-created tasks // Each user's revisions are kept separate via version filtering res.json(tasks); } catch (e) { console.error('[tutorial-refinity] GET /tasks error:', e); res.status(500).json({ error: `Failed to load tasks: ${e.message || String(e)}` }); } }); router.post('/tasks', async (req, res) => { console.log('[tutorial-refinity] POST /tasks - Request received'); console.log('[tutorial-refinity] Headers:', { 'x-user-role': req.headers['x-user-role'], 'user-role': req.headers['user-role'], 'authorization': req.headers['authorization'] ? 'present' : 'missing' }); console.log('[tutorial-refinity] Body:', req.body); try { const isAdmin = isAdminUser(req); if (!isAdmin) { console.log('[tutorial-refinity] POST /tasks: Non-admin user attempted to create task'); return res.status(403).json({ error: 'Only admin can create tutorial revision tasks' }); } const { title, sourceText, createdBy, weekNumber } = req.body || {}; if (!title || sourceText === undefined) { console.error('[tutorial-refinity] POST /tasks: Missing fields', { title: !!title, sourceText: sourceText }); return res.status(400).json({ error: 'Missing title or sourceText' }); } if (!weekNumber || weekNumber < 5) { console.error('[tutorial-refinity] POST /tasks: Invalid weekNumber', { weekNumber }); return res.status(400).json({ error: 'Week number must be 5 or higher' }); } console.log('[tutorial-refinity] Creating task:', { title, weekNumber, createdBy, sourceTextLength: sourceText?.length }); const t = await TutorialRefinityTask.create({ title, sourceText, createdBy, weekNumber }); console.log('[tutorial-refinity] Task created successfully:', { _id: t._id, title: t.title, weekNumber: t.weekNumber, createdBy: t.createdBy, createdAt: t.createdAt }); res.json(t); } catch (e) { console.error('[tutorial-refinity] POST /tasks error:', e); res.status(500).json({ error: `Failed to create task: ${e.message || String(e)}` }); } }); router.put('/tasks/:taskId', async (req, res) => { try { const isAdmin = isAdminUser(req); if (!isAdmin) { return res.status(403).json({ error: 'Only admin can edit tutorial revision tasks' }); } const { taskId } = req.params; const { title, sourceText } = req.body || {}; const updated = await TutorialRefinityTask.findByIdAndUpdate(taskId, { title, sourceText }, { new: true }); if (!updated) return res.status(404).json({ error: 'Task not found' }); res.json(updated); } catch (e) { res.status(500).json({ error: 'Failed to update task' }); } }); router.delete('/tasks/:taskId', async (req, res) => { try { const isAdmin = isAdminUser(req); if (!isAdmin) { return res.status(403).json({ error: 'Only admin can delete tutorial revision tasks' }); } const { taskId } = req.params; await TutorialRefinityVersion.deleteMany({ taskId }); await TutorialRefinityAnnotation.deleteMany({ versionId: { $in: await TutorialRefinityVersion.find({ taskId }).distinct('_id') } }); await TutorialRefinityTask.findByIdAndDelete(taskId); res.json({ success: true }); } catch (e) { res.status(500).json({ error: 'Failed to delete task' }); } }); router.get('/tasks/:taskId/versions', async (req, res) => { try { const { taskId } = req.params; const isAdmin = isAdminUser(req); const currentUser = getCurrentUser(req); let versions = await TutorialRefinityVersion.find({ taskId }).sort({ versionNumber: 1 }); // For non-admin users: show admin-created first version (version 1) + their own versions // This allows students to start revising from the admin-created starting point if (!isAdmin && currentUser) { versions = versions.filter(v => { const author = String(v.originalAuthor || '').toLowerCase(); const revised = String(v.revisedBy || '').toLowerCase(); // Include version 1 (admin-created starting point) OR user's own versions return v.versionNumber === 1 || author === currentUser || revised === currentUser; }); } res.json(versions); } catch (e) { res.status(500).json({ error: 'Failed to load versions' }); } }); router.post('/tasks/:taskId/versions', async (req, res) => { try { const { taskId } = req.params; const { originalAuthor, revisedBy, content, parentVersionId } = req.body || {}; const existingVersions = await TutorialRefinityVersion.find({ taskId }).sort({ versionNumber: -1 }); const nextVersionNumber = existingVersions.length > 0 ? existingVersions[0].versionNumber + 1 : 1; const newVersion = await TutorialRefinityVersion.create({ taskId, originalAuthor, revisedBy, versionNumber: nextVersionNumber, content, parentVersionId, }); res.json(newVersion); } catch (e) { res.status(500).json({ error: 'Failed to create version' }); } }); router.delete('/versions/:id', async (req, res) => { try { const { id } = req.params; const version = await TutorialRefinityVersion.findById(id); if (!version) return res.status(404).json({ error: 'Version not found' }); const isAdmin = isAdminUser(req); const currentUser = getCurrentUser(req); const isOwner = String(version.originalAuthor || '').toLowerCase() === currentUser || String(version.revisedBy || '').toLowerCase() === currentUser; if (!isAdmin && !isOwner) { return res.status(403).json({ error: 'Not authorized' }); } await TutorialRefinityAnnotation.deleteMany({ versionId: id }); await TutorialRefinityVersion.findByIdAndDelete(id); res.json({ success: true }); } catch (e) { res.status(500).json({ error: 'Failed to delete version' }); } }); router.put('/versions/:id', async (req, res) => { try { const { id } = req.params; const { content } = req.body || {}; const version = await TutorialRefinityVersion.findById(id); if (!version) return res.status(404).json({ error: 'Version not found' }); const currentUser = getCurrentUser(req); const isOwner = String(version.originalAuthor || '').toLowerCase() === currentUser || String(version.revisedBy || '').toLowerCase() === currentUser; if (!isOwner) { return res.status(403).json({ error: 'Not authorized' }); } const updated = await TutorialRefinityVersion.findByIdAndUpdate(id, { content }, { new: true }); res.json(updated); } catch (e) { res.status(500).json({ error: 'Failed to update version' }); } }); router.get('/annotations', async (req, res) => { try { const { versionId } = req.query; if (!versionId) return res.status(400).json({ error: 'versionId required' }); const annotations = await TutorialRefinityAnnotation.find({ versionId }).sort({ start: 1, end: 1 }); res.json(annotations); } catch (e) { res.status(500).json({ error: 'Failed to load annotations' }); } }); router.post('/annotations', async (req, res) => { try { const { versionId, start, end, category, comment, correction } = req.body || {}; // Store creator if provided in header const createdBy = req.headers['x-user-name'] ? String(req.headers['x-user-name']).toLowerCase() : undefined; const annotation = await TutorialRefinityAnnotation.create({ versionId, start, end, category, comment, correction, ...(createdBy ? { createdBy } : {}) }); res.json(annotation); } catch (e) { res.status(500).json({ error: 'Failed to create annotation' }); } }); router.put('/annotations/:id', async (req, res) => { try { const { id } = req.params; const { start, end, category, comment, correction } = req.body || {}; const updated = await TutorialRefinityAnnotation.findByIdAndUpdate(id, { start, end, category, comment, correction }, { new: true }); if (!updated) return res.status(404).json({ error: 'Annotation not found' }); res.json(updated); } catch (e) { res.status(500).json({ error: 'Failed to update annotation' }); } }); router.delete('/annotations/:id', async (req, res) => { try { const { id } = req.params; await TutorialRefinityAnnotation.findByIdAndDelete(id); res.json({ success: true }); } catch (e) { res.status(500).json({ error: 'Failed to delete annotation' }); } }); module.exports = router;