Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Backend (server/src/google-calendar.js + endpoints):
- OAuth 2.0 authorization-code flow completo
- Endpoints: /api/google/{status,auth-url,callback,disconnect,sync-pending,pull}
- Auto-refresh de access_token quando expira (com retry 401→refresh→retry)
- Token storage em google_connections (better-sqlite3)
- syncToken pra delta sync eficiente do Google
- Pendência↔evento: ⚓/✅ no summary, dueDate→start.date all-day,
shivaoPendingId/shivaoCompleted em extendedProperties.private
- Graceful disable: 503 + flag isEnabled() se env vars não setadas
Frontend (Arquivo › Google Agenda):
- Card só aparece quando feature ativa no servidor
- Connect: abre OAuth em nova aba + polling 3s pra detectar sucesso
- Auto-sync na criação/edição/deleção de pendência (se conectada + tem dueDate)
- Botão "Sincronizar todas pendências" + "Buscar mudanças do Google"
- Pull automático ao abrir aba Pendências (se passou >2min do último)
- Pendências criadas direto no Google viram pendências locais
Pra ativar em produção, adicionar no Coolify shivao-cloud:
GOOGLE_CLIENT_ID=...apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=...
GOOGLE_REDIRECT_URI=https://shivao.pontualtech.work/api/google/callback
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
477 lines
20 KiB
JavaScript
477 lines
20 KiB
JavaScript
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 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 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 payments (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
asaas_payment_id TEXT UNIQUE,
|
|
asaas_customer_id TEXT,
|
|
plan TEXT NOT NULL,
|
|
cycle TEXT NOT NULL,
|
|
value REAL NOT NULL,
|
|
billing_type TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'PENDING',
|
|
invoice_url TEXT,
|
|
due_date INTEGER NOT NULL,
|
|
paid_at INTEGER,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_payments_user ON payments(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_payments_asaas ON payments(asaas_payment_id);
|
|
|
|
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);
|
|
|
|
CREATE TABLE IF NOT EXISTS google_connections (
|
|
user_id INTEGER PRIMARY KEY,
|
|
access_token TEXT NOT NULL,
|
|
refresh_token TEXT NOT NULL,
|
|
expires_at INTEGER,
|
|
email TEXT,
|
|
sync_token TEXT,
|
|
last_sync_at INTEGER,
|
|
created_at INTEGER NOT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
`);
|
|
|
|
// 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 =====
|
|
// 1) Tabelas que mudaram PRIMARY KEY (state, anchor_session): se schema antigo detectado,
|
|
// recriar limpo. Sem perda de dados crítica neste momento (deploy inicial).
|
|
function recreateIfLegacy(table, newSchema) {
|
|
try {
|
|
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
if (cols.length === 0) return; // tabela ainda não existe (CREATE TABLE pegou o schema novo)
|
|
const hasUserId = cols.some(c => c.name === 'user_id');
|
|
const idCol = cols.find(c => c.name === 'id');
|
|
// Schema antigo: id é PRIMARY KEY mas NÃO é AUTOINCREMENT (rowid alias com CHECK constraint)
|
|
const isLegacyPK = idCol && idCol.pk === 1 && !cols.some(c => c.type === 'INTEGER' && c.pk && c.name === 'id' && (c.dflt_value || '').toString().includes('AUTO'));
|
|
if (!hasUserId || isLegacyPK) {
|
|
console.log(`[migration] recreating ${table} (legacy schema detected)`);
|
|
db.exec(`DROP TABLE IF EXISTS ${table}; ${newSchema}`);
|
|
}
|
|
} catch (e) { console.warn(`[migration] recreate ${table}:`, e.message); }
|
|
}
|
|
|
|
recreateIfLegacy('state', `
|
|
CREATE TABLE 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
|
|
);
|
|
`);
|
|
recreateIfLegacy('anchor_session', `
|
|
CREATE TABLE 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
|
|
);
|
|
`);
|
|
|
|
// 2) Tabelas que só ganharam coluna user_id: ALTER TABLE ADD COLUMN
|
|
function ensureUserIdColumn(table) {
|
|
try {
|
|
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
if (cols.length === 0) return;
|
|
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); }
|
|
}
|
|
['media', 'alarm_log', 'shares', 'audit_log'].forEach(ensureUserIdColumn);
|
|
|
|
// 3) Índices em user_id rodam DEPOIS do ALTER TABLE (senão "no such column")
|
|
try {
|
|
db.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_media_user ON media(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_shares_user ON shares(user_id);
|
|
`);
|
|
} catch (e) { console.warn('[migration] user_id indexes:', e.message); }
|
|
|
|
// 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;
|
|
}
|
|
|
|
// ---- Payments (Asaas) ----
|
|
export function createPayment(p) {
|
|
const now = Date.now();
|
|
const info = db.prepare(`INSERT INTO payments (user_id, asaas_payment_id, asaas_customer_id, plan, cycle, value, billing_type, status, invoice_url, due_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
.run(p.user_id, p.asaas_payment_id || null, p.asaas_customer_id || null, p.plan, p.cycle, p.value, p.billing_type, p.status || 'PENDING', p.invoice_url || null, p.due_date, now, now);
|
|
return info.lastInsertRowid;
|
|
}
|
|
export function findPaymentByAsaasId(asaasId) {
|
|
return db.prepare('SELECT * FROM payments WHERE asaas_payment_id = ?').get(asaasId);
|
|
}
|
|
export function updatePaymentStatus(asaasId, status, paidAt) {
|
|
db.prepare('UPDATE payments SET status = ?, paid_at = ?, updated_at = ? WHERE asaas_payment_id = ?')
|
|
.run(status, paidAt || null, Date.now(), asaasId);
|
|
}
|
|
export function listUserPayments(userId, limit = 50) {
|
|
return db.prepare('SELECT * FROM payments WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(userId, limit);
|
|
}
|
|
export function setUserAsaasCustomerId(userId, customerId) {
|
|
// Cache de mapeamento user → asaas customer pra reaproveitar em pagamentos futuros
|
|
// Guardado no campo do user (vou adicionar coluna se não existir)
|
|
try {
|
|
const cols = db.prepare("PRAGMA table_info(users)").all();
|
|
if (!cols.some(c => c.name === 'asaas_customer_id')) {
|
|
db.exec('ALTER TABLE users ADD COLUMN asaas_customer_id TEXT');
|
|
}
|
|
db.prepare('UPDATE users SET asaas_customer_id = ? WHERE id = ?').run(customerId, userId);
|
|
} catch (e) { console.warn('[db] setAsaasCustomerId:', e.message); }
|
|
}
|
|
export function getUserAsaasCustomerId(userId) {
|
|
try {
|
|
const row = db.prepare('SELECT asaas_customer_id FROM users WHERE id = ?').get(userId);
|
|
return row?.asaas_customer_id || null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
// ---- 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);
|
|
}
|
|
|
|
// ===== Google Calendar connections =====
|
|
export function getGoogleConnection(userId) {
|
|
return db.prepare('SELECT * FROM google_connections WHERE user_id = ?').get(userId);
|
|
}
|
|
export function saveGoogleConnection(userId, data) {
|
|
const now = Date.now();
|
|
const existing = getGoogleConnection(userId);
|
|
if (existing) {
|
|
db.prepare(`UPDATE google_connections SET
|
|
access_token=?, refresh_token=?, expires_at=?, email=?, sync_token=?, last_sync_at=?
|
|
WHERE user_id=?`).run(
|
|
data.access_token,
|
|
data.refresh_token || existing.refresh_token, // refresh_token may not come on every refresh
|
|
data.expires_at || null,
|
|
data.email || existing.email || null,
|
|
data.sync_token || existing.sync_token || null,
|
|
data.last_sync_at || existing.last_sync_at || now,
|
|
userId,
|
|
);
|
|
} else {
|
|
db.prepare(`INSERT INTO google_connections
|
|
(user_id, access_token, refresh_token, expires_at, email, sync_token, last_sync_at, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
userId, data.access_token, data.refresh_token, data.expires_at || null,
|
|
data.email || null, data.sync_token || null, data.last_sync_at || now, now,
|
|
);
|
|
}
|
|
}
|
|
export function deleteGoogleConnection(userId) {
|
|
db.prepare('DELETE FROM google_connections WHERE user_id = ?').run(userId);
|
|
}
|
|
export function setGoogleSyncToken(userId, syncToken, lastSyncAt) {
|
|
db.prepare('UPDATE google_connections SET sync_token=?, last_sync_at=? WHERE user_id=?')
|
|
.run(syncToken, lastSyncAt || Date.now(), userId);
|
|
}
|
|
|
|
export const dataDir = DATA_DIR;
|
|
export default db;
|