'use strict'; /** * NetOps To-Do — datalager. * * Holder oppgaver + aktivitetslogg i minnet og persisterer til JSON-filer i * data/. Skriving er atomisk (tmp-fil + rename) og serialisert gjennom en * promise-kjede, så parallelle requests ikke ødelegger filene. Antakelsen er * én node-prosess per datakatalog (samme antakelse som flock i PHP-versjonen). */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const VALID_STATUS = ['todo', 'progress', 'blocked', 'done']; const VALID_PRIORITIES = ['P1', 'P2', 'P3', 'P4']; const VALID_CATEGORIES = ['L1', 'L2', 'L3', 'L4', 'BGP', 'PEER', 'SEC', 'NMS', 'AUTO', 'DOC', 'INC', 'PROJ']; const STATUS_LABELS = { todo: 'Å gjøre', progress: 'Pågår', blocked: 'Blokkert', done: 'Ferdig' }; const MAX_ACTIVITY = 400; const MAX_COMMENTS = 200; class NotFoundError extends Error {} class ConflictError extends Error { constructor(msg, task) { super(msg); this.task = task; } } function uid(prefix) { return prefix + '_' + crypto.randomBytes(5).toString('hex'); } function sanitizeStr(v, max = 500) { if (typeof v !== 'string') return ''; // Fjern kontrolltegn (bortsett fra \n \r \t) v = v.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '').trim(); return v.length > max ? v.slice(0, max) : v; } function sanitizeTags(v) { if (!Array.isArray(v)) return []; const out = []; for (const t of v) { const s = sanitizeStr(t, 40); if (s) out.push(s); if (out.length >= 20) break; } return out; } function sanitizeComments(v) { if (!Array.isArray(v)) return []; const out = []; for (const c of v) { if (!c || typeof c !== 'object') continue; const text = sanitizeStr(c.text, 1000); if (!text) continue; out.push({ id: typeof c.id === 'string' ? c.id.replace(/[^a-z0-9_]/gi, '').slice(0, 32) || uid('c') : uid('c'), by: sanitizeStr(c.by, 64) || 'anon', at: typeof c.at === 'string' && !isNaN(Date.parse(c.at)) ? c.at : new Date().toISOString(), text, }); if (out.length >= MAX_COMMENTS) break; } return out; } function normalizeTask(input, user, existing = null, opts = {}) { const inp = input && typeof input === 'object' ? input : {}; const ex = existing || {}; const t = {}; t.id = ex.id || (typeof inp.id === 'string' ? inp.id.replace(/[^a-z0-9_]/gi, '').slice(0, 32) : '') || uid('t'); t.title = sanitizeStr(inp.title ?? ex.title ?? '', 200); t.desc = sanitizeStr(inp.desc ?? ex.desc ?? '', 4000); const cat = inp.category ?? ex.category ?? 'L3'; t.category = VALID_CATEGORIES.includes(cat) ? cat : 'L3'; const prio = inp.priority ?? ex.priority ?? 'P3'; t.priority = VALID_PRIORITIES.includes(prio) ? prio : 'P3'; const stat = inp.status ?? ex.status ?? 'todo'; t.status = VALID_STATUS.includes(stat) ? stat : 'todo'; t.estHours = inp.estHours != null ? Math.max(0, Math.min(999, Number(inp.estHours) || 0)) : (ex.estHours ?? 0); t.location = sanitizeStr(inp.location ?? ex.location ?? '', 120); const dl = inp.deadline ?? ex.deadline ?? ''; t.deadline = typeof dl === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dl) ? dl : ''; t.tags = sanitizeTags(inp.tags ?? ex.tags ?? []); const owner = inp.owner != null ? sanitizeStr(inp.owner, 64) : (ex.owner ?? ''); t.owner = owner; t.comments = opts.importComments ? sanitizeComments(inp.comments) : (ex.comments || []); // Audit const now = new Date().toISOString(); t.created = ex.created || now; t.createdBy = ex.createdBy || user; t.updated = now; t.updatedBy = user; t.v = (ex.v || 0) + 1; // Fullført-tidsstempel const wasDone = ex.status === 'done'; if (t.status === 'done' && !wasDone) { t.completed = now.slice(0, 10); t.completedBy = user; } else if (t.status !== 'done') { t.completed = null; t.completedBy = null; } else { t.completed = ex.completed || now.slice(0, 10); t.completedBy = ex.completedBy || user; } return t; } // Beskriver hva som endret seg, til aktivitetsloggen. function diffDetail(before, after) { const detail = []; if (before.status !== after.status) { detail.push(`${STATUS_LABELS[before.status]} → ${STATUS_LABELS[after.status]}`); } if (before.priority !== after.priority) detail.push(`${before.priority} → ${after.priority}`); if ((before.owner || '') !== (after.owner || '')) detail.push(`eier → ${after.owner || 'utildelt'}`); if (before.deadline !== after.deadline) detail.push(`frist → ${after.deadline || 'ingen'}`); const silent = []; if (before.title !== after.title) silent.push('tittel'); if (before.desc !== after.desc) silent.push('beskrivelse'); if (before.category !== after.category) silent.push('kategori'); if (before.location !== after.location) silent.push('lokasjon'); if (before.estHours !== after.estHours) silent.push('estimat'); if (JSON.stringify(before.tags) !== JSON.stringify(after.tags)) silent.push('tags'); if (silent.length) detail.push(silent.join(', ')); return detail; } const PLAN_RETENTION_DAYS = 30; class Store { constructor(dataDir) { this.dataDir = dataDir; this.tasksFile = path.join(dataDir, 'tasks.json'); this.activityFile = path.join(dataDir, 'activity.json'); this.usersFile = path.join(dataDir, 'users.json'); this.plansFile = path.join(dataDir, 'plans.json'); this.tasks = []; this.activity = []; this.knownUsers = new Set(); this.plans = {}; // { [user]: { [YYYY-MM-DD]: [taskId, ...] } } — per bruker, ordnet this._writeChain = Promise.resolve(); } load() { fs.mkdirSync(this.dataDir, { recursive: true }); this.tasks = this._readJson(this.tasksFile, []); this.activity = this._readJson(this.activityFile, []); for (const u of this._readJson(this.usersFile, [])) this.knownUsers.add(u); this.plans = this._readJson(this.plansFile, {}); this._prunePlans(); for (const t of this.tasks) { for (const k of ['owner', 'createdBy', 'updatedBy', 'completedBy']) { if (t[k]) this.knownUsers.add(t[k]); } if (!t.v) t.v = 1; if (!Array.isArray(t.comments)) t.comments = []; } } // Dropp dagsplaner eldre enn PLAN_RETENTION_DAYS så plans.json ikke vokser i det uendelige. _prunePlans() { const cutoff = new Date(Date.now() - PLAN_RETENTION_DAYS * 86400000).toISOString().slice(0, 10); for (const user of Object.keys(this.plans)) { const byDate = this.plans[user]; if (!byDate || typeof byDate !== 'object') { delete this.plans[user]; continue; } for (const date of Object.keys(byDate)) { if (date < cutoff || !Array.isArray(byDate[date])) delete byDate[date]; } if (Object.keys(byDate).length === 0) delete this.plans[user]; } } // Leser JSON og validerer mot fallback-typen (array vs. objekt). _readJson(file, fallback) { try { const data = JSON.parse(fs.readFileSync(file, 'utf8')); if (Array.isArray(fallback)) return Array.isArray(data) ? data : fallback; if (fallback && typeof fallback === 'object') { return data && typeof data === 'object' && !Array.isArray(data) ? data : fallback; } return data ?? fallback; } catch { return fallback; } } // Atomisk, serialisert skriv: tmp-fil i samme katalog, så rename. _persist(file, data) { this._writeChain = this._writeChain.then(async () => { const tmp = file + '.tmp'; await fs.promises.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8'); await fs.promises.rename(tmp, file); }).catch(err => { console.error(`Klarte ikke å skrive ${file}:`, err.message); }); return this._writeChain; } _persistTasks() { return this._persist(this.tasksFile, this.tasks); } _persistActivity() { return this._persist(this.activityFile, this.activity); } _persistPlans() { return this._persist(this.plansFile, this.plans); } rememberUser(user) { if (user && !this.knownUsers.has(user)) { this.knownUsers.add(user); this._persist(this.usersFile, [...this.knownUsers].sort()); } } addActivity(user, action, { taskId = null, title = '', detail = [] } = {}) { const entry = { id: uid('a'), at: new Date().toISOString(), user, action, taskId, title: sanitizeStr(title, 200), detail, }; this.activity.unshift(entry); if (this.activity.length > MAX_ACTIVITY) this.activity.length = MAX_ACTIVITY; this._persistActivity(); return entry; } list() { return this.tasks; } get(id) { return this.tasks.find(t => t.id === id) || null; } create(input, user) { // importComments: lar "angre sletting" og import gjenopprette kommentarer const task = normalizeTask(input, user, null, { importComments: true }); if (!task.title) throw new Error('title er påkrevd'); if (this.get(task.id)) task.id = uid('t'); // unngå kollisjon ved gjenbrukt id (angre-slett) this.tasks.push(task); this.rememberUser(user); if (task.owner) this.rememberUser(task.owner); this._persistTasks(); const activity = this.addActivity(user, 'create', { taskId: task.id, title: task.title }); return { task, activity }; } update(id, input, user, opts = {}) { const existing = this.get(id); if (!existing) throw new NotFoundError('Fant ikke oppgaven'); // Optimistisk låsing: klienten sender versjonen den redigerte fra. if (input && input.v != null && Number(input.v) !== existing.v) { throw new ConflictError('Oppgaven er endret av noen andre', existing); } const updated = normalizeTask(input, user, existing); const idx = this.tasks.indexOf(existing); this.tasks[idx] = updated; this.rememberUser(user); if (updated.owner) this.rememberUser(updated.owner); // Valgfri kommentar knyttet til endringen (typisk ved statusbytte: hva ble // gjort / hva blokkerer). Lagres som vanlig kommentar, men merket kind:status. const noteText = sanitizeStr(opts.comment, 1000); if (noteText) { const comment = { id: uid('c'), by: user, at: new Date().toISOString(), text: noteText, kind: 'status', status: updated.status, }; updated.comments.push(comment); if (updated.comments.length > MAX_COMMENTS) updated.comments.shift(); } this._persistTasks(); let action = 'update'; if (existing.status !== 'done' && updated.status === 'done') action = 'done'; else if (existing.status === 'done' && updated.status !== 'done') action = 'reopen'; const detail = diffDetail(existing, updated); if (noteText) detail.push('💬 ' + noteText.slice(0, 80)); const activity = this.addActivity(user, action, { taskId: id, title: updated.title, detail, }); return { task: updated, activity }; } remove(id, user) { const existing = this.get(id); if (!existing) throw new NotFoundError('Fant ikke oppgaven'); this.tasks = this.tasks.filter(t => t.id !== id); this._persistTasks(); this._scrubFromPlans(id); const activity = this.addActivity(user, 'delete', { taskId: id, title: existing.title }); return { task: existing, activity }; } // ---------- Dagsplan (per bruker, per dato) ---------- getPlan(user, date) { const ids = (this.plans[user] && this.plans[user][date]) || []; // Filtrer bort oppgaver som er slettet siden planen ble lagret. return ids.filter(id => this.get(id)); } setPlan(user, date, ids) { const valid = []; const seen = new Set(); for (const raw of Array.isArray(ids) ? ids : []) { if (typeof raw !== 'string') continue; const id = raw.replace(/[^a-z0-9_]/gi, '').slice(0, 32); if (!id || seen.has(id) || !this.get(id)) continue; seen.add(id); valid.push(id); if (valid.length >= 200) break; } if (!this.plans[user]) this.plans[user] = {}; if (valid.length) this.plans[user][date] = valid; else delete this.plans[user][date]; if (Object.keys(this.plans[user]).length === 0) delete this.plans[user]; this._persistPlans(); return valid; } _scrubFromPlans(taskId) { let changed = false; for (const user of Object.keys(this.plans)) { const byDate = this.plans[user]; for (const date of Object.keys(byDate)) { const before = byDate[date].length; byDate[date] = byDate[date].filter(x => x !== taskId); if (byDate[date].length !== before) changed = true; if (byDate[date].length === 0) delete byDate[date]; } if (Object.keys(byDate).length === 0) delete this.plans[user]; } if (changed) this._persistPlans(); } addComment(id, text, user) { const task = this.get(id); if (!task) throw new NotFoundError('Fant ikke oppgaven'); const clean = sanitizeStr(text, 1000); if (!clean) throw new Error('Tom kommentar'); const comment = { id: uid('c'), by: user, at: new Date().toISOString(), text: clean }; task.comments.push(comment); if (task.comments.length > MAX_COMMENTS) task.comments.shift(); this.rememberUser(user); this._persistTasks(); const activity = this.addActivity(user, 'comment', { taskId: id, title: task.title, detail: [clean.slice(0, 80)], }); return { task, comment, activity }; } deleteComment(id, commentId, user) { const task = this.get(id); if (!task) throw new NotFoundError('Fant ikke oppgaven'); const comment = task.comments.find(c => c.id === commentId); if (!comment) throw new NotFoundError('Fant ikke kommentaren'); if (comment.by !== user) throw new Error('Kan bare slette egne kommentarer'); task.comments = task.comments.filter(c => c.id !== commentId); this._persistTasks(); return { task }; } replaceAll(arr, user, action = 'import') { if (!Array.isArray(arr)) throw new Error('Forventet array'); const out = []; for (const raw of arr) { if (!raw || typeof raw !== 'object' || !raw.title) continue; out.push(normalizeTask(raw, user, null, { importComments: true })); } this.tasks = out; this._persistTasks(); const activity = this.addActivity(user, action, { detail: [`${out.length} oppgaver`] }); return { count: out.length, activity }; } seed(seedArr, user) { if (this.tasks.length > 0) throw new ConflictError('Data finnes allerede', null); return this.replaceAll(seedArr, user, 'seed'); } } module.exports = { Store, NotFoundError, ConflictError, normalizeTask, VALID_CATEGORIES, VALID_PRIORITIES, VALID_STATUS };