204 lines
10 KiB
SQL
204 lines
10 KiB
SQL
-- Chat — DMs, group threads, and Kautilya (AI) threads.
|
||
--
|
||
-- One pipeline serves all three. `conversations.kind` is the only thing that
|
||
-- differs, so the inbox's two tabs are a filter (dm/group vs ai) rather than a
|
||
-- second implementation, and the message list, composer, pagination and
|
||
-- context menu are written once.
|
||
--
|
||
-- Ordering is by a per-conversation `seq`, not by timestamp. Clocks skew and
|
||
-- timestamps collide; a gapless integer gives stable ordering, cheap unread
|
||
-- maths (last_seq - last_read_seq), and a sync cursor the client can resume
|
||
-- from after a reconnect (?after_seq=N).
|
||
|
||
CREATE TABLE IF NOT EXISTS conversations (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
-- dm → exactly two participants
|
||
-- group → many participants, has a title/avatar
|
||
-- ai → one participant; the other side is Kautilya (no user row)
|
||
kind VARCHAR(8) NOT NULL CHECK (kind IN ('dm', 'group', 'ai')),
|
||
title VARCHAR(120), -- groups (and AI thread titles)
|
||
avatar_key TEXT, -- R2 key, groups only
|
||
-- Sorted pair "least(a,b):greatest(a,b)" for DMs, NULL otherwise. Makes
|
||
-- "one thread per pair" a database guarantee instead of a service-layer
|
||
-- race between two people opening the chat simultaneously.
|
||
dm_key TEXT,
|
||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||
-- Monotonic message counter. Bumped inside the send transaction, which
|
||
-- serialises assignment per conversation and leaves no gaps.
|
||
last_seq BIGINT NOT NULL DEFAULT 0,
|
||
-- Denormalized purely for inbox ordering; the preview text and its sender
|
||
-- are read with a LATERAL join on (conversation_id, seq DESC) so there is
|
||
-- one source of truth for message content even after an edit or delete.
|
||
last_message_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
CHECK (kind <> 'dm' OR dm_key IS NOT NULL)
|
||
);
|
||
|
||
CREATE UNIQUE INDEX IF NOT EXISTS conversations_dm_key_uniq
|
||
ON conversations (dm_key) WHERE kind = 'dm';
|
||
|
||
CREATE TABLE IF NOT EXISTS conversation_participants (
|
||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
role VARCHAR(8) NOT NULL DEFAULT 'member'
|
||
CHECK (role IN ('owner', 'admin', 'member')),
|
||
-- Delivery/read state lives here, per participant — NOT per message. A
|
||
-- receipt row per (message × participant) would grow with the product of
|
||
-- both; two cursors give the same three tick states at constant size:
|
||
-- sent → the row exists
|
||
-- delivered → last_delivered_seq >= message.seq
|
||
-- read → last_read_seq >= message.seq
|
||
last_read_seq BIGINT NOT NULL DEFAULT 0,
|
||
last_delivered_seq BIGINT NOT NULL DEFAULT 0,
|
||
muted_until TIMESTAMPTZ,
|
||
-- "Friends since" on the chat profile reads joined_at.
|
||
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
-- Non-null = left the group. Kept (rather than deleted) so their past
|
||
-- messages still resolve to a member row when rendering history.
|
||
left_at TIMESTAMPTZ,
|
||
PRIMARY KEY (conversation_id, user_id)
|
||
);
|
||
|
||
-- The inbox query: this user's threads, most recent first. Partial on left_at
|
||
-- so departed groups drop out without a filter on every read.
|
||
CREATE INDEX IF NOT EXISTS conversation_participants_inbox_idx
|
||
ON conversation_participants (user_id) WHERE left_at IS NULL;
|
||
|
||
CREATE TABLE IF NOT EXISTS messages (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||
seq BIGINT NOT NULL,
|
||
-- NULL for messages no user wrote: 'system' (membership changes) and
|
||
-- 'assistant' (Kautilya). SET NULL on user deletion keeps the thread
|
||
-- readable for everyone else.
|
||
sender_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||
kind VARCHAR(12) NOT NULL DEFAULT 'text'
|
||
CHECK (kind IN ('text', 'system', 'assistant')),
|
||
body TEXT NOT NULL DEFAULT '',
|
||
-- Inline formatting and detected links as offset/length spans
|
||
-- ([{type,offset,length}]), never HTML. Storing spans instead of markup
|
||
-- keeps rendering injection-safe and lets each client render natively.
|
||
entities JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||
-- Client-generated id, so a retried send is deduped rather than doubled.
|
||
client_msg_id UUID,
|
||
reply_to_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||
-- Set when this message was forwarded from another. SET NULL so deleting
|
||
-- the original leaves the forward intact.
|
||
forwarded_from_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||
edited_at TIMESTAMPTZ,
|
||
-- Delete-for-everyone: a tombstone, so the gap in `seq` is explainable and
|
||
-- other participants' clients can replace the bubble in place.
|
||
deleted_at TIMESTAMPTZ,
|
||
deleted_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
UNIQUE (conversation_id, seq)
|
||
);
|
||
|
||
-- History paging, newest first, and the LATERAL that fetches each inbox row's
|
||
-- last message.
|
||
CREATE INDEX IF NOT EXISTS messages_conversation_idx
|
||
ON messages (conversation_id, seq DESC);
|
||
|
||
-- Idempotent send. Partial because client_msg_id is NULL for system and
|
||
-- assistant messages, which no client retries.
|
||
CREATE UNIQUE INDEX IF NOT EXISTS messages_client_dedup
|
||
ON messages (conversation_id, client_msg_id) WHERE client_msg_id IS NOT NULL;
|
||
|
||
-- Delete-for-me. Distinct from messages.deleted_at (delete-for-everyone):
|
||
-- this hides a message from one person's view while others keep seeing it.
|
||
CREATE TABLE IF NOT EXISTS message_hides (
|
||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (message_id, user_id)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS message_attachments (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||
-- Denormalized from the parent message so the chat profile's "shared
|
||
-- media" and "shared links" tabs are one index range scan, with no join
|
||
-- back through messages.
|
||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||
ord SMALLINT NOT NULL DEFAULT 0,
|
||
kind VARCHAR(8) NOT NULL
|
||
CHECK (kind IN ('image', 'video', 'audio', 'file', 'link', 'entity')),
|
||
|
||
-- Media (kind in image/video/audio/file). r2_key points at the PRIVATE
|
||
-- bucket: chat media is never public-by-URL, it is read through a
|
||
-- participant-checked presigned GET.
|
||
r2_key TEXT,
|
||
mime VARCHAR(128),
|
||
size_bytes BIGINT,
|
||
width INTEGER,
|
||
height INTEGER,
|
||
duration_ms INTEGER,
|
||
blurhash VARCHAR(64), -- instant placeholder while loading
|
||
file_name TEXT,
|
||
|
||
-- Link preview (kind = 'link'). Unfurled server-side AFTER the message is
|
||
-- persisted, so a slow remote page never delays the send. Absent when the
|
||
-- sender dismissed the preview in the composer.
|
||
url TEXT,
|
||
title TEXT,
|
||
description TEXT,
|
||
site_name VARCHAR(120),
|
||
image_url TEXT,
|
||
|
||
-- Shared post / comment / profile / collection (kind = 'entity').
|
||
-- entity_id is TEXT because these ids are not all UUIDs, and there is
|
||
-- deliberately no FK: the entity may be deleted or turn private later.
|
||
-- `snapshot` is a fallback for rendering, but permission is re-checked
|
||
-- per viewer at read time — a collection made private must stop resolving
|
||
-- for the recipient, so the snapshot alone must never be trusted.
|
||
entity_type VARCHAR(16) CHECK (entity_type IS NULL OR
|
||
entity_type IN ('post', 'comment', 'profile', 'collection')),
|
||
entity_id TEXT,
|
||
snapshot JSONB,
|
||
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS message_attachments_message_idx
|
||
ON message_attachments (message_id, ord);
|
||
|
||
-- The chat profile's media / links tabs.
|
||
CREATE INDEX IF NOT EXISTS message_attachments_gallery_idx
|
||
ON message_attachments (conversation_id, kind, created_at DESC);
|
||
|
||
-- Starred messages are per-user, not per-conversation: two people in the same
|
||
-- thread star different things.
|
||
CREATE TABLE IF NOT EXISTS message_stars (
|
||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
-- Denormalized so "starred in this chat" is a single index range scan.
|
||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (message_id, user_id)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS message_stars_user_conversation_idx
|
||
ON message_stars (user_id, conversation_id, created_at DESC);
|
||
|
||
-- Reported messages. `snapshot` is the whole point: it copies the reported
|
||
-- message (and limited surrounding context) at report time so moderators read
|
||
-- a report record and never need read access to live threads. message_id is
|
||
-- SET NULL on delete so a report survives the sender deleting the evidence.
|
||
CREATE TABLE IF NOT EXISTS message_reports (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
|
||
reported_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||
category VARCHAR(32) NOT NULL,
|
||
reason TEXT NOT NULL DEFAULT '',
|
||
snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
resolved_at TIMESTAMPTZ,
|
||
resolution_note TEXT
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS message_reports_unresolved_idx
|
||
ON message_reports (created_at DESC) WHERE resolved_at IS NULL;
|