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:
+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