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:
2026-08-19 19:46:22 +05:30
parent 7e3a94b04c
commit e4d361f298
5 changed files with 236 additions and 11 deletions
+77 -5
View File
@@ -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) => {