netops/netops-todo-node/lib/store.js
Jon Vanvik bdeb6254b9 Dagsplan: flytt/navn/deling + plan-badge, kategorier, feltmodus
Dagsplan kan navngis og flyttes til annen dato (slår sammen uten datatap).
Delbar som hovedregel (shared-flagg default true) med toggle for privat;
andres delte planer kan ses skrivebeskyttet via "Plan for"-velger.
getPlanFor håndhever tilgang. Per-oppgave plan-badge (dag + person) via
planIndex filtrert per viewer.

Egendefinerte kategorier (categories.json) i tillegg til de 12 innebygde;
dynamisk validering, deles via WS, addes fra kategori-nedtrekket.

Feltmodus (body.field-mode): større berøringsmål for mobil, auto på <640px.

plans.json migreres array -> {name,items,shared} ved oppstart (bakoverkompat).
26 integrasjonstester (6 nye), alle grønne. README + CLAUDE.md oppdatert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 13:59:19 +02:00

537 lines
21 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'];
// Innebygde kategorier (id, navn, farge, beskrivelse). Egendefinerte kategorier
// lagres i data/categories.json og legges på toppen av disse ved kjøring.
const DEFAULT_CATEGORIES = [
{ id: 'L1', name: 'L1 Fysisk', color: '#ff9f43', desc: 'Kabling, fiber, patch, SFP, rack' },
{ id: 'L2', name: 'L2 Switching', color: '#4fc3f7', desc: 'VLAN, STP, MAC, trunking' },
{ id: 'L3', name: 'L3 Ruting', color: '#7fb8ff', desc: 'OSPF, ISIS, statisk, VRF' },
{ id: 'L4', name: 'L4 Lastbalansering', color: '#bb86fc', desc: 'HAProxy, NGINX, F5, BIG-IP' },
{ id: 'BGP', name: 'BGP', color: '#4ade80', desc: 'iBGP / eBGP, policies, RR' },
{ id: 'PEER', name: 'Peering / IX', color: '#26de81', desc: 'IXP, private peering, PeeringDB' },
{ id: 'SEC', name: 'Firewall / Security', color: '#ff5370', desc: 'ACL, firewall, IPS, audit' },
{ id: 'NMS', name: 'Overvåking / NMS', color: '#ffd93d', desc: 'LibreNMS, Grafana, alerts' },
{ id: 'AUTO', name: 'Automatisering', color: '#00d2d3', desc: 'Ansible, scripts, CI/CD' },
{ id: 'DOC', name: 'Dokumentasjon', color: '#a0a0a0', desc: 'Diagrammer, IPAM, runbooks' },
{ id: 'INC', name: 'Incident / On-call', color: '#ff6b6b', desc: 'Feilsøking, postmortem' },
{ id: 'PROJ', name: 'Prosjekt / Plan', color: '#8172B3', desc: 'Design, anskaffelse, oppgraderinger' },
];
// Farger som tildeles egendefinerte kategorier automatisk når ingen er valgt.
const CATEGORY_PALETTE = ['#5eead4', '#f0abfc', '#fca5a5', '#fcd34d', '#93c5fd', '#86efac', '#fdba74', '#c4b5fd'];
const VALID_CATEGORIES = DEFAULT_CATEGORIES.map(c => c.id);
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 validCats = (opts.validCategories && opts.validCategories.length) ? opts.validCategories : VALID_CATEGORIES;
const cat = inp.category ?? ex.category ?? 'L3';
t.category = validCats.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.categoriesFile = path.join(dataDir, 'categories.json');
this.tasks = [];
this.activity = [];
this.knownUsers = new Set();
// { [user]: { [YYYY-MM-DD]: { name, items: [taskId,...] } } } — per bruker, ordnet
this.plans = {};
this.customCategories = []; // egendefinerte, på toppen av DEFAULT_CATEGORIES
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();
this._loadCategories();
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, og migrer gammelt array-format
// ({date: [ids]}) til nytt objektformat ({date: {name, items}}).
_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)) {
const val = byDate[date];
const valid = Array.isArray(val) || (val && typeof val === 'object' && Array.isArray(val.items));
if (date < cutoff || !valid) { delete byDate[date]; continue; }
if (Array.isArray(val)) byDate[date] = { name: '', items: val }; // migrer
}
if (Object.keys(byDate).length === 0) delete this.plans[user];
}
}
_loadCategories() {
const raw = this._readJson(this.categoriesFile, []);
const builtinIds = new Set(DEFAULT_CATEGORIES.map(c => c.id));
const seen = new Set(builtinIds);
this.customCategories = [];
for (const c of Array.isArray(raw) ? raw : []) {
if (!c || typeof c !== 'object') continue;
const id = typeof c.id === 'string' ? c.id.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 12) : '';
const name = sanitizeStr(c.name, 40);
if (!id || !name || seen.has(id)) continue;
const color = typeof c.color === 'string' && /^#[0-9a-fA-F]{6}$/.test(c.color)
? c.color : CATEGORY_PALETTE[this.customCategories.length % CATEGORY_PALETTE.length];
seen.add(id);
this.customCategories.push({ id, name, color, desc: sanitizeStr(c.desc, 120), custom: true });
}
}
// 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); }
_persistCategories() { return this._persist(this.categoriesFile, this.customCategories); }
// ---------- Kategorier (innebygde + egendefinerte) ----------
categories() { return [...DEFAULT_CATEGORIES, ...this.customCategories]; }
categoryIds() { return this.categories().map(c => c.id); }
addCategory({ name, color } = {}, user) {
const cleanName = sanitizeStr(name, 40);
if (!cleanName) throw new Error('Kategorinavn er påkrevd');
// Generer en id fra navnet (store bokstaver/tall), gjør den unik.
let base = cleanName.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 10) || 'CAT';
let id = base, n = 2;
const taken = new Set(this.categoryIds());
while (taken.has(id)) id = base.slice(0, 9) + (n++);
const col = typeof color === 'string' && /^#[0-9a-fA-F]{6}$/.test(color)
? color : CATEGORY_PALETTE[this.customCategories.length % CATEGORY_PALETTE.length];
const cat = { id, name: cleanName, color: col, desc: '', custom: true };
this.customCategories.push(cat);
this._persistCategories();
if (user) this.addActivity(user, 'category', { title: cleanName });
return cat;
}
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, validCategories: this.categoryIds() });
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, { validCategories: this.categoryIds() });
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) ----------
// Normaliser lagret entry til { name, items, shared } (tåler gammelt array-format).
// shared default true: planer er som hovedregel synlige for alle.
_planEntry(user, date) {
const raw = this.plans[user] && this.plans[user][date];
if (Array.isArray(raw)) return { name: '', items: raw, shared: true };
if (raw && typeof raw === 'object') {
return {
name: typeof raw.name === 'string' ? raw.name : '',
items: Array.isArray(raw.items) ? raw.items : [],
shared: raw.shared !== false,
};
}
return { name: '', items: [], shared: true };
}
getPlan(user, date) {
const e = this._planEntry(user, date);
// Filtrer bort oppgaver som er slettet siden planen ble lagret.
return { name: e.name, items: e.items.filter(id => this.get(id)), shared: e.shared };
}
// Hent en annens plan med tilgangssjekk: kun egen plan eller en som er delt.
// Returnerer null hvis planen er privat og viewer ikke er eier.
getPlanFor(viewer, owner, date) {
if (owner !== viewer && !this._planEntry(owner, date).shared) return null;
return this.getPlan(owner, date);
}
// name/shared === undefined beholder eksisterende verdi.
setPlan(user, date, ids, name, shared) {
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;
}
const cur = this._planEntry(user, date);
const cleanName = name === undefined ? cur.name : sanitizeStr(name, 80);
const isShared = shared === undefined ? cur.shared : shared !== false;
if (!this.plans[user]) this.plans[user] = {};
if (valid.length || cleanName) this.plans[user][date] = { name: cleanName, items: valid, shared: isShared };
else delete this.plans[user][date];
if (Object.keys(this.plans[user]).length === 0) delete this.plans[user];
this._persistPlans();
return { name: cleanName, items: valid, shared: isShared };
}
setPlanName(user, date, name) {
const e = this._planEntry(user, date);
return this.setPlan(user, date, e.items, name, e.shared);
}
setPlanShared(user, date, shared) {
const e = this._planEntry(user, date);
return this.setPlan(user, date, e.items, e.name, shared);
}
// Flytt hele planen til en annen dato. Slår sammen med en evt. eksisterende
// plan på måldatoen (uten å miste data), og tømmer kildedatoen.
movePlan(user, fromDate, toDate) {
if (fromDate === toDate) return this.getPlan(user, toDate);
const src = this._planEntry(user, fromDate);
const dst = this._planEntry(user, toDate);
const merged = [...dst.items];
for (const id of src.items) if (!merged.includes(id)) merged.push(id);
const name = dst.name || src.name;
const shared = this.plans[user] && this.plans[user][toDate] ? dst.shared : src.shared;
if (this.plans[user]) delete this.plans[user][fromDate];
return this.setPlan(user, toDate, merged, name, shared); // persisterer
}
// Hvem har planlagt hvilke oppgaver på hvilke datoer — for badges på kort/rad.
// Filtrert per viewer: delte planer + viewerens egne private.
planIndex(viewer) {
const idx = {};
for (const user of Object.keys(this.plans)) {
for (const date of Object.keys(this.plans[user])) {
const e = this._planEntry(user, date);
if (!e.shared && user !== viewer) continue;
for (const id of e.items) {
if (!this.get(id)) continue;
(idx[id] ||= []).push({ user, date, name: e.name });
}
}
}
return idx;
}
_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 e = this._planEntry(user, date);
const filtered = e.items.filter(x => x !== taskId);
if (filtered.length === e.items.length) continue;
changed = true;
if (filtered.length === 0 && !e.name) delete byDate[date];
else byDate[date] = { name: e.name, items: filtered };
}
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, validCategories: this.categoryIds() }));
}
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 };