Spaces:
Sleeping
Sleeping
| 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(/>/g,'>').replace(/\r?\n/g, '<br/>'); | |
| const html = parts.map(p => { | |
| const text = Array.isArray(p.value) ? p.value.join('') : String(p.value || ''); | |
| if (p.added) return `<span style="background:#dcfce7;color:#065f46">${toHtml(text)}</span>`; | |
| if (p.removed) return `<span style="background:#fee2e2;color:#991b1b;text-decoration:line-through">${toHtml(text)}</span>`; | |
| return `<span>${toHtml(text)}</span>`; | |
| }).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; | |