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

141
DESIGN.md
View File

@@ -6,6 +6,7 @@
- 所有人都可以浏览 Wiki 内容。 - 所有人都可以浏览 Wiki 内容。
- 已注册并完成邮箱验证且拥有对应权限的用户可以创建、编辑、删除 Wiki 内容。 - 已注册并完成邮箱验证且拥有对应权限的用户可以创建、编辑、删除 Wiki 内容。
- 前台以 Home 首页、PokedexMain Game / Event、Habitat DexMain Game / Event、CollectionsMain Game / Event / Ancient Artifacts、材料单、每日 CheckList、Life、Automation、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。 - 前台以 Home 首页、PokedexMain Game / Event、Habitat DexMain Game / Event、CollectionsMain Game / Event / Ancient Artifacts、材料单、每日 CheckList、Life、Automation、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
- Threads 是社区讨论入口,采用 Discord Forum + 聊天室混合形态;用户按 Channel 浏览 Thread并在 Thread 内使用聊天室式消息流讨论。
- Home 首页路径为 `/`,用于聚合公开 Wiki 入口Logo 导航回到 Home用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。 - Home 首页路径为 `/`,用于聚合公开 Wiki 入口Logo 导航回到 Home用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。
- 桌面端使用侧边栏导航,侧边栏可折叠为图标栏;移动端继续使用抽屉式侧边栏。 - 桌面端使用侧边栏导航,侧边栏可折叠为图标栏;移动端继续使用抽屉式侧边栏。
- 全局顶部导航栏承载语言切换、通知、User Profile 和登录 / 退出等账号操作;除 User Profile 可展示用户名外,顶部操作以图标按钮呈现。 - 全局顶部导航栏承载语言切换、通知、User Profile 和登录 / 退出等账号操作;除 User Profile 可展示用户名外,顶部操作以图标按钮呈现。
@@ -302,6 +303,7 @@
- 通知 API 不返回邮箱、角色、权限、session、token/hash、AI prompt、模型响应、内部审核错误、错误堆栈、调试字段或内部审计 payload。 - 通知 API 不返回邮箱、角色、权限、session、token/hash、AI prompt、模型响应、内部审核错误、错误堆栈、调试字段或内部审计 payload。
- 前端在主导航登录区展示通知入口、未读数量和通知列表;点击通知后标记已读并跳转到对应 Life Post 或 Wiki 详情页。 - 前端在主导航登录区展示通知入口、未读数量和通知列表;点击通知后标记已读并跳转到对应 Life Post 或 Wiki 详情页。
- Follow 对象发布 Life Post 的动态属于 Following Feed不进入 Notifications不产生未读数量也不需要标记已读。 - Follow 对象发布 Life Post 的动态属于 Following Feed不进入 Notifications不产生未读数量也不需要标记已读。
- Thread Follow 的未读状态在 Threads 自身侧边栏和 Thread 列表展示,不进入全局 Notifications不影响 NotificationBell 未读数量。
## 滥用防护与限流 ## 滥用防护与限流
@@ -328,8 +330,8 @@
- Wiki 内容写入Pokemon、物品、材料单、栖息地、每日 CheckList 和排序)默认按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间。 - Wiki 内容写入Pokemon、物品、材料单、栖息地、每日 CheckList 和排序)默认按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间。
- 上传默认按用户 ID 限制为 20 次 / 1 小时,并有 30 秒冷却时间。 - 上传默认按用户 ID 限制为 20 次 / 1 小时,并有 30 秒冷却时间。
- Community 写入: - Community 写入:
- Life Post、Life 评论、Wiki 讨论评论和对应删除 / 更新操作默认按用户 ID 限制为 60 次 / 1 小时,并有 5 秒冷却时间。 - Life Post、Life 评论、Wiki 讨论评论、Thread / Thread Message 和对应删除 / 更新操作默认按用户 ID 限制为 60 次 / 1 小时,并有 5 秒冷却时间。
- Life reaction 写入默认按用户 ID 限制为 120 次 / 1 小时,并有 1 秒冷却时间。 - Life reaction 和 Thread reaction / follow 写入默认按用户 ID 限制为 120 次 / 1 小时,并有 1 秒冷却时间。
- Pokemon Fetch 数据和图片候选查询默认按用户 ID 限制为 60 次 / 10 分钟,并有 1 秒冷却时间。 - Pokemon Fetch 数据和图片候选查询默认按用户 ID 限制为 60 次 / 10 分钟,并有 1 秒冷却时间。
## Community 编辑与审计 ## Community 编辑与审计
@@ -954,6 +956,114 @@ API 暴露边界:
- API 不返回 Life Post 的 `deleted_at``deleted_by_user_id` 等内部软删除字段。 - API 不返回 Life Post 的 `deleted_at``deleted_by_user_id` 等内部软删除字段。
- 非作者只有拥有对应 `*-any` 管理权限时才能编辑或删除其他用户的 Life Post 或 Life Comment。 - 非作者只有拥有对应 `*-any` 管理权限时才能编辑或删除其他用户的 Life Post 或 Life Comment。
## Threads
Threads 是社区长期讨论区,形态类似 Discord Forum + 聊天室混合系统。
Channel 可配置:
- 名称
- 是否允许用户创建 Thread
- 可用标签:每个 Channel 内唯一,按 `sort_order` 展示
- 可用语言:使用 `languages.code`,可配置允许的语言集合;未配置时前台回退到启用语言
- `sort_order`
Thread 可配置:
- 标题
- 所属 Channel
- 标签:多选,只能选择该 Channel 可用标签
- 语言:只能选择该 Channel 可用语言或启用语言中的一项
- 创建者、创建时间、最后活跃时间
- 消息数
- Follow 状态
- Reaction 汇总
- 锁定状态
Message 可配置:
- 所属 Thread
- 正文
- 创建者、创建时间
- Reaction 汇总
- AI 审核状态和语言区
前台行为:
- 所有人都可以浏览已公开的 Channel、Thread 和审核通过的 Message。
- `/threads` 展示 Threads 工作区,左侧为 Channel 列表,中间为 Thread List桌面端 Thread 详情使用聊天布局,移动端通过详情页堆叠显示。
- `/threads/:threadId` 打开 Thread 详情;默认进入最新消息位置。
- 用户可在 Channel 内创建 Thread需要已注册、邮箱已验证并拥有 `threads.create` 权限,且 Channel 允许用户创建 Thread。
- 已注册、邮箱已验证并拥有 `threads.messages.create` 权限的用户可以在未锁定 Thread 中发送 Message。
- Message 列表按创建时间正序展示,新消息出现在底部。
- 初始读取最新一页 Message向上滚动或点击 To Top 加载更早历史消息。
- 有新消息且用户不在底部时显示 Jump to Present点击后滚动到最新消息。
- 连续 Message 在展示层自动合并:同一用户连续发送,且相邻消息时间间隔不超过 5 分钟;合并组只显示一次 Avatar、Username 和组首条 Timestamp。合并窗口默认 5 分钟,后续可由系统配置扩展。
- Thread 支持 Follow / UnfollowFollow 后新审核通过 Message 会让 Threads Sidebar 和 Thread List 显示未读红点或未读提示。
- Thread 详情支持未读消息分隔线;用户进入最新位置或显式标记已读后更新 `thread_reads`
- Thread 和 Message 支持 Emoji Reaction内置类型为 `thumbs-up``heart``laugh``fire``eyes`API 只返回各类型数量和当前用户自己的 Reaction不内嵌用户列表。
- Thread List 支持排序:`last-active` 默认按最后活跃倒序;`latest` 按创建时间倒序;`most-discussed` 按公开消息数倒序。
- Thread List 支持语言筛选All languages 或指定启用语言 / Channel 可用语言。
- Thread List 支持按 Channel 标签筛选。
- Thread 新消息实时更新通过 Thread WebSocketWebSocket 使用短期一次性 ticket不把 session token 放入 WebSocket URL。
- Thread Message 是用户生成内容,必须经过 AI 审核;未审核通过的 Message 不向普通访客公开。作者本人和拥有 `admin.threads.messages.delete` 权限的管理用户可以看到自己的未通过/审核中 Message 状态。
- 审核通过的 Message 才计入普通公开消息数、最后活跃排序和未读状态。
- Thread 被锁定后不可新增 Message但仍可浏览和设置 Reaction / Follow。
- 删除 Thread 使用软删除;删除后不出现在列表,详情返回未找到。
- 删除 Message 使用软删除;普通列表不展示已删除 Message不暴露删除字段。
管理员行为:
- 拥有 `admin.threads.channels.read` 可查看 Channel 管理。
- 拥有 `admin.threads.channels.create` / `update` / `delete` 可创建、编辑、删除 Channel配置标签、语言和是否允许用户创建 Thread。
- 拥有 `admin.threads.threads.delete` 可删除任意 Thread。
- 拥有 `admin.threads.threads.lock` 可锁定 / 解锁任意 Thread。
- 拥有 `admin.threads.messages.delete` 可删除任意 Message。
API 暴露边界:
- Channel API 只返回展示和管理需要的 `id``name``allowUserThreads``tags``languages``sortOrder` 和未读摘要;不返回内部审计或调试字段。
- Thread API 只返回 `id``channelId``title`、标签、语言、作者必要署名、创建时间、最后活跃时间、锁定状态、消息数、Reaction 汇总、当前用户 Reaction、Follow 状态和未读状态。
- Message API 只返回 `id``threadId``body`、作者必要署名、创建时间、审核状态、语言区、必要审核原因、Reaction 汇总和当前用户 Reaction。
- API 不返回邮箱、角色、权限、session、token/hash、AI prompt、模型响应、内部审核错误、错误堆栈、删除时间、删除人或内部调试字段。
- Thread 内容正文按作者输入展示,不进入 `entity_translations`Thread 的语言用于筛选和内容区分,不改变系统 UI 语言。
- Channel 名称和标签当前作为管理数据直接存储,不进入 `entity_translations`
当前实现状态:
已实现:
- 数据库已包含 `thread_channels``thread_channel_tags``thread_channel_languages``threads``thread_tag_links``thread_messages``thread_reactions``thread_message_reactions``thread_follows``thread_reads``thread_ws_tickets`
- 初始化会创建默认 ChannelGeneral、Questions、Showcase。
- RBAC 已包含 Thread 用户权限:`threads.create``threads.messages.create``threads.follow``threads.reactions.set`
- RBAC 已包含 Thread 管理权限:`admin.threads.channels.*``admin.threads.threads.delete``admin.threads.threads.lock``admin.threads.messages.delete`
- 公开 API 已支持读取 Channel、分页读取 Thread、读取单个 Thread、读取 Thread Message 历史。
- 写入 API 已支持创建 Thread、发送 Message、Follow / Unfollow、标记已读、设置 / 取消 Thread Reaction、设置 / 取消 Message Reaction。
- 管理 API 已支持创建、编辑、删除 Channel锁定 / 解锁 Thread删除 Thread删除 Message。
- Thread Message 已接入 AI 审核队列;审核通过后才更新 Thread 的公开 `message_count``last_message_id``last_active_at`
- Thread WebSocket 已实现短期 ticket 连接,并可推送新审核通过 Message、Reaction 更新和当前用户 read 状态更新。
- 前端已新增 `/threads``/threads/:threadId`,包含 Channel Sidebar、Thread List、Thread 详情聊天布局、创建 Thread、发送 Message、Follow / Unfollow、Reaction、管理员锁定 / 解锁 Thread、管理员删除 Thread 和管理员删除 Message。
- 前端 Message 展示已支持同一用户 5 分钟内连续消息的合并显示。
- 前端 Message 历史已支持点击 Load older 向上加载更早消息。
- 前端已支持 Jump to Present用户不在底部且收到新消息时可跳到最新。
- 前端 Thread List 已支持 Channel、标签、语言和排序筛选。
- 前端管理端已新增 Thread Channels 管理入口,可配置 Channel 名称、是否允许用户创建 Thread、标签和语言。
未实现 / 待完善:
- Thread 详情中的未读消息分隔线尚未完整实现;当前已记录 read 状态并显示列表未读红点,但没有在消息流中定位并渲染 unread divider。
- WebSocket 没有自动重连、退避重试或跨标签页连接复用;连接断开后需页面重新加载或后续操作重新进入。
- Reaction 用户列表 Modal 尚未实现;当前只显示 Reaction 类型和数量,以及当前用户自己的 Reaction 状态。
- Thread / Message Reaction 取消 API 当前通过 JSON body 传入 `reactionType`,前端可用;若后续需要更标准的 REST 形态,可改为 `DELETE /reaction/:reactionType`
- Channel 排序 UI 尚未实现;数据库已有 `sort_order`,但管理端目前不能拖拽或调整 Channel / Tag / Language 顺序。
- Channel 名称和标签尚未进入 `entity_translations`;当前按管理数据原文展示。
- Thread 创建后的首条 Message 如果审核失败Thread 会存在但普通访客看不到公开 Message前端尚未提供 Message 审核重试入口。
- Thread Message 审核失败 / 拒绝后的重试 API 和 UI 尚未实现。
- Thread 删除、Message 删除和锁定 / 解锁当前直接执行,尚未使用确认 Modal。
- Thread List 的实时排序更新是基础 upsert 行为;复杂筛选条件下收到不匹配当前筛选的新 Thread / Message 时,仍可能需要后续刷新来得到完全一致的列表。
- 移动端已使用响应式堆叠布局,但还不是独立的移动端双页导航体验;后续可优化为 Channel / Thread List / Chat 分步视图。
- 当前没有 Thread 搜索、置顶、收藏、编辑 Thread 标题 / 标签 / 语言、编辑 Message、上传图片、@mention 或通知到全局 NotificationBell。
## 开发中入口 ## 开发中入口
以下前台公开入口当前仅展示“正在开发中”占位页,不提供数据模型、后端 API、编辑表单、管理入口或排序能力 以下前台公开入口当前仅展示“正在开发中”占位页,不提供数据模型、后端 API、编辑表单、管理入口或排序能力
@@ -1102,6 +1212,12 @@ API 暴露边界:
- `GET /api/life-posts/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。 - `GET /api/life-posts/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。
- `GET /api/life-posts/:id/reactions`:分页读取该 Life Post 的公开 Reaction 用户列表;支持 `cursor` / `limit``reactionType` 筛选。 - `GET /api/life-posts/:id/reactions`:分页读取该 Life Post 的公开 Reaction 用户列表;支持 `cursor` / `limit``reactionType` 筛选。
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选;支持 `sort``oldest``latest``most-liked``most-replied` - `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选;支持 `sort``oldest``latest``most-liked``most-replied`
- `GET /api/thread-channels`:读取公开 Channel 列表,登录用户可同时得到 Follow Thread 的未读摘要。
- `GET /api/threads`:支持 `cursor` / `limit` 分页读取 Thread支持 `channelId``language``tagId``sort``last-active``latest``most-discussed`)。
- `GET /api/threads/:id`:读取单个 Thread 详情。
- `GET /api/threads/:id/messages`:读取 Thread 消息;默认返回最新一页,支持 `before` / `limit` 向上加载历史。
- `POST /api/threads/ws-ticket`:创建短期一次性 Thread WebSocket ticket需要登录。
- `GET /api/threads/ws?ticket=...`Thread WebSocket 连接;只接收短期一次性 ticket。
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计、公开社区统计和公开 Follow 统计;登录用户读取时返回自己与目标用户的关系状态。 - `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计、公开社区统计和公开 Follow 统计;登录用户读取时返回自己与目标用户的关系状态。
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。 - `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。 - `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
@@ -1174,6 +1290,27 @@ API 暴露边界:
- 实体讨论评论的点赞和取消点赞需要 `discussions.comments.like` 权限。 - 实体讨论评论的点赞和取消点赞需要 `discussions.comments.like` 权限。
- `PUT /api/discussions/comments/:id/like` - `PUT /api/discussions/comments/:id/like`
- `DELETE /api/discussions/comments/:id/like` - `DELETE /api/discussions/comments/:id/like`
- Thread 创建需要 `threads.create`
- `POST /api/threads`
- Thread Message 创建需要 `threads.messages.create`
- `POST /api/threads/:id/messages`
- Thread Follow 需要 `threads.follow`
- `PUT /api/threads/:id/follow`
- `DELETE /api/threads/:id/follow`
- `POST /api/threads/:id/read`
- Thread 和 Message Reaction 需要 `threads.reactions.set`
- `PUT /api/threads/:id/reaction`
- `DELETE /api/threads/:id/reaction`
- `PUT /api/thread-messages/:id/reaction`
- `DELETE /api/thread-messages/:id/reaction`
- Thread 管理需要 `admin.threads.*` 权限。
- `GET /api/admin/thread-channels`
- `POST /api/admin/thread-channels`
- `PUT /api/admin/thread-channels/:id`
- `DELETE /api/admin/thread-channels/:id`
- `PUT /api/admin/threads/:id/lock`
- `DELETE /api/admin/threads/:id`
- `DELETE /api/admin/thread-messages/:id`
- Life Reaction 的设置、替换和取消。 - Life Reaction 的设置、替换和取消。
- `PUT /api/life-posts/:id/reaction` - `PUT /api/life-posts/:id/reaction`
- `DELETE /api/life-posts/:id/reaction` - `DELETE /api/life-posts/:id/reaction`

