Dagsplan: tredje visning ved siden av tavle/tabell. To-kolonne planlegger der man plukker oppgaver fra backlog og bygger en ordnet plan for dagen (drag-sortering, kapasitet i timer, datovelger). Per bruker, per dato, lagret i plans.json; synkes mellom egne faner via plan-WS-melding. Kommentar ved statusbytte: når en oppgave markeres ferdig/blokkert (knapp, kanban-drag eller modal) tilbys en valgfri kommentar som festes til oppgaven (kind:status) og legges i aktivitetsloggen — atomisk i ett update-kall. 20 integrasjonstester (8 nye), alle grønne. README + CLAUDE.md oppdatert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
400 lines
14 KiB
JavaScript
400 lines
14 KiB
JavaScript
'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 };
|