315 lines
11 KiB
JavaScript
315 lines
11 KiB
JavaScript
// Dashy SQLite layer — uses sql.js (SQLite compiled to WASM)
|
|
// Database is initialized once, seeded from SEED_TASKS / SEED_AUDIT / USERS,
|
|
// persisted to localStorage on every write, and exposed as window.DashyDB.
|
|
|
|
window.DashyDB = (function () {
|
|
const LS_KEY = 'dashy.db.v1';
|
|
let db = null;
|
|
let SQL = null;
|
|
const listeners = new Set();
|
|
let ready = false;
|
|
const readyWaiters = [];
|
|
|
|
// -- schema -----------------------------------------------------------
|
|
const SCHEMA = `
|
|
CREATE TABLE users (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
hue INTEGER NOT NULL,
|
|
initials TEXT NOT NULL,
|
|
email TEXT,
|
|
phone TEXT,
|
|
photo TEXT,
|
|
password_hash TEXT,
|
|
account_type TEXT NOT NULL DEFAULT 'standard' CHECK (account_type IN ('admin','standard')),
|
|
created_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE tasks (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
description TEXT,
|
|
assignee_id TEXT NOT NULL REFERENCES users(id),
|
|
added_by TEXT NOT NULL,
|
|
priority TEXT NOT NULL CHECK (priority IN ('low','med','high')),
|
|
source TEXT NOT NULL CHECK (source IN ('manual','imessage','email','automation')),
|
|
status TEXT NOT NULL DEFAULT 'open',
|
|
added_at TEXT NOT NULL,
|
|
due_at TEXT,
|
|
reminder_at TEXT
|
|
);
|
|
CREATE INDEX idx_tasks_assignee ON tasks(assignee_id);
|
|
CREATE INDEX idx_tasks_status ON tasks(status);
|
|
|
|
CREATE TABLE task_tags (
|
|
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
tag TEXT NOT NULL,
|
|
PRIMARY KEY (task_id, tag)
|
|
);
|
|
|
|
CREATE TABLE task_notes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
author_id TEXT NOT NULL,
|
|
body TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE audit_log (
|
|
id TEXT PRIMARY KEY,
|
|
at TEXT NOT NULL,
|
|
actor TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
summary TEXT NOT NULL,
|
|
target TEXT
|
|
);
|
|
CREATE INDEX idx_audit_at ON audit_log(at);
|
|
|
|
CREATE TABLE sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
device TEXT NOT NULL,
|
|
location TEXT,
|
|
last_active TEXT NOT NULL
|
|
);
|
|
`;
|
|
|
|
function bumpListeners() { listeners.forEach(fn => { try { fn(); } catch(e){} }); }
|
|
|
|
function persist() {
|
|
try {
|
|
const bytes = db.export();
|
|
const b64 = btoa(String.fromCharCode(...bytes));
|
|
localStorage.setItem(LS_KEY, b64);
|
|
} catch(e) { console.warn('DashyDB persist failed', e); }
|
|
bumpListeners();
|
|
}
|
|
|
|
function loadFromLS() {
|
|
const b64 = localStorage.getItem(LS_KEY);
|
|
if (!b64) return null;
|
|
const bin = atob(b64);
|
|
const arr = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
|
return arr;
|
|
}
|
|
|
|
function seed() {
|
|
db.exec('BEGIN');
|
|
USERS.forEach(u => {
|
|
const isAdmin = u.id === 'rod' || u.id === 'ayron';
|
|
db.run(
|
|
'INSERT INTO users (id, name, role, hue, initials, email, phone, password_hash, account_type) VALUES (?,?,?,?,?,?,?,?,?)',
|
|
[u.id, u.name, u.role, u.hue, u.initials, u.id + '@murchison-auto.co', '+64 27 555 0184',
|
|
'pbkdf2$' + Math.random().toString(36).slice(2, 18), isAdmin ? 'admin' : 'standard']
|
|
);
|
|
});
|
|
SEED_TASKS.forEach(t => {
|
|
db.run(
|
|
'INSERT INTO tasks (id, title, description, assignee_id, added_by, priority, source, status, added_at) VALUES (?,?,?,?,?,?,?,?,?)',
|
|
[t.id, t.title, t.description || '', t.assignee, t.addedBy, t.priority, t.source, t.status || 'open', t.addedAt]
|
|
);
|
|
(t.tags || []).forEach(tag => {
|
|
db.run('INSERT INTO task_tags (task_id, tag) VALUES (?,?)', [t.id, tag]);
|
|
});
|
|
});
|
|
SEED_AUDIT.forEach(a => {
|
|
db.run(
|
|
'INSERT INTO audit_log (id, at, actor, action, summary, target) VALUES (?,?,?,?,?,?)',
|
|
[a.id, a.at, a.actor, a.action, a.summary, a.target]
|
|
);
|
|
});
|
|
// sample notes
|
|
db.run("INSERT INTO task_notes (task_id, author_id, body, created_at) VALUES ('t11','lani','Customer wants the work done before Mother''s Day weekend.','2026-05-08T09:14:00')");
|
|
db.run("INSERT INTO sessions (user_id, device, location, last_active) VALUES ('rod','MacBook · Chrome','Christchurch, NZ','2026-05-08T10:30:00')");
|
|
db.run("INSERT INTO sessions (user_id, device, location, last_active) VALUES ('rod','iPhone · Safari','Christchurch, NZ','2026-05-08T08:15:00')");
|
|
db.exec('COMMIT');
|
|
}
|
|
|
|
async function init() {
|
|
SQL = await window.initSqlJs({
|
|
locateFile: f => 'https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/' + f
|
|
});
|
|
|
|
const existing = loadFromLS();
|
|
if (existing) {
|
|
db = new SQL.Database(existing);
|
|
// migration: add account_type if missing
|
|
try {
|
|
const cols = query("PRAGMA table_info(users)").map(c => c.name);
|
|
if (!cols.includes('account_type')) {
|
|
db.exec("ALTER TABLE users ADD COLUMN account_type TEXT NOT NULL DEFAULT 'standard'");
|
|
db.run("UPDATE users SET account_type = 'admin' WHERE id IN ('rod','ayron')");
|
|
persist();
|
|
}
|
|
} catch(e) { console.warn('migration failed', e); }
|
|
} else {
|
|
db = new SQL.Database();
|
|
db.exec(SCHEMA);
|
|
seed();
|
|
persist();
|
|
}
|
|
ready = true;
|
|
readyWaiters.splice(0).forEach(fn => fn());
|
|
bumpListeners();
|
|
}
|
|
|
|
function whenReady() {
|
|
if (ready) return Promise.resolve();
|
|
return new Promise(r => readyWaiters.push(r));
|
|
}
|
|
|
|
// -- query helpers ----------------------------------------------------
|
|
function rowsFrom(stmt) {
|
|
const out = [];
|
|
while (stmt.step()) out.push(stmt.getAsObject());
|
|
stmt.free();
|
|
return out;
|
|
}
|
|
|
|
function query(sql, params = []) {
|
|
if (!db) return [];
|
|
const stmt = db.prepare(sql);
|
|
if (params.length) stmt.bind(params);
|
|
return rowsFrom(stmt);
|
|
}
|
|
|
|
function exec(sql, params = []) {
|
|
if (!db) return;
|
|
db.run(sql, params);
|
|
persist();
|
|
}
|
|
|
|
// -- domain ops -------------------------------------------------------
|
|
function listTasks() {
|
|
const rows = query(`
|
|
SELECT t.*, GROUP_CONCAT(tt.tag, '|') AS tags_csv
|
|
FROM tasks t
|
|
LEFT JOIN task_tags tt ON tt.task_id = t.id
|
|
GROUP BY t.id
|
|
ORDER BY t.added_at DESC
|
|
`);
|
|
return rows.map(r => ({
|
|
id: r.id, title: r.title, description: r.description,
|
|
assignee: r.assignee_id, addedBy: r.added_by,
|
|
priority: r.priority, source: r.source, status: r.status,
|
|
addedAt: r.added_at,
|
|
tags: r.tags_csv ? r.tags_csv.split('|') : []
|
|
}));
|
|
}
|
|
|
|
function listAudit() {
|
|
return query('SELECT id, at, actor, action, summary, target FROM audit_log ORDER BY at DESC');
|
|
}
|
|
|
|
function listUsers() {
|
|
return query('SELECT * FROM users ORDER BY rowid ASC');
|
|
}
|
|
|
|
function moveTask(taskId, toUserId, by) {
|
|
exec('UPDATE tasks SET assignee_id = ? WHERE id = ?', [toUserId, taskId]);
|
|
addAudit({
|
|
actor: by, action: 'task_moved',
|
|
summary: 'Moved task to ' + (USERS.find(u=>u.id===toUserId)||{name:toUserId}).name,
|
|
target: taskId
|
|
});
|
|
}
|
|
|
|
function setPriority(taskId, p) {
|
|
exec('UPDATE tasks SET priority = ? WHERE id = ?', [p, taskId]);
|
|
}
|
|
|
|
function createTask(t) {
|
|
exec(
|
|
'INSERT INTO tasks (id, title, description, assignee_id, added_by, priority, source, status, added_at) VALUES (?,?,?,?,?,?,?,?,?)',
|
|
[t.id, t.title, t.description || '', t.assignee, t.addedBy, t.priority, t.source || 'manual', t.status || 'open', t.addedAt]
|
|
);
|
|
(t.tags || []).forEach(tag => {
|
|
db.run('INSERT INTO task_tags (task_id, tag) VALUES (?,?)', [t.id, tag]);
|
|
});
|
|
persist();
|
|
}
|
|
|
|
function addAudit(row) {
|
|
const id = 'a_' + Date.now() + '_' + Math.random().toString(36).slice(2,5);
|
|
exec(
|
|
'INSERT INTO audit_log (id, at, actor, action, summary, target) VALUES (?,?,?,?,?,?)',
|
|
[id, row.at || new Date().toISOString(), row.actor, row.action, row.summary, row.target || null]
|
|
);
|
|
}
|
|
|
|
function updateUser(userId, edits) {
|
|
const fields = []; const vals = [];
|
|
if (edits.name !== undefined) { fields.push('name = ?'); vals.push(edits.name); }
|
|
if (edits.role !== undefined) { fields.push('role = ?'); vals.push(edits.role); }
|
|
if (edits.photo !== undefined) { fields.push('photo = ?'); vals.push(edits.photo); }
|
|
if (edits.account_type !== undefined) { fields.push('account_type = ?'); vals.push(edits.account_type); }
|
|
if (!fields.length) return;
|
|
vals.push(userId);
|
|
exec('UPDATE users SET ' + fields.join(', ') + ' WHERE id = ?', vals);
|
|
}
|
|
|
|
function createUser(u) {
|
|
const id = u.id || (u.name.toLowerCase().replace(/[^a-z]/g,'').slice(0,8) + '_' + Math.random().toString(36).slice(2,5));
|
|
const initials = (u.name.split(' ').map(s=>s[0]).join('') || 'U').slice(0,2).toUpperCase();
|
|
const hue = u.hue || Math.floor(Math.random() * 360);
|
|
exec(
|
|
'INSERT INTO users (id, name, role, hue, initials, email, phone, account_type, password_hash) VALUES (?,?,?,?,?,?,?,?,?)',
|
|
[id, u.name, u.role || 'Team member', hue, initials,
|
|
u.email || (id + '@murchison-auto.co'), u.phone || '',
|
|
u.account_type || 'standard', 'pbkdf2$' + Math.random().toString(36).slice(2,18)]
|
|
);
|
|
return id;
|
|
}
|
|
|
|
function deleteUser(userId) {
|
|
// reassign any tasks to ROD before delete
|
|
exec("UPDATE tasks SET assignee_id = 'rod' WHERE assignee_id = ?", [userId]);
|
|
exec('DELETE FROM users WHERE id = ?', [userId]);
|
|
}
|
|
|
|
function tableNames() {
|
|
return query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").map(r => r.name);
|
|
}
|
|
|
|
function tableInfo(name) {
|
|
return query(`PRAGMA table_info(${name})`);
|
|
}
|
|
|
|
function rowCount(name) {
|
|
const r = query(`SELECT COUNT(*) AS c FROM ${name}`);
|
|
return r[0] ? r[0].c : 0;
|
|
}
|
|
|
|
function exportFile() {
|
|
const bytes = db.export();
|
|
return new Blob([bytes], { type: 'application/x-sqlite3' });
|
|
}
|
|
|
|
function reset() {
|
|
localStorage.removeItem(LS_KEY);
|
|
db = new SQL.Database();
|
|
db.exec(SCHEMA);
|
|
seed();
|
|
persist();
|
|
}
|
|
|
|
function rawExec(sql) {
|
|
// returns array of {columns, values} for SELECTs, or empty for writes
|
|
const res = db.exec(sql);
|
|
persist();
|
|
return res;
|
|
}
|
|
|
|
function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
|
|
|
|
return {
|
|
init, whenReady, query, exec, rawExec,
|
|
listTasks, listAudit, listUsers,
|
|
createTask, moveTask, setPriority, updateUser, createUser, deleteUser, addAudit,
|
|
tableNames, tableInfo, rowCount, exportFile, reset, subscribe,
|
|
get isReady() { return ready; },
|
|
};
|
|
})();
|