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:
+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 };
|
||||
|
||||
Reference in New Issue
Block a user