/** * SQLite store for the staff live-chat module (1:1 direct messages). * * Frontend-owned operational data — NOT iDempiere business data — exactly like * the push-subscriptions DB. Lives at data/chat.db. * * ISOLATION: unlike pushDb.ts (which opens at import), this lazy-opens inside * try/catch on first use and is only ever imported by chat code. If * better-sqlite3 or the file fails, every export logs and no-ops (returns * empty/null) — chat history degrades but the server never crashes at boot, * and disabling/removing the chat module leaves nothing dangling. */ import Database from 'better-sqlite3' import path from 'path' import fs from 'fs' let db: any = null let initFailed = false const getDb = () => { if (db) return db if (initFailed) return null try { const dbPath = path.join(process.cwd(), 'data', 'chat.db') const dataDir = path.dirname(dbPath) if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }) const _db = new Database(dbPath) _db.pragma('journal_mode = WAL') _db.exec(` CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, sender_user_id INTEGER NOT NULL, recipient_user_id INTEGER NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL, read_at INTEGER, client_msg_id TEXT ); CREATE INDEX IF NOT EXISTS idx_msg_pair ON messages(sender_user_id, recipient_user_id, id); CREATE INDEX IF NOT EXISTS idx_msg_recipient_unread ON messages(recipient_user_id, read_at); CREATE INDEX IF NOT EXISTS idx_msg_client ON messages(client_msg_id); -- Voice-note audio blobs. Kept here (not Strapi) so the chat module stays -- self-contained and the audio survives deploys alongside chat.db. sender/ -- recipient are duplicated on the row so playback can authorize without a join. CREATE TABLE IF NOT EXISTS voice_notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, message_id INTEGER, sender_user_id INTEGER NOT NULL, recipient_user_id INTEGER NOT NULL, mime TEXT NOT NULL, duration INTEGER, bytes BLOB NOT NULL, created_at INTEGER NOT NULL ); -- Emoji reactions: one row per (message, user, emoji); clicking the same -- emoji again deletes the row (toggle). CREATE TABLE IF NOT EXISTS message_reactions ( message_id INTEGER NOT NULL, user_id INTEGER NOT NULL, emoji TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (message_id, user_id, emoji) ); CREATE INDEX IF NOT EXISTS idx_reaction_msg ON message_reactions(message_id); `) // Additive migration: DBs created before share-to-chat predate the `meta` // column (CREATE TABLE IF NOT EXISTS won't add it). Guarded so it's idempotent. try { const cols: any[] = _db.prepare(`PRAGMA table_info(messages)`).all() if (!cols.some((c: any) => c.name === 'meta')) { _db.exec(`ALTER TABLE messages ADD COLUMN meta TEXT`) } } catch (e: any) { console.error('[ChatDB] meta migration failed:', e?.message) } db = _db console.log('[ChatDB] ✅ SQLite database initialized at:', dbPath) startRetention() // daily sweep of old read messages (per-process; PM2 instances:1) return db } catch (err: any) { initFailed = true console.error('[ChatDB] ❌ init failed; chat history disabled:', err?.message) return null } } const parseMeta = (raw: any) => { if (!raw) return null try { return JSON.parse(raw) } catch { return null } } const mapRow = (r: any) => ({ id: r.id, senderUserId: r.sender_user_id, recipientUserId: r.recipient_user_id, body: r.body, createdAt: r.created_at, readAt: r.read_at ?? null, clientMsgId: r.client_msg_id ?? null, meta: parseMeta(r.meta) }) export interface InsertMessageInput { senderUserId: number recipientUserId: number body: string createdAt: number clientMsgId?: string | null meta?: any | null } export const insertMessage = (input: InsertMessageInput) => { const d = getDb() if (!d) return null try { const stmt = d.prepare(` INSERT INTO messages (sender_user_id, recipient_user_id, body, created_at, client_msg_id, meta) VALUES (?, ?, ?, ?, ?, ?) `) const res = stmt.run( input.senderUserId, input.recipientUserId, input.body, input.createdAt, input.clientMsgId ?? null, input.meta ? JSON.stringify(input.meta) : null ) return { id: Number(res.lastInsertRowid), senderUserId: input.senderUserId, recipientUserId: input.recipientUserId, body: input.body, createdAt: input.createdAt, readAt: null, clientMsgId: input.clientMsgId ?? null, meta: input.meta ?? null } } catch (err: any) { console.error('[ChatDB] insert failed:', err?.message) return null } } /** * Full conversation between two users, oldest → newest, capped at `limit`. * Pass `beforeId` to page backwards (load older messages). */ export const getConversation = (userA: number, userB: number, limit = 50, beforeId: number | null = null) => { const d = getDb() if (!d) return [] try { const params: any[] = [userA, userB, userB, userA] let sql = ` SELECT * FROM messages WHERE ((sender_user_id = ? AND recipient_user_id = ?) OR (sender_user_id = ? AND recipient_user_id = ?)) ` if (beforeId) { sql += ` AND id < ?` params.push(beforeId) } sql += ` ORDER BY id DESC LIMIT ?` params.push(limit) const rows = d.prepare(sql).all(...params) const mapped = rows.reverse().map(mapRow) attachReactions(d, mapped) return mapped } catch (err: any) { console.error('[ChatDB] getConversation failed:', err?.message) return [] } } /** Fetch a single message by id (used to authorize/route reactions). */ export const getMessageById = (id: number) => { const d = getDb() if (!d || !id) return null try { const row = d.prepare(`SELECT * FROM messages WHERE id = ? LIMIT 1`).get(id) return row ? mapRow(row) : null } catch (err: any) { console.error('[ChatDB] getMessageById failed:', err?.message) return null } } /** Update a message's body and/or meta (e.g. once a voice transcript is ready). */ export const updateMessageContent = (id: number, patch: { body?: string; meta?: any }) => { const d = getDb() if (!d || !id) return false try { const sets: string[] = [] const vals: any[] = [] if (typeof patch.body === 'string') { sets.push('body = ?'); vals.push(patch.body) } if (patch.meta !== undefined) { sets.push('meta = ?'); vals.push(patch.meta ? JSON.stringify(patch.meta) : null) } if (!sets.length) return false vals.push(id) d.prepare(`UPDATE messages SET ${sets.join(', ')} WHERE id = ?`).run(...vals) return true } catch (err: any) { console.error('[ChatDB] updateMessageContent failed:', err?.message) return false } } // ── voice notes ─────────────────────────────────────────────────────────────── export interface InsertVoiceNoteInput { senderUserId: number recipientUserId: number mime: string duration: number | null bytes: Buffer } export const insertVoiceNote = (input: InsertVoiceNoteInput) => { const d = getDb() if (!d) return null try { const res = d.prepare(` INSERT INTO voice_notes (sender_user_id, recipient_user_id, mime, duration, bytes, created_at) VALUES (?, ?, ?, ?, ?, ?) `).run(input.senderUserId, input.recipientUserId, input.mime, input.duration ?? null, input.bytes, Date.now()) return { id: Number(res.lastInsertRowid) } } catch (err: any) { console.error('[ChatDB] insertVoiceNote failed:', err?.message) return null } } /** Link a voice note to the chat message that carries it (for retention cleanup). */ export const linkVoiceNoteMessage = (voiceId: number, messageId: number) => { const d = getDb() if (!d || !voiceId || !messageId) return false try { d.prepare(`UPDATE voice_notes SET message_id = ? WHERE id = ?`).run(messageId, voiceId) return true } catch (err: any) { console.error('[ChatDB] linkVoiceNoteMessage failed:', err?.message) return false } } export const getVoiceNote = (id: number) => { const d = getDb() if (!d || !id) return null try { const r: any = d.prepare(`SELECT * FROM voice_notes WHERE id = ? LIMIT 1`).get(id) if (!r) return null return { id: r.id, senderUserId: r.sender_user_id, recipientUserId: r.recipient_user_id, mime: r.mime, duration: r.duration ?? null, bytes: r.bytes as Buffer } } catch (err: any) { console.error('[ChatDB] getVoiceNote failed:', err?.message) return null } } // ── reactions ─────────────────────────────────────────────────────────────── /** Toggle a reaction on/off. `on=false` removes it. Returns true on success. */ export const setReaction = (messageId: number, userId: number, emoji: string, on: boolean) => { const d = getDb() if (!d || !messageId || !userId || !emoji) return false try { if (on) { d.prepare(` INSERT OR IGNORE INTO message_reactions (message_id, user_id, emoji, created_at) VALUES (?, ?, ?, ?) `).run(messageId, userId, emoji, Date.now()) } else { d.prepare(`DELETE FROM message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?`) .run(messageId, userId, emoji) } return true } catch (err: any) { console.error('[ChatDB] setReaction failed:', err?.message) return false } } /** Attach a `reactions: [{ userId, emoji }]` array to each mapped message in place. */ const attachReactions = (d: any, msgs: any[]) => { if (!msgs.length) return try { const ids = msgs.map(m => m.id).filter(Boolean) if (!ids.length) return const placeholders = ids.map(() => '?').join(',') const rows: any[] = d.prepare( `SELECT message_id, user_id, emoji FROM message_reactions WHERE message_id IN (${placeholders})` ).all(...ids) if (!rows.length) return const byMsg: Record = {} rows.forEach((r: any) => { (byMsg[r.message_id] ||= []).push({ userId: r.user_id, emoji: r.emoji }) }) msgs.forEach(m => { if (byMsg[m.id]) m.reactions = byMsg[m.id] }) } catch (err: any) { console.error('[ChatDB] attachReactions failed:', err?.message) } } /** Per-sender unread counts for a recipient: { [senderUserId]: count }. */ export const getUnreadCounts = (userId: number): Record => { const d = getDb() if (!d) return {} try { const rows = d.prepare(` SELECT sender_user_id AS peer, COUNT(*) AS cnt FROM messages WHERE recipient_user_id = ? AND read_at IS NULL GROUP BY sender_user_id `).all(userId) const out: Record = {} rows.forEach((r: any) => { out[r.peer] = r.cnt }) return out } catch (err: any) { console.error('[ChatDB] getUnreadCounts failed:', err?.message) return {} } } /** Mark all messages from `peer` to `reader` as read. Returns rows updated. */ export const markConversationRead = (reader: number, peer: number, readAt: number) => { const d = getDb() if (!d) return 0 try { const res = d.prepare(` UPDATE messages SET read_at = ? WHERE recipient_user_id = ? AND sender_user_id = ? AND read_at IS NULL `).run(readAt, reader, peer) return res.changes } catch (err: any) { console.error('[ChatDB] markConversationRead failed:', err?.message) return 0 } } export const findByClientMsgId = (clientMsgId: string) => { const d = getDb() if (!d || !clientMsgId) return null try { const row = d.prepare(`SELECT * FROM messages WHERE client_msg_id = ? LIMIT 1`).get(clientMsgId) return row ? mapRow(row) : null } catch (err: any) { console.error('[ChatDB] findByClientMsgId failed:', err?.message) return null } } /** * Search the caller's own conversations (LIKE on body). Returns the newest * matches first, each annotated with `peer` (the other party in that 1:1). */ export const searchMessages = (userId: number, q: string, limit = 30) => { const d = getDb() const term = (q || '').trim() if (!d || !term) return [] try { // Escape LIKE wildcards so a literal % / _ in the query isn't treated as one. const like = `%${term.replace(/[\\%_]/g, (m) => '\\' + m)}%` const rows = d.prepare(` SELECT * FROM messages WHERE (sender_user_id = ? OR recipient_user_id = ?) AND body LIKE ? ESCAPE '\\' ORDER BY id DESC LIMIT ? `).all(userId, userId, like, limit) return rows.map((r: any) => { const m = mapRow(r) return { ...m, peer: m.senderUserId === userId ? m.recipientUserId : m.senderUserId } }) } catch (err: any) { console.error('[ChatDB] searchMessages failed:', err?.message) return [] } } /** Retention sweep: drop already-read messages older than `days`. */ export const cleanupOldMessages = (days = 60) => { const d = getDb() if (!d) return 0 try { const cutoff = Date.now() - days * 24 * 60 * 60 * 1000 const res = d.prepare(`DELETE FROM messages WHERE read_at IS NOT NULL AND created_at < ?`).run(cutoff) if (res.changes > 0) console.log('[ChatDB] cleaned up', res.changes, 'old messages') // Drop reactions + voice blobs whose owning message is gone, plus voice // uploads that never got linked to a message (failed sends, >1 day old). try { d.prepare(`DELETE FROM message_reactions WHERE message_id NOT IN (SELECT id FROM messages)`).run() } catch {} try { d.prepare(` DELETE FROM voice_notes WHERE (message_id IS NOT NULL AND message_id NOT IN (SELECT id FROM messages)) OR (message_id IS NULL AND created_at < ?) `).run(Date.now() - 24 * 60 * 60 * 1000) } catch {} return res.changes } catch (err: any) { console.error('[ChatDB] cleanupOldMessages failed:', err?.message) return 0 } } // Daily retention sweep, started once the DB is open. No Nitro cron infra exists // here, so we mirror the app's setInterval pattern (e.g. chatPresence.sweepTimer). // Per-process — fine at PM2 instances:1; a multi-instance deploy would just run it // on each instance (harmless: DELETE is idempotent). let retentionStarted = false const DAY_MS = 24 * 60 * 60 * 1000 const startRetention = () => { if (retentionStarted) return retentionStarted = true try { cleanupOldMessages(60) } catch { /* fail-soft */ } setInterval(() => { try { cleanupOldMessages(60) } catch { /* fail-soft */ } }, DAY_MS) }