/** * sdr-api-core.js - SimpleDocReview's HTTP API, ported to run without a server. * * A Hugging Face Static Space has no compute, so `app.py` cannot run. This file * is a port of every route upstream's `ReviewHandler` serves and of the logic * behind them: the rubric analyzer, the DOCX text extractor, status * recalculation, the notification queue, and the record migrations. * * It contains no DOM and no storage engine. Everything environment-specific * arrives through `createApi()`: * * store persistence (app.py's data/reviews.json + uploads/ pair) * now clock (app.py utc_now) * uuidHex identity (app.py uuid.uuid4().hex) * inflateRaw DEFLATE (app.py zipfile) * * That separation is what makes it testable: `sdr-api-shim.js` supplies * IndexedDB and the browser clock, while `tests/parity/` supplies an in-memory * store and a fixed clock so these responses can be diffed against the real * Python server's. * * Comments name the app.py function each part mirrors. When upstream changes * that file, change this one - never the other way round. */ (function (root, factory) { "use strict"; var api = factory(); if (typeof module === "object" && module !== null && module.exports) { module.exports = api; } if (root) { root.SdrApiCore = api; } })(typeof globalThis !== "undefined" ? globalThis : this, function () { "use strict"; // --------------------------------------------------------------- constants // app.py: MAX_UPLOAD_BYTES, ALLOWED_EXTENSIONS, CONTACT_FIELDS, *_LABELS. var MAX_UPLOAD_BYTES = 5 * 1024 * 1024; var ALLOWED_EXTENSIONS = [".txt", ".md", ".docx"]; var CONTACT_FIELDS = ["email", "slack", "teams"]; var STATUS_LABELS = { uploaded: "Uploaded", in_review: "In review", changes_requested: "Changes requested", approved: "Approved" }; var ASSIGNMENT_LABELS = { pending: "Pending", commented: "Commented", changes_requested: "Changes requested", approved: "Approved" }; var SEVERITY_ORDER = { high: 0, medium: 1, low: 2 }; var DOWNLOAD_TYPES = { ".txt": "text/plain", ".md": "text/markdown", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }; // Python's `\w` is Unicode-aware where JavaScript's is ASCII-only, so the // port uses Unicode property escapes. `\b[\w'-]+\b` can only begin and end on // a word character - an apostrophe or hyphen at either edge fails the word // boundary - which is what the outer/inner split below spells out. var WORD_PATTERN = /[\p{L}\p{N}_](?:[\p{L}\p{N}_'-]*[\p{L}\p{N}_])?/gu; // ------------------------------------------------------------------ errors /** * An error carrying the status app.py would have replied with. * * app.py maps `ValueError` to 400 and calls `send_error_json(..., NOT_FOUND)` * for missing records, so the status has to travel with the message for the * responses to stay identical. */ function apiError(message, status) { var error = new Error(message); error.apiStatus = status || 400; return error; } function badZip(message) { // app.py catches zipfile.BadZipFile in do_POST and replies with this text. return apiError(message || "The DOCX file appears to be invalid.", 400); } // ----------------------------------------------------------------- helpers function two(value) { return value < 10 ? "0" + value : String(value); } /** app.py utc_now(): an ISO-8601 UTC stamp truncated to whole seconds. */ function defaultNow() { var when = new Date(); return ( when.getUTCFullYear() + "-" + two(when.getUTCMonth() + 1) + "-" + two(when.getUTCDate()) + "T" + two(when.getUTCHours()) + ":" + two(when.getUTCMinutes()) + ":" + two(when.getUTCSeconds()) + "+00:00" ); } /** app.py uuid.uuid4().hex: 32 lowercase hex characters, no dashes. */ function defaultUuidHex() { var bytes = new Uint8Array(16); globalThis.crypto.getRandomValues(bytes); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; var hex = ""; for (var index = 0; index < bytes.length; index += 1) { hex += (bytes[index] < 16 ? "0" : "") + bytes[index].toString(16); } return hex; } /** DEFLATE with no zlib wrapper - what a ZIP member stores. */ function defaultInflateRaw(bytes) { if (typeof DecompressionStream === "undefined") { return Promise.reject( badZip("This browser cannot read DOCX files: DecompressionStream is unavailable.") ); } var stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw")); return new Response(stream).arrayBuffer().then(function (buffer) { return new Uint8Array(buffer); }); } /** Python `str(value or "")`: falsy values collapse to the empty string. */ function pyStr(value) { if (value === undefined || value === null || value === false || value === 0 || value === "") { return ""; } if (Array.isArray(value) && value.length === 0) { return ""; } return String(value); } /** app.py clean_text(). */ function cleanText(value, fallback, maxLength) { var limit = maxLength === undefined ? 240 : maxLength; var text = pyStr(value).trim(); return (text || (fallback === undefined ? "" : fallback)).slice(0, limit); } /** Python re.escape(), for the characters these patterns actually contain. */ function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\\-]/g, "\\$&"); } function emptyContact() { return { email: "", slack: "", teams: "" }; } function isPlainObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function hasOwn(target, key) { return Object.prototype.hasOwnProperty.call(target, key); } /** app.py normalize_contact(). */ function normalizeContact(payload) { var source = isPlainObject(payload) ? payload : {}; var contact = {}; CONTACT_FIELDS.forEach(function (field) { contact[field] = cleanText(source[field], "", 240); }); return contact; } /** app.py owner_from_payload(). */ function ownerFromPayload(payload) { var source = isPlainObject(payload) ? payload : {}; var owner = { name: cleanText(source.name || source.owner_name, "Owner", 120) }; var contact = normalizeContact(source); CONTACT_FIELDS.forEach(function (field) { owner[field] = contact[field]; }); return owner; } /** * Stable descending sort on one string key. * * Python's `sorted(..., reverse=True)` keeps equal elements in their original * order rather than reversing them. These timestamps have one-second * resolution, so ties are routine and a plain `.reverse()` would reorder * same-second activity that the Python server leaves alone. */ function sortByDesc(items, key) { return items.slice().sort(function (left, right) { var a = left[key] || ""; var b = right[key] || ""; if (a === b) return 0; return a < b ? 1 : -1; }); } function sortByAsc(items, read) { return items.slice().sort(function (left, right) { var a = read(left); var b = read(right); if (a === b) return 0; return a < b ? -1 : 1; }); } // ----------------------------------------------------- filenames and bytes /** app.py sanitize_filename(). */ function sanitizeFilename(filename) { var name = pyStr(filename) || "document"; var segments = name.split(/[\\/]/); name = segments[segments.length - 1].trim(); var dot = name.lastIndexOf("."); var stem = name; var suffix = ""; // pathlib keeps a leading dot in the stem ('.env' -> stem '.env') and reads // a trailing dot as no suffix at all ('notes.' -> stem 'notes.'). if (dot > 0 && dot < name.length - 1) { stem = name.slice(0, dot); suffix = name.slice(dot).toLowerCase(); } if (!stem) stem = "document"; var cleaned = stem.replace(/[^A-Za-z0-9._ -]+/g, "-").replace(/^[ .\-_]+|[ .\-_]+$/g, ""); return (cleaned || "document") + suffix; } /** pathlib's Path(name).suffix.lower(). */ function extensionOf(filename) { var name = pyStr(filename); var segments = name.split(/[\\/]/); name = segments[segments.length - 1]; var dot = name.lastIndexOf("."); if (dot <= 0 || dot === name.length - 1) return ""; return name.slice(dot).toLowerCase(); } function tryDecode(bytes, encoding) { try { return new TextDecoder(encoding, { fatal: true }).decode(bytes); } catch (error) { return null; } } /** * app.py decode_text_bytes(): utf-8-sig, then utf-16, then cp1252, then utf-8 * with replacement characters. * * TextDecoder("utf-8") strips a leading BOM, which is what utf-8-sig does. * Python's utf-16 codec consumes a BOM when present and assumes * little-endian when it is absent. * * One knowing divergence: WHATWG windows-1252 maps the five bytes Python's * cp1252 rejects (0x81, 0x8D, 0x8F, 0x90, 0x9D) rather than raising, so a * file Python would fall through to replacement-character utf-8 decodes here * as windows-1252 instead. */ function decodeTextBytes(bytes) { var utf8 = tryDecode(bytes, "utf-8"); if (utf8 !== null) return utf8; if (bytes.length % 2 === 0) { var bigEndian = bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff; var utf16 = tryDecode(bytes, bigEndian ? "utf-16be" : "utf-16le"); if (utf16 !== null) return utf16; } var cp1252 = tryDecode(bytes, "windows-1252"); if (cp1252 !== null) return cp1252; return new TextDecoder("utf-8").decode(bytes); } // --------------------------------------------------------------- ZIP/DOCX function readUint16(bytes, offset) { return bytes[offset] | (bytes[offset + 1] << 8); } function readUint32(bytes, offset) { return ( (bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >>> 0 ); } function findEndOfCentralDirectory(bytes) { // The record is 22 bytes plus a comment of at most 65535, so it lies within // the last 65557 bytes. Scan backwards for its signature. var earliest = Math.max(0, bytes.length - (22 + 65535)); for (var offset = bytes.length - 22; offset >= earliest; offset -= 1) { if (readUint32(bytes, offset) === 0x06054b50) return offset; } return -1; } /** * Locate one member of a ZIP archive and return its stored bytes. * * Just enough of the format to read a DOCX. Sizes come from the central * directory because a local header may defer them to a data descriptor, and * Word writes members either stored or deflated. ZIP64 is rejected rather * than guessed at; Word does not produce it for a 5 MB document. */ function findZipMember(bytes, wanted) { var eocd = findEndOfCentralDirectory(bytes); if (eocd < 0) throw badZip(); var entryCount = readUint16(bytes, eocd + 10); var directoryOffset = readUint32(bytes, eocd + 16); if (entryCount === 0xffff || directoryOffset === 0xffffffff) { throw badZip("This DOCX file uses ZIP64, which this static build cannot read."); } var cursor = directoryOffset; var decoder = new TextDecoder("utf-8"); for (var index = 0; index < entryCount; index += 1) { if (cursor + 46 > bytes.length || readUint32(bytes, cursor) !== 0x02014b50) { throw badZip(); } var method = readUint16(bytes, cursor + 10); var compressedSize = readUint32(bytes, cursor + 20); var nameLength = readUint16(bytes, cursor + 28); var extraLength = readUint16(bytes, cursor + 30); var commentLength = readUint16(bytes, cursor + 32); var localOffset = readUint32(bytes, cursor + 42); var name = decoder.decode(bytes.subarray(cursor + 46, cursor + 46 + nameLength)); if (name === wanted) { if (localOffset + 30 > bytes.length || readUint32(bytes, localOffset) !== 0x04034b50) { throw badZip(); } var localNameLength = readUint16(bytes, localOffset + 26); var localExtraLength = readUint16(bytes, localOffset + 28); var dataStart = localOffset + 30 + localNameLength + localExtraLength; if (dataStart + compressedSize > bytes.length) throw badZip(); return { method: method, data: bytes.subarray(dataStart, dataStart + compressedSize) }; } cursor += 46 + nameLength + extraLength + commentLength; } return null; } /** Python html.unescape(), narrowed to what WordprocessingML emits. */ function unescapeXml(value) { return String(value).replace( /&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|(amp|lt|gt|quot|apos|nbsp));/g, function (match, decimal, hex, named) { if (decimal) return String.fromCodePoint(parseInt(decimal, 10)); if (hex) return String.fromCodePoint(parseInt(hex, 16)); return { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " }[named]; } ); } /** app.py extract_docx_text(). */ function extractDocxText(bytes, inflateRaw) { var member = findZipMember(bytes, "word/document.xml"); if (!member) { // app.py turns zipfile's KeyError into this ValueError. throw apiError("The DOCX file does not contain document text.", 400); } var raw; if (member.method === 0) { raw = Promise.resolve(member.data); } else if (member.method === 8) { raw = inflateRaw(member.data); } else { raw = Promise.reject(badZip("This DOCX file uses an unsupported compression method.")); } return raw.then(function (xmlBytes) { var xmlText = new TextDecoder("utf-8").decode(xmlBytes); var paragraphPattern = /]*>([\s\S]*?)<\/w:p>/g; var lines = []; var paragraph; while ((paragraph = paragraphPattern.exec(xmlText)) !== null) { var runPattern = /]*>([\s\S]*?)<\/w:t>/g; var runs = []; var run; while ((run = runPattern.exec(paragraph[1])) !== null) { runs.push(unescapeXml(run[1])); } if (runs.length) lines.push(runs.join("")); } return lines .map(function (line) { return line.trim(); }) .filter(function (line) { return line.length > 0; }) .join("\n"); }); } /** app.py extract_text(). */ function extractText(filename, bytes, inflateRaw) { var extension = extensionOf(filename); if (extension === ".txt" || extension === ".md") { return Promise.resolve(decodeTextBytes(bytes).trim()); } if (extension === ".docx") { return Promise.resolve() .then(function () { return extractDocxText(bytes, inflateRaw); }) .then(function (text) { return text.trim(); }); } return Promise.reject(apiError("Unsupported document type.", 400)); } // --------------------------------------------------------------- analyzer function countWords(text) { var matches = text.match(WORD_PATTERN); return matches ? matches.length : 0; } /** app.py snippet_for(). */ function snippetFor(text, pattern) { var search = new RegExp(pattern.source, pattern.flags.replace("g", "")); var match = search.exec(text); if (!match) return ""; var start = Math.max(0, match.index - 70); var end = Math.min(text.length, match.index + match[0].length + 70); return text.slice(start, end).replace(/\s+/g, " ").trim(); } /** app.py has_section(). */ function hasSection(text, terms) { for (var index = 0; index < terms.length; index += 1) { var term = escapeRegExp(terms[index]); if (new RegExp("^\\s*(?:#+\\s*)?" + term + "\\b", "im").test(text)) return true; if (new RegExp("\\b" + term + "\\s*:", "i").test(text)) return true; } return false; } var SECTION_CHECKS = [ ["purpose", ["purpose", "objective"], "Purpose or objective"], ["scope", ["scope", "applicability"], "Scope"], ["owner", ["owner", "document owner", "responsible party"], "Owner"], ["date", ["date", "effective date", "review date"], "Date or review date"], ["risk", ["risk", "risks", "risk assessment"], "Risk section"], ["approval", ["approval", "approved by", "signature", "sign-off"], "Approval or signature"] ]; var PLACEHOLDER_CHECKS = [ ["todo", /\bTODO\b|\bTBD\b|\bTBA\b/i], ["insert", /\[(?:insert|add|fill in)[^\]]*\]|<\s*(?:insert|add|fill in)[^>]*>/i], ["blank", /_{4,}|\{\{[^}]+\}\}/] ]; var VAGUE_LANGUAGE = [ "best effort", "as needed", "reasonable", "as appropriate", "when possible", "from time to time" ]; var LONG_SENTENCE_PATTERN = /[^.!?]{260,}[.!?]/; /** app.py analyze_document(). */ function analyzeDocument(text, now) { var findings = []; var normalized = pyStr(text).trim(); var wordCount = countWords(normalized); SECTION_CHECKS.forEach(function (check) { var key = check[0]; var label = check[2]; if (!hasSection(normalized, check[1])) { findings.push({ id: "missing-" + key, severity: key === "approval" ? "high" : "medium", title: "Missing " + label.toLowerCase(), detail: "Add a clear " + label.toLowerCase() + " section or label before approval.", snippet: "" }); } }); PLACEHOLDER_CHECKS.forEach(function (check) { var pattern = check[1]; if (pattern.test(normalized)) { findings.push({ id: "placeholder-" + check[0], severity: "high", title: "Unresolved placeholder", detail: "Replace template placeholders before the document can be considered final.", snippet: snippetFor(normalized, pattern) }); } }); VAGUE_LANGUAGE.forEach(function (phrase) { var pattern = new RegExp("\\b" + escapeRegExp(phrase) + "\\b", "i"); if (pattern.test(normalized)) { findings.push({ id: "vague-" + phrase.split(" ").join("-"), severity: "medium", title: 'Vague language: "' + phrase + '"', detail: "Make the requirement measurable, accountable, or time-bound.", snippet: snippetFor(normalized, pattern) }); } }); if (wordCount < 80) { findings.push({ id: "short-document", severity: "low", title: "Very short document", detail: "Confirm the document has enough context for reviewers and approvers.", snippet: "" }); } if (LONG_SENTENCE_PATTERN.test(normalized)) { findings.push({ id: "long-sentence", severity: "low", title: "Long sentence", detail: "Break long sentences into shorter reviewable statements.", snippet: snippetFor(normalized, LONG_SENTENCE_PATTERN) }); } // app.py sorts on (severity rank, title). Compare code units rather than // locale: Python compares strings by code point, and these titles contain // quote marks that a locale-aware comparison orders differently. findings.sort(function (left, right) { if (SEVERITY_ORDER[left.severity] !== SEVERITY_ORDER[right.severity]) { return SEVERITY_ORDER[left.severity] - SEVERITY_ORDER[right.severity]; } if (left.title === right.title) return 0; return left.title < right.title ? -1 : 1; }); return { reviewed_at: now(), word_count: wordCount, findings: findings, counts: { high: findings.filter(function (item) { return item.severity === "high"; }).length, medium: findings.filter(function (item) { return item.severity === "medium"; }).length, low: findings.filter(function (item) { return item.severity === "low"; }).length } }; } // ------------------------------------------------------------------ the API /** * Build an API instance over one storage backend. * * @param {object} options * @param {object} options.store load/save the record store and uploads * @param {function} [options.now] app.py utc_now * @param {function} [options.uuidHex] app.py uuid.uuid4().hex * @param {function} [options.inflateRaw] raw DEFLATE, for DOCX */ function createApi(options) { var settings = options || {}; var store = settings.store; var now = settings.now || defaultNow; var uuidHex = settings.uuidHex || defaultUuidHex; var inflateRaw = settings.inflateRaw || defaultInflateRaw; if (!store) throw new Error("createApi requires a store."); // ------------------------------------------------------------ migrations // Ported from app.py's migrate_* family. The default objects are built // eagerly, exactly as Python builds its `defaults` dict, so each call // consumes one id from `uuidHex` whether or not the id is used. The parity // tests depend on that: they run both implementations off the same // deterministic id sequence. function migrateReviewer(reviewer) { var changed = false; var timestamp = now(); var defaults = { id: uuidHex(), name: "Reviewer", created_at: timestamp, updated_at: timestamp }; Object.keys(defaults).forEach(function (key) { if (!reviewer[key]) { reviewer[key] = defaults[key]; changed = true; } }); CONTACT_FIELDS.forEach(function (field) { if (!hasOwn(reviewer, field)) { reviewer[field] = ""; changed = true; } }); return changed; } function migrateNotification(notification) { var changed = false; var defaults = { id: uuidHex(), recipient_type: "owner", recipient_name: "Owner", recipient_contact: emptyContact(), document_id: "", document_title: "", event: "notice", message: "", created_at: now(), read_at: null }; Object.keys(defaults).forEach(function (key) { if (!hasOwn(notification, key)) { notification[key] = defaults[key]; changed = true; } }); if (!isPlainObject(notification.recipient_contact)) { notification.recipient_contact = emptyContact(); changed = true; } else { CONTACT_FIELDS.forEach(function (field) { if (!hasOwn(notification.recipient_contact, field)) { notification.recipient_contact[field] = ""; changed = true; } }); } return changed; } function migrateAssignment(assignment) { var changed = false; var timestamp = now(); var defaults = { id: uuidHex(), reviewer_id: "", reviewer_name: "Reviewer", reviewer_contact: emptyContact(), status: "pending", status_label: "Pending", assigned_at: timestamp, updated_at: timestamp, approved_at: null, approval_name: "", approval_initials: "", comments: [] }; Object.keys(defaults).forEach(function (key) { if (!hasOwn(assignment, key)) { assignment[key] = defaults[key]; changed = true; } }); var label = hasOwn(ASSIGNMENT_LABELS, assignment.status) ? ASSIGNMENT_LABELS[assignment.status] : assignment.status; if (assignment.status_label !== label) { assignment.status_label = label; changed = true; } if (!isPlainObject(assignment.reviewer_contact)) { assignment.reviewer_contact = emptyContact(); changed = true; } else { CONTACT_FIELDS.forEach(function (field) { if (!hasOwn(assignment.reviewer_contact, field)) { assignment.reviewer_contact[field] = ""; changed = true; } }); } if (!Array.isArray(assignment.comments)) { assignment.comments = []; changed = true; } return changed; } /** app.py build_legacy_activity(). */ function buildLegacyActivity(record) { var owner = isPlainObject(record.owner) ? record.owner : {}; var activity = [ { id: uuidHex(), type: "uploaded", actor: owner.name || "Owner", message: "Uploaded " + (record.filename || "document") + ".", created_at: record.created_at || now() } ]; if (record.reviewed_at) { activity.push({ id: uuidHex(), type: "review_run", actor: "System", message: "Ran the local rubric review.", created_at: record.reviewed_at }); } (Array.isArray(record.comments) ? record.comments : []).forEach(function (comment) { activity.push({ id: uuidHex(), type: "comment_added", actor: comment.author || "Reviewer", message: "Commented: " + pyStr(comment.body).slice(0, 120), created_at: comment.created_at || now() }); }); if (record.approved_at) { activity.push({ id: uuidHex(), type: "approved", actor: "Reviewer", message: "Approved the document.", created_at: record.approved_at }); } return sortByAsc(activity, function (item) { return item.created_at; }); } function migrateDocument(record) { var changed = false; var timestamp = now(); var defaults = { id: uuidHex(), filename: "document", extension: ".txt", status: "uploaded", created_at: timestamp, updated_at: timestamp, reviewed_at: null, approved_at: null, word_count: 0, findings: [], comments: [] }; Object.keys(defaults).forEach(function (key) { if (!hasOwn(record, key)) { record[key] = defaults[key]; changed = true; } }); if (!isPlainObject(record.owner)) { record.owner = ownerFromPayload({}); changed = true; } else { var owner = ownerFromPayload(record.owner); if (JSON.stringify(owner) !== JSON.stringify(record.owner)) { record.owner = owner; changed = true; } } if (!Array.isArray(record.assignments)) { record.assignments = []; changed = true; } record.assignments.forEach(function (assignment) { changed = migrateAssignment(assignment) || changed; }); if (!Array.isArray(record.activity) || record.activity.length === 0) { record.activity = buildLegacyActivity(record); changed = true; } return changed; } function migrateDb(data) { var changed = false; if (!Array.isArray(data.documents)) { data.documents = []; changed = true; } if (!Array.isArray(data.reviewers)) { data.reviewers = []; changed = true; } if (!Array.isArray(data.notifications)) { data.notifications = []; changed = true; } data.reviewers.forEach(function (reviewer) { changed = migrateReviewer(reviewer) || changed; }); data.notifications.forEach(function (notification) { changed = migrateNotification(notification) || changed; }); data.documents.forEach(function (record) { changed = migrateDocument(record) || changed; }); return changed; } /** app.py load_db(): read, migrate, and write back when migration ran. */ function loadDb() { return Promise.resolve(store.load()).then(function (loaded) { var data = isPlainObject(loaded) ? loaded : { documents: [], reviewers: [], notifications: [] }; if (migrateDb(data)) { return Promise.resolve(store.save(data)).then(function () { return data; }); } return data; }); } function saveDb(data) { return Promise.resolve(store.save(data)); } // --------------------------------------------------------- record shapes /** app.py add_activity(). */ function addActivity(record, eventType, actor, message, timestamp) { if (!Array.isArray(record.activity)) record.activity = []; record.activity.push({ id: uuidHex(), type: eventType, actor: cleanText(actor, "System", 120), message: cleanText(message, "", 500), created_at: timestamp || now() }); } /** app.py assignment_counts(). */ function assignmentCounts(record) { var counts = { pending: 0, commented: 0, changes_requested: 0, approved: 0 }; (record.assignments || []).forEach(function (assignment) { var status = assignment.status || "pending"; counts[status] = (hasOwn(counts, status) ? counts[status] : 0) + 1; }); return counts; } /** app.py recalculate_document_status(). */ function recalculateDocumentStatus(record) { var assignments = record.assignments || []; if (!assignments.length) return; var statuses = assignments.map(function (assignment) { return assignment.status || "pending"; }); if (statuses.indexOf("changes_requested") !== -1) { record.status = "changes_requested"; record.approved_at = null; return; } var allApproved = statuses.every(function (status) { return status === "approved"; }); if (allApproved) { record.status = "approved"; record.approved_at = record.approved_at || now(); return; } record.status = "in_review"; record.approved_at = null; } /** app.py public_assignment(). */ function publicAssignment(assignment) { return { id: assignment.id, reviewer_id: assignment.reviewer_id, reviewer_name: assignment.reviewer_name, reviewer_contact: assignment.reviewer_contact || emptyContact(), status: assignment.status, status_label: hasOwn(ASSIGNMENT_LABELS, assignment.status) ? ASSIGNMENT_LABELS[assignment.status] : assignment.status, assigned_at: assignment.assigned_at, updated_at: assignment.updated_at, approved_at: assignment.approved_at === undefined ? null : assignment.approved_at, approval_name: assignment.approval_name || "", approval_initials: assignment.approval_initials || "", comments: assignment.comments || [] }; } /** app.py public_document(). */ function publicDocument(record, includeText) { var view = { id: record.id, filename: record.filename, extension: record.extension, status: record.status, status_label: hasOwn(STATUS_LABELS, record.status) ? STATUS_LABELS[record.status] : record.status, created_at: record.created_at, updated_at: record.updated_at, reviewed_at: record.reviewed_at === undefined ? null : record.reviewed_at, approved_at: record.approved_at === undefined ? null : record.approved_at, word_count: record.word_count === undefined ? 0 : record.word_count, findings: record.findings || [], comments: record.comments || [], owner: isPlainObject(record.owner) ? record.owner : ownerFromPayload({}), assignments: (record.assignments || []).map(publicAssignment), assignment_counts: assignmentCounts(record), activity: sortByDesc(record.activity || [], "created_at") }; if (includeText) { view.text = record.text || ""; } return view; } /** app.py public_reviewer(). */ function publicReviewer(reviewer) { return { id: reviewer.id, name: reviewer.name, email: reviewer.email || "", slack: reviewer.slack || "", teams: reviewer.teams || "", created_at: reviewer.created_at, updated_at: reviewer.updated_at }; } /** app.py public_notification(). */ function publicNotification(notification) { return { id: notification.id, recipient_type: notification.recipient_type, recipient_name: notification.recipient_name, recipient_contact: notification.recipient_contact || emptyContact(), document_id: notification.document_id, document_title: notification.document_title, event: notification.event, message: notification.message, created_at: notification.created_at, read_at: notification.read_at === undefined ? null : notification.read_at }; } function findDocument(data, documentId) { return data.documents.filter(function (record) { return record.id === documentId; })[0] || null; } function findReviewer(data, reviewerId) { return data.reviewers.filter(function (reviewer) { return reviewer.id === reviewerId; })[0] || null; } function findAssignment(record, reviewerId) { return (record.assignments || []).filter(function (assignment) { return assignment.reviewer_id === reviewerId; })[0] || null; } /** app.py reviewer_payload(). */ function reviewerPayload(payload, existing) { var reviewer = {}; if (existing) { Object.keys(existing).forEach(function (key) { reviewer[key] = existing[key]; }); } reviewer.name = cleanText(payload.name, "", 120); if (!reviewer.name) throw apiError("Reviewer name is required.", 400); var contact = normalizeContact(payload); CONTACT_FIELDS.forEach(function (field) { reviewer[field] = contact[field]; }); return reviewer; } /** app.py build_assignment(). */ function buildAssignment(reviewer) { var timestamp = now(); var assignment = { id: uuidHex(), reviewer_id: reviewer.id, reviewer_name: reviewer.name, reviewer_contact: normalizeContact(reviewer), status: "pending", status_label: "Pending", assigned_at: timestamp, updated_at: timestamp, approved_at: null, approval_name: "", approval_initials: "", comments: [] }; migrateAssignment(assignment); return assignment; } /** app.py create_notification(). */ function createNotification(data, record, recipientType, recipientName, recipientContact, event, message) { var notification = { id: uuidHex(), recipient_type: recipientType, recipient_name: cleanText(recipientName, "Recipient", 120), recipient_contact: normalizeContact(recipientContact), document_id: record.id, document_title: record.filename, event: event, message: cleanText(message, "", 1000), created_at: now(), read_at: null }; if (!Array.isArray(data.notifications)) data.notifications = []; data.notifications.push(notification); addActivity( record, "notification", "System", "Queued notification for " + notification.recipient_name + ": " + notification.message, notification.created_at ); return notification; } // ------------------------------------------------------------- responses function jsonResponse(payload, status) { return { status: status || 200, headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify(payload) }; } function errorResponse(message, status) { return jsonResponse({ error: message }, status || 400); } function notFound(message) { return errorResponse(message || "Not found.", 404); } function parseJsonBody(body) { // app.py read_json_body(): an empty body is {}, malformed JSON is a 400. if (body === null || body === undefined || body === "") return {}; if (isPlainObject(body)) return body; try { var parsed = JSON.parse(body); return isPlainObject(parsed) ? parsed : {}; } catch (error) { throw apiError("Request body must be valid JSON.", 400); } } // -------------------------------------------------------------- handlers function handleDocumentsIndex() { return loadDb().then(function (data) { var documents = data.documents.map(function (record) { return publicDocument(record, false); }); return jsonResponse({ documents: sortByDesc(documents, "updated_at") }); }); } function handleReviewersIndex() { return loadDb().then(function (data) { var reviewers = data.reviewers.map(publicReviewer); return jsonResponse({ reviewers: sortByAsc(reviewers, function (reviewer) { return String(reviewer.name).toLowerCase(); }) }); }); } function handleNotificationsIndex() { return loadDb().then(function (data) { var notifications = sortByDesc(data.notifications.map(publicNotification), "created_at"); var unread = notifications.filter(function (item) { return !item.read_at; }).length; return jsonResponse({ notifications: notifications, unread_count: unread }); }); } function handleDocumentDetail(documentId) { return loadDb().then(function (data) { var record = findDocument(data, documentId); if (!record) return notFound("Document not found."); return jsonResponse(publicDocument(record, true)); }); } function handleDownload(documentId) { return loadDb().then(function (data) { var record = findDocument(data, documentId); if (!record) return notFound("Document not found."); return Promise.resolve(store.getUpload(record.stored_name)).then(function (bytes) { if (!bytes) return notFound("Original upload is missing."); var extension = extensionOf(record.filename); return { status: 200, headers: { "Content-Type": DOWNLOAD_TYPES[extension] || "application/octet-stream", "Content-Disposition": 'attachment; filename="' + record.filename + '"' }, bytes: bytes, filename: record.filename }; }); }); } /** app.py handle_upload(). */ function handleUpload(upload) { if (!upload || !upload.bytes) throw apiError("Upload cannot be empty.", 400); // app.py measures Content-Length, so its 5 MB ceiling covers the whole // multipart request - the file plus a few hundred bytes of part headers. // There is no request here, so the file itself is what gets measured, // which makes this marginally more permissive for a file within a few // hundred bytes of the limit. if (upload.bytes.length > MAX_UPLOAD_BYTES) { throw apiError("Upload is too large. Maximum size is 5 MB.", 400); } var fields = upload.fields || {}; var filename = sanitizeFilename(upload.filename); var extension = extensionOf(filename); if (ALLOWED_EXTENSIONS.indexOf(extension) === -1) { return Promise.resolve( errorResponse("Unsupported file type. Upload TXT, MD, or DOCX.", 415) ); } if (upload.bytes.length === 0) throw apiError("Uploaded file is empty.", 400); return extractText(filename, upload.bytes, inflateRaw).then(function (text) { if (!text) throw apiError("No readable text was found in the document.", 400); var documentId = uuidHex(); var storedName = documentId + extension; return Promise.resolve(store.putUpload(storedName, upload.bytes)).then(function () { var timestamp = now(); var owner = ownerFromPayload({ name: fields.owner_name || fields.owner, email: fields.owner_email, slack: fields.owner_slack, teams: fields.owner_teams }); var record = { id: documentId, filename: filename, stored_name: storedName, extension: extension, status: "uploaded", created_at: timestamp, updated_at: timestamp, reviewed_at: null, approved_at: null, word_count: countWords(text), text: text, findings: [], comments: [], owner: owner, assignments: [], activity: [] }; addActivity(record, "uploaded", owner.name, "Uploaded " + filename + ".", timestamp); return loadDb().then(function (data) { data.documents.push(record); return saveDb(data).then(function () { return jsonResponse(publicDocument(record, true), 201); }); }); }); }); } /** app.py handle_review(). */ function handleReview(documentId) { return loadDb().then(function (data) { var record = findDocument(data, documentId); if (!record) return notFound("Document not found."); var result = analyzeDocument(record.text || "", now); record.findings = result.findings; record.reviewed_at = result.reviewed_at; record.word_count = result.word_count; if (record.status !== "approved") record.status = "in_review"; record.updated_at = now(); addActivity(record, "review_run", "System", "Ran the local rubric review.", result.reviewed_at); return saveDb(data).then(function () { return jsonResponse(publicDocument(record, true)); }); }); } /** app.py handle_comment(). */ function handleComment(documentId, body) { var payload = parseJsonBody(body); var text = cleanText(payload.body, "", 2000); var author = cleanText(payload.author, "Reviewer", 80); if (!text) throw apiError("Comment body is required.", 400); return loadDb().then(function (data) { var record = findDocument(data, documentId); if (!record) return notFound("Document not found."); var timestamp = now(); if (!Array.isArray(record.comments)) record.comments = []; record.comments.push({ id: uuidHex(), author: author, body: text, created_at: timestamp }); record.updated_at = timestamp; addActivity(record, "comment_added", author, "Commented: " + text.slice(0, 120), timestamp); return saveDb(data).then(function () { return jsonResponse(publicDocument(record, true)); }); }); } /** app.py handle_status(). */ function handleStatus(documentId, status) { return loadDb().then(function (data) { var record = findDocument(data, documentId); if (!record) return notFound("Document not found."); var owner = isPlainObject(record.owner) ? record.owner : {}; record.status = status; record.updated_at = now(); if (status === "approved") { record.approved_at = now(); addActivity(record, "approved", owner.name || "Owner", "Owner approved the document."); } else { record.approved_at = null; addActivity(record, "changes_requested", owner.name || "Owner", "Owner requested changes."); } return saveDb(data).then(function () { return jsonResponse(publicDocument(record, true)); }); }); } /** app.py handle_create_reviewer(). */ function handleCreateReviewer(body) { var payload = parseJsonBody(body); var timestamp = now(); var reviewer = reviewerPayload(payload, null); reviewer.id = uuidHex(); reviewer.created_at = timestamp; reviewer.updated_at = timestamp; return loadDb().then(function (data) { data.reviewers.push(reviewer); return saveDb(data).then(function () { return jsonResponse(publicReviewer(reviewer), 201); }); }); } /** app.py handle_update_reviewer(). */ function handleUpdateReviewer(reviewerId, body) { return loadDb().then(function (data) { var reviewer = findReviewer(data, reviewerId); if (!reviewer) return notFound("Reviewer not found."); var updated = reviewerPayload(parseJsonBody(body), reviewer); Object.keys(updated).forEach(function (key) { reviewer[key] = updated[key]; }); reviewer.updated_at = now(); return saveDb(data).then(function () { return jsonResponse(publicReviewer(reviewer)); }); }); } /** app.py handle_delete_reviewer(). */ function handleDeleteReviewer(reviewerId) { return loadDb().then(function (data) { var before = data.reviewers.length; data.reviewers = data.reviewers.filter(function (reviewer) { return reviewer.id !== reviewerId; }); if (data.reviewers.length === before) return notFound("Reviewer not found."); return saveDb(data).then(function () { return jsonResponse({ ok: true }); }); }); } /** app.py handle_assign_reviewers(). */ function handleAssignReviewers(documentId, body) { var payload = parseJsonBody(body); var requested = payload.reviewer_ids === undefined ? [] : payload.reviewer_ids; if (!Array.isArray(requested)) throw apiError("reviewer_ids must be a list.", 400); var reviewerIds = []; requested.forEach(function (item) { var id = pyStr(item); if (!id.trim()) return; if (reviewerIds.indexOf(id) === -1) reviewerIds.push(id); }); return loadDb().then(function (data) { var record = findDocument(data, documentId); if (!record) return notFound("Document not found."); var reviewersById = {}; data.reviewers.forEach(function (reviewer) { reviewersById[reviewer.id] = reviewer; }); var missing = reviewerIds.filter(function (id) { return !hasOwn(reviewersById, id); }); if (missing.length) { throw apiError("One or more selected reviewers no longer exists.", 400); } var existingById = {}; (record.assignments || []).forEach(function (assignment) { existingById[assignment.reviewer_id] = assignment; }); var assignments = []; var created = []; reviewerIds.forEach(function (id) { if (hasOwn(existingById, id)) { assignments.push(existingById[id]); } else { var assignment = buildAssignment(reviewersById[id]); assignments.push(assignment); created.push(assignment); } }); record.assignments = assignments; var timestamp = now(); record.updated_at = timestamp; if (reviewerIds.length) { record.status = "in_review"; var names = assignments.map(function (assignment) { return assignment.reviewer_name; }).join(", "); addActivity(record, "reviewers_assigned", record.owner.name, "Assigned reviewers: " + names + ".", timestamp); } else { record.status = record.reviewed_at ? "in_review" : "uploaded"; record.approved_at = null; addActivity(record, "reviewers_assigned", record.owner.name, "Cleared reviewer assignments.", timestamp); } created.forEach(function (assignment) { var message = "Please review " + record.filename + " in SimpleDocReview. " + "Open the local app, select the document, and respond as " + assignment.reviewer_name + "."; createNotification( data, record, "reviewer", assignment.reviewer_name, assignment.reviewer_contact, "review_assigned", message ); }); recalculateDocumentStatus(record); return saveDb(data).then(function () { return jsonResponse(publicDocument(record, true)); }); }); } /** * app.py require_assignment(). * * Note the status quirk this preserves: a missing document reaches the * client as a 400 here, not the 404 that GET /api/documents/ returns, * because upstream raises ValueError rather than replying directly. */ function requireAssignment(documentId, payload) { var reviewerId = cleanText(payload.reviewer_id, "", 80); if (!reviewerId) throw apiError("Reviewer is required.", 400); return loadDb().then(function (data) { var record = findDocument(data, documentId); if (!record) throw apiError("Document not found.", 400); var assignment = findAssignment(record, reviewerId); if (!assignment) throw apiError("Reviewer is not assigned to this document.", 400); return { data: data, record: record, assignment: assignment }; }); } /** app.py handle_reviewer_comment(). */ function handleReviewerComment(documentId, body) { var payload = parseJsonBody(body); return requireAssignment(documentId, payload).then(function (context) { var data = context.data; var record = context.record; var assignment = context.assignment; var text = cleanText(payload.body, "", 2000); var signature = cleanText(payload.signature, assignment.reviewer_name, 120); if (!text) throw apiError("Comment body is required.", 400); var timestamp = now(); var comment = { id: uuidHex(), author: signature, reviewer_id: assignment.reviewer_id, body: text, created_at: timestamp }; if (!Array.isArray(assignment.comments)) assignment.comments = []; assignment.comments.push(comment); if (assignment.status !== "approved" && assignment.status !== "changes_requested") { assignment.status = "commented"; } assignment.updated_at = timestamp; migrateAssignment(assignment); if (!Array.isArray(record.comments)) record.comments = []; record.comments.push(comment); record.updated_at = timestamp; addActivity(record, "reviewer_comment", signature, "Commented: " + text.slice(0, 120), timestamp); createNotification( data, record, "owner", record.owner.name, record.owner, "reviewer_comment", signature + " commented on " + record.filename + ": " + text.slice(0, 180) ); recalculateDocumentStatus(record); return saveDb(data).then(function () { return jsonResponse(publicDocument(record, true)); }); }); } /** app.py handle_reviewer_approve(). */ function handleReviewerApprove(documentId, body) { var payload = parseJsonBody(body); return requireAssignment(documentId, payload).then(function (context) { var data = context.data; var record = context.record; var assignment = context.assignment; var signature = cleanText(payload.signature, "", 120); if (!signature) throw apiError("Approval name or initials are required.", 400); var wasFinal = record.status === "approved"; var timestamp = now(); assignment.status = "approved"; assignment.approval_name = signature; assignment.approval_initials = cleanText(payload.initials || signature, "", 24); assignment.approved_at = timestamp; assignment.updated_at = timestamp; migrateAssignment(assignment); record.updated_at = timestamp; addActivity(record, "reviewer_approved", signature, "Approved as " + signature + ".", timestamp); recalculateDocumentStatus(record); if (record.status === "approved" && !wasFinal) { addActivity( record, "approved", "System", "All assigned reviewers approved the document.", record.approved_at ); } createNotification( data, record, "owner", record.owner.name, record.owner, "reviewer_approved", signature + " approved " + record.filename + "." ); return saveDb(data).then(function () { return jsonResponse(publicDocument(record, true)); }); }); } /** app.py handle_reviewer_request_changes(). */ function handleReviewerRequestChanges(documentId, body) { var payload = parseJsonBody(body); return requireAssignment(documentId, payload).then(function (context) { var data = context.data; var record = context.record; var assignment = context.assignment; var text = cleanText(payload.body, "", 2000); var signature = cleanText(payload.signature, assignment.reviewer_name, 120); if (!text) throw apiError("A change request note is required.", 400); var timestamp = now(); assignment.status = "changes_requested"; assignment.approved_at = null; assignment.approval_name = ""; assignment.approval_initials = ""; assignment.updated_at = timestamp; var comment = { id: uuidHex(), author: signature, reviewer_id: assignment.reviewer_id, body: text, created_at: timestamp }; if (!Array.isArray(assignment.comments)) assignment.comments = []; assignment.comments.push(comment); migrateAssignment(assignment); if (!Array.isArray(record.comments)) record.comments = []; record.comments.push(comment); record.updated_at = timestamp; recalculateDocumentStatus(record); addActivity( record, "reviewer_changes_requested", signature, "Requested changes: " + text.slice(0, 120), timestamp ); createNotification( data, record, "owner", record.owner.name, record.owner, "reviewer_changes_requested", signature + " requested changes on " + record.filename + ": " + text.slice(0, 180) ); return saveDb(data).then(function () { return jsonResponse(publicDocument(record, true)); }); }); } /** app.py handle_notification_read(). */ function handleNotificationRead(notificationId) { return loadDb().then(function (data) { var notification = data.notifications.filter(function (item) { return item.id === notificationId; })[0]; if (!notification) return notFound("Notification not found."); notification.read_at = notification.read_at || now(); return saveDb(data).then(function () { return jsonResponse(publicNotification(notification)); }); }); } // ----------------------------------------------------------------- router // Mirrors app.py's do_GET / do_POST / do_PUT / do_DELETE dispatch, in the // same order, so an unmatched path produces the same 404 body. function pathParts(pathname) { return String(pathname) .split("?")[0] .split("#")[0] .split("/") .filter(function (part) { return part.length > 0; }) .map(function (part) { return decodeURIComponent(part); }); } function route(method, pathname, body) { var parts = pathParts(pathname); var verb = String(method || "GET").toUpperCase(); if (verb === "GET") { if (parts.length === 2 && parts[0] === "api") { if (parts[1] === "documents") return handleDocumentsIndex(); if (parts[1] === "reviewers") return handleReviewersIndex(); if (parts[1] === "notifications") return handleNotificationsIndex(); } if (parts.length === 3 && parts[0] === "api" && parts[1] === "documents") { return handleDocumentDetail(parts[2]); } if (parts.length === 4 && parts[0] === "api" && parts[1] === "documents" && parts[3] === "download") { return handleDownload(parts[2]); } return Promise.resolve(notFound()); } if (verb === "POST") { if (parts.length === 2 && parts[0] === "api" && parts[1] === "upload") { return handleUpload(body); } if (parts.length === 2 && parts[0] === "api" && parts[1] === "reviewers") { return handleCreateReviewer(body); } if (parts.length === 4 && parts[0] === "api" && parts[1] === "notifications" && parts[3] === "read") { return handleNotificationRead(parts[2]); } if (parts.length === 4 && parts[0] === "api" && parts[1] === "documents") { var documentId = parts[2]; var action = parts[3]; if (action === "review") return handleReview(documentId); if (action === "comments") return handleComment(documentId, body); if (action === "approve") return handleStatus(documentId, "approved"); if (action === "request-changes") return handleStatus(documentId, "changes_requested"); if (action === "assign-reviewers") return handleAssignReviewers(documentId, body); if (action === "reviewer-comment") return handleReviewerComment(documentId, body); if (action === "reviewer-approve") return handleReviewerApprove(documentId, body); if (action === "reviewer-request-changes") return handleReviewerRequestChanges(documentId, body); } return Promise.resolve(notFound()); } if (verb === "PUT") { if (parts.length === 3 && parts[0] === "api" && parts[1] === "reviewers") { return handleUpdateReviewer(parts[2], body); } return Promise.resolve(notFound()); } if (verb === "DELETE") { if (parts.length === 3 && parts[0] === "api" && parts[1] === "reviewers") { return handleDeleteReviewer(parts[2]); } return Promise.resolve(notFound()); } return Promise.resolve(notFound()); } // One request at a time. app.py serializes on the file it rewrites; here // every handler is a read-modify-write against the same store, and the app // fires three GETs in parallel on load, so interleaving them would let one // response overwrite another's changes. var queue = Promise.resolve(); function handle(method, pathname, body) { var result = queue.then(function () { return Promise.resolve() .then(function () { return route(method, pathname, body); }) .catch(function (error) { if (error && error.apiStatus) { return errorResponse(error.message, error.apiStatus); } throw error; }); }); // Keep the chain alive even when a request rejects, and never let a // rejection here surface as an unhandled promise. queue = result.then(function () { return undefined; }, function () { return undefined; }); return result; } return { handle: handle, isApiPath: function (pathname) { var parts = pathParts(pathname); return parts[0] === "api"; }, // Exposed for the parity tests and for debugging in the browser console. internals: { analyzeDocument: function (text) { return analyzeDocument(text, now); }, extractText: function (filename, bytes) { return extractText(filename, bytes, inflateRaw); }, sanitizeFilename: sanitizeFilename, decodeTextBytes: decodeTextBytes, cleanText: cleanText, countWords: countWords, publicDocument: publicDocument, loadDb: loadDb } }; } return { createApi: createApi, MAX_UPLOAD_BYTES: MAX_UPLOAD_BYTES, ALLOWED_EXTENSIONS: ALLOWED_EXTENSIONS, STATUS_LABELS: STATUS_LABELS, ASSIGNMENT_LABELS: ASSIGNMENT_LABELS, defaultNow: defaultNow, defaultUuidHex: defaultUuidHex, defaultInflateRaw: defaultInflateRaw, sanitizeFilename: sanitizeFilename, decodeTextBytes: decodeTextBytes, cleanText: cleanText }; });