Files
BizGaze_Remote/server/db/schema.pg.sql
T
Sravan e4d361f298 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>
2026-08-19 19:46:22 +05:30

347 lines
11 KiB
SQL

-- PostgreSQL schema for Biz Connect — the target of the SQLite→Postgres migration.
--
-- Every column is defined up front here (unlike the SQLite db.js, which layers columns via ALTER TABLE
-- and hit a real ordering bug). Type mapping from the SQLite schema:
-- SQLite INTEGER epoch-ms timestamp -> BIGINT (ms since epoch; JS Number-safe)
-- SQLite INTEGER 0/1 boolean flag -> SMALLINT (kept numeric so app code still reads 0/1)
-- SQLite INTEGER byte size / duration-> BIGINT (files can exceed INT range)
-- SQLite INTEGER small count (peak) -> INTEGER
-- SQLite AUTOINCREMENT rowid -> BIGINT GENERATED ALWAYS AS IDENTITY
-- TEXT -> TEXT
-- Foreign keys mirror the three the SQLite schema enforced (users→teams, sessions_auth→users,
-- machines→teams). The data-migration script inserts in dependency order so these hold.
CREATE TABLE IF NOT EXISTS teams (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES teams(id),
email TEXT NOT NULL UNIQUE,
pw_hash TEXT NOT NULL,
pw_salt TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'technician',
mfa_secret TEXT,
mfa_enabled SMALLINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
name TEXT,
active SMALLINT NOT NULL DEFAULT 1,
avatar_url TEXT,
status TEXT NOT NULL DEFAULT 'active',
bizgaze_user_id TEXT,
last_seen BIGINT
);
CREATE INDEX IF NOT EXISTS idx_users_bizgaze_user_id ON users(bizgaze_user_id);
CREATE TABLE IF NOT EXISTS sessions_auth (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
mfa_passed SMALLINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS machines (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES teams(id),
name TEXT NOT NULL,
enroll_token TEXT NOT NULL UNIQUE,
unattended SMALLINT NOT NULL DEFAULT 0,
last_seen BIGINT,
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
team_id TEXT NOT NULL,
user_id TEXT,
user_email TEXT,
machine_id TEXT,
machine_name TEXT,
action TEXT NOT NULL,
detail TEXT,
at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions_log (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
agent_email TEXT,
agent_name TEXT,
ticket TEXT,
started_at BIGINT NOT NULL,
ended_at BIGINT,
recording TEXT,
transcript TEXT
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
revoked SMALLINT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
name TEXT,
key_hash TEXT NOT NULL UNIQUE,
scopes TEXT NOT NULL DEFAULT '',
created_by TEXT,
created_at BIGINT NOT NULL,
last_used_at BIGINT,
revoked SMALLINT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS webhooks (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
url TEXT NOT NULL,
secret TEXT NOT NULL,
events TEXT NOT NULL DEFAULT '',
active SMALLINT NOT NULL DEFAULT 1,
created_by TEXT,
created_at BIGINT NOT NULL,
last_status INTEGER,
last_error TEXT,
last_at BIGINT
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
sender_id TEXT NOT NULL,
recipient_id TEXT NOT NULL,
body TEXT NOT NULL,
created_at BIGINT NOT NULL,
read_at BIGINT,
reply_to TEXT,
attachment_id TEXT,
conversation_id TEXT,
mentions TEXT,
delivered_at BIGINT,
poll_id TEXT,
msg_type TEXT,
deleted SMALLINT NOT NULL DEFAULT 0,
edited_at BIGINT,
fwd_from TEXT,
pinned_at BIGINT, -- #13 pin a message
pinned_by TEXT -- #13
);
CREATE INDEX IF NOT EXISTS idx_messages_pair ON messages(team_id, sender_id, recipient_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages(team_id, recipient_id, sender_id, read_at);
CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, created_at);
-- New: attachment lookups drove the /files auth scan (see static.js authAttachment). Index it so the
-- per-Range playback auth is a keyed lookup, not a table scan (the auth cache stays as a second line).
CREATE INDEX IF NOT EXISTS idx_messages_attachment ON messages(attachment_id);
-- Columns/tables added AFTER the initial PG cutover. The CREATE TABLE above only applies to a FRESH
-- database (IF NOT EXISTS is a no-op once the table exists), so add these idempotently for the existing
-- production table too. Safe to run on every boot. (Unlike the up-front rule at the top of this file, a
-- post-cutover column MUST also be ALTER-ed in — otherwise it silently never lands on the live DB.)
ALTER TABLE messages ADD COLUMN IF NOT EXISTS pinned_at BIGINT; -- #13
ALTER TABLE messages ADD COLUMN IF NOT EXISTS pinned_by TEXT; -- #13
-- #18 "Delete for me": a per-user hide. The message row is untouched (everyone else still sees it); this
-- records that THIS user removed it from their own threads + sidebar.
CREATE TABLE IF NOT EXISTS message_hidden (
message_id TEXT NOT NULL,
user_id TEXT NOT NULL,
hidden_at BIGINT,
PRIMARY KEY (message_id, user_id)
);
CREATE TABLE IF NOT EXISTS message_reactions (
message_id TEXT NOT NULL,
user_id TEXT NOT NULL,
emoji TEXT NOT NULL,
created_at BIGINT NOT NULL,
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,
uploader_id TEXT NOT NULL,
name TEXT NOT NULL,
mime TEXT,
size BIGINT,
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'group',
name TEXT,
created_by TEXT,
created_at BIGINT NOT NULL,
avatar_id TEXT,
admin_only SMALLINT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS conversation_members (
conversation_id TEXT NOT NULL,
user_id TEXT NOT NULL,
last_read_at BIGINT NOT NULL DEFAULT 0,
joined_at BIGINT NOT NULL,
admin SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (conversation_id, user_id)
);
CREATE TABLE IF NOT EXISTS call_history (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
room TEXT NOT NULL,
group_id TEXT,
kind TEXT,
title TEXT,
peak INTEGER NOT NULL DEFAULT 0,
participants TEXT,
uids TEXT,
started_at BIGINT NOT NULL,
ended_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_call_history_team ON call_history(team_id, ended_at);
CREATE TABLE IF NOT EXISTS user_aliases (
old_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
team_id TEXT,
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS polls (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
message_id TEXT,
question TEXT NOT NULL,
options TEXT NOT NULL,
multi SMALLINT NOT NULL DEFAULT 0,
closed SMALLINT NOT NULL DEFAULT 0,
created_by TEXT NOT NULL,
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS poll_votes (
poll_id TEXT NOT NULL,
user_id TEXT NOT NULL,
option_idx INTEGER NOT NULL,
created_at BIGINT NOT NULL,
PRIMARY KEY (poll_id, user_id, option_idx)
);
CREATE TABLE IF NOT EXISTS scheduled_meetings (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
group_id TEXT,
room_code TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
scheduled_at BIGINT NOT NULL,
created_by TEXT NOT NULL,
created_at BIGINT NOT NULL,
ended_at BIGINT,
participants TEXT,
reminded SMALLINT NOT NULL DEFAULT 0,
cancelled SMALLINT NOT NULL DEFAULT 0,
duration_mins INTEGER,
recurrence TEXT,
guest_emails TEXT,
lobby SMALLINT
);
CREATE INDEX IF NOT EXISTS idx_sched_team ON scheduled_meetings(team_id, scheduled_at);
CREATE INDEX IF NOT EXISTS idx_sched_code ON scheduled_meetings(room_code);
CREATE TABLE IF NOT EXISTS recordings (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
room TEXT,
group_id TEXT,
meeting_id TEXT,
title TEXT,
kind TEXT NOT NULL,
file TEXT,
mime TEXT,
size BIGINT,
duration_ms BIGINT,
created_by TEXT,
created_by_name TEXT,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rec_team ON recordings(team_id, created_at);
CREATE INDEX IF NOT EXISTS idx_rec_room ON recordings(room);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id);
CREATE TABLE IF NOT EXISTS device_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
tenant_id TEXT,
platform TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at BIGINT NOT NULL,
last_seen BIGINT
);
CREATE INDEX IF NOT EXISTS idx_devtok_user ON device_tokens(user_id);
CREATE TABLE IF NOT EXISTS app_installs (
id TEXT PRIMARY KEY,
install_id TEXT NOT NULL UNIQUE,
user_id TEXT,
user_email TEXT,
tenant_id TEXT,
platform TEXT,
app_version TEXT,
os TEXT,
first_seen BIGINT NOT NULL,
last_seen BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_installs_tenant ON app_installs(tenant_id);
CREATE INDEX IF NOT EXISTS idx_installs_user ON app_installs(user_id);
CREATE TABLE IF NOT EXISTS favorites (
user_id TEXT NOT NULL,
target TEXT NOT NULL,
created_at BIGINT NOT NULL,
PRIMARY KEY (user_id, target)
);