View File

@@ -267,6 +267,17 @@ VALUES
('life.comments.like', 'Like Life comments', 'Like and unlike Life comments.', 'Life', true), ('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.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), ('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), ('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.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), ('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.comments.like',
'life.reactions.set', 'life.reactions.set',
'life.ratings.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', 'users.follow',
'discussions.comments.create', 'discussions.comments.create',
'discussions.comments.delete', 'discussions.comments.delete',
@@ -440,6 +462,10 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.like', 'life.comments.like',
'life.reactions.set', 'life.reactions.set',
'life.ratings.set', 'life.ratings.set',
'threads.create',
'threads.messages.create',
'threads.follow',
'threads.reactions.set',
'users.follow', 'users.follow',
'discussions.comments.create', 'discussions.comments.create',
'discussions.comments.delete', 'discussions.comments.delete',
@@ -513,6 +539,10 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.like', 'life.comments.like',
'life.reactions.set', 'life.reactions.set',
'life.ratings.set', 'life.ratings.set',
'threads.create',
'threads.messages.create',
'threads.follow',
'threads.reactions.set',
'users.follow', 'users.follow',
'discussions.comments.create', 'discussions.comments.create',
'discussions.comments.delete', 'discussions.comments.delete',
@@ -554,6 +584,33 @@ JOIN permissions p ON p.key = 'users.follow'
WHERE r.key IN ('admin', 'editor', 'member') WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING; 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 ( WITH first_owner_user AS (
SELECT u.id SELECT u.id
FROM users u 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 CREATE INDEX IF NOT EXISTS life_post_ratings_user_idx
ON life_post_ratings(user_id, updated_at DESC, post_id DESC); 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 ( CREATE TABLE IF NOT EXISTS skills (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE, name text NOT NULL UNIQUE,

View File

@@ -5,9 +5,10 @@ import {
createApprovedCommentNotification, createApprovedCommentNotification,
createModerationResultNotification createModerationResultNotification
} from './notifications.ts'; } from './notifications.ts';
import { applyApprovedThreadMessage, publishThreadMessageModeration } from './threadsRealtime.ts';
export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed'; export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed';
export type AiModerationTargetType = 'life-post' | 'life-comment' | 'discussion-comment'; export type AiModerationTargetType = 'life-post' | 'life-comment' | 'discussion-comment' | 'thread-message';
export type AiModerationApiFormat = 'gemini-generate-content' | 'openai-chat-completions'; export type AiModerationApiFormat = 'gemini-generate-content' | 'openai-chat-completions';
export type AiModerationAuthMode = 'query-key' | 'bearer-token'; export type AiModerationAuthMode = 'query-key' | 'bearer-token';
@@ -254,6 +255,49 @@ const targetQueries: Record<
AND deleted_at IS NULL AND deleted_at IS NULL
RETURNING id RETURNING id
` `
},
'thread-message': {
select: `
SELECT
tm.id,
tm.body,
tm.ai_moderation_status AS status,
tm.ai_moderation_language_code AS "languageCode",
tm.ai_moderation_reason AS reason,
tm.ai_moderation_content_hash AS "contentHash"
FROM thread_messages tm
JOIN threads t ON t.id = tm.thread_id
WHERE tm.id = $1
AND tm.deleted_at IS NULL
AND t.deleted_at IS NULL
`,
updateStatus: `
UPDATE thread_messages
SET ai_moderation_status = $2,
ai_moderation_language_code = $3,
ai_moderation_reason = CASE WHEN $2 IN ('rejected', 'failed') THEN $4 ELSE NULL END,
ai_moderation_checked_at = now(),
ai_moderation_updated_at = now()
WHERE id = $1
AND deleted_at IS NULL
`,
updateForReview: `
UPDATE thread_messages
SET ai_moderation_status = 'reviewing',
ai_moderation_language_code = $2,
ai_moderation_reason = NULL,
ai_moderation_content_hash = $3,
ai_moderation_checked_at = NULL,
ai_moderation_retry_count = CASE
WHEN $4::boolean THEN 0
WHEN $5::boolean THEN ai_moderation_retry_count + 1
ELSE ai_moderation_retry_count
END,
ai_moderation_updated_at = now()
WHERE id = $1
AND deleted_at IS NULL
RETURNING id
`
} }
}; };
@@ -595,6 +639,15 @@ async function enqueuePendingAiModeration(): Promise<void> {
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND ai_moderation_status IN ('unreviewed', 'reviewing') AND ai_moderation_status IN ('unreviewed', 'reviewing')
UNION ALL
SELECT 'thread-message'::text AS type, tm.id
FROM thread_messages tm
JOIN threads t ON t.id = tm.thread_id
WHERE tm.deleted_at IS NULL
AND t.deleted_at IS NULL
AND tm.ai_moderation_status IN ('unreviewed', 'reviewing')
LIMIT $1 LIMIT $1
`, `,
[retryScanLimit] [retryScanLimit]
@@ -715,9 +768,28 @@ async function updateTargetStatus(
} }
try { try {
await createModerationResultNotification(target, status); if (target.type === 'thread-message') {
if (status === 'approved') { if (status === 'approved') {
await createApprovedCommentNotification(target); await applyApprovedThreadMessage(target.id);
} else {
const row = await queryOne<{ threadId: number }>(
'SELECT thread_id AS "threadId" FROM thread_messages WHERE id = $1',
[target.id]
);
if (row) {
await publishThreadMessageModeration(row.threadId, null);
}
}
return;
}
const notificationTarget = {
type: target.type as Exclude<AiModerationTargetType, 'thread-message'>,
id: target.id
};
await createModerationResultNotification(notificationTarget, status);
if (status === 'approved') {
await createApprovedCommentNotification(notificationTarget);
} }
} catch (error) { } catch (error) {
logger?.warn( logger?.warn(

View File

@@ -945,7 +945,6 @@ export function setupNotificationWebSocketServer(server: Server, logger: Fastify
server.on('upgrade', async (request, socket) => { server.on('upgrade', async (request, socket) => {
const url = new URL(request.url ?? '/', 'http://localhost'); const url = new URL(request.url ?? '/', 'http://localhost');
if (url.pathname !== '/api/notifications/ws') { if (url.pathname !== '/api/notifications/ws') {
socket.destroy();
return; return;
} }

View File

@@ -17,6 +17,13 @@ import {
type AiModerationStatus type AiModerationStatus
} from './aiModeration.ts'; } from './aiModeration.ts';
import { createLifePostReactionNotification, createUserFollowNotification } from './notifications.ts'; import { createLifePostReactionNotification, createUserFollowNotification } from './notifications.ts';
import {
createThreadWebSocketTicket,
publishThreadMessageCreated,
publishThreadMessageModeration,
publishThreadReactionUpdated,
publishThreadReadUpdated
} from './threadsRealtime.ts';
type QueryValue = string | string[] | undefined; type QueryValue = string | string[] | undefined;
@@ -26,6 +33,53 @@ type ListPage<T> = {
nextCursor: string | null; nextCursor: string | null;
hasMore: boolean; hasMore: boolean;
}; };
export type ThreadReactionType = 'thumbs-up' | 'heart' | 'laugh' | 'fire' | 'eyes';
export type ThreadReactionCounts = Record<ThreadReactionType, number>;
export type ThreadChannelTag = { id: number; name: string; sortOrder: number };
export type ThreadChannel = {
id: number;
name: string;
allowUserThreads: boolean;
sortOrder: number;
tags: ThreadChannelTag[];
languages: Array<{ code: string; name: string }>;
unreadCount: number;
};
export type ThreadSummary = {
id: number;
channelId: number;
title: string;
languageCode: string;
tags: ThreadChannelTag[];
locked: boolean;
messageCount: number;
lastActiveAt: Date;
createdAt: Date;
author: { id: number; displayName: string } | null;
reactionCounts: ThreadReactionCounts;
myReactions: ThreadReactionType[];
followed: boolean;
unread: boolean;
};
export type ThreadMessage = {
id: number;
threadId: number;
body: string;
moderationStatus: AiModerationStatus;
moderationLanguageCode: string | null;
moderationReason: string | null;
createdAt: Date;
updatedAt: Date;
author: { id: number; displayName: string } | null;
reactionCounts: ThreadReactionCounts;
myReactions: ThreadReactionType[];
};
export type ThreadMessagesPage = {
items: ThreadMessage[];
beforeCursor: string | null;
hasMoreBefore: boolean;
};
export type ThreadsPage = ListPage<ThreadSummary>;
type DbClient = PoolClient; type DbClient = PoolClient;
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist'; type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
@@ -8837,6 +8891,873 @@ export async function importAdminHabitatsCsv(payload: Record<string, unknown>, u
return getAdminDataToolsSummary(); return getAdminDataToolsSummary();
} }
const threadReactionTypes: ThreadReactionType[] = ['thumbs-up', 'heart', 'laugh', 'fire', 'eyes'];
const defaultThreadLimit = 20;
const maxThreadLimit = 50;
const defaultThreadMessageLimit = 30;
const maxThreadMessageLimit = 80;
type ThreadCursor = { value: string; id: number };
type ThreadMessageCursor = { createdAt: string; id: number };
function emptyThreadReactionCounts(): ThreadReactionCounts {
return { 'thumbs-up': 0, heart: 0, laugh: 0, fire: 0, eyes: 0 };
}
function isThreadReactionType(value: unknown): value is ThreadReactionType {
return typeof value === 'string' && threadReactionTypes.includes(value as ThreadReactionType);
}
function cleanThreadReactionType(value: unknown): ThreadReactionType {
if (!isThreadReactionType(value)) {
throw validationError('server.validation.reactionInvalid');
}
return value;
}
function cleanThreadLimit(value: QueryValue, fallback = defaultThreadLimit, max = maxThreadLimit): number {
const raw = Number(asString(value));
return Number.isInteger(raw) && raw > 0 ? Math.min(raw, max) : fallback;
}
function encodeCursor(value: unknown): string {
return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
}
function decodeThreadCursor(value: QueryValue): ThreadCursor | null {
const cursor = asString(value);
if (!cursor) return null;
try {
const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
if (payload && typeof payload === 'object') {
const record = payload as Record<string, unknown>;
if (typeof record.value === 'string' && Number.isInteger(Number(record.id))) {
return { value: record.value, id: Number(record.id) };
}
}
} catch {
return null;
}
return null;
}
function decodeThreadMessageCursor(value: QueryValue): ThreadMessageCursor | null {
const cursor = asString(value);
if (!cursor) return null;
try {
const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
if (payload && typeof payload === 'object') {
const record = payload as Record<string, unknown>;
if (typeof record.createdAt === 'string' && Number.isInteger(Number(record.id))) {
return { createdAt: record.createdAt, id: Number(record.id) };
}
}
} catch {
return null;
}
return null;
}
function cleanThreadTitle(value: unknown): string {
const title = cleanName(value, 'server.validation.titleRequired');
if (title.length > 140) {
throw validationError('server.validation.valueTooLong');
}
return title;
}
function cleanThreadMessageBody(value: unknown): string {
const body = cleanName(value, 'server.validation.commentRequired');
if (body.length > 2000) {
throw validationError('server.validation.commentTooLong');
}
return body;
}
function cleanThreadLanguageCode(value: unknown): string {
const languageCode = cleanModerationLanguageCode(value);
if (!languageCode) {
throw validationError('server.validation.languageInvalid');
}
return languageCode;
}
function cleanThreadTagIds(value: unknown): number[] {
if (!Array.isArray(value)) {
return [];
}
return cleanIds(value).slice(0, 8);
}
async function publicThreadChannels(userId: number | null): Promise<ThreadChannel[]> {
const rows = await query<{
id: number;
name: string;
allowUserThreads: boolean;
sortOrder: number;
tags: ThreadChannelTag[] | null;
languages: Array<{ code: string; name: string }> | null;
unreadCount: number;
}>(
`
SELECT
tc.id,
tc.name,
tc.allow_user_threads AS "allowUserThreads",
tc.sort_order AS "sortOrder",
COALESCE(tags.items, '[]'::json) AS tags,
COALESCE(channel_languages.items, fallback_languages.items, '[]'::json) AS languages,
CASE
WHEN $1::integer IS NULL THEN 0
ELSE COALESCE(unread.count, 0)
END AS "unreadCount"
FROM thread_channels tc
LEFT JOIN LATERAL (
SELECT json_agg(json_build_object('id', tct.id, 'name', tct.name, 'sortOrder', tct.sort_order) ORDER BY tct.sort_order, tct.id) AS items
FROM thread_channel_tags tct
WHERE tct.channel_id = tc.id
) tags ON true
LEFT JOIN LATERAL (
SELECT json_agg(json_build_object('code', l.code, 'name', l.name) ORDER BY tcl.sort_order, l.sort_order, l.code) AS items
FROM thread_channel_languages tcl
JOIN languages l ON l.code = tcl.language_code
WHERE tcl.channel_id = tc.id
AND l.enabled = true
) channel_languages ON true
LEFT JOIN LATERAL (
SELECT json_agg(json_build_object('code', l.code, 'name', l.name) ORDER BY l.sort_order, l.code) AS items
FROM languages l
WHERE l.enabled = true
) fallback_languages ON true
LEFT JOIN LATERAL (
SELECT COUNT(*)::integer AS count
FROM thread_follows tf
JOIN threads t ON t.id = tf.thread_id
LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = tf.user_id
WHERE tf.user_id = $1::integer
AND t.channel_id = tc.id
AND t.deleted_at IS NULL
AND t.last_message_id IS NOT NULL
AND (tr.last_read_message_id IS NULL OR t.last_message_id > tr.last_read_message_id)
) unread ON true
ORDER BY tc.sort_order, tc.id
`,
[userId]
);
return rows.map((row) => ({
id: row.id,
name: row.name,
allowUserThreads: row.allowUserThreads,
sortOrder: row.sortOrder,
tags: row.tags ?? [],
languages: row.languages ?? [],
unreadCount: row.unreadCount
}));
}
export async function listThreadChannels(userId: number | null): Promise<ThreadChannel[]> {
return publicThreadChannels(userId);
}
export async function listAdminThreadChannels(): Promise<ThreadChannel[]> {
return publicThreadChannels(null);
}
async function channelAllowsLanguage(channelId: number, languageCode: string): Promise<boolean> {
const row = await queryOne<{ allowed: boolean }>(
`
SELECT CASE
WHEN EXISTS (SELECT 1 FROM thread_channel_languages WHERE channel_id = $1) THEN EXISTS (
SELECT 1
FROM thread_channel_languages tcl
JOIN languages l ON l.code = tcl.language_code
WHERE tcl.channel_id = $1 AND tcl.language_code = $2 AND l.enabled = true
)
ELSE EXISTS (SELECT 1 FROM languages WHERE code = $2 AND enabled = true)
END AS allowed
`,
[channelId, languageCode]
);
return row?.allowed === true;
}
async function validateThreadTags(channelId: number, tagIds: number[]): Promise<void> {
if (!tagIds.length) return;
const rows = await query<{ id: number }>(
'SELECT id FROM thread_channel_tags WHERE channel_id = $1 AND id = ANY($2::integer[])',
[channelId, tagIds]
);
if (rows.length !== tagIds.length) {
throw validationError('server.validation.invalidField');
}
}
async function threadReactionCounts(threadIds: number[], userId: number | null): Promise<{
counts: Map<number, ThreadReactionCounts>;
mine: Map<number, ThreadReactionType[]>;
}> {
const counts = new Map<number, ThreadReactionCounts>();
const mine = new Map<number, ThreadReactionType[]>();
for (const id of threadIds) counts.set(id, emptyThreadReactionCounts());
if (!threadIds.length) return { counts, mine };
const countRows = await query<{ threadId: number; reactionType: ThreadReactionType; count: number }>(
`
SELECT thread_id AS "threadId", reaction_type AS "reactionType", COUNT(*)::integer AS count
FROM thread_reactions
WHERE thread_id = ANY($1::integer[])
GROUP BY thread_id, reaction_type
`,
[threadIds]
);
for (const row of countRows) {
const item = counts.get(row.threadId);
if (item && isThreadReactionType(row.reactionType)) {
item[row.reactionType] = row.count;
}
}
if (userId !== null) {
const myRows = await query<{ threadId: number; reactionType: ThreadReactionType }>(
`
SELECT thread_id AS "threadId", reaction_type AS "reactionType"
FROM thread_reactions
WHERE user_id = $1
AND thread_id = ANY($2::integer[])
`,
[userId, threadIds]
);
for (const row of myRows) {
if (!isThreadReactionType(row.reactionType)) continue;
mine.set(row.threadId, [...(mine.get(row.threadId) ?? []), row.reactionType]);
}
}
return { counts, mine };
}
async function threadMessageReactionCounts(messageIds: number[], userId: number | null): Promise<{
counts: Map<number, ThreadReactionCounts>;
mine: Map<number, ThreadReactionType[]>;
}> {
const counts = new Map<number, ThreadReactionCounts>();
const mine = new Map<number, ThreadReactionType[]>();
for (const id of messageIds) counts.set(id, emptyThreadReactionCounts());
if (!messageIds.length) return { counts, mine };
const countRows = await query<{ messageId: number; reactionType: ThreadReactionType; count: number }>(
`
SELECT message_id AS "messageId", reaction_type AS "reactionType", COUNT(*)::integer AS count
FROM thread_message_reactions
WHERE message_id = ANY($1::integer[])
GROUP BY message_id, reaction_type
`,
[messageIds]
);
for (const row of countRows) {
const item = counts.get(row.messageId);
if (item && isThreadReactionType(row.reactionType)) {
item[row.reactionType] = row.count;
}
}
if (userId !== null) {
const myRows = await query<{ messageId: number; reactionType: ThreadReactionType }>(
`
SELECT message_id AS "messageId", reaction_type AS "reactionType"
FROM thread_message_reactions
WHERE user_id = $1
AND message_id = ANY($2::integer[])
`,
[userId, messageIds]
);
for (const row of myRows) {
if (!isThreadReactionType(row.reactionType)) continue;
mine.set(row.messageId, [...(mine.get(row.messageId) ?? []), row.reactionType]);
}
}
return { counts, mine };
}
async function hydrateThreads(rows: Array<ThreadSummary & { lastActiveCursor: string }>, userId: number | null): Promise<ThreadSummary[]> {
const ids = rows.map((row) => row.id);
const tags = await query<{ threadId: number; id: number; name: string; sortOrder: number }>(
`
SELECT ttl.thread_id AS "threadId", tct.id, tct.name, tct.sort_order AS "sortOrder"
FROM thread_tag_links ttl
JOIN thread_channel_tags tct ON tct.id = ttl.tag_id
WHERE ttl.thread_id = ANY($1::integer[])
ORDER BY tct.sort_order, tct.id
`,
[ids]
);
const tagsByThread = new Map<number, ThreadChannelTag[]>();
for (const tag of tags) {
tagsByThread.set(tag.threadId, [...(tagsByThread.get(tag.threadId) ?? []), { id: tag.id, name: tag.name, sortOrder: tag.sortOrder }]);
}
const reactions = await threadReactionCounts(ids, userId);
return rows.map((row) => ({
id: row.id,
channelId: row.channelId,
title: row.title,
languageCode: row.languageCode,
tags: tagsByThread.get(row.id) ?? [],
locked: row.locked,
messageCount: row.messageCount,
lastActiveAt: row.lastActiveAt,
createdAt: row.createdAt,
author: row.author,
reactionCounts: reactions.counts.get(row.id) ?? emptyThreadReactionCounts(),
myReactions: reactions.mine.get(row.id) ?? [],
followed: row.followed,
unread: row.unread
}));
}
export async function listThreads(paramsQuery: QueryParams, userId: number | null): Promise<ThreadsPage> {
const limit = cleanThreadLimit(paramsQuery.limit);
const channelId = optionalPositiveInteger(asString(paramsQuery.channelId), 'server.validation.invalidField');
const tagId = optionalPositiveInteger(asString(paramsQuery.tagId), 'server.validation.invalidField');
const language = asString(paramsQuery.language);
const sort = asString(paramsQuery.sort) ?? 'last-active';
const cursor = decodeThreadCursor(paramsQuery.cursor);
const conditions = ['t.deleted_at IS NULL'];
const params: unknown[] = [];
if (channelId !== null) {
params.push(channelId);
conditions.push(`t.channel_id = $${params.length}`);
}
if (tagId !== null) {
params.push(tagId);
conditions.push(`EXISTS (SELECT 1 FROM thread_tag_links ttl WHERE ttl.thread_id = t.id AND ttl.tag_id = $${params.length})`);
}
if (language && language !== 'all') {
params.push(cleanThreadLanguageCode(language));
conditions.push(`t.language_code = $${params.length}`);
}
const orderField = sort === 'latest' ? 't.created_at' : sort === 'most-discussed' ? 't.message_count' : 't.last_active_at';
const orderCursorField = sort === 'latest' ? 'created_at' : sort === 'most-discussed' ? 'message_count' : 'last_active_at';
if (cursor) {
params.push(cursor.value, cursor.id);
conditions.push(`(${orderField}, t.id) < ($${params.length - 1}::${sort === 'most-discussed' ? 'integer' : 'timestamptz'}, $${params.length}::integer)`);
}
params.push(limit + 1, userId);
const limitParam = params.length - 1;
const userParam = params.length;
const rows = await query<ThreadSummary & { lastActiveCursor: string; createdAtCursor: string; messageCountCursor: number }>(
`
SELECT
t.id,
t.channel_id AS "channelId",
t.title,
t.language_code AS "languageCode",
t.locked,
t.message_count AS "messageCount",
t.last_active_at AS "lastActiveAt",
t.last_active_at::text AS "lastActiveCursor",
t.created_at AS "createdAt",
t.created_at::text AS "createdAtCursor",
t.message_count AS "messageCountCursor",
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author,
($${userParam}::integer IS NOT NULL AND tf.user_id IS NOT NULL) AS followed,
($${userParam}::integer IS NOT NULL AND t.last_message_id IS NOT NULL AND (tr.last_read_message_id IS NULL OR t.last_message_id > tr.last_read_message_id)) AS unread
FROM threads t
LEFT JOIN users u ON u.id = t.created_by_user_id
LEFT JOIN thread_follows tf ON tf.thread_id = t.id AND tf.user_id = $${userParam}::integer
LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = $${userParam}::integer
WHERE ${conditions.join(' AND ')}
ORDER BY ${orderField} DESC, t.id DESC
LIMIT $${limitParam}
`,
params
);
const items = await hydrateThreads(rows.slice(0, limit), userId);
const last = rows.slice(0, limit).at(-1) as (typeof rows)[number] | undefined;
const nextValue = last ? (sort === 'latest' ? last.createdAtCursor : sort === 'most-discussed' ? String(last.messageCountCursor) : last.lastActiveCursor) : null;
return {
items,
nextCursor: rows.length > limit && last && nextValue ? encodeCursor({ value: nextValue, id: last.id }) : null,
hasMore: rows.length > limit
};
}
export async function getThread(threadIdValue: number, userId: number | null): Promise<ThreadSummary | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const rows = await query<ThreadSummary & { lastActiveCursor: string }>(
`
SELECT
t.id,
t.channel_id AS "channelId",
t.title,
t.language_code AS "languageCode",
t.locked,
t.message_count AS "messageCount",
t.last_active_at AS "lastActiveAt",
t.last_active_at::text AS "lastActiveCursor",
t.created_at AS "createdAt",
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author,
($2::integer IS NOT NULL AND tf.user_id IS NOT NULL) AS followed,
($2::integer IS NOT NULL AND t.last_message_id IS NOT NULL AND (tr.last_read_message_id IS NULL OR t.last_message_id > tr.last_read_message_id)) AS unread
FROM threads t
LEFT JOIN users u ON u.id = t.created_by_user_id
LEFT JOIN thread_follows tf ON tf.thread_id = t.id AND tf.user_id = $2::integer
LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = $2::integer
WHERE t.id = $1
AND t.deleted_at IS NULL
`,
[threadId, userId]
);
return (await hydrateThreads(rows, userId))[0] ?? null;
}
async function getThreadMessageById(messageId: number, userId: number | null, canViewAll = false): Promise<ThreadMessage | null> {
const rows = await query<ThreadMessage>(
`
SELECT
tm.id,
tm.thread_id AS "threadId",
tm.body,
tm.ai_moderation_status AS "moderationStatus",
tm.ai_moderation_language_code AS "moderationLanguageCode",
tm.ai_moderation_reason AS "moderationReason",
tm.created_at AS "createdAt",
tm.updated_at AS "updatedAt",
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author
FROM thread_messages tm
LEFT JOIN users u ON u.id = tm.created_by_user_id
WHERE tm.id = $1
AND tm.deleted_at IS NULL
AND ${moderationVisibilitySql('tm', 'tm.created_by_user_id', userId, canViewAll)}
`,
[messageId]
);
const row = rows[0];
if (!row) return null;
const reactions = await threadMessageReactionCounts([row.id], userId);
return {
...row,
reactionCounts: reactions.counts.get(row.id) ?? emptyThreadReactionCounts(),
myReactions: reactions.mine.get(row.id) ?? []
};
}
export async function listThreadMessages(
threadIdValue: number,
paramsQuery: QueryParams,
userId: number | null,
canViewAll = false
): Promise<ThreadMessagesPage | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const thread = await getThread(threadId, userId);
if (!thread) return null;
const limit = cleanThreadLimit(paramsQuery.limit, defaultThreadMessageLimit, maxThreadMessageLimit);
const before = decodeThreadMessageCursor(paramsQuery.before);
const conditions = ['tm.thread_id = $1', 'tm.deleted_at IS NULL'];
const params: unknown[] = [threadId];
if (before) {
params.push(before.createdAt, before.id);
conditions.push(`(tm.created_at, tm.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
}
if (!canViewAll) {
if (userId !== null) {
params.push(userId);
conditions.push(`(tm.ai_moderation_status = 'approved' OR tm.created_by_user_id = $${params.length})`);
} else {
conditions.push("tm.ai_moderation_status = 'approved'");
}
}
params.push(limit + 1);
const rows = await query<ThreadMessage & { createdAtCursor: string }>(
`
SELECT
tm.id,
tm.thread_id AS "threadId",
tm.body,
tm.ai_moderation_status AS "moderationStatus",
tm.ai_moderation_language_code AS "moderationLanguageCode",
tm.ai_moderation_reason AS "moderationReason",
tm.created_at AS "createdAt",
tm.created_at::text AS "createdAtCursor",
tm.updated_at AS "updatedAt",
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author
FROM thread_messages tm
LEFT JOIN users u ON u.id = tm.created_by_user_id
WHERE ${conditions.join(' AND ')}
ORDER BY tm.created_at DESC, tm.id DESC
LIMIT $${params.length}
`,
params
);
const pageRows = rows.slice(0, limit).reverse();
const reactions = await threadMessageReactionCounts(pageRows.map((row) => row.id), userId);
const items = pageRows.map((row) => ({
id: row.id,
threadId: row.threadId,
body: row.body,
moderationStatus: row.moderationStatus,
moderationLanguageCode: row.moderationLanguageCode,
moderationReason: row.moderationReason,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
author: row.author,
reactionCounts: reactions.counts.get(row.id) ?? emptyThreadReactionCounts(),
myReactions: reactions.mine.get(row.id) ?? []
}));
const oldest = rows.slice(0, limit).at(-1);
return {
items,
beforeCursor: rows.length > limit && oldest ? encodeCursor({ createdAt: oldest.createdAtCursor, id: oldest.id }) : null,
hasMoreBefore: rows.length > limit
};
}
export async function createThread(payload: Record<string, unknown>, userId: number): Promise<ThreadSummary> {
const channelId = requirePositiveInteger(payload.channelId, 'server.validation.invalidField');
const title = cleanThreadTitle(payload.title);
const languageCode = cleanThreadLanguageCode(payload.languageCode);
const tagIds = cleanThreadTagIds(payload.tagIds);
const messageBody = cleanThreadMessageBody(payload.body);
const channel = await queryOne<{ id: number; allowUserThreads: boolean }>(
'SELECT id, allow_user_threads AS "allowUserThreads" FROM thread_channels WHERE id = $1',
[channelId]
);
if (!channel || !channel.allowUserThreads) {
throw validationError('server.validation.invalidField');
}
if (!(await channelAllowsLanguage(channelId, languageCode))) {
throw validationError('server.validation.languageInvalid');
}
await validateThreadTags(channelId, tagIds);
const ids = await withTransaction(async (client) => {
const threadResult = await client.query<{ id: number }>(
`
INSERT INTO threads (channel_id, title, language_code, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $3, $4, $4)
RETURNING id
`,
[channelId, title, languageCode, userId]
);
const threadId = threadResult.rows[0].id;
for (const tagId of tagIds) {
await client.query('INSERT INTO thread_tag_links (thread_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [threadId, tagId]);
}
const messageResult = await client.query<{ id: number }>(
`
INSERT INTO thread_messages (thread_id, body, ai_moderation_status, ai_moderation_language_code, created_by_user_id)
VALUES ($1, $2, 'unreviewed', $3, $4)
RETURNING id
`,
[threadId, messageBody, languageCode, userId]
);
await client.query('INSERT INTO thread_follows (thread_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [threadId, userId]);
return { threadId, messageId: messageResult.rows[0].id };
});
await requestAiModerationReview({ type: 'thread-message', id: ids.messageId }, { languageCode, resetRetries: true });
return (await getThread(ids.threadId, userId)) as ThreadSummary;
}
export async function createThreadMessage(threadIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const body = cleanThreadMessageBody(payload.body);
const thread = await queryOne<{ id: number; locked: boolean; languageCode: string }>(
'SELECT id, locked, language_code AS "languageCode" FROM threads WHERE id = $1 AND deleted_at IS NULL',
[threadId]
);
if (!thread) return null;
if (thread.locked) {
throw validationError('server.validation.invalidField');
}
const result = await queryOne<{ id: number }>(
`
INSERT INTO thread_messages (thread_id, body, ai_moderation_status, ai_moderation_language_code, created_by_user_id)
VALUES ($1, $2, 'unreviewed', $3, $4)
RETURNING id
`,
[threadId, body, thread.languageCode, userId]
);
if (!result) return null;
await requestAiModerationReview({ type: 'thread-message', id: result.id }, { languageCode: thread.languageCode, resetRetries: true });
return getThreadMessageById(result.id, userId, false);
}
export async function markThreadRead(threadIdValue: number, userId: number): Promise<ThreadSummary | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const row = await queryOne<{ lastMessageId: number | null }>(
'SELECT last_message_id AS "lastMessageId" FROM threads WHERE id = $1 AND deleted_at IS NULL',
[threadId]
);
if (!row) return null;
await pool.query(
`
INSERT INTO thread_reads (thread_id, user_id, last_read_message_id, last_read_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (thread_id, user_id)
DO UPDATE SET last_read_message_id = EXCLUDED.last_read_message_id,
last_read_at = now()
`,
[threadId, userId, row.lastMessageId]
);
const thread = await getThread(threadId, userId);
await publishThreadReadUpdated(userId, threadId, thread?.unread ?? false, 0);
return thread;
}
export async function followThread(threadIdValue: number, userId: number): Promise<ThreadSummary | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const thread = await getThread(threadId, userId);
if (!thread) return null;
await pool.query('INSERT INTO thread_follows (thread_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [threadId, userId]);
return getThread(threadId, userId);
}
export async function unfollowThread(threadIdValue: number, userId: number): Promise<ThreadSummary | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const thread = await getThread(threadId, userId);
if (!thread) return null;
await pool.query('DELETE FROM thread_follows WHERE thread_id = $1 AND user_id = $2', [threadId, userId]);
return getThread(threadId, userId);
}
export async function setThreadReaction(threadIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadSummary | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const reactionType = cleanThreadReactionType(payload.reactionType);
const thread = await getThread(threadId, userId);
if (!thread) return null;
await pool.query(
`
INSERT INTO thread_reactions (thread_id, user_id, reaction_type)
VALUES ($1, $2, $3)
ON CONFLICT (thread_id, user_id, reaction_type)
DO UPDATE SET updated_at = now()
`,
[threadId, userId, reactionType]
);
const updated = await getThread(threadId, userId);
if (updated) {
await publishThreadReactionUpdated(userId, {
type: 'thread.reactions.updated',
target: 'thread',
threadId,
messageId: null,
reactionCounts: updated.reactionCounts,
myReactions: updated.myReactions
});
}
return updated;
}
export async function deleteThreadReaction(threadIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadSummary | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const reactionType = cleanThreadReactionType(payload.reactionType);
const thread = await getThread(threadId, userId);
if (!thread) return null;
await pool.query('DELETE FROM thread_reactions WHERE thread_id = $1 AND user_id = $2 AND reaction_type = $3', [threadId, userId, reactionType]);
return getThread(threadId, userId);
}
export async function setThreadMessageReaction(messageIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
const reactionType = cleanThreadReactionType(payload.reactionType);
const message = await getThreadMessageById(messageId, userId);
if (!message || message.moderationStatus !== 'approved') return null;
await pool.query(
`
INSERT INTO thread_message_reactions (message_id, user_id, reaction_type)
VALUES ($1, $2, $3)
ON CONFLICT (message_id, user_id, reaction_type)
DO UPDATE SET updated_at = now()
`,
[messageId, userId, reactionType]
);
const updated = await getThreadMessageById(messageId, userId);
if (updated) {
await publishThreadReactionUpdated(userId, {
type: 'thread.reactions.updated',
target: 'message',
threadId: updated.threadId,
messageId,
reactionCounts: updated.reactionCounts,
myReactions: updated.myReactions
});
}
return updated;
}
export async function deleteThreadMessageReaction(messageIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
const reactionType = cleanThreadReactionType(payload.reactionType);
const message = await getThreadMessageById(messageId, userId);
if (!message) return null;
await pool.query('DELETE FROM thread_message_reactions WHERE message_id = $1 AND user_id = $2 AND reaction_type = $3', [messageId, userId, reactionType]);
return getThreadMessageById(messageId, userId);
}
export async function applyApprovedThreadMessage(messageId: number): Promise<void> {
const row = await queryOne<{ threadId: number }>(
`
UPDATE threads t
SET last_message_id = tm.id,
message_count = (
SELECT COUNT(*)::integer
FROM thread_messages visible_message
WHERE visible_message.thread_id = t.id
AND visible_message.deleted_at IS NULL
AND visible_message.ai_moderation_status = 'approved'
),
last_active_at = GREATEST(t.last_active_at, tm.created_at),
updated_at = now()
FROM thread_messages tm
WHERE tm.id = $1
AND tm.thread_id = t.id
AND tm.deleted_at IS NULL
AND tm.ai_moderation_status = 'approved'
RETURNING t.id AS "threadId"
`,
[messageId]
);
if (!row) return;
const message = await getThreadMessageById(messageId, null, true);
const thread = await getThread(row.threadId, null);
if (message && thread) {
await publishThreadMessageCreated(thread, message);
} else {
await publishThreadMessageModeration(row.threadId, message);
}
}
export async function createAdminThreadChannel(payload: Record<string, unknown>, userId: number): Promise<ThreadChannel[]> {
const name = cleanName(payload.name, 'server.validation.nameRequired');
const allowUserThreads = payload.allowUserThreads !== false;
const tagNames = Array.isArray(payload.tags) ? payload.tags.map((tag) => cleanName(tag, 'server.validation.nameRequired')).slice(0, 20) : [];
const languageCodes = Array.isArray(payload.languages) ? payload.languages.map(cleanThreadLanguageCode).slice(0, 20) : [];
await withTransaction(async (client) => {
const sortOrder = await nextSortOrder(client, 'thread_channels');
const result = await client.query<{ id: number }>(
`
INSERT INTO thread_channels (name, allow_user_threads, sort_order, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $3, $4, $4)
RETURNING id
`,
[name, allowUserThreads, sortOrder, userId]
);
await replaceThreadChannelConfig(client, result.rows[0].id, tagNames, languageCodes);
});
return listAdminThreadChannels();
}
async function replaceThreadChannelConfig(client: DbClient, channelId: number, tagNames: string[], languageCodes: string[]): Promise<void> {
await client.query('DELETE FROM thread_channel_tags WHERE channel_id = $1', [channelId]);
await client.query('DELETE FROM thread_channel_languages WHERE channel_id = $1', [channelId]);
for (const [index, tagName] of [...new Set(tagNames)].entries()) {
await client.query('INSERT INTO thread_channel_tags (channel_id, name, sort_order) VALUES ($1, $2, $3)', [channelId, tagName, (index + 1) * 10]);
}
for (const [index, languageCode] of [...new Set(languageCodes)].entries()) {
await client.query('INSERT INTO thread_channel_languages (channel_id, language_code, sort_order) VALUES ($1, $2, $3)', [
channelId,
languageCode,
(index + 1) * 10
]);
}
}
export async function updateAdminThreadChannel(channelIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadChannel[] | null> {
const channelId = requirePositiveInteger(channelIdValue, 'server.validation.recordInvalid');
const name = cleanName(payload.name, 'server.validation.nameRequired');
const allowUserThreads = payload.allowUserThreads !== false;
const tagNames = Array.isArray(payload.tags) ? payload.tags.map((tag) => cleanName(tag, 'server.validation.nameRequired')).slice(0, 20) : [];
const languageCodes = Array.isArray(payload.languages) ? payload.languages.map(cleanThreadLanguageCode).slice(0, 20) : [];
const updated = await withTransaction(async (client) => {
const result = await client.query(
`
UPDATE thread_channels
SET name = $1, allow_user_threads = $2, updated_by_user_id = $3, updated_at = now()
WHERE id = $4
`,
[name, allowUserThreads, userId, channelId]
);
if (!result.rowCount) return false;
await replaceThreadChannelConfig(client, channelId, tagNames, languageCodes);
return true;
});
return updated ? listAdminThreadChannels() : null;
}
export async function deleteAdminThreadChannel(channelIdValue: number): Promise<boolean> {
const channelId = requirePositiveInteger(channelIdValue, 'server.validation.recordInvalid');
const result = await pool.query('DELETE FROM thread_channels WHERE id = $1', [channelId]);
return Boolean(result.rowCount);
}
export async function updateThreadLock(threadIdValue: number, locked: boolean, userId: number): Promise<ThreadSummary | null> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const result = await pool.query(
'UPDATE threads SET locked = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3 AND deleted_at IS NULL',
[locked, userId, threadId]
);
return result.rowCount ? getThread(threadId, userId) : null;
}
export async function deleteThread(threadIdValue: number, userId: number): Promise<boolean> {
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
const result = await pool.query(
'UPDATE threads SET deleted_at = now(), deleted_by_user_id = $1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL',
[userId, threadId]
);
return Boolean(result.rowCount);
}
export async function deleteThreadMessage(messageIdValue: number, userId: number): Promise<boolean> {
const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
const result = await pool.query<{ threadId: number }>(
`
UPDATE thread_messages
SET deleted_at = now(), deleted_by_user_id = $1, updated_at = now()
WHERE id = $2
AND deleted_at IS NULL
RETURNING thread_id AS "threadId"
`,
[userId, messageId]
);
if (!result.rowCount) return false;
await pool.query(
`
UPDATE threads t
SET message_count = (
SELECT COUNT(*)::integer
FROM thread_messages tm
WHERE tm.thread_id = t.id
AND tm.deleted_at IS NULL
AND tm.ai_moderation_status = 'approved'
),
last_message_id = (
SELECT tm.id
FROM thread_messages tm
WHERE tm.thread_id = t.id
AND tm.deleted_at IS NULL
AND tm.ai_moderation_status = 'approved'
ORDER BY tm.created_at DESC, tm.id DESC
LIMIT 1
),
updated_at = now()
WHERE t.id = $1
`,
[result.rows[0].threadId]
);
return true;
}
export async function createThreadsWsTicketForUser(userId: number): Promise<{ ticket: string; expiresAt: Date }> {
return createThreadWebSocketTicket(userId);
}
export async function wipeAdminData(payload: Record<string, unknown>): Promise<{ scopes: DataToolScopeSummary[] }> { export async function wipeAdminData(payload: Record<string, unknown>): Promise<{ scopes: DataToolScopeSummary[] }> {
const scopes = cleanDataToolScopes(payload.scopes); const scopes = cleanDataToolScopes(payload.scopes);
await withTransaction(async (client) => { await withTransaction(async (client) => {

View File

@@ -50,8 +50,13 @@ import {
createLifePost, createLifePost,
createPokemon, createPokemon,
createRecipe, createRecipe,
createAdminThreadChannel,
createThread,
createThreadMessage,
createThreadsWsTicketForUser,
deleteConfig, deleteConfig,
deleteAncientArtifact, deleteAncientArtifact,
deleteAdminThreadChannel,
deleteDailyChecklistItem, deleteDailyChecklistItem,
deleteDish, deleteDish,
deleteDishCategory, deleteDishCategory,
@@ -67,10 +72,15 @@ import {
deleteLifePostReaction, deleteLifePostReaction,
deletePokemon, deletePokemon,
deleteRecipe, deleteRecipe,
deleteThread,
deleteThreadMessage,
deleteThreadMessageReaction,
deleteThreadReaction,
exportAdminData, exportAdminData,
fetchPokemonData, fetchPokemonData,
fetchPokemonImageOptions, fetchPokemonImageOptions,
followUser, followUser,
followThread,
getAdminDataToolsSummary, getAdminDataToolsSummary,
getAncientArtifact, getAncientArtifact,
getHabitat, getHabitat,
@@ -81,6 +91,7 @@ import {
getPokemon, getPokemon,
getPublicUserProfile, getPublicUserProfile,
getRecipe, getRecipe,
getThread,
globalSearch, globalSearch,
importAdminData, importAdminData,
importAdminHabitatsCsv, importAdminHabitatsCsv,
@@ -88,6 +99,7 @@ import {
isConfigType, isConfigType,
listAncientArtifacts, listAncientArtifacts,
listEntityDiscussionComments, listEntityDiscussionComments,
listAdminThreadChannels,
listConfig, listConfig,
listDailyChecklistItems, listDailyChecklistItems,
listHabitats, listHabitats,
@@ -100,6 +112,9 @@ import {
listPokemon, listPokemon,
listPokemonFetchOptions, listPokemonFetchOptions,
listRecipes, listRecipes,
listThreadChannels,
listThreadMessages,
listThreads,
listUserCommentActivities, listUserCommentActivities,
listUserLifePosts, listUserLifePosts,
listUserReactionActivities, listUserReactionActivities,
@@ -112,6 +127,7 @@ import {
reorderItems, reorderItems,
reorderLanguages, reorderLanguages,
reorderRecipes, reorderRecipes,
markThreadRead,
retryEntityDiscussionCommentModeration, retryEntityDiscussionCommentModeration,
retryLifeCommentModeration, retryLifeCommentModeration,
retryLifePostModeration, retryLifePostModeration,
@@ -120,6 +136,8 @@ import {
setLifePostReaction, setLifePostReaction,
setEntityDiscussionCommentLike, setEntityDiscussionCommentLike,
setLifeCommentLike, setLifeCommentLike,
setThreadMessageReaction,
setThreadReaction,
updateConfig, updateConfig,
updateAncientArtifact, updateAncientArtifact,
updateDailyChecklistItem, updateDailyChecklistItem,
@@ -131,7 +149,10 @@ import {
updateLifePost, updateLifePost,
updatePokemon, updatePokemon,
updateRecipe, updateRecipe,
updateAdminThreadChannel,
updateThreadLock,
unfollowUser, unfollowUser,
unfollowThread,
wipeAdminData wipeAdminData
} from './queries.ts'; } from './queries.ts';
import { import {
@@ -160,6 +181,7 @@ import {
markNotificationRead, markNotificationRead,
setupNotificationWebSocketServer setupNotificationWebSocketServer
} from './notifications.ts'; } from './notifications.ts';
import { setupThreadWebSocketServer } from './threadsRealtime.ts';
const app = Fastify({ const app = Fastify({
logger: true, logger: true,
@@ -1689,6 +1711,129 @@ app.delete('/api/discussions/comments/:id/like', async (request, reply) => {
return comment ? comment : notFound(reply, request); return comment ? comment : notFound(reply, request);
}); });
app.get('/api/thread-channels', async (request) => {
const user = await optionalUser(request);
return listThreadChannels(user?.id ?? null);
});
app.get('/api/threads', async (request) => {
const user = await optionalUser(request);
return listThreads(request.query as Record<string, string | string[] | undefined>, user?.id ?? null);
});
app.post('/api/threads/ws-ticket', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
return createThreadsWsTicketForUser(user.id);
});
app.post('/api/threads', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.create', 'communityWrite');
return user ? reply.code(201).send(await createThread(request.body as Record<string, unknown>, user.id)) : undefined;
});
app.get('/api/threads/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const user = await optionalUser(request);
const thread = await getThread(Number(id), user?.id ?? null);
return thread ? thread : notFound(reply, request);
});
app.get('/api/threads/:id/messages', async (request, reply) => {
const { id } = request.params as { id: string };
const user = await optionalUser(request);
const canViewAll = user ? userHasPermission(user, 'admin.threads.messages.delete') : false;
const messages = await listThreadMessages(
Number(id),
request.query as Record<string, string | string[] | undefined>,
user?.id ?? null,
canViewAll
);
return messages ? messages : notFound(reply, request);
});
app.post('/api/threads/:id/messages', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.messages.create', 'communityWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const message = await createThreadMessage(Number(id), request.body as Record<string, unknown>, user.id);
return message ? reply.code(201).send(message) : notFound(reply, request);
});
app.put('/api/threads/:id/follow', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.follow', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const thread = await followThread(Number(id), user.id);
return thread ? thread : notFound(reply, request);
});
app.delete('/api/threads/:id/follow', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.follow', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const thread = await unfollowThread(Number(id), user.id);
return thread ? thread : notFound(reply, request);
});
app.post('/api/threads/:id/read', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.follow', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const thread = await markThreadRead(Number(id), user.id);
return thread ? thread : notFound(reply, request);
});
app.put('/api/threads/:id/reaction', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.reactions.set', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const thread = await setThreadReaction(Number(id), request.body as Record<string, unknown>, user.id);
return thread ? thread : notFound(reply, request);
});
app.delete('/api/threads/:id/reaction', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.reactions.set', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const thread = await deleteThreadReaction(Number(id), request.body as Record<string, unknown>, user.id);
return thread ? thread : notFound(reply, request);
});
app.put('/api/thread-messages/:id/reaction', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.reactions.set', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const message = await setThreadMessageReaction(Number(id), request.body as Record<string, unknown>, user.id);
return message ? message : notFound(reply, request);
});
app.delete('/api/thread-messages/:id/reaction', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'threads.reactions.set', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const message = await deleteThreadMessageReaction(Number(id), request.body as Record<string, unknown>, user.id);
return message ? message : notFound(reply, request);
});
app.get('/api/pokemon', async (request) => app.get('/api/pokemon', async (request) =>
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request)) listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
); );
@@ -2215,6 +2360,69 @@ app.post('/api/admin/data-tools/wipe', async (request, reply) => {
return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined; return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined;
}); });
app.get('/api/admin/thread-channels', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.threads.channels.read');
return user ? listAdminThreadChannels() : undefined;
});
app.post('/api/admin/thread-channels', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.channels.create', 'adminWrite');
return user
? reply.code(201).send(await createAdminThreadChannel(request.body as Record<string, unknown>, user.id))
: undefined;
});
app.put('/api/admin/thread-channels/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.channels.update', 'adminWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const channels = await updateAdminThreadChannel(Number(id), request.body as Record<string, unknown>, user.id);
return channels ? channels : notFound(reply, request);
});
app.delete('/api/admin/thread-channels/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.channels.delete', 'adminWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteAdminThreadChannel(Number(id));
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.put('/api/admin/threads/:id/lock', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.threads.lock', 'adminWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const payload = request.body as Record<string, unknown>;
const thread = await updateThreadLock(Number(id), payload.locked === true, user.id);
return thread ? thread : notFound(reply, request);
});
app.delete('/api/admin/threads/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.threads.delete', 'adminWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteThread(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.delete('/api/admin/thread-messages/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.messages.delete', 'adminWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteThreadMessage(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.get('/api/admin/config/:type', async (request, reply) => { app.get('/api/admin/config/:type', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.config.read'); const user = await requirePermission(request, reply, 'admin.config.read');
if (!user) { if (!user) {
@@ -2286,6 +2494,7 @@ try {
await syncSystemWordingCatalog(); await syncSystemWordingCatalog();
await startAiModerationWorker(app.log); await startAiModerationWorker(app.log);
setupNotificationWebSocketServer(app.server, app.log); setupNotificationWebSocketServer(app.server, app.log);
setupThreadWebSocketServer(app.server, app.log);
await app.listen({ host: '0.0.0.0', port }); await app.listen({ host: '0.0.0.0', port });
} catch (error) { } catch (error) {
app.log.error(error); app.log.error(error);

View File

@@ -0,0 +1,421 @@
import type { FastifyBaseLogger } from 'fastify';
import { Buffer } from 'node:buffer';
import { createHash, randomBytes } from 'node:crypto';
import type { Server } from 'node:http';
import type { Duplex } from 'node:stream';
import { pool, query, queryOne } from './db.ts';
import type { ThreadMessage, ThreadReactionCounts, ThreadReactionType, ThreadSummary } from './queries.ts';
export type ThreadWsMessage =
| { type: 'threads.connected'; followedUnreadCount: number }
| { type: 'thread.message.created'; threadId: number; message: ThreadMessage; thread: ThreadSummary }
| { type: 'thread.message.moderation'; threadId: number; message: ThreadMessage | null }
| {
type: 'thread.reactions.updated';
target: 'thread' | 'message';
threadId: number;
messageId: number | null;
reactionCounts: ThreadReactionCounts;
myReactions: ThreadReactionType[];
}
| { type: 'thread.read.updated'; threadId: number; unread: boolean; unreadCount: number };
const websocketGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const websocketTicketMinutes = 2;
const threadClients = new Map<number, Set<Duplex>>();
const clientUsers = new WeakMap<Duplex, number>();
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export async function createThreadWebSocketTicket(userId: number): Promise<{ ticket: string; expiresAt: Date }> {
const ticket = randomBytes(32).toString('base64url');
const expiresAt = new Date(Date.now() + websocketTicketMinutes * 60_000);
await pool.query(
`
INSERT INTO thread_ws_tickets (ticket_hash, user_id, expires_at)
VALUES ($1, $2, $3)
`,
[hashToken(ticket), userId, expiresAt]
);
await pool.query('DELETE FROM thread_ws_tickets WHERE expires_at < now()');
return { ticket, expiresAt };
}
async function consumeThreadWebSocketTicket(ticket: string): Promise<number | null> {
if (!ticket) {
return null;
}
const row = await queryOne<{ userId: number }>(
`
DELETE FROM thread_ws_tickets
WHERE ticket_hash = $1
AND expires_at > now()
RETURNING user_id AS "userId"
`,
[hashToken(ticket)]
);
return row?.userId ?? null;
}
async function followedUnreadCount(userId: number): Promise<number> {
const row = await queryOne<{ count: number }>(
`
SELECT COUNT(*)::integer AS count
FROM thread_follows tf
JOIN threads t ON t.id = tf.thread_id
LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = tf.user_id
WHERE tf.user_id = $1
AND t.deleted_at IS NULL
AND t.last_message_id IS NOT NULL
AND (
tr.last_read_message_id IS NULL
OR t.last_message_id > tr.last_read_message_id
)
`,
[userId]
);
return row?.count ?? 0;
}
function wsFrame(data: Buffer, opcode = 0x1): Buffer {
const length = data.byteLength;
if (length < 126) {
return Buffer.concat([Buffer.from([0x80 | opcode, length]), data]);
}
if (length < 65536) {
const header = Buffer.alloc(4);
header[0] = 0x80 | opcode;
header[1] = 126;
header.writeUInt16BE(length, 2);
return Buffer.concat([header, data]);
}
const header = Buffer.alloc(10);
header[0] = 0x80 | opcode;
header[1] = 127;
header.writeBigUInt64BE(BigInt(length), 2);
return Buffer.concat([header, data]);
}
function sendWsJson(socket: Duplex, message: ThreadWsMessage): void {
if (!socket.destroyed) {
socket.write(wsFrame(Buffer.from(JSON.stringify(message), 'utf8')));
}
}
function websocketPayload(buffer: Buffer): { opcode: number; payload: Buffer } | null {
if (buffer.byteLength < 2) {
return null;
}
const opcode = buffer[0] & 0x0f;
const masked = (buffer[1] & 0x80) !== 0;
let length = buffer[1] & 0x7f;
let offset = 2;
if (length === 126) {
if (buffer.byteLength < offset + 2) return null;
length = buffer.readUInt16BE(offset);
offset += 2;
} else if (length === 127) {
if (buffer.byteLength < offset + 8) return null;
const longLength = buffer.readBigUInt64BE(offset);
if (longLength > BigInt(Number.MAX_SAFE_INTEGER)) return null;
length = Number(longLength);
offset += 8;
}
let mask: Buffer | null = null;
if (masked) {
if (buffer.byteLength < offset + 4) return null;
mask = buffer.subarray(offset, offset + 4);
offset += 4;
}
if (buffer.byteLength < offset + length) {
return null;
}
const payload = Buffer.from(buffer.subarray(offset, offset + length));
if (mask) {
for (let index = 0; index < payload.byteLength; index += 1) {
payload[index] ^= mask[index % 4];
}
}
return { opcode, payload };
}
function closeSocket(socket: Duplex, statusCode = 1000): void {
if (socket.destroyed) {
return;
}
const payload = Buffer.alloc(2);
payload.writeUInt16BE(statusCode, 0);
socket.end(wsFrame(payload, 0x8));
}
function rejectUpgrade(socket: Duplex, statusCode: number, statusText: string): void {
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\n\r\n`);
socket.destroy();
}
function addThreadClient(userId: number, socket: Duplex): void {
clientUsers.set(socket, userId);
let clients = threadClients.get(userId);
if (!clients) {
clients = new Set();
threadClients.set(userId, clients);
}
clients.add(socket);
socket.on('close', () => {
clients?.delete(socket);
if (clients?.size === 0) {
threadClients.delete(userId);
}
});
}
async function recipientUserIds(threadId: number): Promise<number[]> {
const rows = await query<{ userId: number }>(
`
SELECT DISTINCT user_id AS "userId"
FROM thread_follows
WHERE thread_id = $1
`,
[threadId]
);
return rows.map((row) => row.userId);
}
function connectedUserIds(): number[] {
return [...threadClients.keys()];
}
async function publishToUsers(userIds: number[], message: ThreadWsMessage): Promise<void> {
for (const userId of userIds) {
const clients = threadClients.get(userId);
if (!clients) {
continue;
}
for (const socket of clients) {
sendWsJson(socket, message);
}
}
}
export async function publishThreadMessageCreated(thread: ThreadSummary, message: ThreadMessage): Promise<void> {
const users = [...new Set([...(await recipientUserIds(thread.id)), ...connectedUserIds()])];
if (message.author?.id && !users.includes(message.author.id)) {
users.push(message.author.id);
}
await publishToUsers(users, {
type: 'thread.message.created',
threadId: thread.id,
message,
thread
});
}
export async function applyApprovedThreadMessage(messageId: number): Promise<void> {
const row = await queryOne<{
threadId: number;
channelId: number;
title: string;
languageCode: string;
locked: boolean;
messageCount: number;
lastActiveAt: Date;
threadCreatedAt: Date;
threadAuthor: { id: number; displayName: string } | null;
messageBody: string;
moderationStatus: ThreadMessage['moderationStatus'];
moderationLanguageCode: string | null;
moderationReason: string | null;
messageCreatedAt: Date;
messageUpdatedAt: Date;
messageAuthor: { id: number; displayName: string } | null;
}>(
`
WITH updated_thread AS (
UPDATE threads t
SET last_message_id = tm.id,
message_count = (
SELECT COUNT(*)::integer
FROM thread_messages visible_message
WHERE visible_message.thread_id = t.id
AND visible_message.deleted_at IS NULL
AND visible_message.ai_moderation_status = 'approved'
),
last_active_at = GREATEST(t.last_active_at, tm.created_at),
updated_at = now()
FROM thread_messages tm
WHERE tm.id = $1
AND tm.thread_id = t.id
AND tm.deleted_at IS NULL
AND tm.ai_moderation_status = 'approved'
RETURNING
t.id,
t.channel_id,
t.title,
t.language_code,
t.locked,
t.message_count,
t.last_active_at,
t.created_at,
t.created_by_user_id
)
SELECT
ut.id AS "threadId",
ut.channel_id AS "channelId",
ut.title,
ut.language_code AS "languageCode",
ut.locked,
ut.message_count AS "messageCount",
ut.last_active_at AS "lastActiveAt",
ut.created_at AS "threadCreatedAt",
CASE WHEN thread_user.id IS NULL THEN NULL ELSE json_build_object('id', thread_user.id, 'displayName', thread_user.display_name) END AS "threadAuthor",
tm.body AS "messageBody",
tm.ai_moderation_status AS "moderationStatus",
tm.ai_moderation_language_code AS "moderationLanguageCode",
tm.ai_moderation_reason AS "moderationReason",
tm.created_at AS "messageCreatedAt",
tm.updated_at AS "messageUpdatedAt",
CASE WHEN message_user.id IS NULL THEN NULL ELSE json_build_object('id', message_user.id, 'displayName', message_user.display_name) END AS "messageAuthor"
FROM updated_thread ut
JOIN thread_messages tm ON tm.id = $1
LEFT JOIN users thread_user ON thread_user.id = ut.created_by_user_id
LEFT JOIN users message_user ON message_user.id = tm.created_by_user_id
`,
[messageId]
);
if (!row) {
return;
}
await publishThreadMessageCreated(
{
id: row.threadId,
channelId: row.channelId,
title: row.title,
languageCode: row.languageCode,
tags: [],
locked: row.locked,
messageCount: row.messageCount,
lastActiveAt: row.lastActiveAt,
createdAt: row.threadCreatedAt,
author: row.threadAuthor,
reactionCounts: { 'thumbs-up': 0, heart: 0, laugh: 0, fire: 0, eyes: 0 },
myReactions: [],
followed: true,
unread: true
},
{
id: messageId,
threadId: row.threadId,
body: row.messageBody,
moderationStatus: row.moderationStatus,
moderationLanguageCode: row.moderationLanguageCode,
moderationReason: row.moderationReason,
createdAt: row.messageCreatedAt,
updatedAt: row.messageUpdatedAt,
author: row.messageAuthor,
reactionCounts: { 'thumbs-up': 0, heart: 0, laugh: 0, fire: 0, eyes: 0 },
myReactions: []
}
);
}
export async function publishThreadMessageModeration(threadId: number, message: ThreadMessage | null): Promise<void> {
await publishToUsers([...new Set([...(await recipientUserIds(threadId)), ...connectedUserIds()])], {
type: 'thread.message.moderation',
threadId,
message
});
}
export async function publishThreadReactionUpdated(
userId: number,
message: Extract<ThreadWsMessage, { type: 'thread.reactions.updated' }>
): Promise<void> {
const users = await recipientUserIds(message.threadId);
for (const connectedUserId of connectedUserIds()) {
if (!users.includes(connectedUserId)) {
users.push(connectedUserId);
}
}
if (!users.includes(userId)) {
users.push(userId);
}
await publishToUsers(users, message);
}
export async function publishThreadReadUpdated(userId: number, threadId: number, unread: boolean, unreadCount: number): Promise<void> {
await publishToUsers([userId], { type: 'thread.read.updated', threadId, unread, unreadCount });
}
export function setupThreadWebSocketServer(server: Server, logger: FastifyBaseLogger): void {
server.on('upgrade', async (request, socket) => {
const url = new URL(request.url ?? '/', 'http://localhost');
if (url.pathname !== '/api/threads/ws') {
return;
}
const key = request.headers['sec-websocket-key'];
if (request.method !== 'GET' || typeof key !== 'string' || key.trim() === '') {
rejectUpgrade(socket, 400, 'Bad Request');
return;
}
try {
const ticket = url.searchParams.get('ticket') ?? '';
const userId = await consumeThreadWebSocketTicket(ticket);
if (!userId) {
rejectUpgrade(socket, 401, 'Unauthorized');
return;
}
const accept = createHash('sha1').update(`${key}${websocketGuid}`).digest('base64');
socket.write(
[
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${accept}`,
'\r\n'
].join('\r\n')
);
addThreadClient(userId, socket);
sendWsJson(socket, {
type: 'threads.connected',
followedUnreadCount: await followedUnreadCount(userId)
});
socket.on('data', (buffer: Buffer) => {
const frame = websocketPayload(buffer);
if (!frame) {
return;
}
if (frame.opcode === 0x8) {
closeSocket(socket);
} else if (frame.opcode === 0x9) {
socket.write(wsFrame(frame.payload, 0x0a));
}
});
socket.on('error', () => {
socket.destroy();
});
} catch (error) {
logger.warn({ err: error }, 'Thread WebSocket upgrade failed');
rejectUpgrade(socket, 500, 'Internal Server Error');
}
});
}

View File

@@ -18,6 +18,7 @@ import {
iconLife, iconLife,
iconPokemon, iconPokemon,
iconRecipe, iconRecipe,
iconThreads,
type AppIcon type AppIcon
} from './src/icons'; } from './src/icons';
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n'; import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n';
@@ -101,7 +102,8 @@ const navItems = computed<NavItem[]>(() => {
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() }, { label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },
{ label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() }, { label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() },
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist }, { label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
{ label: t('nav.life'), to: '/life', icon: iconLife } { label: t('nav.life'), to: '/life', icon: iconLife },
{ label: t('nav.threads'), to: '/threads', icon: iconThreads }
]; ];
if (can('admin.access')) { if (can('admin.access')) {

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import ThreadsView from '../../src/views/ThreadsView.vue';
definePageMeta({
title: 'Threads'
});
</script>
<template>
<ThreadsView />
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import ThreadsView from '../../src/views/ThreadsView.vue';
definePageMeta({
title: 'Threads'
});
</script>
<template>
<ThreadsView />
</template>

View File

@@ -50,10 +50,12 @@ export const iconReactionLike: AppIcon = 'mdi:thumb-up-outline';
export const iconReactionThanks: AppIcon = 'mdi:hand-heart-outline'; export const iconReactionThanks: AppIcon = 'mdi:hand-heart-outline';
export const iconSave: AppIcon = 'mdi:content-save-outline'; export const iconSave: AppIcon = 'mdi:content-save-outline';
export const iconSearch: AppIcon = 'mdi:magnify'; export const iconSearch: AppIcon = 'mdi:magnify';
export const iconSend: AppIcon = 'mdi:send-outline';
export const iconStar: AppIcon = 'mdi:star'; export const iconStar: AppIcon = 'mdi:star';
export const iconStarOutline: AppIcon = 'mdi:star-outline'; export const iconStarOutline: AppIcon = 'mdi:star-outline';
export const iconSuccess: AppIcon = 'mdi:check-circle-outline'; export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
export const iconTranslate: AppIcon = 'mdi:translate'; export const iconTranslate: AppIcon = 'mdi:translate';
export const iconThreads: AppIcon = 'mdi:forum-outline';
export const iconUndo: AppIcon = 'mdi:undo'; export const iconUndo: AppIcon = 'mdi:undo';
export const iconUpload: AppIcon = 'mdi:upload-outline'; export const iconUpload: AppIcon = 'mdi:upload-outline';
export const iconVersion: AppIcon = 'mdi:tag-outline'; export const iconVersion: AppIcon = 'mdi:tag-outline';

View File

@@ -575,6 +575,121 @@ export interface LifeReactionUsersParams {
reactionType?: LifeReactionType; reactionType?: LifeReactionType;
} }
export type ThreadReactionType = 'thumbs-up' | 'heart' | 'laugh' | 'fire' | 'eyes';
export type ThreadReactionCounts = Record<ThreadReactionType, number>;
export type ThreadSort = 'last-active' | 'latest' | 'most-discussed';
export interface ThreadChannelTag {
id: number;
name: string;
sortOrder: number;
}
export interface ThreadChannel {
id: number;
name: string;
allowUserThreads: boolean;
sortOrder: number;
tags: ThreadChannelTag[];
languages: Array<{ code: string; name: string }>;
unreadCount: number;
}
export interface ThreadSummary {
id: number;
channelId: number;
title: string;
languageCode: string;
tags: ThreadChannelTag[];
locked: boolean;
messageCount: number;
lastActiveAt: string;
createdAt: string;
author: UserSummary | null;
reactionCounts: ThreadReactionCounts;
myReactions: ThreadReactionType[];
followed: boolean;
unread: boolean;
}
export interface ThreadMessage {
id: number;
threadId: number;
body: string;
moderationStatus: AiModerationStatus;
moderationLanguageCode: string | null;
moderationReason: string | null;
createdAt: string;
updatedAt: string;
author: UserSummary | null;
reactionCounts: ThreadReactionCounts;
myReactions: ThreadReactionType[];
}
export interface ThreadsPage {
items: ThreadSummary[];
nextCursor: string | null;
hasMore: boolean;
}
export interface ThreadMessagesPage {
items: ThreadMessage[];
beforeCursor: string | null;
hasMoreBefore: boolean;
}
export interface ThreadsParams {
cursor?: string | null;
limit?: number;
channelId?: number | string | null;
language?: string;
tagId?: number | string | null;
sort?: ThreadSort;
}
export interface ThreadMessagesParams {
before?: string | null;
limit?: number;
}
export interface ThreadPayload {
channelId: number;
title: string;
body: string;
languageCode: string;
tagIds: number[];
}
export interface ThreadMessagePayload {
body: string;
}
export interface ThreadWsTicket {
ticket: string;
expiresAt: string;
}
export type ThreadWsMessage =
| { type: 'threads.connected'; followedUnreadCount: number }
| { type: 'thread.message.created'; threadId: number; message: ThreadMessage; thread: ThreadSummary }
| { type: 'thread.message.moderation'; threadId: number; message: ThreadMessage | null }
| {
type: 'thread.reactions.updated';
target: 'thread' | 'message';
threadId: number;
messageId: number | null;
reactionCounts: ThreadReactionCounts;
myReactions: ThreadReactionType[];
}
| { type: 'thread.read.updated'; threadId: number; unread: boolean; unreadCount: number };
export interface AdminThreadChannelPayload {
name: string;
allowUserThreads: boolean;
tags: string[];
languages: string[];
}
export interface NotificationTarget { export interface NotificationTarget {
type: NotificationTargetType; type: NotificationTargetType;
id: number; id: number;
@@ -1087,6 +1202,15 @@ export function notificationWebSocketUrl(ticket: string): string {
return base.toString(); return base.toString();
} }
export function threadWebSocketUrl(ticket: string): string {
const base = new URL(browserApiBaseUrl, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
base.pathname = '/api/threads/ws';
base.search = '';
base.searchParams.set('ticket', ticket);
return base.toString();
}
async function getErrorMessage(response: Response): Promise<string> { async function getErrorMessage(response: Response): Promise<string> {
try { try {
const data = (await response.json()) as { message?: unknown }; const data = (await response.json()) as { message?: unknown };
@@ -1127,7 +1251,7 @@ async function getJson<T>(path: string, options?: AbortSignal | ApiRequestOption
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> { async function sendJson<T>(path: string, method: 'DELETE' | 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
const headers = requestHeaders(); const headers = requestHeaders();
headers.set('Content-Type', 'application/json'); headers.set('Content-Type', 'application/json');
@@ -1352,6 +1476,51 @@ export const api = {
reactionType: params.reactionType reactionType: params.reactionType
})}` })}`
), ),
threadChannels: () => getJson<ThreadChannel[]>('/api/thread-channels'),
threads: (params: ThreadsParams = {}) =>
getJson<ThreadsPage>(
`/api/threads${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit,
channelId: params.channelId,
language: params.language,
tagId: params.tagId,
sort: params.sort
})}`
),
thread: (id: string | number) => getJson<ThreadSummary>(`/api/threads/${id}`),
createThread: (payload: ThreadPayload) => sendJson<ThreadSummary>('/api/threads', 'POST', payload),
threadMessages: (id: string | number, params: ThreadMessagesParams = {}) =>
getJson<ThreadMessagesPage>(
`/api/threads/${id}/messages${buildQuery({
before: params.before ?? undefined,
limit: params.limit
})}`
),
createThreadMessage: (id: string | number, payload: ThreadMessagePayload) =>
sendJson<ThreadMessage>(`/api/threads/${id}/messages`, 'POST', payload),
followThread: (id: string | number) => sendJson<ThreadSummary>(`/api/threads/${id}/follow`, 'PUT', {}),
unfollowThread: (id: string | number) => deleteAndGetJson<ThreadSummary>(`/api/threads/${id}/follow`),
markThreadRead: (id: string | number) => sendJson<ThreadSummary>(`/api/threads/${id}/read`, 'POST', {}),
setThreadReaction: (id: string | number, reactionType: ThreadReactionType) =>
sendJson<ThreadSummary>(`/api/threads/${id}/reaction`, 'PUT', { reactionType }),
deleteThreadReaction: (id: string | number, reactionType: ThreadReactionType) =>
sendJson<ThreadSummary>(`/api/threads/${id}/reaction`, 'DELETE', { reactionType }),
setThreadMessageReaction: (id: string | number, reactionType: ThreadReactionType) =>
sendJson<ThreadMessage>(`/api/thread-messages/${id}/reaction`, 'PUT', { reactionType }),
deleteThreadMessageReaction: (id: string | number, reactionType: ThreadReactionType) =>
sendJson<ThreadMessage>(`/api/thread-messages/${id}/reaction`, 'DELETE', { reactionType }),
threadWsTicket: () => sendJson<ThreadWsTicket>('/api/threads/ws-ticket', 'POST', {}),
adminThreadChannels: () => getJson<ThreadChannel[]>('/api/admin/thread-channels'),
createAdminThreadChannel: (payload: AdminThreadChannelPayload) =>
sendJson<ThreadChannel[]>('/api/admin/thread-channels', 'POST', payload),
updateAdminThreadChannel: (id: string | number, payload: AdminThreadChannelPayload) =>
sendJson<ThreadChannel[]>(`/api/admin/thread-channels/${id}`, 'PUT', payload),
deleteAdminThreadChannel: (id: string | number) => deleteJson(`/api/admin/thread-channels/${id}`),
lockThread: (id: string | number, locked: boolean) =>
sendJson<ThreadSummary>(`/api/admin/threads/${id}/lock`, 'PUT', { locked }),
deleteThread: (id: string | number) => deleteJson(`/api/admin/threads/${id}`),
deleteThreadMessage: (id: string | number) => deleteJson(`/api/admin/thread-messages/${id}`),
setLifeRating: (id: string | number, rating: number) => setLifeRating: (id: string | number, rating: number) =>
sendJson<LifePost>(`/api/life-posts/${id}/rating`, 'PUT', { rating }), sendJson<LifePost>(`/api/life-posts/${id}/rating`, 'PUT', { rating }),
deleteLifeRating: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/rating`), deleteLifeRating: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/rating`),

View File

@@ -9689,12 +9689,366 @@ button:disabled,
font-size: 14px; font-size: 14px;
} }
.threads-layout {
min-height: min(78vh, 860px);
display: grid;
grid-template-columns: minmax(180px, 220px) minmax(260px, 360px) minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.threads-sidebar,
.threads-list-panel,
.thread-chat-panel {
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface);
box-shadow: var(--shadow-soft);
}
.threads-sidebar {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
}
.threads-sidebar h2 {
margin: 0 0 6px;
color: var(--ink-soft);
font-size: 14px;
font-weight: 900;
}
.thread-channel,
.thread-list-item {
width: 100%;
min-width: 0;
display: grid;
gap: 6px;
border: 1px solid transparent;
border-radius: var(--radius-control);
background: transparent;
color: var(--ink-soft);
text-align: left;
cursor: pointer;
}
.thread-channel {
min-height: 44px;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
padding: 8px 10px;
font-weight: 900;
}
.thread-channel:hover,
.thread-channel.active,
.thread-list-item:hover,
.thread-list-item.active {
border-color: color-mix(in srgb, var(--pokemon-blue) 35%, var(--line));
background: color-mix(in srgb, var(--pokemon-blue) 8%, var(--surface));
color: var(--ink);
}
.thread-unread-dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--pokemon-red);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--pokemon-red) 16%, transparent);
}
.threads-list-panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 12px;
padding: 14px;
overflow: hidden;
}
.thread-filters {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.thread-filters label {
display: grid;
gap: 5px;
color: var(--ink-soft);
font-size: 13px;
font-weight: 900;
}
.thread-filters select,
.thread-composer textarea {
width: 100%;
}
.thread-tag-filter {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.thread-chip {
min-height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 9px;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: var(--surface-soft);
color: var(--ink-soft);
font-size: 13px;
font-weight: 800;
}
button.thread-chip {
cursor: pointer;
}
.thread-chip.active,
.thread-list-item.unread .thread-list-item__title {
color: var(--ink);
}
.thread-chip.active {
border-color: var(--pokemon-blue);
background: color-mix(in srgb, var(--pokemon-yellow) 26%, var(--surface));
}
.thread-list {
min-height: 0;
display: grid;
align-content: start;
gap: 10px;
overflow: auto;
}
.thread-list-item {
padding: 12px;
}
.thread-list-item__title {
display: flex;
align-items: center;
gap: 8px;
color: var(--ink);
font-weight: 900;
line-height: 1.35;
}
.thread-list-item__meta,
.threads-empty,
.thread-chat-header p,
.thread-message-meta time {
color: var(--muted);
font-size: 13px;
}
.thread-list-item__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.thread-chat-panel {
position: relative;
min-height: 620px;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
overflow: hidden;
}
.thread-chat-header {
display: flex;
justify-content: space-between;
gap: 14px;
padding: 16px;
border-bottom: 1px solid var(--line);
}
.thread-chat-header h2 {
margin: 0;
font-size: 22px;
line-height: 1.25;
}
.thread-chat-header p {
margin: 4px 0 0;
}
.thread-chat-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
align-items: center;
}
.thread-reactions {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 10px 16px;
border-bottom: 1px solid var(--line);
}
.thread-reactions--message {
padding: 0;
border-bottom: 0;
}
.thread-reaction {
min-width: 44px;
min-height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface-soft);
color: var(--ink-soft);
cursor: pointer;
}
.thread-reaction.active {
border-color: var(--pokemon-blue);
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface));
color: var(--ink);
}
.thread-message-scroll {
min-height: 0;
overflow: auto;
padding: 16px;
background: color-mix(in srgb, var(--surface-soft) 58%, var(--surface));
}
.thread-message-list {
display: grid;
gap: 16px;
}
.thread-message-group {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 10px;
}
.thread-avatar {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 2px solid var(--line-strong);
border-radius: 50%;
background: var(--pokemon-yellow);
color: var(--pokeball-black);
font-weight: 900;
}
.thread-message-group__body {
min-width: 0;
display: grid;
gap: 6px;
}
.thread-message-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: baseline;
}
.thread-message {
display: grid;
gap: 8px;
}
.thread-message p {
margin: 0;
white-space: pre-wrap;
}
.thread-composer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
padding: 12px;
border-top: 1px solid var(--line);
background: var(--surface);
}
.thread-jump-button {
position: absolute;
right: 18px;
bottom: 86px;
min-height: 38px;
padding: 7px 12px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-control);
background: var(--pokemon-yellow);
color: var(--pokeball-black);
font-weight: 900;
box-shadow: var(--shadow-control);
}
.thread-load-older {
width: fit-content;
margin: 0 auto 14px;
}
.threads-empty {
margin: 0;
padding: 18px;
}
.threads-empty--select {
align-self: center;
justify-self: center;
}
@media (max-width: 1100px) {
.threads-layout {
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
}
.thread-chat-panel {
grid-column: 1 / -1;
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
.threads-layout,
.dish-category-summary, .dish-category-summary,
.dish-card { .dish-card {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.thread-filters,
.thread-composer,
.thread-chat-header {
grid-template-columns: 1fr;
}
.thread-chat-header {
display: grid;
}
.thread-chat-actions {
justify-content: flex-start;
}
.thread-chat-panel {
min-height: 70dvh;
}
.dish-form-row, .dish-form-row,
.dish-form-row--3, .dish-form-row--3,
.dish-form-row--4 { .dish-form-row--4 {

View File

@@ -27,6 +27,7 @@ import {
iconProfile, iconProfile,
iconRecipe, iconRecipe,
iconSave, iconSave,
iconThreads,
iconTranslate, iconTranslate,
iconUpload, iconUpload,
type AppIcon type AppIcon
@@ -67,6 +68,7 @@ import {
type Skill, type Skill,
type SystemWording, type SystemWording,
type SystemWordingSurface, type SystemWordingSurface,
type ThreadChannel,
type TranslationMap type TranslationMap
} from '../services/api'; } from '../services/api';
@@ -86,7 +88,8 @@ type AdminTab =
| 'ancientArtifacts' | 'ancientArtifacts'
| 'recipes' | 'recipes'
| 'dish' | 'dish'
| 'habitats'; | 'habitats'
| 'threadChannels';
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access'; type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] }; type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] }; type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
@@ -139,7 +142,8 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
ancientArtifacts: iconArtifact, ancientArtifacts: iconArtifact,
recipes: iconRecipe, recipes: iconRecipe,
dish: iconDish, dish: iconDish,
habitats: iconHabitat habitats: iconHabitat,
threadChannels: iconThreads
}; };
const { locale, t } = useI18n(); const { locale, t } = useI18n();
@@ -166,6 +170,7 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
{ key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] }, { key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] },
{ key: 'dish', label: t('pages.admin.dishList'), permission: ['dish.create', 'dish.update', 'dish.delete', 'dish.order'] }, { key: 'dish', label: t('pages.admin.dishList'), permission: ['dish.create', 'dish.update', 'dish.delete', 'dish.order'] },
{ key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] }, { key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] },
{ key: 'threadChannels', label: t('pages.admin.threadChannels'), permission: 'admin.threads.channels.read' },
{ key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] } { key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] }
] ]
}, },
@@ -235,6 +240,7 @@ const dishItemRows = ref<Item[]>([]);
const dishSkillRows = ref<Skill[]>([]); const dishSkillRows = ref<Skill[]>([]);
const dishFlavorRows = ref<NamedEntity[]>([]); const dishFlavorRows = ref<NamedEntity[]>([]);
const habitatRows = ref<Habitat[]>([]); const habitatRows = ref<Habitat[]>([]);
const threadChannelRows = ref<ThreadChannel[]>([]);
const wordingRows = ref<SystemWording[]>([]); const wordingRows = ref<SystemWording[]>([]);
const aiModerationSettings = ref<AiModerationSettings | null>(null); const aiModerationSettings = ref<AiModerationSettings | null>(null);
const rateLimitSettings = ref<RateLimitSettings | null>(null); const rateLimitSettings = ref<RateLimitSettings | null>(null);
@@ -301,6 +307,7 @@ const userRoleForm = ref({ userId: 0, roleIds: [] as number[] });
const roleForm = ref({ id: 0, key: '', name: '', description: '', level: 100, enabled: true }); const roleForm = ref({ id: 0, key: '', name: '', description: '', level: 100, enabled: true });
const rolePermissionForm = ref({ roleId: 0, permissionIds: [] as number[] }); const rolePermissionForm = ref({ roleId: 0, permissionIds: [] as number[] });
const permissionForm = ref({ id: 0, key: '', name: '', description: '', category: 'General', enabled: true }); const permissionForm = ref({ id: 0, key: '', name: '', description: '', category: 'General', enabled: true });
const threadChannelForm = ref({ id: 0, name: '', allowUserThreads: true, tagsText: '', languages: [] as string[] });
const editingLanguageCode = ref(''); const editingLanguageCode = ref('');
const configModalOpen = ref(false); const configModalOpen = ref(false);
const checklistModalOpen = ref(false); const checklistModalOpen = ref(false);
@@ -312,6 +319,7 @@ const userRoleModalOpen = ref(false);
const roleModalOpen = ref(false); const roleModalOpen = ref(false);
const rolePermissionsModalOpen = ref(false); const rolePermissionsModalOpen = ref(false);
const permissionModalOpen = ref(false); const permissionModalOpen = ref(false);
const threadChannelModalOpen = ref(false);
const dataToolImportModalOpen = ref(false); const dataToolImportModalOpen = ref(false);
const dataToolWipeModalOpen = ref(false); const dataToolWipeModalOpen = ref(false);
const wordingLocale = ref(getCurrentLocale()); const wordingLocale = ref(getCurrentLocale());
@@ -404,6 +412,9 @@ const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRo
const permissionModalTitle = computed(() => const permissionModalTitle = computed(() =>
permissionForm.value.id ? t('pages.admin.editPermission') : t('pages.admin.newPermission') permissionForm.value.id ? t('pages.admin.editPermission') : t('pages.admin.newPermission')
); );
const threadChannelModalTitle = computed(() =>
threadChannelForm.value.id ? t('pages.admin.editThreadChannel') : t('pages.admin.newThreadChannel')
);
const rolePermissionsModalTitle = computed(() => t('pages.admin.rolePermissions')); const rolePermissionsModalTitle = computed(() => t('pages.admin.rolePermissions'));
const userRoleModalTitle = computed(() => t('pages.admin.userRoles')); const userRoleModalTitle = computed(() => t('pages.admin.userRoles'));
const editingUser = computed(() => userRows.value.find((user) => user.id === userRoleForm.value.userId) ?? null); const editingUser = computed(() => userRows.value.find((user) => user.id === userRoleForm.value.userId) ?? null);
@@ -693,6 +704,10 @@ function resetPermissionForm() {
permissionForm.value = { id: 0, key: '', name: '', description: '', category: 'General', enabled: true }; permissionForm.value = { id: 0, key: '', name: '', description: '', category: 'General', enabled: true };
} }
function resetThreadChannelForm() {
threadChannelForm.value = { id: 0, name: '', allowUserThreads: true, tagsText: '', languages: languageRows.value.map((language) => language.code) };
}
function selectWordingModule(module: string) { function selectWordingModule(module: string) {
wordingModule.value = module; wordingModule.value = module;
} }
@@ -862,6 +877,27 @@ function closePermissionModal() {
resetPermissionForm(); resetPermissionForm();
} }
function openNewThreadChannel() {
resetThreadChannelForm();
threadChannelModalOpen.value = true;
}
function closeThreadChannelModal() {
threadChannelModalOpen.value = false;
resetThreadChannelForm();
}
function editThreadChannel(channel: ThreadChannel) {
threadChannelForm.value = {
id: channel.id,
name: channel.name,
allowUserThreads: channel.allowUserThreads,
tagsText: channel.tags.map((tag) => tag.name).join(', '),
languages: channel.languages.map((language) => language.code)
};
threadChannelModalOpen.value = true;
}
function editLanguage(item: Language) { function editLanguage(item: Language) {
editingLanguageCode.value = item.code; editingLanguageCode.value = item.code;
languageForm.value = { languageForm.value = {
@@ -1103,6 +1139,33 @@ async function loadChecklist() {
} }
} }
async function loadThreadChannels() {
await loadLanguages();
threadChannelRows.value = await api.adminThreadChannels();
}
function threadChannelTagNames() {
return threadChannelForm.value.tagsText
.split(',')
.map((tag) => tag.trim())
.filter(Boolean);
}
async function saveThreadChannel() {
await run(async () => {
const payload = {
name: threadChannelForm.value.name,
allowUserThreads: threadChannelForm.value.allowUserThreads,
tags: threadChannelTagNames(),
languages: threadChannelForm.value.languages
};
threadChannelRows.value = threadChannelForm.value.id
? await api.updateAdminThreadChannel(threadChannelForm.value.id, payload)
: await api.createAdminThreadChannel(payload);
closeThreadChannelModal();
});
}
async function saveChecklistItem() { async function saveChecklistItem() {
await run(async () => { await run(async () => {
const payload = { const payload = {
@@ -1414,6 +1477,7 @@ async function loadCurrentTab(showSkeleton = false) {
if (activeTab.value === 'recipes') await loadRecipes(); if (activeTab.value === 'recipes') await loadRecipes();
if (activeTab.value === 'dish') await loadDishAdmin(); if (activeTab.value === 'dish') await loadDishAdmin();
if (activeTab.value === 'habitats') await loadHabitats(); if (activeTab.value === 'habitats') await loadHabitats();
if (activeTab.value === 'threadChannels') await loadThreadChannels();
} finally { } finally {
if (showSkeleton) { if (showSkeleton) {
contentLoading.value = false; contentLoading.value = false;
@@ -1485,6 +1549,16 @@ async function removeChecklistItem(id: number) {
}); });
} }
async function removeThreadChannel(id: number) {
await run(async () => {
await api.deleteAdminThreadChannel(id);
if (threadChannelForm.value.id === id) {
closeThreadChannelModal();
}
await loadThreadChannels();
});
}
async function removePokemon(id: number) { async function removePokemon(id: number) {
await run(async () => { await run(async () => {
await api.deletePokemon(id); await api.deletePokemon(id);
@@ -2037,6 +2111,39 @@ onMounted(() => {
</div> </div>
</section> </section>
<section v-else-if="canEdit && activeTab === 'threadChannels'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.threadChannels') }}</h2>
<button v-if="can('admin.threads.channels.create')" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewThreadChannel">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.new') }}
</button>
</div>
<ul v-if="threadChannelRows.length" class="row-list access-list">
<li v-for="channel in threadChannelRows" :key="channel.id">
<span class="access-row">
<strong>{{ channel.name }}</strong>
<span class="system-wording-row__meta">
<span class="config-flag">{{ channel.allowUserThreads ? t('pages.admin.userThreadsAllowed') : t('pages.admin.userThreadsDisabled') }}</span>
<span v-for="tag in channel.tags" :key="tag.id" class="config-flag">{{ tag.name }}</span>
<span v-for="language in channel.languages" :key="language.code" class="config-flag">{{ language.name }}</span>
</span>
</span>
<span class="row-actions">
<button v-if="can('admin.threads.channels.update')" type="button" :disabled="busy" @click="editThreadChannel(channel)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</button>
<button v-if="can('admin.threads.channels.delete')" type="button" :disabled="busy" @click="removeThreadChannel(channel.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
</span>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
<div class="detail-section__header"> <div class="detail-section__header">
<h2>{{ t('pages.admin.config') }}</h2> <h2>{{ t('pages.admin.config') }}</h2>
@@ -2669,6 +2776,45 @@ onMounted(() => {
</template> </template>
</Modal> </Modal>
<Modal v-if="threadChannelModalOpen" :title="threadChannelModalTitle" :close-label="t('common.close')" @close="closeThreadChannelModal">
<form id="admin-thread-channel-form" class="modal-edit-form" @submit.prevent="saveThreadChannel">
<div class="field">
<label for="thread-channel-name">{{ t('common.name') }}</label>
<input id="thread-channel-name" v-model="threadChannelForm.name" required maxlength="80" />
</div>
<div class="check-row">
<label>
<input v-model="threadChannelForm.allowUserThreads" type="checkbox" />
{{ t('pages.admin.allowUserThreads') }}
</label>
</div>
<div class="field">
<label for="thread-channel-tags">{{ t('pages.threads.tags') }}</label>
<input id="thread-channel-tags" v-model="threadChannelForm.tagsText" :placeholder="t('pages.admin.threadTagsPlaceholder')" />
</div>
<div class="field">
<span class="field-label">{{ t('pages.threads.language') }}</span>
<div class="permission-groups">
<label v-for="language in languageRows" :key="language.code" class="data-tool-scope">
<input v-model="threadChannelForm.languages" type="checkbox" :value="language.code" />
<span>{{ language.name }}</span>
</label>
</div>
</div>
</form>
<template #footer>
<button type="submit" form="admin-thread-channel-form" class="link-button" :disabled="busy || !threadChannelForm.name.trim()">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
<button type="button" class="plain-button" :disabled="busy" @click="closeThreadChannelModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</template>
</Modal>
<Modal v-if="checklistModalOpen" :title="checklistModalTitle" :close-label="t('common.close')" size="wide" @close="closeChecklistModal"> <Modal v-if="checklistModalOpen" :title="checklistModalTitle" :close-label="t('common.close')" size="wide" @close="closeChecklistModal">
<form id="admin-checklist-form" class="modal-edit-form" @submit.prevent="saveChecklistItem"> <form id="admin-checklist-form" class="modal-edit-form" @submit.prevent="saveChecklistItem">
<TranslationFields <TranslationFields

View File

@@ -0,0 +1,774 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import Modal from '../components/Modal.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import {
iconAdd,
iconBell,
iconChevronUp,
iconComment,
iconDelete,
iconSend,
iconThreads,
type AppIcon
} from '../icons';
import {
api,
threadWebSocketUrl,
type AuthUser,
type ThreadChannel,
type ThreadChannelTag,
type ThreadMessage,
type ThreadReactionType,
type ThreadSort,
type ThreadSummary,
type ThreadWsMessage
} from '../services/api';
type MessageGroup = {
key: string;
author: ThreadMessage['author'];
createdAt: string;
messages: ThreadMessage[];
};
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const channels = ref<ThreadChannel[]>([]);
const threads = ref<ThreadSummary[]>([]);
const messages = ref<ThreadMessage[]>([]);
const currentUser = ref<AuthUser | null>(null);
const activeThread = ref<ThreadSummary | null>(null);
const selectedChannelId = ref<number | null>(null);
const selectedTagId = ref<number | null>(null);
const selectedLanguage = ref('all');
const sort = ref<ThreadSort>('last-active');
const nextCursor = ref<string | null>(null);
const hasMoreThreads = ref(false);
const beforeCursor = ref<string | null>(null);
const hasMoreBefore = ref(false);
const loading = ref(true);
const loadingThreads = ref(false);
const loadingMessages = ref(false);
const loadingOlder = ref(false);
const busy = ref(false);
const errorMessage = ref('');
const createModalOpen = ref(false);
const composerBody = ref('');
const threadForm = ref({ title: '', body: '', languageCode: '', tagIds: [] as number[] });
const socket = ref<WebSocket | null>(null);
const chatScroller = ref<HTMLElement | null>(null);
const showJump = ref(false);
const reactionOptions: Array<{ type: ThreadReactionType; label: string; icon: AppIcon }> = [
{ type: 'thumbs-up', label: '👍', icon: 'mdi:thumb-up-outline' },
{ type: 'heart', label: '❤️', icon: 'mdi:heart-outline' },
{ type: 'laugh', label: '😂', icon: 'mdi:emoticon-lol-outline' },
{ type: 'fire', label: '🔥', icon: 'mdi:fire' },
{ type: 'eyes', label: '👀', icon: 'mdi:eye-outline' }
];
const selectedChannel = computed(() => channels.value.find((channel) => channel.id === selectedChannelId.value) ?? null);
const activeThreadId = computed(() => {
const value = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id;
const id = Number(value);
return Number.isInteger(id) && id > 0 ? id : null;
});
const canCreateThread = computed(() => currentUser.value?.permissions.includes('threads.create') === true);
const canCreateMessage = computed(() => currentUser.value?.permissions.includes('threads.messages.create') === true);
const canFollow = computed(() => currentUser.value?.permissions.includes('threads.follow') === true);
const canReact = computed(() => currentUser.value?.permissions.includes('threads.reactions.set') === true);
const canLockThreads = computed(() => currentUser.value?.permissions.includes('admin.threads.threads.lock') === true);
const canDeleteThreads = computed(() => currentUser.value?.permissions.includes('admin.threads.threads.delete') === true);
const canDeleteMessages = computed(() => currentUser.value?.permissions.includes('admin.threads.messages.delete') === true);
const languageOptions = computed(() => {
const channelLanguages = selectedChannel.value?.languages ?? channels.value.flatMap((channel) => channel.languages);
const byCode = new Map(channelLanguages.map((language) => [language.code, language]));
return [...byCode.values()];
});
const tagOptions = computed(() => selectedChannel.value?.tags ?? []);
const currentThreadList = computed(() => threads.value);
const messageGroups = computed<MessageGroup[]>(() => {
const groups: MessageGroup[] = [];
for (const message of messages.value) {
const previous = groups.at(-1);
const previousMessage = previous?.messages.at(-1);
const sameAuthor = previousMessage?.author?.id === message.author?.id;
const withinMergeWindow =
previousMessage && new Date(message.createdAt).getTime() - new Date(previousMessage.createdAt).getTime() <= 5 * 60 * 1000;
if (previous && sameAuthor && withinMergeWindow) {
previous.messages.push(message);
} else {
groups.push({ key: String(message.id), author: message.author, createdAt: message.createdAt, messages: [message] });
}
}
return groups;
});
function canUseThreads() {
return currentUser.value?.emailVerified === true;
}
function formatDateTime(value: string) {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
}
function authorName(author: ThreadMessage['author']) {
return author?.displayName ?? t('pages.life.byUnknown');
}
function authorInitial(author: ThreadMessage['author']) {
return authorName(author).trim().charAt(0).toUpperCase() || '?';
}
function reactionCount(threadOrMessage: ThreadSummary | ThreadMessage, type: ThreadReactionType) {
return threadOrMessage.reactionCounts[type] ?? 0;
}
function reactionActive(threadOrMessage: ThreadSummary | ThreadMessage, type: ThreadReactionType) {
return threadOrMessage.myReactions.includes(type);
}
function updateThreadInList(thread: ThreadSummary) {
const index = threads.value.findIndex((item) => item.id === thread.id);
if (index >= 0) {
threads.value[index] = thread;
} else {
threads.value = [thread, ...threads.value];
}
if (activeThread.value?.id === thread.id) {
activeThread.value = thread;
}
}
function updateMessageInList(message: ThreadMessage) {
const index = messages.value.findIndex((item) => item.id === message.id);
if (index >= 0) {
messages.value[index] = message;
} else {
messages.value = [...messages.value, message];
}
}
function isNearBottom() {
const el = chatScroller.value;
return !el || el.scrollHeight - el.scrollTop - el.clientHeight < 80;
}
async function scrollToBottom() {
await nextTick();
const el = chatScroller.value;
if (el) {
el.scrollTop = el.scrollHeight;
}
showJump.value = false;
}
async function loadCurrentUser() {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
async function loadChannels() {
channels.value = await api.threadChannels();
if (!selectedChannelId.value && channels.value[0]) {
selectedChannelId.value = channels.value[0].id;
}
if (!threadForm.value.languageCode) {
threadForm.value.languageCode = channels.value[0]?.languages[0]?.code ?? 'en';
}
}
async function loadThreads(reset = true) {
loadingThreads.value = true;
try {
const page = await api.threads({
cursor: reset ? null : nextCursor.value,
limit: 20,
channelId: selectedChannelId.value,
language: selectedLanguage.value,
tagId: selectedTagId.value,
sort: sort.value
});
threads.value = reset ? page.items : [...threads.value, ...page.items];
nextCursor.value = page.nextCursor;
hasMoreThreads.value = page.hasMore;
} finally {
loadingThreads.value = false;
}
}
async function loadActiveThread() {
const id = activeThreadId.value;
if (!id) {
activeThread.value = null;
messages.value = [];
return;
}
activeThread.value = await api.thread(id);
updateThreadInList(activeThread.value);
}
async function loadMessages(reset = true) {
const id = activeThreadId.value;
if (!id) return;
if (reset) {
loadingMessages.value = true;
} else {
loadingOlder.value = true;
}
try {
const page = await api.threadMessages(id, { before: reset ? null : beforeCursor.value, limit: 40 });
messages.value = reset ? page.items : [...page.items, ...messages.value];
beforeCursor.value = page.beforeCursor;
hasMoreBefore.value = page.hasMoreBefore;
if (reset) {
await scrollToBottom();
if (canFollow.value) {
try {
activeThread.value = await api.markThreadRead(id);
updateThreadInList(activeThread.value);
} catch {
// Read state is best-effort and does not block browsing.
}
}
}
} finally {
loadingMessages.value = false;
loadingOlder.value = false;
}
}
async function loadAll() {
loading.value = true;
errorMessage.value = '';
try {
await loadCurrentUser();
await loadChannels();
await loadThreads(true);
if (activeThreadId.value) {
await loadActiveThread();
await loadMessages(true);
}
connectSocket();
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
} finally {
loading.value = false;
}
}
async function selectThread(thread: ThreadSummary) {
await router.push(`/threads/${thread.id}`);
}
function selectChannel(channelId: number | null) {
selectedChannelId.value = channelId;
selectedTagId.value = null;
void loadThreads(true);
}
function openCreateThread() {
const channel = selectedChannel.value ?? channels.value[0];
threadForm.value = {
title: '',
body: '',
languageCode: channel?.languages[0]?.code ?? 'en',
tagIds: []
};
createModalOpen.value = true;
}
function closeCreateThread() {
createModalOpen.value = false;
}
function toggleThreadTag(tag: ThreadChannelTag) {
const tags = new Set(threadForm.value.tagIds);
if (tags.has(tag.id)) {
tags.delete(tag.id);
} else {
tags.add(tag.id);
}
threadForm.value.tagIds = [...tags];
}
async function submitThread() {
const channel = selectedChannel.value ?? channels.value[0];
if (!channel) return;
busy.value = true;
errorMessage.value = '';
try {
const thread = await api.createThread({
channelId: channel.id,
title: threadForm.value.title,
body: threadForm.value.body,
languageCode: threadForm.value.languageCode,
tagIds: threadForm.value.tagIds
});
closeCreateThread();
updateThreadInList(thread);
await router.push(`/threads/${thread.id}`);
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.createFailed');
} finally {
busy.value = false;
}
}
async function submitMessage() {
const id = activeThreadId.value;
if (!id || !composerBody.value.trim()) return;
busy.value = true;
errorMessage.value = '';
try {
const message = await api.createThreadMessage(id, { body: composerBody.value });
composerBody.value = '';
updateMessageInList(message);
await scrollToBottom();
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.messageFailed');
} finally {
busy.value = false;
}
}
async function toggleFollow() {
const thread = activeThread.value;
if (!thread) return;
busy.value = true;
try {
const updated = thread.followed ? await api.unfollowThread(thread.id) : await api.followThread(thread.id);
updateThreadInList(updated);
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.followFailed');
} finally {
busy.value = false;
}
}
async function toggleThreadReaction(thread: ThreadSummary, type: ThreadReactionType) {
if (!canReact.value) return;
try {
const updated = reactionActive(thread, type) ? await api.deleteThreadReaction(thread.id, type) : await api.setThreadReaction(thread.id, type);
updateThreadInList(updated);
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.reactionFailed');
}
}
async function toggleThreadLock() {
const thread = activeThread.value;
if (!thread) return;
busy.value = true;
try {
const updated = await api.lockThread(thread.id, !thread.locked);
updateThreadInList(updated);
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.operationFailed');
} finally {
busy.value = false;
}
}
async function removeActiveThread() {
const thread = activeThread.value;
if (!thread) return;
busy.value = true;
try {
await api.deleteThread(thread.id);
threads.value = threads.value.filter((item) => item.id !== thread.id);
activeThread.value = null;
messages.value = [];
await router.push('/threads');
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.operationFailed');
} finally {
busy.value = false;
}
}
async function removeMessage(message: ThreadMessage) {
busy.value = true;
try {
await api.deleteThreadMessage(message.id);
messages.value = messages.value.filter((item) => item.id !== message.id);
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.operationFailed');
} finally {
busy.value = false;
}
}
async function toggleMessageReaction(message: ThreadMessage, type: ThreadReactionType) {
if (!canReact.value) return;
try {
const updated = reactionActive(message, type)
? await api.deleteThreadMessageReaction(message.id, type)
: await api.setThreadMessageReaction(message.id, type);
updateMessageInList(updated);
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.reactionFailed');
}
}
function handleThreadWsMessage(message: ThreadWsMessage) {
if (message.type === 'thread.message.created') {
updateThreadInList(message.thread);
if (activeThreadId.value === message.threadId) {
const stick = isNearBottom();
updateMessageInList(message.message);
if (stick) {
void scrollToBottom();
} else {
showJump.value = true;
}
}
} else if (message.type === 'thread.reactions.updated') {
if (message.target === 'thread') {
const thread = threads.value.find((item) => item.id === message.threadId);
if (thread) {
updateThreadInList({ ...thread, reactionCounts: message.reactionCounts, myReactions: message.myReactions });
}
} else if (message.messageId) {
const existing = messages.value.find((item) => item.id === message.messageId);
if (existing) {
updateMessageInList({ ...existing, reactionCounts: message.reactionCounts, myReactions: message.myReactions });
}
}
} else if (message.type === 'thread.read.updated') {
const thread = threads.value.find((item) => item.id === message.threadId);
if (thread) {
updateThreadInList({ ...thread, unread: message.unread });
}
}
}
async function connectSocket() {
if (!canUseThreads() || socket.value) return;
try {
const { ticket } = await api.threadWsTicket();
const nextSocket = new WebSocket(threadWebSocketUrl(ticket));
socket.value = nextSocket;
nextSocket.addEventListener('message', (event) => {
try {
handleThreadWsMessage(JSON.parse(String(event.data)) as ThreadWsMessage);
} catch {
// Invalid socket frames are ignored.
}
});
nextSocket.addEventListener('close', () => {
if (socket.value === nextSocket) {
socket.value = null;
}
});
} catch {
socket.value = null;
}
}
function onScroll() {
showJump.value = !isNearBottom();
}
watch([selectedLanguage, selectedTagId, sort], () => {
void loadThreads(true);
});
watch(activeThreadId, async () => {
errorMessage.value = '';
await loadActiveThread();
await loadMessages(true);
});
onMounted(() => {
void loadAll();
});
onBeforeUnmount(() => {
socket.value?.close();
});
</script>
<template>
<section class="page-stack threads-page">
<PageHeader :title="t('pages.threads.title')" :subtitle="t('pages.threads.subtitle')">
<template #kicker>{{ t('pages.threads.kicker') }}</template>
<button v-if="canCreateThread" type="button" class="ui-button ui-button--primary" @click="openCreateThread">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('pages.threads.newThread') }}
</button>
</PageHeader>
<StatusMessage v-if="errorMessage" variant="warning">{{ errorMessage }}</StatusMessage>
<div class="threads-layout">
<aside class="threads-sidebar" :aria-label="t('pages.threads.channels')">
<h2>{{ t('pages.threads.channels') }}</h2>
<button
type="button"
class="thread-channel"
:class="{ active: selectedChannelId === null }"
@click="selectChannel(null)"
>
<Icon :icon="iconThreads" class="ui-icon" aria-hidden="true" />
<span>{{ t('pages.threads.allChannels') }}</span>
</button>
<button
v-for="channel in channels"
:key="channel.id"
type="button"
class="thread-channel"
:class="{ active: selectedChannelId === channel.id }"
@click="selectChannel(channel.id)"
>
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
<span>{{ channel.name }}</span>
<span v-if="channel.unreadCount > 0" class="thread-unread-dot" :aria-label="t('pages.threads.unread')"></span>
</button>
</aside>
<section class="threads-list-panel">
<div class="thread-filters">
<label>
<span>{{ t('pages.threads.language') }}</span>
<select v-model="selectedLanguage">
<option value="all">{{ t('pages.threads.allLanguages') }}</option>
<option v-for="language in languageOptions" :key="language.code" :value="language.code">{{ language.name }}</option>
</select>
</label>
<label>
<span>{{ t('pages.threads.sort') }}</span>
<select v-model="sort">
<option value="last-active">{{ t('pages.threads.sortLastActive') }}</option>
<option value="latest">{{ t('pages.threads.sortLatest') }}</option>
<option value="most-discussed">{{ t('pages.threads.sortMostDiscussed') }}</option>
</select>
</label>
</div>
<div v-if="tagOptions.length" class="thread-tag-filter" :aria-label="t('pages.threads.tags')">
<button type="button" class="thread-chip" :class="{ active: selectedTagId === null }" @click="selectedTagId = null">
{{ t('common.all') }}
</button>
<button
v-for="tag in tagOptions"
:key="tag.id"
type="button"
class="thread-chip"
:class="{ active: selectedTagId === tag.id }"
@click="selectedTagId = tag.id"
>
{{ tag.name }}
</button>
</div>
<div v-if="loading || loadingThreads" class="thread-list" aria-busy="true">
<article v-for="index in 4" :key="index" class="thread-list-item">
<Skeleton width="70%" height="20px" />
<Skeleton width="45%" />
</article>
</div>
<div v-else-if="currentThreadList.length" class="thread-list">
<button
v-for="thread in currentThreadList"
:key="thread.id"
type="button"
class="thread-list-item"
:class="{ active: activeThread?.id === thread.id, unread: thread.unread }"
@click="selectThread(thread)"
>
<span class="thread-list-item__title">
<span v-if="thread.unread" class="thread-unread-dot" :aria-label="t('pages.threads.unread')"></span>
{{ thread.title }}
</span>
<span class="thread-list-item__meta">
{{ thread.author?.displayName ?? t('pages.life.byUnknown') }} · {{ formatDateTime(thread.lastActiveAt) }}
</span>
<span class="thread-list-item__tags">
<span v-for="tag in thread.tags" :key="tag.id" class="thread-chip">{{ tag.name }}</span>
<span class="thread-chip">{{ thread.messageCount }}</span>
</span>
</button>
<button v-if="hasMoreThreads" type="button" class="ui-button ui-button--ghost" :disabled="loadingThreads" @click="loadThreads(false)">
{{ t('pages.threads.loadMoreThreads') }}
</button>
</div>
<p v-else class="threads-empty">{{ t('pages.threads.noThreads') }}</p>
</section>
<section class="thread-chat-panel">
<template v-if="activeThread">
<header class="thread-chat-header">
<div>
<h2>{{ activeThread.title }}</h2>
<p>
{{ activeThread.author?.displayName ?? t('pages.life.byUnknown') }} · {{ formatDateTime(activeThread.createdAt) }}
</p>
</div>
<div class="thread-chat-actions">
<button v-if="canFollow" type="button" class="ui-button ui-button--small" :disabled="busy" @click="toggleFollow">
<Icon :icon="iconBell" class="ui-icon" aria-hidden="true" />
{{ activeThread.followed ? t('pages.threads.unfollow') : t('pages.threads.follow') }}
</button>
<button v-if="canLockThreads" type="button" class="ui-button ui-button--small" :disabled="busy" @click="toggleThreadLock">
{{ activeThread.locked ? t('pages.threads.unlock') : t('pages.threads.lock') }}
</button>
<button v-if="canDeleteThreads" type="button" class="ui-button ui-button--red ui-button--small" :disabled="busy" @click="removeActiveThread">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
<span v-if="activeThread.locked" class="config-flag">{{ t('pages.threads.locked') }}</span>
</div>
</header>
<div class="thread-reactions">
<button
v-for="option in reactionOptions"
:key="option.type"
type="button"
class="thread-reaction"
:class="{ active: reactionActive(activeThread, option.type) }"
:disabled="!canReact"
@click="toggleThreadReaction(activeThread, option.type)"
>
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
<span>{{ reactionCount(activeThread, option.type) }}</span>
</button>
</div>
<div ref="chatScroller" class="thread-message-scroll" @scroll="onScroll">
<button
v-if="hasMoreBefore"
type="button"
class="ui-button ui-button--ghost thread-load-older"
:disabled="loadingOlder"
@click="loadMessages(false)"
>
<Icon :icon="iconChevronUp" class="ui-icon" aria-hidden="true" />
{{ t('pages.threads.loadOlder') }}
</button>
<div v-if="loadingMessages" class="thread-message-list" aria-busy="true">
<article v-for="index in 4" :key="index" class="thread-message-group">
<Skeleton width="160px" />
<Skeleton width="90%" />
</article>
</div>
<div v-else-if="messages.length" class="thread-message-list">
<article v-for="group in messageGroups" :key="group.key" class="thread-message-group">
<div class="thread-avatar" aria-hidden="true">{{ authorInitial(group.author) }}</div>
<div class="thread-message-group__body">
<div class="thread-message-meta">
<strong>{{ authorName(group.author) }}</strong>
<time :datetime="group.createdAt">{{ formatDateTime(group.createdAt) }}</time>
</div>
<div v-for="message in group.messages" :key="message.id" class="thread-message">
<p>{{ message.body }}</p>
<span v-if="message.moderationStatus === 'reviewing'" class="config-flag">{{ t('pages.threads.messageReviewing') }}</span>
<span v-else-if="message.moderationStatus === 'rejected' || message.moderationStatus === 'failed'" class="config-flag">
{{ t('pages.threads.messageRejected') }}
</span>
<div class="thread-reactions thread-reactions--message">
<button
v-for="option in reactionOptions"
:key="option.type"
type="button"
class="thread-reaction"
:class="{ active: reactionActive(message, option.type) }"
:disabled="!canReact || message.moderationStatus !== 'approved'"
@click="toggleMessageReaction(message, option.type)"
>
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
<span>{{ reactionCount(message, option.type) }}</span>
</button>
<button
v-if="canDeleteMessages"
type="button"
class="thread-reaction"
:disabled="busy"
:aria-label="t('common.delete')"
@click="removeMessage(message)"
>
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
</button>
</div>
</div>
</div>
</article>
</div>
<p v-else class="threads-empty">{{ t('pages.threads.noMessages') }}</p>
</div>
<button v-if="showJump" type="button" class="thread-jump-button" @click="scrollToBottom">
{{ t('pages.threads.jumpToPresent') }}
</button>
<form class="thread-composer" @submit.prevent="submitMessage">
<label class="sr-only" for="thread-message-body">{{ t('pages.threads.message') }}</label>
<textarea
id="thread-message-body"
v-model="composerBody"
rows="2"
:disabled="busy || !canCreateMessage || activeThread.locked"
:placeholder="t('pages.threads.message')"
></textarea>
<button class="ui-button ui-button--primary" type="submit" :disabled="busy || !composerBody.trim() || !canCreateMessage || activeThread.locked">
<Icon :icon="iconSend" class="ui-icon" aria-hidden="true" />
{{ busy ? t('pages.threads.sending') : t('pages.threads.send') }}
</button>
</form>
</template>
<p v-else class="threads-empty threads-empty--select">{{ t('pages.threads.selectThread') }}</p>
</section>
</div>
<Modal v-if="createModalOpen" :title="t('pages.threads.newThread')" :close-label="t('common.close')" @close="closeCreateThread">
<form class="modal-edit-form" @submit.prevent="submitThread">
<div class="field">
<label for="thread-title">{{ t('pages.threads.threadTitle') }}</label>
<input id="thread-title" v-model="threadForm.title" required maxlength="140" :disabled="busy" />
</div>
<div class="field">
<label for="thread-language">{{ t('pages.threads.language') }}</label>
<select id="thread-language" v-model="threadForm.languageCode" :disabled="busy">
<option v-for="language in languageOptions" :key="language.code" :value="language.code">{{ language.name }}</option>
</select>
</div>
<div v-if="tagOptions.length" class="field">
<span class="field-label">{{ t('pages.threads.tags') }}</span>
<div class="thread-tag-filter">
<button
v-for="tag in tagOptions"
:key="tag.id"
type="button"
class="thread-chip"
:class="{ active: threadForm.tagIds.includes(tag.id) }"
:disabled="busy"
@click="toggleThreadTag(tag)"
>
{{ tag.name }}
</button>
</div>
</div>
<div class="field">
<label for="thread-body">{{ t('pages.threads.firstMessage') }}</label>
<textarea id="thread-body" v-model="threadForm.body" rows="5" required maxlength="2000" :disabled="busy"></textarea>
</div>
<button class="ui-button ui-button--primary" type="submit" :disabled="busy || !threadForm.title.trim() || !threadForm.body.trim()">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.create') }}
</button>
</form>
</Modal>
</section>
</template>

View File

@@ -65,6 +65,7 @@ export const systemWordingMessages = {
clothes: 'Clothes', clothes: 'Clothes',
checklist: 'CheckList', checklist: 'CheckList',
life: 'Life', life: 'Life',
threads: 'Threads',
admin: 'Admin', admin: 'Admin',
main: 'Main navigation', main: 'Main navigation',
openMenu: 'Open navigation', openMenu: 'Open navigation',
@@ -1020,6 +1021,45 @@ export const systemWordingMessages = {
moderationRetryFailed: 'Review retry failed', moderationRetryFailed: 'Review retry failed',
charactersLeft: '{count} characters left' charactersLeft: '{count} characters left'
}, },
threads: {
kicker: 'Community Threads',
title: 'Threads',
subtitle: 'Browse channel discussions and chat inside each thread.',
channels: 'Channels',
allChannels: 'All channels',
newThread: 'New Thread',
threadTitle: 'Title',
firstMessage: 'First message',
message: 'Message',
send: 'Send',
sending: 'Sending',
follow: 'Follow',
unfollow: 'Unfollow',
lock: 'Lock',
unlock: 'Unlock',
locked: 'Locked',
unread: 'Unread',
tags: 'Tags',
language: 'Language',
allLanguages: 'All languages',
sort: 'Sort',
sortLastActive: 'Last active',
sortLatest: 'Latest',
sortMostDiscussed: 'Most discussed',
loadMoreThreads: 'Load more threads',
loadOlder: 'Load older',
jumpToPresent: 'Jump to Present',
unreadDivider: 'Unread messages',
noThreads: 'No threads yet',
noMessages: 'No messages yet',
selectThread: 'Select a thread',
messageReviewing: 'Reviewing',
messageRejected: 'Not approved',
createFailed: 'Thread could not be created',
messageFailed: 'Message could not be sent',
reactionFailed: 'Reaction could not be updated',
followFailed: 'Follow could not be updated'
},
admin: { admin: {
title: 'Admin', title: 'Admin',
subtitle: 'Manage Wiki content, configuration, localization, and access.', subtitle: 'Manage Wiki content, configuration, localization, and access.',
@@ -1041,6 +1081,13 @@ export const systemWordingMessages = {
recipeList: 'Recipe list', recipeList: 'Recipe list',
dishList: 'Dish list', dishList: 'Dish list',
habitatList: 'Habitat list', habitatList: 'Habitat list',
threadChannels: 'Thread channels',
newThreadChannel: 'New Thread channel',
editThreadChannel: 'Edit Thread channel',
allowUserThreads: 'Allow users to create Threads',
userThreadsAllowed: 'Users can create Threads',
userThreadsDisabled: 'User Thread creation disabled',
threadTagsPlaceholder: 'tag, tag, tag',
dataTools: 'Data tools', dataTools: 'Data tools',
dataToolRefresh: 'Refresh', dataToolRefresh: 'Refresh',
dataToolExport: 'Export data', dataToolExport: 'Export data',
@@ -1461,6 +1508,7 @@ export const systemWordingMessages = {
clothes: '服装', clothes: '服装',
checklist: 'CheckList', checklist: 'CheckList',
life: 'Life', life: 'Life',
threads: '讨论',
admin: '管理', admin: '管理',
main: '主导航', main: '主导航',
openMenu: '打开导航', openMenu: '打开导航',
@@ -2390,6 +2438,45 @@ export const systemWordingMessages = {
moderationRetryFailed: '重新审核失败', moderationRetryFailed: '重新审核失败',
charactersLeft: '还可以输入 {count} 个字符' charactersLeft: '还可以输入 {count} 个字符'
}, },
threads: {
kicker: '社区讨论',
title: '讨论',
subtitle: '按频道浏览帖子,并在帖子内用聊天消息讨论。',
channels: '频道',
allChannels: '全部频道',
newThread: '新帖子',
threadTitle: '标题',
firstMessage: '首条消息',
message: '消息',
send: '发送',
sending: '发送中',
follow: '关注',
unfollow: '取消关注',
lock: '锁定',
unlock: '解锁',
locked: '已锁定',
unread: '未读',
tags: '标签',
language: '语言',
allLanguages: '全部语言',
sort: '排序',
sortLastActive: '最后活跃',
sortLatest: '最新发布',
sortMostDiscussed: '讨论数',
loadMoreThreads: '加载更多帖子',
loadOlder: '加载旧消息',
jumpToPresent: '跳到最新',
unreadDivider: '未读消息',
noThreads: '暂无帖子',
noMessages: '暂无消息',
selectThread: '选择一个帖子',
messageReviewing: '审核中',
messageRejected: '未通过',
createFailed: '帖子创建失败',
messageFailed: '消息发送失败',
reactionFailed: 'Reaction 更新失败',
followFailed: '关注状态更新失败'
},
admin: { admin: {
title: '管理', title: '管理',
subtitle: '管理 Wiki 内容、配置、本地化和访问权限。', subtitle: '管理 Wiki 内容、配置、本地化和访问权限。',
@@ -2411,6 +2498,13 @@ export const systemWordingMessages = {
recipeList: '材料单列表', recipeList: '材料单列表',
dishList: '料理列表', dishList: '料理列表',
habitatList: '栖息地列表', habitatList: '栖息地列表',
threadChannels: '讨论频道',
newThreadChannel: '新增讨论频道',
editThreadChannel: '编辑讨论频道',
allowUserThreads: '允许用户创建帖子',
userThreadsAllowed: '用户可创建帖子',
userThreadsDisabled: '用户不可创建帖子',
threadTagsPlaceholder: '标签, 标签, 标签',
dataTools: '数据工具', dataTools: '数据工具',
dataToolRefresh: '刷新', dataToolRefresh: '刷新',
dataToolExport: '导出数据', dataToolExport: '导出数据',