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
+29 -5
View File
@@ -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 };