feat(threads): add real-time forum and chat system

Implement DB schema, API, and WebSocket for channels and messages
Add frontend views, AI moderation, and admin management
This commit is contained in:
2026-05-07 11:28:14 +08:00
parent 23a7301598
commit cbb101336b
16 changed files with 3567 additions and 10 deletions

View File

@@ -267,6 +267,17 @@ VALUES
('life.comments.like', 'Like Life comments', 'Like and unlike Life comments.', 'Life', true),
('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', 'Life', true),
('life.ratings.set', 'Set Life ratings', 'Set and remove Life star ratings.', 'Life', true),
('threads.create', 'Create Threads', 'Create forum threads.', 'Threads', true),
('threads.messages.create', 'Create Thread messages', 'Create chat messages inside Threads.', 'Threads', true),
('threads.follow', 'Follow Threads', 'Follow Threads and manage read state.', 'Threads', true),
('threads.reactions.set', 'Set Thread reactions', 'Set and remove Thread and Thread message reactions.', 'Threads', true),
('admin.threads.channels.read', 'View Thread channels', 'View Thread channel configuration.', 'Threads', true),
('admin.threads.channels.create', 'Create Thread channels', 'Create Thread channels.', 'Threads', true),
('admin.threads.channels.update', 'Update Thread channels', 'Edit Thread channel configuration.', 'Threads', true),
('admin.threads.channels.delete', 'Delete Thread channels', 'Delete Thread channels.', 'Threads', true),
('admin.threads.threads.delete', 'Delete any Thread', 'Delete any Thread.', 'Threads', true),
('admin.threads.threads.lock', 'Lock Threads', 'Lock and unlock Threads.', 'Threads', true),
('admin.threads.messages.delete', 'Delete any Thread message', 'Delete any Thread message.', 'Threads', true),
('users.follow', 'Follow users', 'Follow and unfollow public user profiles.', 'Users', true),
('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true),
('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true),
@@ -367,6 +378,17 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.like',
'life.reactions.set',
'life.ratings.set',
'threads.create',
'threads.messages.create',
'threads.follow',
'threads.reactions.set',
'admin.threads.channels.read',
'admin.threads.channels.create',
'admin.threads.channels.update',
'admin.threads.channels.delete',
'admin.threads.threads.delete',
'admin.threads.threads.lock',
'admin.threads.messages.delete',
'users.follow',
'discussions.comments.create',
'discussions.comments.delete',
@@ -440,6 +462,10 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.like',
'life.reactions.set',
'life.ratings.set',
'threads.create',
'threads.messages.create',
'threads.follow',
'threads.reactions.set',
'users.follow',
'discussions.comments.create',
'discussions.comments.delete',
@@ -513,6 +539,10 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.like',
'life.reactions.set',
'life.ratings.set',
'threads.create',
'threads.messages.create',
'threads.follow',
'threads.reactions.set',
'users.follow',
'discussions.comments.create',
'discussions.comments.delete',
@@ -554,6 +584,33 @@ JOIN permissions p ON p.key = 'users.follow'
WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
'threads.create',
'threads.messages.create',
'threads.follow',
'threads.reactions.set'
])
WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
'admin.threads.channels.read',
'admin.threads.channels.create',
'admin.threads.channels.update',
'admin.threads.channels.delete',
'admin.threads.threads.delete',
'admin.threads.threads.lock',
'admin.threads.messages.delete'
])
WHERE r.key = 'admin'
ON CONFLICT DO NOTHING;
WITH first_owner_user AS (
SELECT u.id
FROM users u
@@ -805,6 +862,184 @@ CREATE INDEX IF NOT EXISTS life_post_ratings_post_idx
CREATE INDEX IF NOT EXISTS life_post_ratings_user_idx
ON life_post_ratings(user_id, updated_at DESC, post_id DESC);
CREATE TABLE IF NOT EXISTS thread_channels (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
allow_user_threads boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (length(name) BETWEEN 1 AND 80)
);
CREATE INDEX IF NOT EXISTS thread_channels_sort_order_idx
ON thread_channels(sort_order, id);
INSERT INTO thread_channels (name, allow_user_threads, sort_order)
VALUES
('General', true, 10),
('Questions', true, 20),
('Showcase', true, 30)
ON CONFLICT (name) DO NOTHING;
CREATE TABLE IF NOT EXISTS thread_channel_tags (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
channel_id integer NOT NULL REFERENCES thread_channels(id) ON DELETE CASCADE,
name text NOT NULL,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (channel_id, name),
CHECK (length(name) BETWEEN 1 AND 40)
);
CREATE INDEX IF NOT EXISTS thread_channel_tags_channel_sort_idx
ON thread_channel_tags(channel_id, sort_order, id);
CREATE TABLE IF NOT EXISTS thread_channel_languages (
channel_id integer NOT NULL REFERENCES thread_channels(id) ON DELETE CASCADE,
language_code text NOT NULL REFERENCES languages(code) ON DELETE CASCADE,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
PRIMARY KEY (channel_id, language_code)
);
CREATE INDEX IF NOT EXISTS thread_channel_languages_sort_idx
ON thread_channel_languages(channel_id, sort_order, language_code);
CREATE TABLE IF NOT EXISTS threads (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
channel_id integer NOT NULL REFERENCES thread_channels(id) ON DELETE CASCADE,
title text NOT NULL,
language_code text NOT NULL REFERENCES languages(code) ON DELETE RESTRICT,
locked boolean NOT NULL DEFAULT false,
message_count integer NOT NULL DEFAULT 0 CHECK (message_count >= 0),
last_message_id integer,
last_active_at timestamptz NOT NULL DEFAULT now(),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
deleted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (length(title) BETWEEN 1 AND 140)
);
CREATE INDEX IF NOT EXISTS threads_channel_last_active_idx
ON threads(channel_id, last_active_at DESC, id DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS threads_created_at_idx
ON threads(created_at DESC, id DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS threads_language_idx
ON threads(language_code, last_active_at DESC, id DESC)
WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS thread_tag_links (
thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
tag_id integer NOT NULL REFERENCES thread_channel_tags(id) ON DELETE CASCADE,
PRIMARY KEY (thread_id, tag_id)
);
CREATE INDEX IF NOT EXISTS thread_tag_links_tag_idx
ON thread_tag_links(tag_id, thread_id);
CREATE TABLE IF NOT EXISTS thread_messages (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
ai_moderation_reason text,
ai_moderation_content_hash text,
ai_moderation_checked_at timestamptz,
ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
ai_moderation_updated_at timestamptz NOT NULL DEFAULT now(),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
deleted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'threads_last_message_fk'
) THEN
ALTER TABLE threads
ADD CONSTRAINT threads_last_message_fk
FOREIGN KEY (last_message_id) REFERENCES thread_messages(id) ON DELETE SET NULL;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS thread_messages_thread_created_idx
ON thread_messages(thread_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS thread_messages_user_idx
ON thread_messages(created_by_user_id, created_at DESC, id DESC);
CREATE TABLE IF NOT EXISTS thread_reactions (
thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reaction_type text NOT NULL CHECK (reaction_type IN ('thumbs-up', 'heart', 'laugh', 'fire', 'eyes')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (thread_id, user_id, reaction_type)
);
CREATE INDEX IF NOT EXISTS thread_reactions_thread_idx
ON thread_reactions(thread_id, reaction_type);
CREATE TABLE IF NOT EXISTS thread_message_reactions (
message_id integer NOT NULL REFERENCES thread_messages(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reaction_type text NOT NULL CHECK (reaction_type IN ('thumbs-up', 'heart', 'laugh', 'fire', 'eyes')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, user_id, reaction_type)
);
CREATE INDEX IF NOT EXISTS thread_message_reactions_message_idx
ON thread_message_reactions(message_id, reaction_type);
CREATE TABLE IF NOT EXISTS thread_follows (
thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (thread_id, user_id)
);
CREATE INDEX IF NOT EXISTS thread_follows_user_idx
ON thread_follows(user_id, created_at DESC, thread_id DESC);
CREATE TABLE IF NOT EXISTS thread_reads (
thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
last_read_message_id integer REFERENCES thread_messages(id) ON DELETE SET NULL,
last_read_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (thread_id, user_id)
);
CREATE INDEX IF NOT EXISTS thread_reads_user_idx
ON thread_reads(user_id, thread_id);
CREATE TABLE IF NOT EXISTS thread_ws_tickets (
ticket_hash text PRIMARY KEY,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (length(ticket_hash) BETWEEN 32 AND 128)
);
CREATE INDEX IF NOT EXISTS thread_ws_tickets_expires_idx
ON thread_ws_tickets(expires_at);
CREATE TABLE IF NOT EXISTS skills (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,