// Composite-key tables: MOVE only the rows that won't collide with the survivor's existing rows
// (NOT EXISTS / NOT IN — portable across SQLite & Postgres, replacing SQLite-only UPDATE OR IGNORE),
// then DELETE whatever remains (the survivor already had that membership/reaction/vote).
awaitrun('UPDATE message_reactions SET user_id=? WHERE user_id=? AND NOT EXISTS (SELECT 1 FROM message_reactions x WHERE x.message_id=message_reactions.message_id AND x.emoji=message_reactions.emoji AND x.user_id=?)',intoId,fromId,intoId);
awaitrun('UPDATE conversation_members SET user_id=? WHERE user_id=? AND conversation_id NOT IN (SELECT conversation_id FROM conversation_members WHERE user_id=?)',intoId,fromId,intoId);
awaitrun('UPDATE poll_votes SET user_id=? WHERE user_id=? AND NOT EXISTS (SELECT 1 FROM poll_votes x WHERE x.poll_id=poll_votes.poll_id AND x.option_idx=poll_votes.option_idx AND x.user_id=?)',intoId,fromId,intoId);
awaitrun("UPDATE favorites SET target='dm:'||? WHERE target='dm:'||? AND NOT EXISTS (SELECT 1 FROM favorites x WHERE x.user_id=favorites.user_id AND x.target='dm:'||?)",intoId,fromId,intoId);
awaitrun('INSERT INTO user_aliases (old_id,user_id,team_id,created_at) VALUES (?,?,?,?) ON CONFLICT(old_id) DO UPDATE SET user_id=excluded.user_id, team_id=excluded.team_id, created_at=excluded.created_at',fromId,intoId,(_iu&&_iu.team_id)||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),
allByAttachment:(attachmentId)=>db.prepare('SELECT sender_id, recipient_id, conversation_id FROM messages WHERE attachment_id=? AND deleted=0').all(attachmentId),
// #18 Delete-for-me: hide a message from ONE user's view (the row + everyone else are untouched).
hideForUser:(messageId,userId)=>db.prepare('INSERT INTO message_hidden (message_id,user_id,hidden_at) VALUES (?,?,?) ON CONFLICT(message_id,user_id) DO NOTHING').run(messageId,userId,now()),
hiddenForUser:async(userId)=>(awaitdb.prepare('SELECT message_id FROM message_hidden WHERE user_id=?').all(userId)).map((r)=>r.message_id),
// Last message in a group that THIS user hasn't hidden (so a "delete for me" on the last message
// rolls the sidebar preview back to the previous one, instead of showing what they just removed).
lastInConversationForUser:(conversationId,userId)=>db.prepare('SELECT * FROM messages WHERE conversation_id=? AND id NOT IN (SELECT message_id FROM message_hidden WHERE user_id=?) ORDER BY created_at DESC LIMIT 1').get(conversationId,userId),
// #13 Pin a message: set/clear pinned_at + pinned_by.
setPinned:(id,pinnedAt,pinnedBy)=>db.prepare('UPDATE messages SET pinned_at=?, pinned_by=? WHERE id=?').run(pinnedAt,pinnedBy,id),
// Pinned messages in a group / DM (newest pin first, deleted excluded).
pinnedInConversation:(conversationId)=>db.prepare('SELECT * FROM messages WHERE conversation_id=? AND pinned_at IS NOT NULL AND deleted=0 ORDER BY pinned_at DESC').all(conversationId),
pinnedInDm:(teamId,a,b)=>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=?)) AND pinned_at IS NOT NULL AND deleted=0 ORDER BY pinned_at DESC').all(teamId,a,b,b,a),
// 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),
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),
db.prepare('INSERT INTO conversation_members (conversation_id,user_id,last_read_at,joined_at,admin) VALUES (?,?,?,?,?) ON CONFLICT(conversation_id,user_id) DO NOTHING').run(conversationId,userId,0,now(),admin?1:0),
members:async(conversationId)=>(awaitdb.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC').all(conversationId)).map((r)=>r.user_id),
isMember:async(conversationId,userId)=>!!(awaitdb.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId,userId)),
isAdmin:async(conversationId,userId)=>!!(awaitdb.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=? AND admin=1').get(conversationId,userId)),
admins:async(conversationId)=>(awaitdb.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:async(conversationId)=>{constr=awaitdb.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC LIMIT 1').get(conversationId);returnr?r.user_id:null;},
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:async(conversationId,userId)=>{constr=awaitdb.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:async(id)=>{awaitdb.prepare('DELETE FROM conversation_members WHERE conversation_id=?').run(id);awaitdb.prepare('DELETE FROM conversations WHERE id=?').run(id);},
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
hasVoted:async(pollId,userId,idx)=>!!(awaitdb.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 INTO poll_votes (poll_id,user_id,option_idx,created_at) VALUES (?,?,?,?) ON CONFLICT(poll_id,user_id,option_idx) DO NOTHING').run(pollId,userId,idx,now()),
// 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),
// 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')