Files
spsays-bff/migrations/0048_create_chat.up.sql
T
2026-08-29 03:49:30 +05:30

204 lines
10 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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;