Chat moderation: Report message + Block user (App Store guideline 1.2)
Apple requires user-generated-content apps to offer a way to report objectionable content and block abusive users. The chat had neither, which is the #1 rejection cause for messaging apps. Added both, server-enforced. Server: - schema: message_reports + user_blocks tables. - repos: reports {add,listForTeam,setStatus}, blocks {add,remove,has,listFor}, users.adminsOf(); thread + threadByConversation now exclude blocked senders in SQL (like message_hidden) so the LIMIT counts only visible rows (no pagination stall). - routes: POST /api/messages/report, /api/users/block|unblock, GET /api/users/blocked, GET /api/reports + POST /api/reports/resolve (admin only). - enforcement: a blocked sender's DM/group messages are persisted but not delivered (no live push, no background notification) to anyone who blocked them; blocked users can't ring you (/api/calls/dm/start + /api/calls/invite); admins can delete reported content (delete route now allows role=admin). Client (home.html, all platforms via the web UI — no rebuild): - message menu gains Report (canned-reason picker) + Block/Unblock. - profile menu: "Blocked users" manager (list + unblock) for everyone; "Reported messages" review (delete / block / resolve) for admins. - blocked DMs hidden from the sidebar; block list loaded on boot. - reports route to the workspace's OWN admins (org-internal moderation). Verified: db-smoke 22/22 + a new moderation suite 12/12 (report+admin-list, non-admin 403, block hides post-block history but sender still sees sent, blocked call 403, unblock restores history + calling). New flag/ban icons added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -163,6 +163,31 @@ CREATE TABLE IF NOT EXISTS message_reactions (
|
||||
PRIMARY KEY (message_id, user_id, emoji)
|
||||
);
|
||||
|
||||
-- UGC moderation (App Store Review guideline 1.2): report a message + block a user.
|
||||
-- Reports are workspace-internal — surfaced to the tenant's admins, who can delete the message / act.
|
||||
CREATE TABLE IF NOT EXISTS message_reports (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
reporter_id TEXT NOT NULL,
|
||||
reported_id TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
snippet TEXT,
|
||||
created_at BIGINT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reports_team ON message_reports(team_id, created_at);
|
||||
|
||||
-- A one-directional block: blocker no longer receives the blocked user's messages or calls.
|
||||
CREATE TABLE IF NOT EXISTS user_blocks (
|
||||
blocker_id TEXT NOT NULL,
|
||||
blocked_id TEXT NOT NULL,
|
||||
team_id TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (blocker_id, blocked_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_blocker ON user_blocks(blocker_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
|
||||
+103
-1
@@ -1507,6 +1507,8 @@ function profileHTML(u){
|
||||
+ '<div class="ps-div"></div>'
|
||||
+ '<a href="/dashboard">'+ic('layoutDashboard',16)+' Dashboard</a>'
|
||||
+ '<a id="psettings">'+ic('settings',16)+' Settings</a>'
|
||||
+ '<a id="pblocked">'+ic('ban',16)+' Blocked users</a>'
|
||||
+ (u.role==='admin'?'<a id="preports">'+ic('flag',16)+' Reported messages</a>':'')
|
||||
+ '<a class="danger" id="plogout">'+ic('logOut',16)+' Logout</a>'
|
||||
+ '</div></div>';
|
||||
}
|
||||
@@ -1519,6 +1521,10 @@ function wireProfile(){
|
||||
if(lo)lo.onclick=async()=>{ await unsubscribePush(); try{await fetch('/api/logout',{method:'POST'});}catch(_){} location.href='/';};
|
||||
const ps=document.getElementById('psettings');
|
||||
if(ps)ps.onclick=()=>{ menu.classList.remove('open'); openSettings(); };
|
||||
const pbk=document.getElementById('pblocked');
|
||||
if(pbk)pbk.onclick=()=>{ menu.classList.remove('open'); openBlockedManager(); };
|
||||
const prp=document.getElementById('preports');
|
||||
if(prp)prp.onclick=()=>{ menu.classList.remove('open'); openReportsAdmin(); };
|
||||
const stog=menu.querySelector('#psStatusToggle'); if(stog) stog.onclick=(e)=>{ e.stopPropagation(); const o=menu.querySelector('#psOptions'); if(o) o.style.display=(o.style.display==='none'?'block':'none'); };
|
||||
menu.querySelectorAll('.ps-opt').forEach(a=>a.onclick=async(e)=>{ e.stopPropagation(); const st=a.dataset.st; ME.status=st;
|
||||
const cd=menu.querySelector('.ps-current .st-dot'); if(cd) cd.className='st-dot '+st;
|
||||
@@ -1665,6 +1671,8 @@ function openStorage(){
|
||||
// ---------- Chat (1:1 + groups) ----------
|
||||
let ME={};
|
||||
let CONTACTS=[]; // team users (for new DMs / picking group members)
|
||||
let BLOCKED=new Set(); // ids of users I've blocked (UGC guideline 1.2) — hide their content, gate calls
|
||||
async function loadBlocked(){ try{ const b=await fetch('/api/users/blocked').then(r=>r.json()); if(b&&Array.isArray(b.ids)) BLOCKED=new Set(b.ids); }catch(_){} }
|
||||
let ROWS=[]; // sidebar items: {kind:'dm'|'group', id, name, online?, members?, last_body, last_at, last_from_me, unread}
|
||||
const _lastSeen={}; // #8: userId -> last-seen ts, kept STICKY so a rebuild/presence event that lacks it can't flap the label back to "Offline"
|
||||
let selected=null; // {kind,id} or null = welcome
|
||||
@@ -2102,7 +2110,7 @@ async function loadSidebar(){
|
||||
// the last_seen DB write), which used to flip the subtitle from "last seen …" to "Offline". Remember any
|
||||
// known value and backfill nulls from it so the label stays stable.
|
||||
items.forEach(it=>{ if(it.kind==='dm'&&!it.self){ if(it.lastSeen) _lastSeen[it.id]=it.lastSeen; else if(_lastSeen[it.id]) it.lastSeen=_lastSeen[it.id]; } });
|
||||
ROWS=items;
|
||||
ROWS=(BLOCKED&&BLOCKED.size)?items.filter(it=>!(it.kind==='dm' && !it.self && BLOCKED.has(it.id))):items; // hide blocked DMs
|
||||
renderChats(searchVal());
|
||||
updateRailUnread();
|
||||
prewarmNotifAvatars();
|
||||
@@ -2313,9 +2321,102 @@ function msgMenuItems(m){
|
||||
if(m.body) items.push({ic:'copy', label:'Copy', fn:()=>copyMessageText(m)});
|
||||
if(m.attachment && !m.deleted) items.push({ic:'download', label:'Save', fn:()=>saveAttachment(m)}); // #9: native long-press "Save" is disabled now, so offer Save explicitly
|
||||
if(!m.deleted && !m.poll) items.push({ic:(m.pinned?'pinOff':'pin'), label:(m.pinned?'Unpin':'Pin'), fn:()=>pinMessage(m.id, !m.pinned)}); // #13
|
||||
// UGC moderation (App Store guideline 1.2): report someone else's message + block its sender.
|
||||
const sys=(m.from==='__system__'||m.system);
|
||||
if(!mine && !sys && m.from){
|
||||
items.push({ic:'flag', label:'Report', fn:()=>reportMessage(m)});
|
||||
const blk=BLOCKED.has(m.from);
|
||||
items.push({ic:'ban', label:(blk?'Unblock user':'Block user'), danger:!blk, fn:()=>toggleBlock(m.from, m.fromName||nameForId(m.from))});
|
||||
}
|
||||
items.push({ic:'trash', label:'Delete', danger:true, fn:()=>openDeleteDialog(m)}); // #18: branded dialog offers "for me" / "for everyone"
|
||||
return items;
|
||||
}
|
||||
// Best-effort display name for a user id (block confirmations / labels).
|
||||
function nameForId(id){ if(!id) return 'this user'; if(id===(ME&&ME.id)) return 'you'; const c=(CONTACTS||[]).find(x=>x&&x.id===id); if(c&&c.name) return c.name; const r=(ROWS||[]).find(x=>x&&x.kind==='dm'&&x.id===id); return (r&&r.name)||'this user'; }
|
||||
// Report a message to the workspace admins. Small branded picker of canned reasons.
|
||||
function reportMessage(m){
|
||||
if(!m||!m.id) return;
|
||||
document.querySelectorAll('.bz-modal-ov').forEach(x=>x.remove());
|
||||
const reasons=['Spam','Harassment or bullying','Inappropriate or offensive','Other'];
|
||||
const ov=document.createElement('div'); ov.className='bz-modal-ov';
|
||||
ov.style.cssText='position:fixed;inset:0;z-index:100000;background:rgba(16,26,53,.45);backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center;padding:20px';
|
||||
ov.innerHTML='<div style="background:#fff;border-radius:16px;max-width:360px;width:100%;padding:18px;box-shadow:0 20px 60px rgba(0,0,0,.3)">'
|
||||
+'<h3 style="margin:0 0 4px;font-size:1.05rem;display:flex;align-items:center;gap:.4rem;color:#1f2430">'+ic('flag',18)+'Report message</h3>'
|
||||
+'<p style="margin:0 0 12px;color:#6b7280;font-size:.85rem">This message is sent to your workspace admins for review.</p>'
|
||||
+'<div style="display:flex;flex-direction:column;gap:8px">'+reasons.map(r=>'<button data-r="'+pEsc(r)+'" style="text-align:left;padding:.6rem .8rem;border:1px solid #e6e9ef;background:#f8fafc;border-radius:10px;font-size:.9rem;cursor:pointer;color:#1f2430">'+pEsc(r)+'</button>').join('')+'</div>'
|
||||
+'<div style="display:flex;justify-content:flex-end;margin-top:12px"><button data-x="1" style="padding:.5rem .9rem;border:none;background:transparent;color:#6b7280;font-size:.9rem;cursor:pointer">Cancel</button></div>'
|
||||
+'</div>';
|
||||
document.body.appendChild(ov);
|
||||
ov.addEventListener('click', async (e)=>{
|
||||
if(e.target===ov||e.target.closest('[data-x]')){ ov.remove(); return; }
|
||||
const rb=e.target.closest('[data-r]'); if(!rb) return;
|
||||
ov.remove();
|
||||
try{ await postJSON('/api/messages/report',{ id:m.id, reason:rb.dataset.r }); toast('Reported. Your admins will review it.'); }
|
||||
catch(err){ toast((err&&err.message)||'Could not report'); }
|
||||
});
|
||||
}
|
||||
// Block / unblock a user. Blocking severs their messages + calls to me (server-enforced); reloads views.
|
||||
async function toggleBlock(uid, name){
|
||||
if(!uid || uid===(ME&&ME.id)) return;
|
||||
const nm=name||nameForId(uid);
|
||||
if(BLOCKED.has(uid)){
|
||||
try{ await postJSON('/api/users/unblock',{ userId:uid }); BLOCKED.delete(uid); toast('Unblocked '+nm); }catch(e){ toast((e&&e.message)||'Could not unblock'); return; }
|
||||
} else {
|
||||
const ok=await bzConfirm("You won't receive messages or calls from "+nm+". You can unblock them anytime.", {title:'Block '+nm+'?', okText:'Block', danger:true});
|
||||
if(!ok) return;
|
||||
try{ await postJSON('/api/users/block',{ userId:uid }); BLOCKED.add(uid); toast('Blocked '+nm); }catch(e){ toast((e&&e.message)||'Could not block'); return; }
|
||||
}
|
||||
try{ await loadSidebar(); }catch(_){}
|
||||
try{ if(typeof reloadThread==='function') reloadThread(); }catch(_){}
|
||||
}
|
||||
// "Blocked users" manager (profile menu) — list + unblock.
|
||||
async function openBlockedManager(){
|
||||
let list=[]; try{ const b=await fetch('/api/users/blocked').then(r=>r.json()); if(b&&Array.isArray(b.users)) list=b.users; if(b&&Array.isArray(b.ids)) BLOCKED=new Set(b.ids); }catch(_){}
|
||||
document.querySelectorAll('.bz-modal-ov').forEach(x=>x.remove());
|
||||
const ov=document.createElement('div'); ov.className='bz-modal-ov';
|
||||
ov.style.cssText='position:fixed;inset:0;z-index:100000;background:rgba(16,26,53,.45);backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center;padding:20px';
|
||||
const rows=list.length?list.map(u=>'<div style="display:flex;align-items:center;gap:.6rem;padding:.5rem .1rem;border-bottom:1px solid #f0f2f6"><span style="flex:1;font-size:.9rem;color:#1f2430">'+pEsc(u.name||'Unknown')+'</span><button data-u="'+pEsc(u.id)+'" style="padding:.35rem .7rem;border:1px solid #e6e9ef;background:#fff;border-radius:8px;font-size:.82rem;cursor:pointer;color:#1F3B73">Unblock</button></div>').join(''):'<p style="color:#6b7280;font-size:.88rem;text-align:center;padding:1.2rem 0">You haven’t blocked anyone.</p>';
|
||||
ov.innerHTML='<div style="background:#fff;border-radius:16px;max-width:380px;width:100%;padding:18px;box-shadow:0 20px 60px rgba(0,0,0,.3)">'
|
||||
+'<h3 style="margin:0 0 10px;font-size:1.05rem;display:flex;align-items:center;gap:.4rem;color:#1f2430">'+ic('ban',18)+'Blocked users</h3>'
|
||||
+'<div style="max-height:52vh;overflow:auto">'+rows+'</div>'
|
||||
+'<div style="display:flex;justify-content:flex-end;margin-top:12px"><button data-x="1" style="padding:.5rem .9rem;border:none;background:#1F3B73;color:#fff;border-radius:8px;font-size:.9rem;cursor:pointer">Done</button></div>'
|
||||
+'</div>';
|
||||
document.body.appendChild(ov);
|
||||
ov.addEventListener('click', async (e)=>{
|
||||
if(e.target===ov||e.target.closest('[data-x]')){ ov.remove(); return; }
|
||||
const ub=e.target.closest('[data-u]'); if(!ub) return;
|
||||
try{ await postJSON('/api/users/unblock',{ userId:ub.dataset.u }); BLOCKED.delete(ub.dataset.u); ub.parentNode.remove(); toast('Unblocked'); await loadSidebar(); }catch(err){ toast((err&&err.message)||'Could not unblock'); }
|
||||
});
|
||||
}
|
||||
// Admin: reported-messages review (profile menu, admins only).
|
||||
async function openReportsAdmin(){
|
||||
let list=[]; try{ list=await fetch('/api/reports').then(r=>r.json()); }catch(_){} if(!Array.isArray(list)) list=[];
|
||||
document.querySelectorAll('.bz-modal-ov').forEach(x=>x.remove());
|
||||
const ov=document.createElement('div'); ov.className='bz-modal-ov';
|
||||
ov.style.cssText='position:fixed;inset:0;z-index:100000;background:rgba(16,26,53,.45);backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center;padding:20px';
|
||||
const row=(r)=>'<div data-id="'+pEsc(r.id)+'" style="padding:.6rem 0;border-bottom:1px solid #f0f2f6">'
|
||||
+'<div style="font-size:.9rem;color:#1f2430"><b>'+pEsc(r.reported)+'</b> <span style="color:#6b7280">reported by</span> '+pEsc(r.reporter)+(r.status==='resolved'?' <span style="color:#16a34a;font-size:.78rem">· resolved</span>':'')+'</div>'
|
||||
+(r.reason?'<div style="font-size:.82rem;color:#b45309;margin-top:.15rem">'+pEsc(r.reason)+'</div>':'')
|
||||
+(r.snippet?'<div style="font-size:.82rem;color:#6b7280;font-style:italic;margin-top:.2rem">“'+pEsc(r.snippet)+'”</div>':'')
|
||||
+'<div style="display:flex;gap:.4rem;flex-wrap:wrap;margin-top:.45rem">'
|
||||
+'<button data-del="'+pEsc(r.messageId)+'" style="padding:.3rem .6rem;border:1px solid #fca5a5;background:#fff;color:#b91c1c;border-radius:8px;font-size:.78rem;cursor:pointer">Delete message</button>'
|
||||
+'<button data-block="'+pEsc(r.reportedId)+'" data-name="'+pEsc(r.reported)+'" style="padding:.3rem .6rem;border:1px solid #e6e9ef;background:#fff;color:#1f2430;border-radius:8px;font-size:.78rem;cursor:pointer">Block user</button>'
|
||||
+(r.status!=='resolved'?'<button data-resolve="'+pEsc(r.id)+'" style="padding:.3rem .6rem;border:1px solid #e6e9ef;background:#fff;color:#1F3B73;border-radius:8px;font-size:.78rem;cursor:pointer">Mark resolved</button>':'')
|
||||
+'</div></div>';
|
||||
const bodyHTML=list.length?list.map(row).join(''):'<p style="color:#6b7280;font-size:.88rem;text-align:center;padding:1.2rem 0">No reports.</p>';
|
||||
ov.innerHTML='<div style="background:#fff;border-radius:16px;max-width:430px;width:100%;padding:18px;box-shadow:0 20px 60px rgba(0,0,0,.3)">'
|
||||
+'<h3 style="margin:0 0 10px;font-size:1.05rem;display:flex;align-items:center;gap:.4rem;color:#1f2430">'+ic('flag',18)+'Reported messages</h3>'
|
||||
+'<div style="max-height:58vh;overflow:auto">'+bodyHTML+'</div>'
|
||||
+'<div style="display:flex;justify-content:flex-end;margin-top:12px"><button data-x="1" style="padding:.5rem .9rem;border:none;background:#1F3B73;color:#fff;border-radius:8px;font-size:.9rem;cursor:pointer">Done</button></div>'
|
||||
+'</div>';
|
||||
document.body.appendChild(ov);
|
||||
ov.addEventListener('click', async (e)=>{
|
||||
if(e.target===ov||e.target.closest('[data-x]')){ ov.remove(); return; }
|
||||
const del=e.target.closest('[data-del]'); if(del){ if(await bzConfirm('Delete this message for everyone in the chat?', {title:'Delete message?', okText:'Delete', danger:true})){ try{ await postJSON('/api/messages/delete',{ id:del.dataset.del }); try{ markMsgDeleted(del.dataset.del); }catch(_){} toast('Message deleted'); del.textContent='Deleted'; del.disabled=true; }catch(err){ toast((err&&err.message)||'Could not delete'); } } return; }
|
||||
const bl=e.target.closest('[data-block]'); if(bl){ await toggleBlock(bl.dataset.block, bl.dataset.name); return; }
|
||||
const rs=e.target.closest('[data-resolve]'); if(rs){ try{ await postJSON('/api/reports/resolve',{ id:rs.dataset.resolve, status:'resolved' }); const row=rs.closest('[data-id]'); if(row) row.style.opacity=.55; rs.remove(); toast('Marked resolved'); }catch(err){ toast('Could not update'); } return; }
|
||||
});
|
||||
}
|
||||
// #9 (mobile): long-press a message → a dimmed + blurred backdrop and a bottom action sheet with quick
|
||||
// reactions and every action (reply / forward / copy / pin / delete). This replaces the old hover-style
|
||||
// ".show-actions" reveal on touch — which could sit hidden behind an image and stopped working after the
|
||||
@@ -6453,6 +6554,7 @@ window.addEventListener('message',(e)=>{
|
||||
if(_meet && /^\d{6}$/.test(_meet) && !me){ document.getElementById('loading').style.display='none'; return startGuestMeeting(_meet); }
|
||||
if(!me){ await renderLogin(); document.getElementById('loading').style.display='none'; return; }
|
||||
ME=me;
|
||||
loadBlocked(); // fetch my block list (UGC moderation) — non-blocking
|
||||
document.getElementById('hdrRight').innerHTML=bellHTML()+profileHTML(me);
|
||||
loadNotifs(); wireBell(); wireProfile();
|
||||
placeHdrRight(); window.addEventListener('resize', placeHdrRight); // mobile: bell+profile live in the chat-list header (no top bar)
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
bluetooth: '<path d="m7 7 10 10-5 5V2l5 5L7 17"/>',
|
||||
speaker: '<path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a9 9 0 0 1 0 14"/>',
|
||||
speakerOff: '<path d="M11 5 6 9H2v6h4l5 4z"/><line x1="22" y1="9" x2="16" y2="15"/><line x1="16" y1="9" x2="22" y2="15"/>',
|
||||
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" x2="4" y1="22" y2="15"/>',
|
||||
ban: '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
|
||||
};
|
||||
window.ICON = P;
|
||||
window.ic = function (name, size) {
|
||||
|
||||
+29
-5
@@ -54,6 +54,8 @@ const users = {
|
||||
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),
|
||||
// Workspace admins (for routing UGC reports to a moderator).
|
||||
adminsOf: async (tenantId) => (await db.prepare("SELECT id FROM users WHERE team_id=? AND role='admin'").all(tenantId)).map((r) => r.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),
|
||||
@@ -234,13 +236,16 @@ const messages = {
|
||||
// JS afterwards — so the LIMIT counts only VISIBLE messages. Filtering after the LIMIT returned < PAGE rows
|
||||
// whenever a recent message had been hidden, and the client read that as "no older history" and stopped
|
||||
// paginating (a chat with a deleted recent message wouldn't scroll back).
|
||||
// `a` is the VIEWER. Exclude messages from users the viewer has blocked (in SQL, like message_hidden,
|
||||
// so the LIMIT counts only VISIBLE messages and pagination doesn't stall).
|
||||
thread: (teamId, a, b, limit = 500, before = null) => {
|
||||
const cond = before != null ? ' AND created_at < ?' : '';
|
||||
const args = before != null ? [teamId, a, b, b, a, a, before, limit] : [teamId, a, b, b, a, a, limit];
|
||||
const args = before != null ? [teamId, a, b, b, a, a, a, before, limit] : [teamId, a, b, b, a, a, a, limit];
|
||||
return db.prepare(`SELECT * FROM (
|
||||
SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL
|
||||
AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?))
|
||||
AND id NOT IN (SELECT message_id FROM message_hidden WHERE user_id=?)${cond}
|
||||
AND id NOT IN (SELECT message_id FROM message_hidden WHERE user_id=?)
|
||||
AND sender_id NOT IN (SELECT blocked_id FROM user_blocks WHERE blocker_id=?)${cond}
|
||||
ORDER BY created_at DESC LIMIT ?
|
||||
) t ORDER BY created_at ASC`).all(...args);
|
||||
},
|
||||
@@ -259,10 +264,11 @@ const messages = {
|
||||
// Group conversation helpers.
|
||||
threadByConversation: (conversationId, userId, limit = 500, before = null) => {
|
||||
const cond = before != null ? ' AND created_at < ?' : '';
|
||||
const args = before != null ? [conversationId, userId, before, limit] : [conversationId, userId, limit];
|
||||
const args = before != null ? [conversationId, userId, userId, before, limit] : [conversationId, userId, userId, limit];
|
||||
return db.prepare(`SELECT * FROM (
|
||||
SELECT * FROM messages WHERE conversation_id=?
|
||||
AND id NOT IN (SELECT message_id FROM message_hidden WHERE user_id=?)${cond}
|
||||
AND id NOT IN (SELECT message_id FROM message_hidden WHERE user_id=?)
|
||||
AND sender_id NOT IN (SELECT blocked_id FROM user_blocks WHERE blocker_id=?)${cond}
|
||||
ORDER BY created_at DESC LIMIT ?
|
||||
) t ORDER BY created_at ASC`).all(...args);
|
||||
},
|
||||
@@ -431,4 +437,22 @@ const appInstalls = {
|
||||
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, callHistory, recordings, polls, pollVotes, pushSubs, favorites, deviceTokens, appInstalls };
|
||||
// UGC moderation (App Store guideline 1.2).
|
||||
const reports = {
|
||||
add: ({ id, teamId, messageId, reporterId, reportedId, reason, snippet }) =>
|
||||
db.prepare('INSERT INTO message_reports (id,team_id,message_id,reporter_id,reported_id,reason,snippet,created_at,status) VALUES (?,?,?,?,?,?,?,?,?)')
|
||||
.run(id, teamId, messageId, reporterId, reportedId, reason || null, snippet || null, now(), 'open'),
|
||||
listForTeam: (teamId, limit = 200) => db.prepare('SELECT * FROM message_reports WHERE team_id=? ORDER BY created_at DESC LIMIT ?').all(teamId, limit),
|
||||
setStatus: (id, status) => db.prepare('UPDATE message_reports SET status=? WHERE id=?').run(status, id),
|
||||
openCountForTeam: async (teamId) => (await db.prepare("SELECT COUNT(*) AS c FROM message_reports WHERE team_id=? AND status='open'").get(teamId)).c,
|
||||
};
|
||||
|
||||
const blocks = {
|
||||
add: (blockerId, blockedId, teamId) =>
|
||||
db.prepare('INSERT INTO user_blocks (blocker_id,blocked_id,team_id,created_at) VALUES (?,?,?,?) ON CONFLICT(blocker_id,blocked_id) DO NOTHING').run(blockerId, blockedId, teamId, now()),
|
||||
remove: (blockerId, blockedId) => db.prepare('DELETE FROM user_blocks WHERE blocker_id=? AND blocked_id=?').run(blockerId, blockedId),
|
||||
has: async (blockerId, blockedId) => !!(await db.prepare('SELECT 1 FROM user_blocks WHERE blocker_id=? AND blocked_id=?').get(blockerId, blockedId)),
|
||||
listFor: async (blockerId) => (await db.prepare('SELECT blocked_id FROM user_blocks WHERE blocker_id=? ORDER BY created_at DESC').all(blockerId)).map((r) => r.blocked_id),
|
||||
};
|
||||
|
||||
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, callHistory, recordings, polls, pollVotes, pushSubs, favorites, deviceTokens, appInstalls, reports, blocks };
|
||||
|
||||
+77
-5
@@ -968,6 +968,7 @@ route('POST', '/api/calls/dm/start', async (req, res) => {
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { to } = await readBody(req);
|
||||
if (!to || !await R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' });
|
||||
if (await R.blocks.has(to, u.id)) return json(res, 403, { error: 'This user is unavailable.' }); // callee blocked the caller → don't ring
|
||||
json(res, 200, await CALLS.startDmCall(u, to, u.team_id));
|
||||
});
|
||||
|
||||
@@ -978,7 +979,7 @@ route('POST', '/api/calls/invite', async (req, res) => {
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { room, userIds } = await readBody(req);
|
||||
if (!room || !meetingRooms.has(String(room))) return json(res, 404, { error: 'call not found' });
|
||||
const ids = await asyncFilter((Array.isArray(userIds) ? userIds : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id));
|
||||
const ids = await asyncFilter((Array.isArray(userIds) ? userIds : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id) && !(await R.blocks.has(x, u.id))); // skip anyone who blocked the caller
|
||||
// #15: adding people to a 1:1 call promotes it to a persistent GROUP call (survives leaves + lets an added
|
||||
// person rejoin after dropping). When that happens promoteDmToGroup's own group-call broadcast already rings
|
||||
// the invitees in, so we only send the plain call-invite for a call that WASN'T promoted (group/ad-hoc).
|
||||
@@ -1587,6 +1588,7 @@ route('POST', '/api/messages', async (req, res) => {
|
||||
const conv = await R.conversations.byId(group); const gname = (conv && conv.name) || 'Group';
|
||||
const pushBody = (u.name || u.email) + ': ' + (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment');
|
||||
for (const mid of await R.conversations.members(group)) {
|
||||
if (mid !== u.id && await R.blocks.has(mid, u.id)) continue; // member blocked the sender → deliver nothing to them
|
||||
try { CHAT.pushToUser(mid, push); } catch (_) {} // includes sender's other tabs
|
||||
if (mid !== u.id) PUSH.sendToUser(mid, { title: gname, body: pushBody, kind: 'group', id: group, tag: 'group:' + group });
|
||||
}
|
||||
@@ -1600,10 +1602,13 @@ route('POST', '/api/messages', async (req, res) => {
|
||||
await R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: toId, body: text, replyTo: replyTo || null, attachmentId: attachmentId || null });
|
||||
const dto = await buildMsgDTO(await R.messages.byId(id), await namesFor(u.team_id), u.id);
|
||||
const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } };
|
||||
try { CHAT.pushToUser(toId, push); } catch (_) {}
|
||||
// If the recipient has blocked the sender, persist the message but deliver nothing to them (no live
|
||||
// push, no background notification). The sender's own devices still sync it, so from their side it looks sent.
|
||||
const blockedByRcpt = (toId !== u.id) && await R.blocks.has(toId, u.id);
|
||||
if (!blockedByRcpt) try { CHAT.pushToUser(toId, push); } catch (_) {}
|
||||
if (toId !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices (skip for self-notes)
|
||||
// Background/closed-tab push to the recipient (opens the DM). Not for a note-to-self.
|
||||
if (toId !== u.id) PUSH.sendToUser(toId, { title: (u.name || u.email), body: (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment'), kind: 'dm', id: u.id, tag: 'dm:' + u.id, icon: u.avatar_url || undefined });
|
||||
// Background/closed-tab push to the recipient (opens the DM). Not for a note-to-self, not if blocked.
|
||||
if (toId !== u.id && !blockedByRcpt) PUSH.sendToUser(toId, { title: (u.name || u.email), body: (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment'), kind: 'dm', id: u.id, tag: 'dm:' + u.id, icon: u.avatar_url || undefined });
|
||||
json(res, 200, dto);
|
||||
});
|
||||
|
||||
@@ -1653,13 +1658,80 @@ route('POST', '/api/messages/delete', async (req, res) => {
|
||||
if (!id) return json(res, 400, { error: 'id required' });
|
||||
const m = await R.messages.byId(id);
|
||||
if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
|
||||
if (m.sender_id !== u.id) return json(res, 403, { error: 'you can only delete your own messages' });
|
||||
if (m.sender_id !== u.id && u.role !== 'admin') return json(res, 403, { error: 'you can only delete your own messages' }); // admins can remove reported content (guideline 1.2)
|
||||
await R.messages.markDeleted(id);
|
||||
const evt = { type: 'chat-deleted', id, conversation_id: m.conversation_id || null };
|
||||
if (m.conversation_id) { for (const mid of await R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
|
||||
else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} }
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
// ── UGC moderation (App Store Review guideline 1.2) ────────────────────────────────────────────────
|
||||
// Report a message. Stored + surfaced to the workspace admins (who can delete it / act). Internal only.
|
||||
route('POST', '/api/messages/report', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { id, reason } = await readBody(req);
|
||||
if (!id) return json(res, 400, { error: 'id required' });
|
||||
const m = await R.messages.byId(id);
|
||||
if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
|
||||
const canSee = m.conversation_id ? await R.conversations.isMember(m.conversation_id, u.id) : (m.sender_id === u.id || m.recipient_id === u.id);
|
||||
if (!canSee) return json(res, 403, { error: 'not allowed' });
|
||||
const snippet = String(m.body || (m.attachment_id ? '[attachment]' : '')).slice(0, 160);
|
||||
await R.reports.add({ id: A.id(), teamId: u.team_id, messageId: m.id, reporterId: u.id, reportedId: m.sender_id, reason: String(reason || '').slice(0, 200), snippet });
|
||||
try { for (const aid of await R.users.adminsOf(u.team_id)) { if (aid !== u.id) { try { CHAT.pushToUser(aid, { type: 'report-new' }); } catch (_) {} } } } catch (_) {}
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
// Block a user: I stop receiving their messages and calls (one-directional).
|
||||
route('POST', '/api/users/block', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { userId } = await readBody(req);
|
||||
const target = await R.users.resolve(userId);
|
||||
if (!target || target === u.id) return json(res, 400, { error: 'invalid user' });
|
||||
if (!await R.users.inTenant(target, u.team_id)) return json(res, 404, { error: 'no such user' });
|
||||
await R.blocks.add(u.id, target, u.team_id);
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
route('POST', '/api/users/unblock', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { userId } = await readBody(req);
|
||||
if (!userId) return json(res, 400, { error: 'userId required' });
|
||||
await R.blocks.remove(u.id, await R.users.resolve(userId));
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
// My block list (ids + names) — powers the "Blocked users" manager and the client-side hide.
|
||||
route('GET', '/api/users/blocked', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const ids = await R.blocks.listFor(u.id);
|
||||
const names = await namesFor(u.team_id);
|
||||
json(res, 200, { ids, users: ids.map((id) => ({ id, name: names[id] || 'Unknown' })) });
|
||||
});
|
||||
|
||||
// Admin: list the workspace's reports + resolve them.
|
||||
route('GET', '/api/reports', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
if (u.role !== 'admin') return json(res, 403, { error: 'admins only' });
|
||||
const names = await namesFor(u.team_id);
|
||||
const rows = await R.reports.listForTeam(u.team_id);
|
||||
json(res, 200, rows.map((r) => ({ id: r.id, messageId: r.message_id, reporter: names[r.reporter_id] || 'Unknown', reported: names[r.reported_id] || 'Unknown', reportedId: r.reported_id, reason: r.reason || '', snippet: r.snippet || '', at: r.created_at, status: r.status })));
|
||||
});
|
||||
|
||||
route('POST', '/api/reports/resolve', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
if (u.role !== 'admin') return json(res, 403, { error: 'admins only' });
|
||||
const { id, status } = await readBody(req);
|
||||
if (!id) return json(res, 400, { error: 'id required' });
|
||||
await R.reports.setStatus(id, status === 'open' ? 'open' : 'resolved');
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
// #18 Delete-for-me: hide a message from MY view only (any message I can see, mine or not). The row and
|
||||
// everyone else are untouched. Echoed to my OTHER devices so it disappears there too.
|
||||
route('POST', '/api/messages/hide', async (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user