Spaces:
Running
Running
Eric Putnam
Claude Opus 5 (1M context)
feat: deploy simpledocreview as a browser-only static space
3d968d9 Download sdr-api-core.js from Hoctar77/SimpleDocReview: direct link, hf CLI and curl.
- Browser
- Download file 60.8 kB
-
https://huggingface.co/spaces/Hoctar77/SimpleDocReview/resolve/main/sdr-api-core.js
- Command line
-
hf download hf://spaces/Hoctar77/SimpleDocReview/sdr-api-core.js
-
curl -L -o sdr-api-core.js https://huggingface.co/spaces/Hoctar77/SimpleDocReview/resolve/main/sdr-api-core.js
60.8 kB
| /** | |
| * 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 = /<w:p\b[^>]*>([\s\S]*?)<\/w:p>/g; | |
| var lines = []; | |
| var paragraph; | |
| while ((paragraph = paragraphPattern.exec(xmlText)) !== null) { | |
| var runPattern = /<w:t\b[^>]*>([\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/<id> 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 | |
| }; | |
| }); | |