// Data-access layer (Phase 1). // All SQL lives here, never in route/signaling handlers. This decouples the rest of // the app from SQLite so the store can later move to Postgres without touching callers. // // TENANT ABSTRACTION: a "tenant" currently maps 1:1 to a team (column `team_id`). // Repo signatures take `tenantId` so that when the tenant is later elevated to a // first-class Organization (Phase 3), callers and the API/auth built on top stay unchanged. const db = require('./db'); const A = require('./auth'); const now = () => Date.now(); const teams = { first: () => db.prepare('SELECT * FROM teams LIMIT 1').get(), byId: (id) => db.prepare('SELECT * FROM teams WHERE id=?').get(id), create: (name) => { const id = A.id(); db.prepare('INSERT INTO teams (id,name,created_at) VALUES (?,?,?)').run(id, name, now()); return db.prepare('SELECT * FROM teams WHERE id=?').get(id); }, }; const users = { anyExists: () => !!db.prepare('SELECT 1 FROM users LIMIT 1').get(), byId: (id) => db.prepare('SELECT * FROM users WHERE id=?').get(id), byEmail: (email) => db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email), emailExists: (email) => !!db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email), listByTenant: (tenantId) => db.prepare('SELECT id,email,name,role,active,avatar_url,created_at FROM users WHERE team_id=?').all(tenantId), inTenant: (id, tenantId) => db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, tenantId), create: ({ tenantId, email, hash, salt, role, name, mfaSecret }) => { const id = A.id(); db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at) VALUES (?,?,?,?,?,?,?,?,0,?)`) .run(id, tenantId, email, hash, salt, role, name || null, mfaSecret, now()); return id; }, enableMfa: (id) => db.prepare('UPDATE users SET mfa_enabled=1 WHERE id=?').run(id), setName: (id, name) => db.prepare('UPDATE users SET name=? WHERE id=?').run(name, id), setRole: (id, role) => db.prepare('UPDATE users SET role=? WHERE id=?').run(role, id), setPassword: (id, hash, salt) => db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, id), setActive: (id, active) => db.prepare('UPDATE users SET active=? WHERE id=?').run(active ? 1 : 0, id), setAvatar: (id, url) => db.prepare('UPDATE users SET avatar_url=? WHERE id=?').run(url || null, id), remove: (id) => db.prepare('DELETE FROM users WHERE id=?').run(id), }; const authSessions = { byToken: (token) => db.prepare('SELECT * FROM sessions_auth WHERE token=?').get(token), create: ({ token, userId, mfaPassed, ttl }) => db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,?,?,?)') .run(token, userId, mfaPassed ? 1 : 0, now(), now() + ttl), markMfaPassed: (token) => db.prepare('UPDATE sessions_auth SET mfa_passed=1 WHERE token=?').run(token), deleteByToken: (token) => db.prepare('DELETE FROM sessions_auth WHERE token=?').run(token), deleteByUser: (userId) => db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(userId), }; const machines = { byEnrollToken: (t) => db.prepare('SELECT * FROM machines WHERE enroll_token=?').get(t), inTenant: (id, tenantId) => db.prepare('SELECT * FROM machines WHERE id=? AND team_id=?').get(id, tenantId), listByTenant: (tenantId) => db.prepare('SELECT id,name,unattended,last_seen FROM machines WHERE team_id=?').all(tenantId), create: ({ tenantId, name, enrollToken, unattended }) => { const id = A.id(); db.prepare('INSERT INTO machines (id,team_id,name,enroll_token,unattended,created_at) VALUES (?,?,?,?,?,?)') .run(id, tenantId, name, enrollToken, unattended ? 1 : 0, now()); return id; }, touch: (id) => db.prepare('UPDATE machines SET last_seen=? WHERE id=?').run(now(), id), }; const audit = { add: (e) => db.prepare(`INSERT INTO audit_log (team_id,user_id,user_email,machine_id,machine_name,action,detail,at) VALUES (@team_id,@user_id,@user_email,@machine_id,@machine_name,@action,@detail,@at)`) .run({ team_id: e.team_id, user_id: e.user_id || null, user_email: e.user_email || null, machine_id: e.machine_id || null, machine_name: e.machine_name || null, action: e.action, detail: e.detail || null, at: now(), }), listByTenant: (tenantId) => db.prepare("SELECT * FROM audit_log WHERE team_id=? OR team_id='adhoc' ORDER BY at DESC LIMIT 200").all(tenantId), }; const sessionsLog = { byId: (id) => db.prepare('SELECT * FROM sessions_log WHERE id=?').get(id), byIdInTenant: (id, tenantId) => db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(id, tenantId), create: ({ id, tenantId, agentEmail, agentName, ticket }) => db.prepare('INSERT INTO sessions_log (id,team_id,agent_email,agent_name,ticket,started_at) VALUES (?,?,?,?,?,?)') .run(id, tenantId, agentEmail, agentName, ticket || null, now()), end: (id) => db.prepare('UPDATE sessions_log SET ended_at=? WHERE id=? AND ended_at IS NULL').run(now(), id), setRecording: (id, fname) => db.prepare('UPDATE sessions_log SET recording=? WHERE id=?').run(fname, id), setTranscript: (id, fname) => db.prepare('UPDATE sessions_log SET transcript=? WHERE id=?').run(fname, id), // Role-scoping is the caller's job: pass agentEmail to restrict to one agent (non-admins). report: ({ tenantId, agentEmail, from, to }) => { let sql = 'SELECT * FROM sessions_log WHERE team_id=?'; const args = [tenantId]; if (agentEmail) { sql += ' AND agent_email=?'; args.push(agentEmail); } if (from) { sql += ' AND started_at>=?'; args.push(from); } if (to) { sql += ' AND started_at<=?'; args.push(to); } sql += ' ORDER BY started_at DESC LIMIT 500'; return db.prepare(sql).all(...args); }, }; const refreshTokens = { create: ({ userId, tokenHash, ttl }) => db.prepare('INSERT INTO refresh_tokens (token_hash,user_id,created_at,expires_at,revoked) VALUES (?,?,?,?,0)') .run(tokenHash, userId, now(), now() + ttl), byHash: (h) => db.prepare('SELECT * FROM refresh_tokens WHERE token_hash=?').get(h), revoke: (h) => db.prepare('UPDATE refresh_tokens SET revoked=1 WHERE token_hash=?').run(h), revokeByUser: (userId) => db.prepare('UPDATE refresh_tokens SET revoked=1 WHERE user_id=?').run(userId), }; const apiKeys = { create: ({ id, tenantId, name, keyHash, scopes, createdBy }) => db.prepare('INSERT INTO api_keys (id,team_id,name,key_hash,scopes,created_by,created_at,revoked) VALUES (?,?,?,?,?,?,?,0)') .run(id, tenantId, name || null, keyHash, scopes || '', createdBy || null, now()), byHash: (h) => db.prepare('SELECT * FROM api_keys WHERE key_hash=?').get(h), listByTenant: (tenantId) => db.prepare('SELECT id,name,scopes,created_by,created_at,last_used_at,revoked FROM api_keys WHERE team_id=? ORDER BY created_at DESC').all(tenantId), revoke: (id, tenantId) => db.prepare('UPDATE api_keys SET revoked=1 WHERE id=? AND team_id=?').run(id, tenantId), touch: (id) => db.prepare('UPDATE api_keys SET last_used_at=? WHERE id=?').run(now(), id), }; const webhooks = { create: ({ id, tenantId, url, secret, events, createdBy }) => db.prepare('INSERT INTO webhooks (id,team_id,url,secret,events,active,created_by,created_at) VALUES (?,?,?,?,?,1,?,?)') .run(id, tenantId, url, secret, events || '', createdBy || null, now()), activeForTenant: (tenantId) => db.prepare('SELECT * FROM webhooks WHERE team_id=? AND active=1').all(tenantId), listByTenant: (tenantId) => db.prepare('SELECT id,url,events,active,created_by,created_at,last_status,last_error,last_at FROM webhooks WHERE team_id=? ORDER BY created_at DESC').all(tenantId), remove: (id, tenantId) => db.prepare('DELETE FROM webhooks WHERE id=? AND team_id=?').run(id, tenantId), setStatus: (id, status, err) => db.prepare('UPDATE webhooks SET last_status=?, last_error=?, last_at=? WHERE id=?').run(status, err || null, now(), id), }; const messages = { send: ({ id, teamId, senderId, recipientId, body, replyTo, attachmentId, conversationId, mentions, msgType }) => db.prepare('INSERT INTO messages (id,team_id,sender_id,recipient_id,body,created_at,reply_to,attachment_id,conversation_id,mentions,msg_type) VALUES (?,?,?,?,?,?,?,?,?,?,?)') .run(id, teamId, senderId, recipientId || '', body, now(), replyTo || null, attachmentId || null, conversationId || null, (mentions && mentions.length) ? JSON.stringify(mentions) : null, msgType || null), byId: (id) => db.prepare('SELECT * FROM messages WHERE id=?').get(id), byAttachment: (attachmentId) => db.prepare('SELECT * FROM messages WHERE attachment_id=? LIMIT 1').get(attachmentId), setPoll: (messageId, pollId) => db.prepare('UPDATE messages SET poll_id=? WHERE id=?').run(pollId, messageId), markDelivered: (id) => db.prepare('UPDATE messages SET delivered_at=? WHERE id=? AND delivered_at IS NULL').run(now(), id), // Full 1:1 (DM) thread between two users (both directions), oldest first. thread: (teamId, a, b, limit = 300) => db.prepare(`SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)) ORDER BY created_at ASC LIMIT ?`).all(teamId, a, b, b, a, limit), markRead: (teamId, recipientId, senderId) => db.prepare('UPDATE messages SET read_at=? WHERE team_id=? AND conversation_id IS NULL AND recipient_id=? AND sender_id=? AND read_at IS NULL') .run(now(), teamId, recipientId, senderId), // Recent DM messages involving a user (newest first) — reduced into per-contact conversations. recentFor: (teamId, userId, limit = 1000) => db.prepare('SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL AND (sender_id=? OR recipient_id=?) ORDER BY created_at DESC LIMIT ?') .all(teamId, userId, userId, limit), // Group conversation helpers. threadByConversation: (conversationId, limit = 300) => db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at ASC LIMIT ?').all(conversationId, limit), lastInConversation: (conversationId) => db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1').get(conversationId), unreadInConversation: (conversationId, userId, since) => db.prepare('SELECT COUNT(*) AS c FROM messages WHERE conversation_id=? AND sender_id<>? AND created_at>?').get(conversationId, userId, since).c, }; const reactions = { // Toggle with ONE reaction per user per message: picking an emoji replaces any prior // reaction by that user; picking the same one again removes it. Returns true if added. toggle: (messageId, userId, emoji) => { const had = db.prepare('SELECT 1 FROM message_reactions WHERE message_id=? AND user_id=? AND emoji=?').get(messageId, userId, emoji); db.prepare('DELETE FROM message_reactions WHERE message_id=? AND user_id=?').run(messageId, userId); if (had) return false; db.prepare('INSERT INTO message_reactions (message_id,user_id,emoji,created_at) VALUES (?,?,?,?)').run(messageId, userId, emoji, now()); return true; }, forMessage: (messageId) => db.prepare('SELECT user_id, emoji FROM message_reactions WHERE message_id=? ORDER BY created_at ASC').all(messageId), // All reactions on the messages in a 1:1 thread. forPair: (teamId, a, b) => db.prepare(`SELECT r.message_id, r.user_id, r.emoji FROM message_reactions r JOIN messages m ON r.message_id = m.id WHERE m.team_id=? AND m.conversation_id IS NULL AND ((m.sender_id=? AND m.recipient_id=?) OR (m.sender_id=? AND m.recipient_id=?))`).all(teamId, a, b, b, a), // All reactions on the messages in a group conversation. forConversation: (conversationId) => db.prepare(`SELECT r.message_id, r.user_id, r.emoji FROM message_reactions r JOIN messages m ON r.message_id = m.id WHERE m.conversation_id=?`).all(conversationId), }; const conversations = { create: ({ id, teamId, name, createdBy }) => db.prepare('INSERT INTO conversations (id,team_id,type,name,created_by,created_at) VALUES (?,?,?,?,?,?)').run(id, teamId, 'group', name || null, createdBy || null, now()), byId: (id) => db.prepare('SELECT * FROM conversations WHERE id=?').get(id), addMember: (conversationId, userId, admin) => db.prepare('INSERT OR IGNORE INTO conversation_members (conversation_id,user_id,last_read_at,joined_at,admin) VALUES (?,?,?,?,?)').run(conversationId, userId, 0, now(), admin ? 1 : 0), members: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC').all(conversationId).map((r) => r.user_id), isMember: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId), // ---- Group admins (multiple allowed) ---- isAdmin: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=? AND admin=1').get(conversationId, userId), admins: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? AND admin=1').all(conversationId).map((r) => r.user_id), setMemberAdmin: (conversationId, userId, v) => db.prepare('UPDATE conversation_members SET admin=? WHERE conversation_id=? AND user_id=?').run(v ? 1 : 0, conversationId, userId), oldestMember: (conversationId) => { const r = db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC LIMIT 1').get(conversationId); return r ? r.user_id : null; }, listForUser: (teamId, userId) => db.prepare('SELECT c.* FROM conversations c JOIN conversation_members m ON m.conversation_id=c.id WHERE c.team_id=? AND m.user_id=?').all(teamId, userId), lastReadAt: (conversationId, userId) => { const r = db.prepare('SELECT last_read_at FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId); return r ? r.last_read_at : 0; }, memberReads: (conversationId) => db.prepare('SELECT user_id, last_read_at FROM conversation_members WHERE conversation_id=?').all(conversationId), setAdminOnly: (id, v) => db.prepare('UPDATE conversations SET admin_only=? WHERE id=?').run(v ? 1 : 0, id), markRead: (conversationId, userId) => db.prepare('UPDATE conversation_members SET last_read_at=? WHERE conversation_id=? AND user_id=?').run(now(), conversationId, userId), rename: (id, name) => db.prepare('UPDATE conversations SET name=? WHERE id=?').run(name, id), setAvatar: (id, attachmentId) => db.prepare('UPDATE conversations SET avatar_id=? WHERE id=?').run(attachmentId || null, id), byAvatar: (attachmentId) => db.prepare('SELECT * FROM conversations WHERE avatar_id=? LIMIT 1').get(attachmentId), removeMember: (conversationId, userId) => db.prepare('DELETE FROM conversation_members WHERE conversation_id=? AND user_id=?').run(conversationId, userId), remove: (id) => { db.prepare('DELETE FROM conversation_members WHERE conversation_id=?').run(id); db.prepare('DELETE FROM conversations WHERE id=?').run(id); }, }; const attachments = { create: ({ id, teamId, uploaderId, name, mime, size }) => db.prepare('INSERT INTO attachments (id,team_id,uploader_id,name,mime,size,created_at) VALUES (?,?,?,?,?,?,?)') .run(id, teamId, uploaderId, name, mime || null, size || 0, now()), byId: (id) => db.prepare('SELECT * FROM attachments WHERE id=?').get(id), }; const scheduledMeetings = { create: ({ id, teamId, groupId, roomCode, title, description, scheduledAt, createdBy, participants, durationMins, recurrence }) => db.prepare('INSERT INTO scheduled_meetings (id,team_id,group_id,room_code,title,description,scheduled_at,created_by,created_at,participants,duration_mins,recurrence) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)') .run(id, teamId, groupId || null, roomCode, title, description || null, scheduledAt, createdBy, now(), (participants && participants.length) ? JSON.stringify(participants) : null, durationMins || null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null), byId: (id) => db.prepare('SELECT * FROM scheduled_meetings WHERE id=?').get(id), byCode: (code) => db.prepare('SELECT * FROM scheduled_meetings WHERE room_code=? ORDER BY created_at DESC LIMIT 1').get(code), // Meetings a user can see: created by them, a member of the group, or an invited participant. listForUser: (teamId, userId) => db.prepare(`SELECT s.* FROM scheduled_meetings s WHERE s.team_id=? AND ( s.created_by=? OR (s.group_id IS NOT NULL AND EXISTS (SELECT 1 FROM conversation_members cm WHERE cm.conversation_id=s.group_id AND cm.user_id=?)) OR (s.participants IS NOT NULL AND s.participants LIKE '%'||?||'%')) ORDER BY s.scheduled_at ASC`).all(teamId, userId, userId, '"' + userId + '"'), dueForReminder: (fromTs, toTs) => db.prepare('SELECT * FROM scheduled_meetings WHERE reminded=0 AND ended_at IS NULL AND scheduled_at>=? AND scheduled_at<=?').all(fromTs, toTs), markReminded: (id) => db.prepare('UPDATE scheduled_meetings SET reminded=1 WHERE id=?').run(id), end: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId), cancel: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET cancelled=1, ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId), reschedule: (id, teamId, ts) => db.prepare('UPDATE scheduled_meetings SET scheduled_at=?, reminded=0 WHERE id=? AND team_id=?').run(ts, id, teamId), // recurrence: roll to next occurrence update: (id, teamId, { title, description, scheduledAt, durationMins, participants, recurrence }) => db.prepare('UPDATE scheduled_meetings SET title=?, description=?, scheduled_at=?, duration_mins=?, participants=?, recurrence=?, reminded=0 WHERE id=? AND team_id=?') .run(title, description || null, scheduledAt, durationMins || null, (participants && participants.length) ? JSON.stringify(participants) : null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, id, teamId), remove: (id, teamId) => db.prepare('DELETE FROM scheduled_meetings WHERE id=? AND team_id=?').run(id, teamId), }; const recordings = { create: ({ id, teamId, room, groupId, meetingId, title, kind, file, mime, size, durationMs, createdBy, createdByName }) => db.prepare('INSERT INTO recordings (id,team_id,room,group_id,meeting_id,title,kind,file,mime,size,duration_ms,created_by,created_by_name,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)') .run(id, teamId, room || null, groupId || null, meetingId || null, title || null, kind, file || null, mime || null, size || null, durationMs || null, createdBy || null, createdByName || null, now()), byId: (id) => db.prepare('SELECT * FROM recordings WHERE id=?').get(id), forTeam: (teamId) => db.prepare('SELECT * FROM recordings WHERE team_id=? ORDER BY created_at DESC').all(teamId), }; const polls = { create: ({ id, teamId, conversationId, messageId, question, options, multi, createdBy }) => db.prepare('INSERT INTO polls (id,team_id,conversation_id,message_id,question,options,multi,closed,created_by,created_at) VALUES (?,?,?,?,?,?,?,0,?,?)') .run(id, teamId, conversationId, messageId || null, question, JSON.stringify(options), multi ? 1 : 0, createdBy, now()), byId: (id) => db.prepare('SELECT * FROM polls WHERE id=?').get(id), close: (id) => db.prepare('UPDATE polls SET closed=1 WHERE id=?').run(id), }; const pollVotes = { forPoll: (pollId) => db.prepare('SELECT user_id, option_idx FROM poll_votes WHERE poll_id=?').all(pollId), hasVoted: (pollId, userId, idx) => !!db.prepare('SELECT 1 FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').get(pollId, userId, idx), add: (pollId, userId, idx) => db.prepare('INSERT OR IGNORE INTO poll_votes (poll_id,user_id,option_idx,created_at) VALUES (?,?,?,?)').run(pollId, userId, idx, now()), remove: (pollId, userId, idx) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').run(pollId, userId, idx), clearUser: (pollId, userId) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=?').run(pollId, userId), }; const pushSubs = { // Upsert by endpoint: re-subscribing the same browser updates its keys/owner. add: ({ id, userId, endpoint, p256dh, auth }) => db.prepare('INSERT INTO push_subscriptions (id,user_id,endpoint,p256dh,auth,created_at) VALUES (?,?,?,?,?,?) ON CONFLICT(endpoint) DO UPDATE SET user_id=excluded.user_id, p256dh=excluded.p256dh, auth=excluded.auth') .run(id, userId, endpoint, p256dh, auth, now()), byUser: (userId) => db.prepare('SELECT * FROM push_subscriptions WHERE user_id=?').all(userId), removeByEndpoint: (endpoint) => db.prepare('DELETE FROM push_subscriptions WHERE endpoint=?').run(endpoint), }; module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes, pushSubs };