import Database from 'better-sqlite3'; import path from 'node:path'; import fs from 'node:fs'; const DATA_DIR = process.env.DATA_DIR || './data'; fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(path.join(DATA_DIR, 'media'), { recursive: true }); const db = new Database(path.join(DATA_DIR, 'shivao.db')); db.pragma('journal_mode = WAL'); db.pragma('synchronous = NORMAL'); db.exec(` -- ===== Users + Licenses (multi-tenant SaaS) ===== CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, name TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login INTEGER ); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); CREATE TABLE IF NOT EXISTS licenses ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, plan TEXT NOT NULL DEFAULT 'free', status TEXT NOT NULL DEFAULT 'active', started_at INTEGER NOT NULL, expires_at INTEGER, asaas_subscription_id TEXT, updated_at INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_licenses_user ON licenses(user_id); -- ===== Tabelas de dados (originalmente single-tenant, agora com user_id) ===== CREATE TABLE IF NOT EXISTS state ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL DEFAULT 1, data TEXT NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(user_id), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS media ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL DEFAULT 1, parent_id TEXT, parent_type TEXT, kind TEXT NOT NULL, mime TEXT NOT NULL, size INTEGER NOT NULL, filename TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_media_parent ON media(parent_id); CREATE INDEX IF NOT EXISTS idx_media_user ON media(user_id); CREATE TABLE IF NOT EXISTS anchor_session ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL DEFAULT 1, active INTEGER NOT NULL DEFAULT 0, boat_name TEXT, anchor_lat REAL, anchor_lng REAL, radius INTEGER, started_at INTEGER, last_heartbeat INTEGER, last_lat REAL, last_lng REAL, last_distance REAL, alarm_fired INTEGER DEFAULT 0, UNIQUE(user_id), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS alarm_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL DEFAULT 1, ts INTEGER NOT NULL, type TEXT NOT NULL, payload TEXT, sent TEXT, failed TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS shares ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL DEFAULT 1, boat_name TEXT, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, revoked INTEGER DEFAULT 0, zones TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_shares_expires ON shares(expires_at); CREATE INDEX IF NOT EXISTS idx_shares_user ON shares(user_id); CREATE TABLE IF NOT EXISTS share_positions ( id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT NOT NULL, lat REAL NOT NULL, lng REAL NOT NULL, speed REAL DEFAULT 0, ts INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_sp_token_ts ON share_positions(token, ts DESC); CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, action TEXT NOT NULL, entity TEXT, entity_id TEXT, summary TEXT, ip TEXT ); CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts DESC); `); // migration: add zones column if missing try { const cols = db.prepare("PRAGMA table_info(shares)").all(); if (!cols.some(c => c.name === 'zones')) { db.exec('ALTER TABLE shares ADD COLUMN zones TEXT'); } } catch (e) { /* ignore */ } // ===== Migração multi-tenant: adicionar user_id em tabelas existentes ===== // Idempotente: roda toda startup, só ALTER se coluna não existir function ensureUserIdColumn(table) { try { const cols = db.prepare(`PRAGMA table_info(${table})`).all(); if (!cols.some(c => c.name === 'user_id')) { db.exec(`ALTER TABLE ${table} ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1`); console.log(`[migration] added user_id to ${table}`); } } catch (e) { console.warn(`[migration] ${table}:`, e.message); } } ['state', 'media', 'anchor_session', 'alarm_log', 'shares', 'audit_log'].forEach(ensureUserIdColumn); // Garante user default (id=1, Karlão) — donos de dados pré-multi-tenant function ensureDefaultUser() { const existing = db.prepare('SELECT id FROM users WHERE id = 1').get(); if (existing) return; // Senha temporária — Karlão troca via /api/auth/me PATCH ou via UI quando logar pela 1ª vez // bcryptjs hash de 'ChangeMe2026!' com cost 10 const placeholderHash = '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'; // 'changeme' const now = Date.now(); db.prepare(`INSERT INTO users (id, email, password_hash, name, created_at, updated_at) VALUES (1, ?, ?, ?, ?, ?)`) .run('karlao@outlook.com', placeholderHash, 'Karlão (default)', now, now); // Licença Captain (todas features) pro user default — gratuita pra sempre, é o dono do servidor db.prepare(`INSERT INTO licenses (user_id, plan, status, started_at, expires_at, updated_at) VALUES (1, 'captain', 'active', ?, NULL, ?)`) .run(now, now); console.log('[migration] default user (id=1, karlao@outlook.com) created with captain plan'); } ensureDefaultUser(); // ===== Multi-tenant helpers (Users + Licenses) ===== export function createUser(email, passwordHash, name) { const now = Date.now(); const info = db.prepare('INSERT INTO users (email, password_hash, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)') .run(email.toLowerCase().trim(), passwordHash, name || null, now, now); // Toda conta nova começa free db.prepare(`INSERT INTO licenses (user_id, plan, status, started_at, expires_at, updated_at) VALUES (?, 'free', 'active', ?, NULL, ?)`) .run(info.lastInsertRowid, now, now); return info.lastInsertRowid; } export function findUserByEmail(email) { return db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase().trim()); } export function findUserById(id) { return db.prepare('SELECT id, email, name, created_at, last_login FROM users WHERE id = ?').get(id); } export function updateLastLogin(id) { db.prepare('UPDATE users SET last_login = ?, updated_at = ? WHERE id = ?').run(Date.now(), Date.now(), id); } export function getActiveLicense(userId) { // Pega licença mais recente ativa (se expires_at NULL ou no futuro) const now = Date.now(); return db.prepare(`SELECT * FROM licenses WHERE user_id = ? AND status = 'active' AND (expires_at IS NULL OR expires_at > ?) ORDER BY started_at DESC LIMIT 1`).get(userId, now); } export function setLicense(userId, plan, expiresAt, asaasSubId) { const now = Date.now(); // Desativa licenças anteriores db.prepare(`UPDATE licenses SET status = 'replaced', updated_at = ? WHERE user_id = ? AND status = 'active'`).run(now, userId); db.prepare(`INSERT INTO licenses (user_id, plan, status, started_at, expires_at, asaas_subscription_id, updated_at) VALUES (?, ?, 'active', ?, ?, ?, ?)`) .run(userId, plan, now, expiresAt || null, asaasSubId || null, now); } // ---- State (per-user JSON blob) ---- export function getState(userId) { const row = db.prepare('SELECT data, updated_at FROM state WHERE user_id = ?').get(userId); if (!row) return { data: null, updated_at: 0 }; return { data: JSON.parse(row.data), updated_at: row.updated_at }; } export function setState(userId, data) { const json = JSON.stringify(data); const now = Date.now(); db.prepare(` INSERT INTO state (user_id, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at `).run(userId, json, now); return now; } // ---- Media metadata (per-user) ---- export function insertMedia(userId, m) { db.prepare(` INSERT INTO media (id, user_id, parent_id, parent_type, kind, mime, size, filename, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(m.id, userId, m.parent_id || null, m.parent_type || null, m.kind, m.mime, m.size, m.filename, m.created_at || Date.now()); } export function listMedia(userId) { return db.prepare('SELECT * FROM media WHERE user_id = ? ORDER BY created_at DESC').all(userId); } export function getMedia(userId, id) { return db.prepare('SELECT * FROM media WHERE user_id = ? AND id = ?').get(userId, id); } export function deleteMedia(userId, id) { return db.prepare('DELETE FROM media WHERE user_id = ? AND id = ?').run(userId, id); } // ---- Anchor session (per-user) ---- export function getAnchor(userId) { return db.prepare('SELECT * FROM anchor_session WHERE user_id = ?').get(userId); } export function setAnchor(userId, a) { const cur = getAnchor(userId); if (cur) { db.prepare(`UPDATE anchor_session SET active=?, boat_name=?, anchor_lat=?, anchor_lng=?, radius=?, started_at=?, last_heartbeat=?, last_lat=?, last_lng=?, last_distance=?, alarm_fired=? WHERE user_id=?`) .run(a.active ? 1 : 0, a.boat_name, a.anchor_lat, a.anchor_lng, a.radius, a.started_at, a.last_heartbeat, a.last_lat, a.last_lng, a.last_distance, a.alarm_fired ? 1 : 0, userId); } else { db.prepare(`INSERT INTO anchor_session (user_id, active, boat_name, anchor_lat, anchor_lng, radius, started_at, last_heartbeat, last_lat, last_lng, last_distance, alarm_fired) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) .run(userId, a.active ? 1 : 0, a.boat_name, a.anchor_lat, a.anchor_lng, a.radius, a.started_at, a.last_heartbeat, a.last_lat, a.last_lng, a.last_distance, a.alarm_fired ? 1 : 0); } } export function clearAnchor(userId) { db.prepare('UPDATE anchor_session SET active=0, alarm_fired=0 WHERE user_id=?').run(userId); } export function updateHeartbeat(userId, lat, lng, dist) { db.prepare('UPDATE anchor_session SET last_heartbeat=?, last_lat=?, last_lng=?, last_distance=? WHERE user_id=?') .run(Date.now(), lat, lng, dist, userId); } export function setAlarmFired(userId, fired) { db.prepare('UPDATE anchor_session SET alarm_fired=? WHERE user_id=?').run(fired ? 1 : 0, userId); } // Pra dead-man switch (busca todos anchor sessions ativos pra checar) export function listActiveAnchors() { return db.prepare('SELECT * FROM anchor_session WHERE active = 1').all(); } // ---- Alarm log (per-user) ---- export function logAlarm(userId, type, payload, sent, failed) { db.prepare('INSERT INTO alarm_log (user_id, ts, type, payload, sent, failed) VALUES (?, ?, ?, ?, ?, ?)') .run(userId, Date.now(), type, JSON.stringify(payload || {}), JSON.stringify(sent || []), JSON.stringify(failed || [])); } export function recentAlarms(userId, limit = 50) { return db.prepare('SELECT * FROM alarm_log WHERE user_id = ? ORDER BY ts DESC LIMIT ?').all(userId, limit); } // ---- Shares (per-user) ---- export function createShare(userId, token, boatName, expiresAt, zones) { db.prepare('INSERT INTO shares (token, user_id, boat_name, created_at, expires_at, zones) VALUES (?, ?, ?, ?, ?, ?)') .run(token, userId, boatName, Date.now(), expiresAt, zones ? JSON.stringify(zones) : null); } export function updateShareZones(userId, token, zones) { // Garante que share pertence ao user (não permite editar share alheio) db.prepare('UPDATE shares SET zones = ? WHERE token = ? AND user_id = ?').run(zones ? JSON.stringify(zones) : null, token, userId); } export function getShare(token) { // Público — não filtra por user (qualquer um com o token vê) return db.prepare('SELECT * FROM shares WHERE token = ?').get(token); } export function listActiveShares(userId) { const now = Date.now(); return db.prepare('SELECT * FROM shares WHERE user_id = ? AND revoked = 0 AND expires_at > ? ORDER BY created_at DESC').all(userId, now); } export function revokeShare(userId, token) { return db.prepare('UPDATE shares SET revoked = 1 WHERE token = ? AND user_id = ?').run(token, userId); } export function addSharePosition(token, lat, lng, speed) { db.prepare('INSERT INTO share_positions (token, lat, lng, speed, ts) VALUES (?, ?, ?, ?, ?)') .run(token, lat, lng, speed || 0, Date.now()); db.prepare(`DELETE FROM share_positions WHERE token = ? AND id NOT IN (SELECT id FROM share_positions WHERE token = ? ORDER BY ts DESC LIMIT 500)`).run(token, token); } export function getSharePositions(token, limit = 500) { return db.prepare('SELECT lat, lng, speed, ts FROM share_positions WHERE token = ? ORDER BY ts ASC LIMIT ?').all(token, limit); } export function cleanupExpiredShares() { const now = Date.now(); // delete positions of shares that expired more than 7 days ago const cutoff = now - 7 * 24 * 3600 * 1000; const toDelete = db.prepare('SELECT token FROM shares WHERE expires_at < ? OR revoked = 1').all(cutoff).map(r => r.token); for (const t of toDelete) { db.prepare('DELETE FROM share_positions WHERE token = ?').run(t); db.prepare('DELETE FROM shares WHERE token = ?').run(t); } return toDelete.length; } // ---- Audit log (per-user) ---- export function audit(userId, action, entity, entityId, summary, ip) { db.prepare('INSERT INTO audit_log (user_id, ts, action, entity, entity_id, summary, ip) VALUES (?, ?, ?, ?, ?, ?, ?)') .run(userId, Date.now(), action, entity || null, entityId || null, summary ? JSON.stringify(summary) : null, ip || null); } export function recentAudit(userId, limit = 100) { return db.prepare('SELECT * FROM audit_log WHERE user_id = ? ORDER BY ts DESC LIMIT ?').all(userId, limit); } export const dataDir = DATA_DIR; export default db;