04db586dc3
Provisioning now keys the Biz Connect account on bz.bizgazeUserId (the same value whether the person signs in with email or mobile) instead of the typed identifier, so both logins resolve to a single contact. Legacy rows get the person-id stamped on next login; an existing duplicate created under the same identifier is folded in via a transactional users.mergeInto() that reassigns all messages/memberships/reactions/votes/favorites/ownership to the survivor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
365 lines
28 KiB
JavaScript
365 lines
28 KiB
JavaScript
// 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),
|
|
// Match on the stable BizGaze person-id so email- and mobile-logins resolve to one account (#2).
|
|
byBizgazeId: (bizId) => (bizId ? db.prepare('SELECT * FROM users WHERE bizgaze_user_id=?').get(String(bizId)) : undefined),
|
|
setBizgazeId: (id, bizId) => db.prepare('UPDATE users SET bizgaze_user_id=? WHERE id=?').run(bizId != null ? String(bizId) : null, id),
|
|
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),
|
|
setStatus: (id, status) => db.prepare('UPDATE users SET status=? WHERE id=?').run(status, id),
|
|
remove: (id) => db.prepare('DELETE FROM users WHERE id=?').run(id),
|
|
// Fold one account (fromId) into another (intoId): reassign everything the merged-away user
|
|
// owns/authored to the survivor, then delete the empty row. Used when a person turns out to
|
|
// have two Biz Connect accounts — one per login identifier (email vs mobile) — for the same
|
|
// BizGaze person (#2). Runs in a single transaction so a failure leaves the data untouched.
|
|
// For composite-key tables we UPDATE OR IGNORE (move what won't collide) then DELETE the rest
|
|
// (the survivor already has that membership/reaction/vote).
|
|
mergeInto: (fromId, intoId) => {
|
|
if (!fromId || !intoId || fromId === intoId) return;
|
|
const run = (sql, ...a) => db.prepare(sql).run(...a);
|
|
db.exec('BEGIN');
|
|
try {
|
|
run('UPDATE messages SET sender_id=? WHERE sender_id=?', intoId, fromId);
|
|
run('UPDATE messages SET recipient_id=? WHERE recipient_id=?', intoId, fromId);
|
|
run('UPDATE OR IGNORE message_reactions SET user_id=? WHERE user_id=?', intoId, fromId);
|
|
run('DELETE FROM message_reactions WHERE user_id=?', fromId);
|
|
run('UPDATE OR IGNORE conversation_members SET user_id=? WHERE user_id=?', intoId, fromId);
|
|
run('DELETE FROM conversation_members WHERE user_id=?', fromId);
|
|
run('UPDATE OR IGNORE poll_votes SET user_id=? WHERE user_id=?', intoId, fromId);
|
|
run('DELETE FROM poll_votes WHERE user_id=?', fromId);
|
|
run('UPDATE OR IGNORE favorites SET user_id=? WHERE user_id=?', intoId, fromId);
|
|
run('DELETE FROM favorites WHERE user_id=?', fromId);
|
|
// A DM favourite pointing AT the merged-away user should now point at the survivor.
|
|
run("UPDATE OR IGNORE favorites SET target='dm:'||? WHERE target='dm:'||?", intoId, fromId);
|
|
run("DELETE FROM favorites WHERE target='dm:'||?", fromId);
|
|
run('UPDATE conversations SET created_by=? WHERE created_by=?', intoId, fromId);
|
|
run('UPDATE polls SET created_by=? WHERE created_by=?', intoId, fromId);
|
|
run('UPDATE scheduled_meetings SET created_by=? WHERE created_by=?', intoId, fromId);
|
|
run('UPDATE recordings SET created_by=? WHERE created_by=?', intoId, fromId);
|
|
run('UPDATE attachments SET uploader_id=? WHERE uploader_id=?', intoId, fromId);
|
|
run('UPDATE push_subscriptions SET user_id=? WHERE user_id=?', intoId, fromId);
|
|
run('UPDATE OR IGNORE device_tokens SET user_id=? WHERE user_id=?', intoId, fromId);
|
|
run('DELETE FROM device_tokens WHERE user_id=?', fromId);
|
|
run('UPDATE app_installs SET user_id=? WHERE user_id=?', intoId, fromId);
|
|
// Drop the merged-away account's auth so a stale token can't resurrect the empty row.
|
|
run('DELETE FROM sessions_auth WHERE user_id=?', fromId);
|
|
run('DELETE FROM refresh_tokens WHERE user_id=?', fromId);
|
|
run('DELETE FROM users WHERE id=?', fromId);
|
|
db.exec('COMMIT');
|
|
} catch (e) {
|
|
db.exec('ROLLBACK');
|
|
throw e;
|
|
}
|
|
},
|
|
};
|
|
|
|
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),
|
|
// Delete-for-everyone: clear the content but keep the row (renders as a placeholder).
|
|
markDeleted: (id) => db.prepare("UPDATE messages SET deleted=1, body='', attachment_id=NULL, poll_id=NULL WHERE id=?").run(id),
|
|
// Shared media/files in a conversation (group) or DM — newest first.
|
|
attachmentsForConversation: (teamId, conversationId) => db.prepare(`SELECT a.id, a.name, a.mime, a.size, m.created_at FROM messages m JOIN attachments a ON a.id=m.attachment_id WHERE m.team_id=? AND m.conversation_id=? AND m.deleted=0 ORDER BY m.created_at DESC`).all(teamId, conversationId),
|
|
attachmentsForDm: (teamId, a, b) => db.prepare(`SELECT at.id, at.name, at.mime, at.size, m.created_at FROM messages m JOIN attachments at ON at.id=m.attachment_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=?)) AND m.deleted=0 ORDER BY m.created_at DESC`).all(teamId, a, b, b, a),
|
|
linksForConversation: (teamId, conversationId) => db.prepare("SELECT body, created_at FROM messages WHERE team_id=? AND conversation_id=? AND deleted=0 AND body LIKE '%http%' ORDER BY created_at DESC").all(teamId, conversationId),
|
|
linksForDm: (teamId, a, b) => db.prepare("SELECT body, created_at FROM messages WHERE team_id=? AND conversation_id IS NULL AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)) AND deleted=0 AND body LIKE '%http%' ORDER BY created_at DESC").all(teamId, a, b, b, a),
|
|
// 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),
|
|
};
|
|
|
|
const favorites = {
|
|
set: (userId, target, on) => on
|
|
? db.prepare('INSERT OR IGNORE INTO favorites (user_id,target,created_at) VALUES (?,?,?)').run(userId, target, now())
|
|
: db.prepare('DELETE FROM favorites WHERE user_id=? AND target=?').run(userId, target),
|
|
forUser: (userId) => db.prepare('SELECT target FROM favorites WHERE user_id=?').all(userId).map((r) => r.target),
|
|
};
|
|
|
|
const deviceTokens = {
|
|
// Upsert by token: re-registering the same device refreshes its owner/platform/last_seen.
|
|
register: ({ id, userId, tenantId, platform, token }) =>
|
|
db.prepare('INSERT INTO device_tokens (id,user_id,tenant_id,platform,token,created_at,last_seen) VALUES (?,?,?,?,?,?,?) ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, tenant_id=excluded.tenant_id, platform=excluded.platform, last_seen=excluded.last_seen')
|
|
.run(id, userId, tenantId || null, platform, token, now(), now()),
|
|
byUser: (userId) => db.prepare('SELECT * FROM device_tokens WHERE user_id=?').all(userId),
|
|
removeByToken: (token) => db.prepare('DELETE FROM device_tokens WHERE token=?').run(token),
|
|
};
|
|
|
|
const appInstalls = {
|
|
// Upsert by install_id: each launch refreshes version/os/last_seen and fills in the user once
|
|
// they sign in (COALESCE keeps a known user if a later anonymous ping arrives).
|
|
record: ({ id, installId, userId, userEmail, tenantId, platform, appVersion, os }) =>
|
|
db.prepare(`INSERT INTO app_installs (id,install_id,user_id,user_email,tenant_id,platform,app_version,os,first_seen,last_seen)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT(install_id) DO UPDATE SET
|
|
user_id=COALESCE(excluded.user_id, app_installs.user_id),
|
|
user_email=COALESCE(excluded.user_email, app_installs.user_email),
|
|
tenant_id=COALESCE(excluded.tenant_id, app_installs.tenant_id),
|
|
platform=excluded.platform, app_version=excluded.app_version, os=excluded.os,
|
|
last_seen=excluded.last_seen`)
|
|
.run(id, installId, userId || null, userEmail || null, tenantId || null, platform || null, appVersion || null, os || null, now(), now()),
|
|
listForTenant: (tenantId) => db.prepare('SELECT * FROM app_installs WHERE tenant_id=? ORDER BY last_seen DESC').all(tenantId),
|
|
};
|
|
|
|
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes, pushSubs, favorites, deviceTokens, appInstalls };
|