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),
db.prepare('SELECT COUNT(*) AS c FROM messages WHERE conversation_id=? AND sender_id<>? AND created_at>?').get(conversationId,userId,since).c,
};
constreactions={
// 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)=>{
consthad=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)returnfalse;
db.prepare('INSERT INTO message_reactions (message_id,user_id,emoji,created_at) VALUES (?,?,?,?)').run(messageId,userId,emoji,now());
returntrue;
},
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),
};
constconversations={
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)=>{constr=db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC LIMIT 1').get(conversationId);returnr?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)=>{constr=db.prepare('SELECT last_read_at FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId,userId);returnr?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);},
};
constattachments={
create:({id,teamId,uploaderId,name,mime,size})=>
db.prepare('INSERT INTO attachments (id,team_id,uploader_id,name,mime,size,created_at) VALUES (?,?,?,?,?,?,?)')
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
db.prepare('UPDATE scheduled_meetings SET title=?, description=?, scheduled_at=?, duration_mins=?, participants=?, recurrence=?, reminded=0 WHERE id=? AND team_id=?')
byId:(id)=>db.prepare('SELECT * FROM polls WHERE id=?').get(id),
close:(id)=>db.prepare('UPDATE polls SET closed=1 WHERE id=?').run(id),
};
constpollVotes={
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),
// 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),