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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user