feat: add chat

This commit is contained in:
Krispire Admin
2026-08-29 03:49:30 +05:30
parent 3b10e726b7
commit d50031cdb3
21 changed files with 6960 additions and 11 deletions
+203
View File
@@ -0,0 +1,203 @@
-- 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;