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;