feat(moderation): add AI moderation for user-generated content
Add AI moderation settings, caching, and status tracking Require AI approval for Life Posts, Comments, and Discussions Implement language filtering and moderation status UI Add retry mechanism for failed moderation checks
This commit is contained in:
@@ -10,3 +10,4 @@ VITE_API_BASE_URL=http://localhost:3001
|
||||
VITE_SITE_URL=https://pokopiawiki.tootaio.com
|
||||
RESEND_API_KEY=
|
||||
EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>"
|
||||
AI_MODERATION_API_KEY=
|
||||
|
||||
68
DESIGN.md
68
DESIGN.md
@@ -293,9 +293,43 @@
|
||||
- 被删除实体的讨论会随实体删除一并清理。
|
||||
- 讨论按创建时间正序展示。
|
||||
- 讨论列表按顶层评论分页读取,支持 `limit` / `cursor`;每页顶层评论携带其一层回复,响应包含 `items`、`nextCursor`、`hasMore`、`total`。
|
||||
- 实体讨论评论和回复必须进入 AI 审核;未审核通过的评论不向普通访客公开。
|
||||
- 作者本人和拥有 `discussions.comments.delete-any` 权限的管理用户可看到相关评论的审核状态,并可触发重新审核。
|
||||
- 审核状态包括:`unreviewed`、`reviewing`、`approved`、`rejected`、`failed`;前端面向用户展示为未审核、审核中、审核通过、审核不通过、审核失败。
|
||||
- 新增或更新审核目标时先进入不可公开状态;只有 AI 审核通过后才进入普通公开讨论列表。
|
||||
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
|
||||
- AI 审核会自动识别评论适合的语言区,语言区使用启用状态的 `languages.code`,但不影响系统 UI 语言。
|
||||
- 讨论列表支持按语言区读取;`language=all` 或不传语言参数时读取全部已公开语言区,传入具体语言 code 时只读取对应语言区。
|
||||
- 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
||||
- API 对外只返回评论作者的 `id` 和 `displayName`。
|
||||
- API 不返回邮箱、token/hash、内部调试字段、`deleted_at`、`deleted_by_user_id` 等内部删除字段。
|
||||
- API 不返回邮箱、token/hash、内部调试字段、AI prompt、模型原始响应、内部审核错误、`deleted_at`、`deleted_by_user_id` 等内部字段。
|
||||
|
||||
## AI 审核
|
||||
|
||||
- Life Post、Life Comment、实体讨论评论和实体讨论回复都是用户生成内容,必须经过 AI 审核。
|
||||
- AI 审核支持 Gemini-compatible `generateContent` API 和 OpenAI-compatible `chat/completions` API;End Point、API Key、模型、API 格式、鉴权方式、RPM 限流和启用状态可由拥有 `admin.ai-moderation.*` 权限的管理员配置。
|
||||
- 默认使用 Gemini-compatible `generateContent` API 和 Bearer token 鉴权,以兼容 NewAPI 等转发服务;鉴权方式仍支持 Gemini 原生 query `key`。
|
||||
- 后端日志必须对 API Key 脱敏,且不回显给前端。
|
||||
- 默认 End Point 为 `https://ai.example.com/v1beta`;API Key 不写入前端包,不回显给前端,管理 API 只返回是否已配置。
|
||||
- 管理配置存储在后端受控表中;API 不返回 API Key 明文、模型原始响应、prompt、请求体、内部错误堆栈或调试字段。
|
||||
- 后端日志可以记录安全脱敏后的第三方 HTTP 状态和错误摘要,用于排查 Endpoint、模型或鉴权配置问题;日志不得包含 API Key、审核 prompt 或用户正文。
|
||||
- 服务端审核请求必须限流,按配置的每分钟请求数串行发送,避免触发第三方 API RPM 限制。
|
||||
- 为节省 Token:
|
||||
- 审核只发送待审核正文、允许的语言 code 和最小必要规则,不发送用户资料、页面上下文、审计 payload 或无关业务数据。
|
||||
- 对相同正文和相同 API 配置/模型使用内容 hash 缓存审核结果,避免重复调用 AI。
|
||||
- 审核请求使用结构化 JSON 输出、低温度和较小输出 token 上限。
|
||||
- 安全要求:
|
||||
- 用户正文必须作为不可信内容处理,不能作为系统指令或开发指令执行。
|
||||
- 不允许通过用户正文关闭、绕过或降低安全审核。
|
||||
- 不使用会关闭 Gemini 安全拦截的配置;如果 Gemini 安全机制拦截 prompt 或候选结果,该内容按审核不通过处理。
|
||||
- OpenAI-compatible 转发模式下仍必须使用独立系统指令和结构化 JSON 解析;模型未返回明确合法结果时按审核失败处理。
|
||||
- 模型返回格式不合法、网络失败、超时或限流失败时,内容标记为审核失败,不得公开。
|
||||
- 只有 `approved` 状态可向普通访客公开;`unreviewed`、`reviewing`、`rejected`、`failed` 均不可公开。
|
||||
- 审核语言区独立于系统 UI 语言:
|
||||
- 前台可选择 All languages 或具体语言区浏览内容。
|
||||
- 发布时客户端可传当前语言区作为 hint,但最终语言区由服务端 AI 审核结果决定。
|
||||
- 如果 AI 无法识别到启用语言区,回退到默认语言。
|
||||
- 审核状态对普通访客不用于解释内部流程;只在作者本人或有管理权限的用户需要处理内容时展示。
|
||||
|
||||
## 全局配置数据
|
||||
|
||||
@@ -629,19 +663,27 @@ Life Post 可配置:
|
||||
- Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
|
||||
- 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。
|
||||
- Feed 使用 Tabs 展示 Life 标签筛选;包含 All 和后台配置的 Life 标签;点击标签后按该标签筛选,搜索和标签筛选可以同时生效。
|
||||
- Feed 使用语言筛选展示 All languages 和启用语言;语言区筛选独立于系统 UI 语言,搜索、标签和语言筛选可以同时生效。
|
||||
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
|
||||
- 当前没有图片上传、转发、置顶或单独审核流程。
|
||||
- 当前没有图片上传、转发或置顶。
|
||||
- Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。
|
||||
- 作者本人和拥有对应 `*-any` 管理权限的用户可以看到相关内容的审核状态,并可触发重新审核。
|
||||
- Life Post 必须展示审核状态:审核中、未审核、审核失败、审核不通过;审核通过也可作为状态展示。
|
||||
- 新增或更新 Life Post 后先进入不可公开状态,AI 审核通过后才出现在普通公开 Feed。
|
||||
- Life Comment 和回复审核通过后才出现在普通公开评论列表、评论数量和评论预览中。
|
||||
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
|
||||
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
||||
|
||||
API 暴露边界:
|
||||
|
||||
- Life Post 作者信息只返回 `id` 和 `displayName`。
|
||||
- Life Post 标签只返回 `id` 和按当前语言解析后的 `name`。
|
||||
- Life Post 可返回面向用户展示所需的审核状态、审核语言区和是否可重审;不返回内部错误、AI prompt、模型响应或 retry 细节。
|
||||
- Life Comment 作者信息只返回 `id` 和 `displayName`。
|
||||
- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。
|
||||
- Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。每个 Life Post 的评论字段只包含 `commentCount` 和 `commentPreview`,不内嵌完整评论列表。
|
||||
- Life Comment 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`、`total`;`cursor` 是不透明分页令牌。
|
||||
- API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。
|
||||
- Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。每个 Life Post 的评论字段只包含已公开或当前用户可见评论的 `commentCount` 和 `commentPreview`,不内嵌完整评论列表。
|
||||
- Life Comment 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`、`total`;`cursor` 是不透明分页令牌;普通访客只读取审核通过评论。
|
||||
- API 不返回邮箱、token/hash、内部调试字段、AI prompt、模型原始响应、内部审核错误或不必要的审计 payload。
|
||||
- API 不返回 Life Post 的 `deleted_at`、`deleted_by_user_id` 等内部软删除字段。
|
||||
- 非作者只有拥有对应 `*-any` 管理权限时才能编辑或删除其他用户的 Life Post 或 Life Comment。
|
||||
|
||||
@@ -727,13 +769,13 @@ API 暴露边界:
|
||||
- `GET /api/items/:id`
|
||||
- `GET /api/recipes`
|
||||
- `GET /api/recipes/:id`
|
||||
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `tagId` 按 Life 标签筛选。
|
||||
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论。
|
||||
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `tagId` 按 Life 标签筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区。
|
||||
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选。
|
||||
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计和公开社区统计。
|
||||
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
||||
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
|
||||
- `GET /api/users/:id/comments`:分页读取该用户未删除的 Life 评论和实体讨论评论。
|
||||
- `GET /api/discussions/:entityType/:entityId/comments`:支持 `cursor` / `limit` 分页读取实体讨论;`entityType` 支持 `pokemon`、`items`、`recipes`、`habitats`。
|
||||
- `GET /api/discussions/:entityType/:entityId/comments`:支持 `cursor` / `limit` 分页读取实体讨论;支持 `language` 按审核语言区筛选;`entityType` 支持 `pokemon`、`items`、`recipes`、`habitats`。
|
||||
|
||||
认证 API:
|
||||
|
||||
@@ -772,14 +814,17 @@ API 暴露边界:
|
||||
- `POST /api/life-posts`
|
||||
- `PUT /api/life-posts/:id`
|
||||
- `DELETE /api/life-posts/:id`
|
||||
- `POST /api/life-posts/:id/moderation/retry`
|
||||
- Life Comment 的创建,以及作者本人对 Life Comment 的删除,需要对应 `life.comments.*` 权限;管理他人内容需要对应 `*-any` 权限。
|
||||
- `POST /api/life-posts/:postId/comments`
|
||||
- `POST /api/life-posts/:postId/comments/:commentId/replies`
|
||||
- `DELETE /api/life-comments/:id`
|
||||
- `POST /api/life-comments/:id/moderation/retry`
|
||||
- 实体讨论评论的创建、回复,以及作者本人对评论的删除,需要对应 `discussions.comments.*` 权限;管理他人内容需要对应 `*-any` 权限。
|
||||
- `POST /api/discussions/:entityType/:entityId/comments`
|
||||
- `POST /api/discussions/:entityType/:entityId/comments/:commentId/replies`
|
||||
- `DELETE /api/discussions/comments/:id`
|
||||
- `POST /api/discussions/comments/:id/moderation/retry`
|
||||
- Life Reaction 的设置、替换和取消。
|
||||
- `PUT /api/life-posts/:id/reaction`
|
||||
- `DELETE /api/life-posts/:id/reaction`
|
||||
@@ -787,8 +832,11 @@ API 暴露边界:
|
||||
- 全局配置项的查看、创建、更新、删除、排序需要对应 `admin.config.*` 权限。
|
||||
- 语言的查看、创建、更新、删除、排序需要对应 `admin.languages.*` 权限。
|
||||
- 系统级文案的查看和更新需要对应 `admin.wordings.*` 权限。
|
||||
- `GET /api/admin/system-wordings`
|
||||
- `PUT /api/admin/system-wordings/:key`
|
||||
- `GET /api/admin/system-wordings`
|
||||
- AI 审核配置的查看和更新需要对应 `admin.ai-moderation.*` 权限。
|
||||
- `GET /api/admin/ai-moderation`
|
||||
- `PUT /api/admin/ai-moderation`
|
||||
- `PUT /api/admin/system-wordings/:key`
|
||||
- Pokemon、物品、材料单、栖息地的列表排序需要对应实体的 `order` 权限。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
@@ -134,6 +134,49 @@ CREATE TABLE IF NOT EXISTS user_roles (
|
||||
CREATE INDEX IF NOT EXISTS user_roles_role_id_idx
|
||||
ON user_roles(role_id, user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_moderation_settings (
|
||||
id boolean PRIMARY KEY DEFAULT true CHECK (id = true),
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
api_format text NOT NULL DEFAULT 'gemini-generate-content' CHECK (api_format IN ('gemini-generate-content', 'openai-chat-completions')),
|
||||
auth_mode text NOT NULL DEFAULT 'bearer-token' CHECK (auth_mode IN ('query-key', 'bearer-token')),
|
||||
endpoint text NOT NULL DEFAULT 'https://ai.example.com/v1beta',
|
||||
api_key text NOT NULL DEFAULT '',
|
||||
model text NOT NULL DEFAULT 'gemini-2.0-flash-lite',
|
||||
requests_per_minute integer NOT NULL DEFAULT 10 CHECK (requests_per_minute BETWEEN 1 AND 60),
|
||||
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(endpoint) BETWEEN 1 AND 300),
|
||||
CHECK (length(model) BETWEEN 1 AND 120)
|
||||
);
|
||||
|
||||
ALTER TABLE ai_moderation_settings
|
||||
ADD COLUMN IF NOT EXISTS api_format text NOT NULL DEFAULT 'gemini-generate-content' CHECK (api_format IN ('gemini-generate-content', 'openai-chat-completions')),
|
||||
ADD COLUMN IF NOT EXISTS auth_mode text NOT NULL DEFAULT 'bearer-token' CHECK (auth_mode IN ('query-key', 'bearer-token'));
|
||||
|
||||
INSERT INTO ai_moderation_settings (id)
|
||||
VALUES (true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
UPDATE ai_moderation_settings
|
||||
SET api_format = 'gemini-generate-content',
|
||||
auth_mode = 'bearer-token',
|
||||
updated_at = now()
|
||||
WHERE api_format = 'openai-chat-completions'
|
||||
AND auth_mode = 'query-key'
|
||||
AND endpoint ~* '/v1beta/?$';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_moderation_cache (
|
||||
content_hash text NOT NULL,
|
||||
model text NOT NULL,
|
||||
status text NOT NULL CHECK (status IN ('approved', 'rejected')),
|
||||
language_code text REFERENCES languages(code) ON DELETE SET NULL,
|
||||
checked_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (content_hash, model),
|
||||
CHECK (length(content_hash) BETWEEN 32 AND 128),
|
||||
CHECK (length(model) BETWEEN 1 AND 120)
|
||||
);
|
||||
|
||||
INSERT INTO permissions (key, name, description, category, system_permission)
|
||||
VALUES
|
||||
('admin.access', 'Access admin', 'Open the management area.', 'Admin', true),
|
||||
@@ -155,6 +198,8 @@ VALUES
|
||||
('admin.languages.order', 'Order languages', 'Reorder languages.', 'Languages', true),
|
||||
('admin.wordings.read', 'View system wordings', 'View system wording values.', 'System wordings', true),
|
||||
('admin.wordings.update', 'Update system wordings', 'Edit system wording values.', 'System wordings', true),
|
||||
('admin.ai-moderation.read', 'View AI moderation settings', 'View AI moderation configuration.', 'AI moderation', true),
|
||||
('admin.ai-moderation.update', 'Update AI moderation settings', 'Edit AI moderation configuration.', 'AI moderation', true),
|
||||
('admin.config.read', 'View system config', 'View management configuration records.', 'System config', true),
|
||||
('admin.config.create', 'Create system config', 'Create management configuration records.', 'System config', true),
|
||||
('admin.config.update', 'Update system config', 'Edit management configuration records.', 'System config', true),
|
||||
@@ -236,6 +281,8 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
||||
'admin.languages.order',
|
||||
'admin.wordings.read',
|
||||
'admin.wordings.update',
|
||||
'admin.ai-moderation.read',
|
||||
'admin.ai-moderation.update',
|
||||
'admin.config.read',
|
||||
'admin.config.create',
|
||||
'admin.config.update',
|
||||
@@ -283,7 +330,17 @@ WHERE r.key = 'admin'
|
||||
SELECT 1
|
||||
FROM role_permissions existing_role_permission
|
||||
WHERE existing_role_permission.role_id = r.id
|
||||
)
|
||||
)
|
||||
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.ai-moderation.read',
|
||||
'admin.ai-moderation.update'
|
||||
])
|
||||
WHERE r.key = 'admin'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
@@ -476,6 +533,12 @@ CREATE TABLE IF NOT EXISTS life_tags (
|
||||
CREATE TABLE IF NOT EXISTS life_posts (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
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_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,
|
||||
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||
deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||
@@ -509,6 +572,12 @@ CREATE TABLE IF NOT EXISTS life_post_comments (
|
||||
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
|
||||
parent_comment_id integer REFERENCES life_post_comments(id) ON DELETE SET NULL,
|
||||
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 1000),
|
||||
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_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,
|
||||
@@ -807,6 +876,12 @@ CREATE TABLE IF NOT EXISTS entity_discussion_comments (
|
||||
entity_id integer NOT NULL,
|
||||
parent_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
|
||||
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 1000),
|
||||
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_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,
|
||||
@@ -822,3 +897,51 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comments_parent_idx
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx
|
||||
ON entity_discussion_comments(created_by_user_id);
|
||||
|
||||
ALTER TABLE life_posts
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_content_hash text,
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_checked_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_updated_at timestamptz NOT NULL DEFAULT now();
|
||||
|
||||
ALTER TABLE life_post_comments
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_content_hash text,
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_checked_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_updated_at timestamptz NOT NULL DEFAULT now();
|
||||
|
||||
ALTER TABLE entity_discussion_comments
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_content_hash text,
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_checked_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS ai_moderation_updated_at timestamptz NOT NULL DEFAULT now();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS life_posts_ai_moderation_status_idx
|
||||
ON life_posts(ai_moderation_status, ai_moderation_updated_at, id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS life_posts_ai_moderation_language_idx
|
||||
ON life_posts(ai_moderation_language_code, created_at DESC, id DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS life_post_comments_ai_moderation_status_idx
|
||||
ON life_post_comments(ai_moderation_status, ai_moderation_updated_at, id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS life_post_comments_ai_moderation_language_idx
|
||||
ON life_post_comments(ai_moderation_language_code, created_at, id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_status_idx
|
||||
ON entity_discussion_comments(ai_moderation_status, ai_moderation_updated_at, id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_language_idx
|
||||
ON entity_discussion_comments(entity_type, entity_id, ai_moderation_language_code, created_at, id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
1008
backend/src/aiModeration.ts
Normal file
1008
backend/src/aiModeration.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,10 @@ import { readFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { PoolClient } from 'pg';
|
||||
import {
|
||||
requestAiModerationReview,
|
||||
type AiModerationStatus
|
||||
} from './aiModeration.ts';
|
||||
|
||||
type QueryValue = string | string[] | undefined;
|
||||
|
||||
@@ -175,10 +179,12 @@ type DailyChecklistPayload = {
|
||||
type LifePostPayload = {
|
||||
body: string;
|
||||
tagIds: number[];
|
||||
languageCode: string | null;
|
||||
};
|
||||
|
||||
type LifeCommentPayload = {
|
||||
body: string;
|
||||
languageCode: string | null;
|
||||
};
|
||||
|
||||
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||
@@ -187,6 +193,7 @@ type DiscussionEntityDefinition = {
|
||||
};
|
||||
type EntityDiscussionCommentPayload = {
|
||||
body: string;
|
||||
languageCode: string | null;
|
||||
};
|
||||
type EntityDiscussionCommentRow = {
|
||||
id: number;
|
||||
@@ -195,6 +202,8 @@ type EntityDiscussionCommentRow = {
|
||||
parentCommentId: number | null;
|
||||
body: string;
|
||||
deleted: boolean;
|
||||
moderationStatus: AiModerationStatus;
|
||||
moderationLanguageCode: string | null;
|
||||
createdAt: Date;
|
||||
createdAtCursor?: string;
|
||||
updatedAt: Date;
|
||||
@@ -219,6 +228,8 @@ type LifeCommentRow = {
|
||||
parentCommentId: number | null;
|
||||
body: string;
|
||||
deleted: boolean;
|
||||
moderationStatus: AiModerationStatus;
|
||||
moderationLanguageCode: string | null;
|
||||
createdAt: Date;
|
||||
createdAtCursor?: string;
|
||||
updatedAt: Date;
|
||||
@@ -232,6 +243,8 @@ type LifeComment = Omit<LifeCommentRow, 'createdAtCursor'> & {
|
||||
type LifePostRow = {
|
||||
id: number;
|
||||
body: string;
|
||||
moderationStatus: AiModerationStatus;
|
||||
moderationLanguageCode: string | null;
|
||||
createdAt: Date;
|
||||
createdAtCursor: string;
|
||||
updatedAt: Date;
|
||||
@@ -474,6 +487,19 @@ export function cleanLocale(value: unknown): string {
|
||||
return localePattern.test(locale) ? locale : defaultLocale;
|
||||
}
|
||||
|
||||
function cleanModerationLanguageCode(value: unknown): string | null {
|
||||
const languageCode = typeof value === 'string' ? value.trim() : '';
|
||||
if (!languageCode || languageCode === 'all') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!localePattern.test(languageCode)) {
|
||||
throw validationError('server.validation.invalidField');
|
||||
}
|
||||
|
||||
return languageCode;
|
||||
}
|
||||
|
||||
function sqlLiteral(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
@@ -2190,7 +2216,8 @@ function cleanLifePostPayload(payload: Record<string, unknown>): LifePostPayload
|
||||
|
||||
return {
|
||||
body,
|
||||
tagIds
|
||||
tagIds,
|
||||
languageCode: cleanModerationLanguageCode(payload.languageCode)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2200,7 +2227,7 @@ function cleanLifeCommentPayload(payload: Record<string, unknown>): LifeCommentP
|
||||
throw validationError('server.validation.commentTooLong');
|
||||
}
|
||||
|
||||
return { body };
|
||||
return { body, languageCode: cleanModerationLanguageCode(payload.languageCode) };
|
||||
}
|
||||
|
||||
function emptyLifeReactionCounts(): LifeReactionCounts {
|
||||
@@ -2246,6 +2273,45 @@ function cleanUserCommentActivitySourceFilter(value: QueryValue): UserCommentAct
|
||||
return source;
|
||||
}
|
||||
|
||||
function cleanModerationLanguageFilter(value: QueryValue): string | null {
|
||||
return cleanModerationLanguageCode(asString(value));
|
||||
}
|
||||
|
||||
function addModerationVisibilityCondition(
|
||||
conditions: string[],
|
||||
params: unknown[],
|
||||
alias: string,
|
||||
ownerColumn: string,
|
||||
userId: number | null,
|
||||
canViewAll: boolean
|
||||
): void {
|
||||
if (canViewAll) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (userId !== null) {
|
||||
params.push(userId);
|
||||
conditions.push(`(${alias}.ai_moderation_status = 'approved' OR ${ownerColumn} = $${params.length})`);
|
||||
return;
|
||||
}
|
||||
|
||||
conditions.push(`${alias}.ai_moderation_status = 'approved'`);
|
||||
}
|
||||
|
||||
function addModerationLanguageCondition(
|
||||
conditions: string[],
|
||||
params: unknown[],
|
||||
alias: string,
|
||||
languageCode: string | null
|
||||
): void {
|
||||
if (!languageCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
params.push(languageCode);
|
||||
conditions.push(`${alias}.ai_moderation_language_code = $${params.length}`);
|
||||
}
|
||||
|
||||
function lifePostProjection(locale = defaultLocale): string {
|
||||
const tagName = localizedName('life-tags', 'lt', locale);
|
||||
|
||||
@@ -2253,6 +2319,8 @@ function lifePostProjection(locale = defaultLocale): string {
|
||||
SELECT
|
||||
lp.id,
|
||||
lp.body,
|
||||
lp.ai_moderation_status AS "moderationStatus",
|
||||
lp.ai_moderation_language_code AS "moderationLanguageCode",
|
||||
lp.created_at AS "createdAt",
|
||||
lp.created_at::text AS "createdAtCursor",
|
||||
lp.updated_at AS "updatedAt",
|
||||
@@ -2373,6 +2441,8 @@ function hydrateLifePost(
|
||||
return {
|
||||
id: post.id,
|
||||
body: post.body,
|
||||
moderationStatus: post.moderationStatus,
|
||||
moderationLanguageCode: post.moderationLanguageCode,
|
||||
createdAt: post.createdAt,
|
||||
updatedAt: post.updatedAt,
|
||||
author: post.author,
|
||||
@@ -2393,6 +2463,8 @@ function lifeCommentProjection(whereClause: string): string {
|
||||
lc.parent_comment_id AS "parentCommentId",
|
||||
CASE WHEN lc.deleted_at IS NULL THEN lc.body ELSE '' END AS body,
|
||||
lc.deleted_at IS NOT NULL AS deleted,
|
||||
lc.ai_moderation_status AS "moderationStatus",
|
||||
lc.ai_moderation_language_code AS "moderationLanguageCode",
|
||||
lc.created_at AS "createdAt",
|
||||
lc.created_at::text AS "createdAtCursor",
|
||||
lc.updated_at AS "updatedAt",
|
||||
@@ -2432,7 +2504,11 @@ function buildLifeCommentTree(rows: LifeCommentRow[]): LifeComment[] {
|
||||
return topLevelComments;
|
||||
}
|
||||
|
||||
async function lifeCommentCountsForPosts(postIds: number[]): Promise<Map<number, number>> {
|
||||
async function lifeCommentCountsForPosts(
|
||||
postIds: number[],
|
||||
userId: number | null,
|
||||
canViewAll: boolean
|
||||
): Promise<Map<number, number>> {
|
||||
const countsByPost = new Map<number, number>();
|
||||
for (const postId of postIds) {
|
||||
countsByPost.set(postId, 0);
|
||||
@@ -2442,14 +2518,18 @@ async function lifeCommentCountsForPosts(postIds: number[]): Promise<Map<number,
|
||||
return countsByPost;
|
||||
}
|
||||
|
||||
const params: unknown[] = [postIds];
|
||||
const conditions = ['lc.post_id = ANY($1::integer[])'];
|
||||
addModerationVisibilityCondition(conditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||
|
||||
const rows = await query<{ postId: number; total: number }>(
|
||||
`
|
||||
SELECT post_id AS "postId", COUNT(*)::integer AS total
|
||||
FROM life_post_comments
|
||||
WHERE post_id = ANY($1::integer[])
|
||||
FROM life_post_comments lc
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
GROUP BY post_id
|
||||
`,
|
||||
[postIds]
|
||||
params
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -2459,12 +2539,21 @@ async function lifeCommentCountsForPosts(postIds: number[]): Promise<Map<number,
|
||||
return countsByPost;
|
||||
}
|
||||
|
||||
async function lifeCommentPreviewForPosts(postIds: number[]): Promise<Map<number, LifeComment[]>> {
|
||||
async function lifeCommentPreviewForPosts(
|
||||
postIds: number[],
|
||||
userId: number | null,
|
||||
canViewAll: boolean
|
||||
): Promise<Map<number, LifeComment[]>> {
|
||||
const commentsByPost = new Map<number, LifeComment[]>();
|
||||
if (postIds.length === 0) {
|
||||
return commentsByPost;
|
||||
}
|
||||
|
||||
const params: unknown[] = [postIds];
|
||||
const previewConditions = ['lc.post_id = ANY($1::integer[])', 'lc.parent_comment_id IS NULL'];
|
||||
addModerationVisibilityCondition(previewConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||
params.push(lifeCommentPreviewLimit);
|
||||
|
||||
const rows = await query<LifeCommentRow>(
|
||||
`
|
||||
WITH preview_top AS (
|
||||
@@ -2474,15 +2563,14 @@ async function lifeCommentPreviewForPosts(postIds: number[]): Promise<Map<number
|
||||
lc.id,
|
||||
ROW_NUMBER() OVER (PARTITION BY lc.post_id ORDER BY lc.created_at DESC, lc.id DESC) AS preview_rank
|
||||
FROM life_post_comments lc
|
||||
WHERE lc.post_id = ANY($1::integer[])
|
||||
AND lc.parent_comment_id IS NULL
|
||||
WHERE ${previewConditions.join(' AND ')}
|
||||
) ranked
|
||||
WHERE preview_rank <= $2
|
||||
WHERE preview_rank <= $${params.length}
|
||||
)
|
||||
${lifeCommentProjection('WHERE lc.id IN (SELECT id FROM preview_top)')}
|
||||
ORDER BY lc.post_id, lc.created_at, lc.id
|
||||
`,
|
||||
[postIds, lifeCommentPreviewLimit]
|
||||
params
|
||||
);
|
||||
|
||||
for (const postId of postIds) {
|
||||
@@ -2492,20 +2580,28 @@ async function lifeCommentPreviewForPosts(postIds: number[]): Promise<Map<number
|
||||
return commentsByPost;
|
||||
}
|
||||
|
||||
export async function listLifeComments(postIdValue: number, paramsQuery: QueryParams = {}): Promise<LifeCommentsPage | null> {
|
||||
export async function listLifeComments(
|
||||
postIdValue: number,
|
||||
paramsQuery: QueryParams = {},
|
||||
userId: number | null = null,
|
||||
canViewAll = false
|
||||
): Promise<LifeCommentsPage | null> {
|
||||
const postId = requirePositiveInteger(postIdValue, 'server.validation.recordInvalid');
|
||||
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
||||
const limit = cleanCommentLimit(paramsQuery.limit);
|
||||
const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
|
||||
const postParams: unknown[] = [postId];
|
||||
const postConditions = ['lp.id = $1', 'lp.deleted_at IS NULL'];
|
||||
addModerationVisibilityCondition(postConditions, postParams, 'lp', 'lp.created_by_user_id', userId, canViewAll);
|
||||
const exists = await queryOne<{ exists: boolean }>(
|
||||
`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM life_posts
|
||||
WHERE id = $1
|
||||
AND deleted_at IS NULL
|
||||
FROM life_posts lp
|
||||
WHERE ${postConditions.join(' AND ')}
|
||||
) AS exists
|
||||
`,
|
||||
[postId]
|
||||
postParams
|
||||
);
|
||||
|
||||
if (exists?.exists !== true) {
|
||||
@@ -2514,6 +2610,8 @@ export async function listLifeComments(postIdValue: number, paramsQuery: QueryPa
|
||||
|
||||
const params: unknown[] = [postId];
|
||||
const topLevelConditions = ['lc.post_id = $1', 'lc.parent_comment_id IS NULL'];
|
||||
addModerationVisibilityCondition(topLevelConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||
addModerationLanguageCondition(topLevelConditions, params, 'lc', languageCode);
|
||||
|
||||
if (cursor) {
|
||||
params.push(cursor.createdAt, cursor.id);
|
||||
@@ -2533,21 +2631,31 @@ export async function listLifeComments(postIdValue: number, paramsQuery: QueryPa
|
||||
const topLevelComments = hasMore ? topLevelRows.slice(0, limit) : topLevelRows;
|
||||
const topLevelIds = topLevelComments.map((comment) => comment.id);
|
||||
const replyRows = topLevelIds.length
|
||||
? await query<LifeCommentRow>(
|
||||
? await (async () => {
|
||||
const replyParams: unknown[] = [topLevelIds];
|
||||
const replyConditions = ['lc.parent_comment_id = ANY($1::integer[])'];
|
||||
addModerationVisibilityCondition(replyConditions, replyParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||
addModerationLanguageCondition(replyConditions, replyParams, 'lc', languageCode);
|
||||
return query<LifeCommentRow>(
|
||||
`
|
||||
${lifeCommentProjection('WHERE lc.parent_comment_id = ANY($1::integer[])')}
|
||||
${lifeCommentProjection(`WHERE ${replyConditions.join(' AND ')}`)}
|
||||
ORDER BY lc.created_at, lc.id
|
||||
`,
|
||||
[topLevelIds]
|
||||
)
|
||||
replyParams
|
||||
);
|
||||
})()
|
||||
: [];
|
||||
const totalParams: unknown[] = [postId];
|
||||
const totalConditions = ['lc.post_id = $1'];
|
||||
addModerationVisibilityCondition(totalConditions, totalParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||
addModerationLanguageCondition(totalConditions, totalParams, 'lc', languageCode);
|
||||
const total = await queryOne<{ total: number }>(
|
||||
`
|
||||
SELECT COUNT(*)::integer AS total
|
||||
FROM life_post_comments
|
||||
WHERE post_id = $1
|
||||
FROM life_post_comments lc
|
||||
WHERE ${totalConditions.join(' AND ')}
|
||||
`,
|
||||
[postId]
|
||||
totalParams
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -2645,12 +2753,14 @@ async function listLifePostsWithFilters(
|
||||
paramsQuery: QueryParams = {},
|
||||
userId: number | null = null,
|
||||
locale = defaultLocale,
|
||||
filters: LifePostFilters = {}
|
||||
filters: LifePostFilters = {},
|
||||
canViewAll = false
|
||||
): Promise<LifePostsPage> {
|
||||
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
||||
const limit = cleanLifePostLimit(paramsQuery.limit);
|
||||
const search = asString(paramsQuery.search)?.trim();
|
||||
const tagIdValue = asString(paramsQuery.tagId)?.trim();
|
||||
const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
|
||||
const params: unknown[] = [];
|
||||
const conditions: string[] = ['lp.deleted_at IS NULL'];
|
||||
|
||||
@@ -2659,6 +2769,9 @@ async function listLifePostsWithFilters(
|
||||
conditions.push(`lp.created_by_user_id = $${params.length}`);
|
||||
}
|
||||
|
||||
addModerationVisibilityCondition(conditions, params, 'lp', 'lp.created_by_user_id', userId, canViewAll);
|
||||
addModerationLanguageCondition(conditions, params, 'lp', languageCode);
|
||||
|
||||
if (search) {
|
||||
params.push(`%${search}%`);
|
||||
conditions.push(`lp.body ILIKE $${params.length}`);
|
||||
@@ -2695,8 +2808,8 @@ async function listLifePostsWithFilters(
|
||||
const posts = hasMore ? rows.slice(0, limit) : rows;
|
||||
|
||||
const postIds = posts.map((post) => post.id);
|
||||
const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds);
|
||||
const commentCountsByPost = await lifeCommentCountsForPosts(postIds);
|
||||
const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds, userId, canViewAll);
|
||||
const commentCountsByPost = await lifeCommentCountsForPosts(postIds, userId, canViewAll);
|
||||
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId);
|
||||
|
||||
return {
|
||||
@@ -2709,9 +2822,10 @@ async function listLifePostsWithFilters(
|
||||
export async function listLifePosts(
|
||||
paramsQuery: QueryParams = {},
|
||||
userId: number | null = null,
|
||||
locale = defaultLocale
|
||||
locale = defaultLocale,
|
||||
canViewAll = false
|
||||
): Promise<LifePostsPage> {
|
||||
return listLifePostsWithFilters(paramsQuery, userId, locale);
|
||||
return listLifePostsWithFilters(paramsQuery, userId, locale, {}, canViewAll);
|
||||
}
|
||||
|
||||
async function getPublicProfileUser(userIdValue: number): Promise<PublicProfileUser | null> {
|
||||
@@ -2747,7 +2861,13 @@ export async function getPublicUserProfile(userIdValue: number): Promise<PublicU
|
||||
COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1 AND action = 'update'), 0) AS "wikiUpdates",
|
||||
COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1 AND action = 'delete'), 0) AS "wikiDeletes",
|
||||
COALESCE((SELECT COUNT(*)::integer FROM entity_image_uploads WHERE created_by_user_id = $1), 0) AS "imageUploads",
|
||||
COALESCE((SELECT COUNT(*)::integer FROM life_posts WHERE created_by_user_id = $1 AND deleted_at IS NULL), 0) AS "lifePosts",
|
||||
COALESCE((
|
||||
SELECT COUNT(*)::integer
|
||||
FROM life_posts
|
||||
WHERE created_by_user_id = $1
|
||||
AND deleted_at IS NULL
|
||||
AND ai_moderation_status = 'approved'
|
||||
), 0) AS "lifePosts",
|
||||
COALESCE((
|
||||
SELECT COUNT(*)::integer
|
||||
FROM life_post_comments lc
|
||||
@@ -2755,6 +2875,8 @@ export async function getPublicUserProfile(userIdValue: number): Promise<PublicU
|
||||
WHERE lc.created_by_user_id = $1
|
||||
AND lc.deleted_at IS NULL
|
||||
AND lp.deleted_at IS NULL
|
||||
AND lc.ai_moderation_status = 'approved'
|
||||
AND lp.ai_moderation_status = 'approved'
|
||||
), 0) AS "lifeComments",
|
||||
COALESCE((
|
||||
SELECT COUNT(*)::integer
|
||||
@@ -2762,12 +2884,14 @@ export async function getPublicUserProfile(userIdValue: number): Promise<PublicU
|
||||
JOIN life_posts lp ON lp.id = lpr.post_id
|
||||
WHERE lpr.user_id = $1
|
||||
AND lp.deleted_at IS NULL
|
||||
AND lp.ai_moderation_status = 'approved'
|
||||
), 0) AS "lifeReactions",
|
||||
COALESCE((
|
||||
SELECT COUNT(*)::integer
|
||||
FROM entity_discussion_comments
|
||||
WHERE created_by_user_id = $1
|
||||
AND deleted_at IS NULL
|
||||
AND ai_moderation_status = 'approved'
|
||||
), 0) AS "discussionComments"
|
||||
`,
|
||||
[user.id]
|
||||
@@ -2814,36 +2938,40 @@ export async function listUserLifePosts(
|
||||
userIdValue: number,
|
||||
paramsQuery: QueryParams = {},
|
||||
viewerUserId: number | null = null,
|
||||
locale = defaultLocale
|
||||
locale = defaultLocale,
|
||||
canViewAll = false
|
||||
): Promise<LifePostsPage | null> {
|
||||
const user = await getPublicProfileUser(userIdValue);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return listLifePostsWithFilters(paramsQuery, viewerUserId, locale, { authorId: user.id });
|
||||
return listLifePostsWithFilters(paramsQuery, viewerUserId, locale, { authorId: user.id }, canViewAll);
|
||||
}
|
||||
|
||||
async function hydrateLifePostsById(
|
||||
postIds: number[],
|
||||
viewerUserId: number | null,
|
||||
locale: string
|
||||
locale: string,
|
||||
canViewAll = false
|
||||
): Promise<Map<number, LifePost>> {
|
||||
const postById = new Map<number, LifePost>();
|
||||
if (postIds.length === 0) {
|
||||
return postById;
|
||||
}
|
||||
|
||||
const params: unknown[] = [postIds];
|
||||
const conditions = ['lp.id = ANY($1::integer[])', 'lp.deleted_at IS NULL'];
|
||||
addModerationVisibilityCondition(conditions, params, 'lp', 'lp.created_by_user_id', viewerUserId, canViewAll);
|
||||
const posts = await query<LifePostRow>(
|
||||
`
|
||||
${lifePostProjection(locale)}
|
||||
WHERE lp.id = ANY($1::integer[])
|
||||
AND lp.deleted_at IS NULL
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
`,
|
||||
[postIds]
|
||||
params
|
||||
);
|
||||
const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds);
|
||||
const commentCountsByPost = await lifeCommentCountsForPosts(postIds);
|
||||
const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds, viewerUserId, canViewAll);
|
||||
const commentCountsByPost = await lifeCommentCountsForPosts(postIds, viewerUserId, canViewAll);
|
||||
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, viewerUserId);
|
||||
|
||||
for (const post of posts) {
|
||||
@@ -2868,7 +2996,7 @@ export async function listUserReactionActivities(
|
||||
const limit = cleanLifePostLimit(paramsQuery.limit);
|
||||
const reactionType = cleanLifeReactionFilter(paramsQuery.reactionType);
|
||||
const params: unknown[] = [user.id];
|
||||
const conditions = ['lpr.user_id = $1', 'lp.deleted_at IS NULL'];
|
||||
const conditions = ['lpr.user_id = $1', 'lp.deleted_at IS NULL', "lp.ai_moderation_status = 'approved'"];
|
||||
|
||||
if (reactionType) {
|
||||
params.push(reactionType);
|
||||
@@ -2997,6 +3125,8 @@ export async function listUserCommentActivities(
|
||||
WHERE lc.created_by_user_id = $1
|
||||
AND lc.deleted_at IS NULL
|
||||
AND lp.deleted_at IS NULL
|
||||
AND lc.ai_moderation_status = 'approved'
|
||||
AND lp.ai_moderation_status = 'approved'
|
||||
|
||||
UNION ALL
|
||||
|
||||
@@ -3027,6 +3157,7 @@ export async function listUserCommentActivities(
|
||||
LEFT JOIN habitats h ON edc.entity_type = 'habitats' AND h.id = edc.entity_id
|
||||
WHERE edc.created_by_user_id = $1
|
||||
AND edc.deleted_at IS NULL
|
||||
AND edc.ai_moderation_status = 'approved'
|
||||
)
|
||||
SELECT
|
||||
source,
|
||||
@@ -3087,8 +3218,8 @@ async function getLifePostById(id: number, userId: number | null = null, locale
|
||||
return null;
|
||||
}
|
||||
|
||||
const commentPreviewByPost = await lifeCommentPreviewForPosts([post.id]);
|
||||
const commentCountsByPost = await lifeCommentCountsForPosts([post.id]);
|
||||
const commentPreviewByPost = await lifeCommentPreviewForPosts([post.id], userId, false);
|
||||
const commentCountsByPost = await lifeCommentCountsForPosts([post.id], userId, false);
|
||||
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId);
|
||||
return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost);
|
||||
}
|
||||
@@ -3113,8 +3244,8 @@ export async function createLifePost(payload: Record<string, unknown>, userId: n
|
||||
const id = await withTransaction(async (client) => {
|
||||
const result = await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO life_posts (body, created_by_user_id, updated_by_user_id)
|
||||
VALUES ($1, $2, $2)
|
||||
INSERT INTO life_posts (body, ai_moderation_status, ai_moderation_language_code, created_by_user_id, updated_by_user_id)
|
||||
VALUES ($1, 'reviewing', NULL, $2, $2)
|
||||
RETURNING id
|
||||
`,
|
||||
[cleanPayload.body, userId]
|
||||
@@ -3125,6 +3256,7 @@ export async function createLifePost(payload: Record<string, unknown>, userId: n
|
||||
return createdId;
|
||||
});
|
||||
|
||||
await requestAiModerationReview({ type: 'life-post', id }, { languageCode: cleanPayload.languageCode, resetRetries: true });
|
||||
return getLifePostById(id, userId, locale);
|
||||
}
|
||||
|
||||
@@ -3141,7 +3273,15 @@ export async function updateLifePost(
|
||||
const result = await client.query<{ id: number }>(
|
||||
`
|
||||
UPDATE life_posts
|
||||
SET body = $1, updated_by_user_id = $2, updated_at = now()
|
||||
SET body = $1,
|
||||
ai_moderation_status = 'reviewing',
|
||||
ai_moderation_language_code = NULL,
|
||||
ai_moderation_content_hash = NULL,
|
||||
ai_moderation_checked_at = NULL,
|
||||
ai_moderation_retry_count = 0,
|
||||
ai_moderation_updated_at = now(),
|
||||
updated_by_user_id = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $3
|
||||
AND ($4 = true OR created_by_user_id = $2)
|
||||
AND deleted_at IS NULL
|
||||
@@ -3159,6 +3299,13 @@ export async function updateLifePost(
|
||||
return resultId;
|
||||
});
|
||||
|
||||
if (updatedId) {
|
||||
await requestAiModerationReview(
|
||||
{ type: 'life-post', id: updatedId },
|
||||
{ languageCode: cleanPayload.languageCode, resetRetries: true }
|
||||
);
|
||||
}
|
||||
|
||||
return updatedId ? getLifePostById(updatedId, userId, locale) : null;
|
||||
}
|
||||
|
||||
@@ -3181,6 +3328,27 @@ export async function deleteLifePost(id: number, userId: number, allowAny = fals
|
||||
return Boolean(result);
|
||||
}
|
||||
|
||||
export async function retryLifePostModeration(id: number, userId: number, locale = defaultLocale, allowAny = false) {
|
||||
const postId = requirePositiveInteger(id, 'server.validation.recordInvalid');
|
||||
const row = await queryOne<{ id: number }>(
|
||||
`
|
||||
SELECT id
|
||||
FROM life_posts
|
||||
WHERE id = $1
|
||||
AND ($3 = true OR created_by_user_id = $2)
|
||||
AND deleted_at IS NULL
|
||||
`,
|
||||
[postId, userId, allowAny]
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await requestAiModerationReview({ type: 'life-post', id: postId }, { incrementRetries: true });
|
||||
return getLifePostById(postId, userId, locale);
|
||||
}
|
||||
|
||||
export async function setLifePostReaction(
|
||||
postId: number,
|
||||
payload: Record<string, unknown>,
|
||||
@@ -3198,6 +3366,7 @@ export async function setLifePostReaction(
|
||||
FROM life_posts
|
||||
WHERE id = $1
|
||||
AND deleted_at IS NULL
|
||||
AND ai_moderation_status = 'approved'
|
||||
)
|
||||
ON CONFLICT (post_id, user_id)
|
||||
DO UPDATE SET reaction_type = EXCLUDED.reaction_type, updated_at = now()
|
||||
@@ -3220,6 +3389,7 @@ export async function deleteLifePostReaction(postId: number, userId: number, loc
|
||||
FROM life_posts
|
||||
WHERE id = $1
|
||||
AND deleted_at IS NULL
|
||||
AND ai_moderation_status = 'approved'
|
||||
)
|
||||
RETURNING post_id AS "postId"
|
||||
`,
|
||||
@@ -3234,19 +3404,27 @@ export async function createLifeComment(postId: number, payload: Record<string,
|
||||
|
||||
const result = await queryOne<{ id: number }>(
|
||||
`
|
||||
INSERT INTO life_post_comments (post_id, body, created_by_user_id)
|
||||
SELECT $1, $2, $3
|
||||
INSERT INTO life_post_comments (post_id, body, ai_moderation_status, ai_moderation_language_code, created_by_user_id)
|
||||
SELECT $1, $2, 'reviewing', NULL, $3
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM life_posts
|
||||
WHERE id = $1
|
||||
AND deleted_at IS NULL
|
||||
AND ai_moderation_status = 'approved'
|
||||
)
|
||||
RETURNING id
|
||||
`,
|
||||
[postId, cleanPayload.body, userId]
|
||||
);
|
||||
|
||||
if (result) {
|
||||
await requestAiModerationReview(
|
||||
{ type: 'life-comment', id: result.id },
|
||||
{ languageCode: cleanPayload.languageCode, resetRetries: true }
|
||||
);
|
||||
}
|
||||
|
||||
return result ? getLifeCommentById(result.id) : null;
|
||||
}
|
||||
|
||||
@@ -3260,20 +3438,36 @@ export async function createLifeCommentReply(
|
||||
|
||||
const result = await queryOne<{ id: number }>(
|
||||
`
|
||||
INSERT INTO life_post_comments (post_id, parent_comment_id, body, created_by_user_id)
|
||||
SELECT lc.post_id, lc.id, $3, $4
|
||||
INSERT INTO life_post_comments (
|
||||
post_id,
|
||||
parent_comment_id,
|
||||
body,
|
||||
ai_moderation_status,
|
||||
ai_moderation_language_code,
|
||||
created_by_user_id
|
||||
)
|
||||
SELECT lc.post_id, lc.id, $3, 'reviewing', NULL, $4
|
||||
FROM life_post_comments lc
|
||||
JOIN life_posts lp ON lp.id = lc.post_id
|
||||
WHERE lc.post_id = $1
|
||||
AND lc.id = $2
|
||||
AND lc.parent_comment_id IS NULL
|
||||
AND lc.deleted_at IS NULL
|
||||
AND lc.ai_moderation_status = 'approved'
|
||||
AND lp.deleted_at IS NULL
|
||||
AND lp.ai_moderation_status = 'approved'
|
||||
RETURNING id
|
||||
`,
|
||||
[postId, commentId, cleanPayload.body, userId]
|
||||
);
|
||||
|
||||
if (result) {
|
||||
await requestAiModerationReview(
|
||||
{ type: 'life-comment', id: result.id },
|
||||
{ languageCode: cleanPayload.languageCode, resetRetries: true }
|
||||
);
|
||||
}
|
||||
|
||||
return result ? getLifeCommentById(result.id) : null;
|
||||
}
|
||||
|
||||
@@ -3293,6 +3487,27 @@ export async function deleteLifeComment(id: number, userId: number, allowAny = f
|
||||
return Boolean(result);
|
||||
}
|
||||
|
||||
export async function retryLifeCommentModeration(id: number, userId: number, allowAny = false) {
|
||||
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
||||
const row = await queryOne<{ id: number }>(
|
||||
`
|
||||
SELECT id
|
||||
FROM life_post_comments
|
||||
WHERE id = $1
|
||||
AND ($3 = true OR created_by_user_id = $2)
|
||||
AND deleted_at IS NULL
|
||||
`,
|
||||
[commentId, userId, allowAny]
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await requestAiModerationReview({ type: 'life-comment', id: commentId }, { incrementRetries: true });
|
||||
return getLifeCommentById(commentId);
|
||||
}
|
||||
|
||||
function cleanDiscussionEntityType(value: unknown): DiscussionEntityType {
|
||||
if (typeof value !== 'string' || !Object.hasOwn(discussionEntityDefinitions, value)) {
|
||||
throw validationError('server.validation.entityTypeInvalid');
|
||||
@@ -3307,7 +3522,7 @@ function cleanEntityDiscussionCommentPayload(payload: Record<string, unknown>):
|
||||
throw validationError('server.validation.commentTooLong');
|
||||
}
|
||||
|
||||
return { body };
|
||||
return { body, languageCode: cleanModerationLanguageCode(payload.languageCode) };
|
||||
}
|
||||
|
||||
async function entityDiscussionExists(
|
||||
@@ -3333,6 +3548,8 @@ function entityDiscussionCommentProjection(whereClause: string): string {
|
||||
edc.parent_comment_id AS "parentCommentId",
|
||||
CASE WHEN edc.deleted_at IS NULL THEN edc.body ELSE '' END AS body,
|
||||
edc.deleted_at IS NOT NULL AS deleted,
|
||||
edc.ai_moderation_status AS "moderationStatus",
|
||||
edc.ai_moderation_language_code AS "moderationLanguageCode",
|
||||
edc.created_at AS "createdAt",
|
||||
edc.created_at::text AS "createdAtCursor",
|
||||
edc.updated_at AS "updatedAt",
|
||||
@@ -3391,12 +3608,15 @@ async function getEntityDiscussionCommentById(id: number): Promise<EntityDiscuss
|
||||
export async function listEntityDiscussionComments(
|
||||
entityTypeValue: string,
|
||||
entityIdValue: number,
|
||||
paramsQuery: QueryParams = {}
|
||||
paramsQuery: QueryParams = {},
|
||||
userId: number | null = null,
|
||||
canViewAll = false
|
||||
): Promise<EntityDiscussionCommentsPage | null> {
|
||||
const entityType = cleanDiscussionEntityType(entityTypeValue);
|
||||
const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid');
|
||||
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
||||
const limit = cleanCommentLimit(paramsQuery.limit);
|
||||
const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
|
||||
|
||||
if (!(await entityDiscussionExists(pool, entityType, entityId))) {
|
||||
return null;
|
||||
@@ -3404,6 +3624,8 @@ export async function listEntityDiscussionComments(
|
||||
|
||||
const params: unknown[] = [entityType, entityId];
|
||||
const topLevelConditions = ['edc.entity_type = $1', 'edc.entity_id = $2', 'edc.parent_comment_id IS NULL'];
|
||||
addModerationVisibilityCondition(topLevelConditions, params, 'edc', 'edc.created_by_user_id', userId, canViewAll);
|
||||
addModerationLanguageCondition(topLevelConditions, params, 'edc', languageCode);
|
||||
|
||||
if (cursor) {
|
||||
params.push(cursor.createdAt, cursor.id);
|
||||
@@ -3423,22 +3645,31 @@ export async function listEntityDiscussionComments(
|
||||
const topLevelComments = hasMore ? topLevelRows.slice(0, limit) : topLevelRows;
|
||||
const topLevelIds = topLevelComments.map((comment) => comment.id);
|
||||
const replyRows = topLevelIds.length
|
||||
? await query<EntityDiscussionCommentRow>(
|
||||
? await (async () => {
|
||||
const replyParams: unknown[] = [topLevelIds];
|
||||
const replyConditions = ['edc.parent_comment_id = ANY($1::integer[])'];
|
||||
addModerationVisibilityCondition(replyConditions, replyParams, 'edc', 'edc.created_by_user_id', userId, canViewAll);
|
||||
addModerationLanguageCondition(replyConditions, replyParams, 'edc', languageCode);
|
||||
return query<EntityDiscussionCommentRow>(
|
||||
`
|
||||
${entityDiscussionCommentProjection('WHERE edc.parent_comment_id = ANY($1::integer[])')}
|
||||
${entityDiscussionCommentProjection(`WHERE ${replyConditions.join(' AND ')}`)}
|
||||
ORDER BY edc.created_at, edc.id
|
||||
`,
|
||||
[topLevelIds]
|
||||
)
|
||||
replyParams
|
||||
);
|
||||
})()
|
||||
: [];
|
||||
const totalParams: unknown[] = [entityType, entityId];
|
||||
const totalConditions = ['edc.entity_type = $1', 'edc.entity_id = $2'];
|
||||
addModerationVisibilityCondition(totalConditions, totalParams, 'edc', 'edc.created_by_user_id', userId, canViewAll);
|
||||
addModerationLanguageCondition(totalConditions, totalParams, 'edc', languageCode);
|
||||
const total = await queryOne<{ total: number }>(
|
||||
`
|
||||
SELECT COUNT(*)::integer AS total
|
||||
FROM entity_discussion_comments
|
||||
WHERE entity_type = $1
|
||||
AND entity_id = $2
|
||||
FROM entity_discussion_comments edc
|
||||
WHERE ${totalConditions.join(' AND ')}
|
||||
`,
|
||||
[entityType, entityId]
|
||||
totalParams
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -3474,8 +3705,15 @@ export async function createEntityDiscussionComment(
|
||||
|
||||
const result = await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO entity_discussion_comments (entity_type, entity_id, body, created_by_user_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
INSERT INTO entity_discussion_comments (
|
||||
entity_type,
|
||||
entity_id,
|
||||
body,
|
||||
ai_moderation_status,
|
||||
ai_moderation_language_code,
|
||||
created_by_user_id
|
||||
)
|
||||
VALUES ($1, $2, $3, 'reviewing', NULL, $4)
|
||||
RETURNING id
|
||||
`,
|
||||
[entityType, entityId, cleanPayload.body, userId]
|
||||
@@ -3484,6 +3722,13 @@ export async function createEntityDiscussionComment(
|
||||
return result.rows[0].id;
|
||||
});
|
||||
|
||||
if (id) {
|
||||
await requestAiModerationReview(
|
||||
{ type: 'discussion-comment', id },
|
||||
{ languageCode: cleanPayload.languageCode, resetRetries: true }
|
||||
);
|
||||
}
|
||||
|
||||
return id ? getEntityDiscussionCommentById(id) : null;
|
||||
}
|
||||
|
||||
@@ -3511,15 +3756,18 @@ export async function createEntityDiscussionReply(
|
||||
entity_id,
|
||||
parent_comment_id,
|
||||
body,
|
||||
ai_moderation_status,
|
||||
ai_moderation_language_code,
|
||||
created_by_user_id
|
||||
)
|
||||
SELECT edc.entity_type, edc.entity_id, edc.id, $4, $5
|
||||
SELECT edc.entity_type, edc.entity_id, edc.id, $4, 'reviewing', NULL, $5
|
||||
FROM entity_discussion_comments edc
|
||||
WHERE edc.entity_type = $1
|
||||
AND edc.entity_id = $2
|
||||
AND edc.id = $3
|
||||
AND edc.parent_comment_id IS NULL
|
||||
AND edc.deleted_at IS NULL
|
||||
AND edc.ai_moderation_status = 'approved'
|
||||
RETURNING id
|
||||
`,
|
||||
[entityType, entityId, commentId, cleanPayload.body, userId]
|
||||
@@ -3528,6 +3776,13 @@ export async function createEntityDiscussionReply(
|
||||
return result.rows[0]?.id ?? null;
|
||||
});
|
||||
|
||||
if (id) {
|
||||
await requestAiModerationReview(
|
||||
{ type: 'discussion-comment', id },
|
||||
{ languageCode: cleanPayload.languageCode, resetRetries: true }
|
||||
);
|
||||
}
|
||||
|
||||
return id ? getEntityDiscussionCommentById(id) : null;
|
||||
}
|
||||
|
||||
@@ -3550,6 +3805,31 @@ export async function deleteEntityDiscussionComment(id: number, userId: number,
|
||||
return Boolean(result);
|
||||
}
|
||||
|
||||
export async function retryEntityDiscussionCommentModeration(
|
||||
id: number,
|
||||
userId: number,
|
||||
allowAny = false
|
||||
): Promise<EntityDiscussionComment | null> {
|
||||
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
||||
const row = await queryOne<{ id: number }>(
|
||||
`
|
||||
SELECT id
|
||||
FROM entity_discussion_comments
|
||||
WHERE id = $1
|
||||
AND ($3 = true OR created_by_user_id = $2)
|
||||
AND deleted_at IS NULL
|
||||
`,
|
||||
[commentId, userId, allowAny]
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await requestAiModerationReview({ type: 'discussion-comment', id: commentId }, { incrementRetries: true });
|
||||
return getEntityDiscussionCommentById(commentId);
|
||||
}
|
||||
|
||||
async function deleteEntityDiscussionCommentsForEntity(
|
||||
client: DbClient,
|
||||
entityType: DiscussionEntityType,
|
||||
|
||||
@@ -88,6 +88,9 @@ import {
|
||||
reorderLanguages,
|
||||
reorderPokemon,
|
||||
reorderRecipes,
|
||||
retryEntityDiscussionCommentModeration,
|
||||
retryLifeCommentModeration,
|
||||
retryLifePostModeration,
|
||||
setLifePostReaction,
|
||||
updateConfig,
|
||||
updateDailyChecklistItem,
|
||||
@@ -98,6 +101,11 @@ import {
|
||||
updatePokemon,
|
||||
updateRecipe
|
||||
} from './queries.ts';
|
||||
import {
|
||||
getAiModerationSettings,
|
||||
startAiModerationWorker,
|
||||
updateAiModerationSettings
|
||||
} from './aiModeration.ts';
|
||||
import {
|
||||
getSystemWordings,
|
||||
listSystemWordingRows,
|
||||
@@ -758,11 +766,15 @@ app.get('/api/users/:id/profile', async (request, reply) => {
|
||||
app.get('/api/users/:id/life-posts', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const user = await optionalUser(request);
|
||||
const canViewAll = user
|
||||
? userHasPermission(user, 'life.posts.update-any') || userHasPermission(user, 'life.posts.delete-any')
|
||||
: false;
|
||||
const posts = await listUserLifePosts(
|
||||
Number(id),
|
||||
request.query as Record<string, string | string[] | undefined>,
|
||||
user?.id ?? null,
|
||||
requestLocale(request)
|
||||
requestLocale(request),
|
||||
canViewAll
|
||||
);
|
||||
return posts ? posts : notFound(reply, request);
|
||||
});
|
||||
@@ -791,12 +803,27 @@ app.get('/api/users/:id/comments', async (request, reply) => {
|
||||
|
||||
app.get('/api/life-posts', async (request) => {
|
||||
const user = await optionalUser(request);
|
||||
return listLifePosts(request.query as Record<string, string | string[] | undefined>, user?.id ?? null, requestLocale(request));
|
||||
const canViewAll = user
|
||||
? userHasPermission(user, 'life.posts.update-any') || userHasPermission(user, 'life.posts.delete-any')
|
||||
: false;
|
||||
return listLifePosts(
|
||||
request.query as Record<string, string | string[] | undefined>,
|
||||
user?.id ?? null,
|
||||
requestLocale(request),
|
||||
canViewAll
|
||||
);
|
||||
});
|
||||
|
||||
app.get('/api/life-posts/:postId/comments', async (request, reply) => {
|
||||
const { postId } = request.params as { postId: string };
|
||||
const comments = await listLifeComments(Number(postId), request.query as Record<string, string | string[] | undefined>);
|
||||
const user = await optionalUser(request);
|
||||
const canViewAll = user ? userHasPermission(user, 'life.comments.delete-any') : false;
|
||||
const comments = await listLifeComments(
|
||||
Number(postId),
|
||||
request.query as Record<string, string | string[] | undefined>,
|
||||
user?.id ?? null,
|
||||
canViewAll
|
||||
);
|
||||
return comments ? comments : notFound(reply, request);
|
||||
});
|
||||
|
||||
@@ -853,6 +880,26 @@ app.put('/api/life-posts/:id', async (request, reply) => {
|
||||
return post ? post : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.post('/api/life-posts/:id/moderation/retry', async (request, reply) => {
|
||||
const user = await requireAnyPermissionWithRateLimits(
|
||||
request,
|
||||
reply,
|
||||
['life.posts.update', 'life.posts.update-any'],
|
||||
'communityWrite'
|
||||
);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
const { id } = request.params as { id: string };
|
||||
const post = await retryLifePostModeration(
|
||||
Number(id),
|
||||
user.id,
|
||||
requestLocale(request),
|
||||
userHasPermission(user, 'life.posts.update-any')
|
||||
);
|
||||
return post ? post : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.put('/api/life-posts/:id/reaction', async (request, reply) => {
|
||||
const user = await requirePermissionWithRateLimits(request, reply, 'life.reactions.set', 'communityReaction');
|
||||
if (!user) {
|
||||
@@ -903,12 +950,35 @@ app.delete('/api/life-comments/:id', async (request, reply) => {
|
||||
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.post('/api/life-comments/:id/moderation/retry', async (request, reply) => {
|
||||
const user = await requireAnyPermissionWithRateLimits(
|
||||
request,
|
||||
reply,
|
||||
['life.comments.create', 'life.comments.delete-any'],
|
||||
'communityWrite'
|
||||
);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
const { id } = request.params as { id: string };
|
||||
const comment = await retryLifeCommentModeration(
|
||||
Number(id),
|
||||
user.id,
|
||||
userHasPermission(user, 'life.comments.delete-any')
|
||||
);
|
||||
return comment ? comment : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.get('/api/discussions/:entityType/:entityId/comments', async (request, reply) => {
|
||||
const { entityType, entityId } = request.params as { entityType: string; entityId: string };
|
||||
const user = await optionalUser(request);
|
||||
const canViewAll = user ? userHasPermission(user, 'discussions.comments.delete-any') : false;
|
||||
const comments = await listEntityDiscussionComments(
|
||||
entityType,
|
||||
Number(entityId),
|
||||
request.query as Record<string, string | string[] | undefined>
|
||||
request.query as Record<string, string | string[] | undefined>,
|
||||
user?.id ?? null,
|
||||
canViewAll
|
||||
);
|
||||
return comments ? comments : notFound(reply, request);
|
||||
});
|
||||
@@ -970,6 +1040,26 @@ app.delete('/api/discussions/comments/:id', async (request, reply) => {
|
||||
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.post('/api/discussions/comments/:id/moderation/retry', async (request, reply) => {
|
||||
const user = await requireAnyPermissionWithRateLimits(
|
||||
request,
|
||||
reply,
|
||||
['discussions.comments.create', 'discussions.comments.delete-any'],
|
||||
'communityWrite'
|
||||
);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = request.params as { id: string };
|
||||
const comment = await retryEntityDiscussionCommentModeration(
|
||||
Number(id),
|
||||
user.id,
|
||||
userHasPermission(user, 'discussions.comments.delete-any')
|
||||
);
|
||||
return comment ? comment : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.get('/api/pokemon', async (request) =>
|
||||
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||
);
|
||||
@@ -1307,6 +1397,19 @@ app.put('/api/admin/system-wordings/:key', async (request, reply) => {
|
||||
return updateSystemWordingValue(key, request.body as Record<string, unknown>, user.id);
|
||||
});
|
||||
|
||||
app.get('/api/admin/ai-moderation', async (request, reply) => {
|
||||
const user = await requirePermission(request, reply, 'admin.ai-moderation.read');
|
||||
return user ? getAiModerationSettings() : undefined;
|
||||
});
|
||||
|
||||
app.put('/api/admin/ai-moderation', async (request, reply) => {
|
||||
const user = await requirePermissionWithRateLimits(request, reply, 'admin.ai-moderation.update', 'adminWrite');
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
return updateAiModerationSettings(request.body as Record<string, unknown>, user.id);
|
||||
});
|
||||
|
||||
app.get('/api/admin/config/:type', async (request, reply) => {
|
||||
const user = await requirePermission(request, reply, 'admin.config.read');
|
||||
if (!user) {
|
||||
@@ -1376,6 +1479,7 @@ const port = Number(process.env.BACKEND_PORT ?? 3001);
|
||||
try {
|
||||
await initializeDatabase();
|
||||
await syncSystemWordingCatalog();
|
||||
await startAiModerationWorker(app.log);
|
||||
await app.listen({ host: '0.0.0.0', port });
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { iconCancel, iconComment, iconDelete, iconReply } from '../icons';
|
||||
import StatusBadge from './StatusBadge.vue';
|
||||
import Tabs, { type TabOption } from './Tabs.vue';
|
||||
import { iconCancel, iconComment, iconDelete, iconReply, iconWarning } from '../icons';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
onAuthTokenChange,
|
||||
setAuthToken,
|
||||
type AiModerationStatus,
|
||||
type AuthUser,
|
||||
type DiscussionEntityType,
|
||||
type EntityDiscussionComment
|
||||
type EntityDiscussionComment,
|
||||
type Language
|
||||
} from '../services/api';
|
||||
import Skeleton from './Skeleton.vue';
|
||||
|
||||
@@ -21,6 +25,7 @@ const props = defineProps<{
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const comments = ref<EntityDiscussionComment[]>([]);
|
||||
const languages = ref<Language[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
@@ -33,8 +38,11 @@ const loadError = ref('');
|
||||
const formError = ref('');
|
||||
const commentErrors = ref<Record<string, string>>({});
|
||||
const commentInput = ref<HTMLTextAreaElement | null>(null);
|
||||
const activeLanguageCode = ref('all');
|
||||
const moderationBusyId = ref<number | null>(null);
|
||||
const commentMaxLength = 1000;
|
||||
const discussionPageSize = 20;
|
||||
const allLanguageValue = 'all';
|
||||
let requestId = 0;
|
||||
let removeAuthListener: (() => void) | null = null;
|
||||
const nextCursor = ref<string | null>(null);
|
||||
@@ -47,6 +55,11 @@ function can(permissionKey: string) {
|
||||
|
||||
const canComment = computed(() => can('discussions.comments.create'));
|
||||
const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length));
|
||||
const selectedLanguageCode = computed(() => (activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value));
|
||||
const languageTabs = computed<TabOption[]>(() => [
|
||||
{ value: allLanguageValue, label: t('discussion.allLanguages') },
|
||||
...languages.value.map((language) => ({ value: language.code, label: language.name }))
|
||||
]);
|
||||
|
||||
async function loadCurrentUser() {
|
||||
authReady.value = false;
|
||||
@@ -68,6 +81,20 @@ async function loadCurrentUser() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLanguages() {
|
||||
try {
|
||||
languages.value = (await api.languages()).filter((language) => language.enabled);
|
||||
if (
|
||||
activeLanguageCode.value !== allLanguageValue &&
|
||||
!languages.value.some((language) => language.code === activeLanguageCode.value)
|
||||
) {
|
||||
activeLanguageCode.value = allLanguageValue;
|
||||
}
|
||||
} catch {
|
||||
languages.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function mergeComments(existing: EntityDiscussionComment[], incoming: EntityDiscussionComment[]) {
|
||||
const ids = new Set(existing.map((comment) => comment.id));
|
||||
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
|
||||
@@ -89,7 +116,8 @@ async function loadDiscussion(reset = true) {
|
||||
try {
|
||||
const page = await api.entityDiscussion(props.entityType, props.entityId, {
|
||||
limit: discussionPageSize,
|
||||
cursor: reset ? null : nextCursor.value
|
||||
cursor: reset ? null : nextCursor.value,
|
||||
language: selectedLanguageCode.value
|
||||
});
|
||||
if (nextRequestId === requestId) {
|
||||
comments.value = reset ? page.items : mergeComments(comments.value, page.items);
|
||||
@@ -143,6 +171,36 @@ function canManageComment(comment: EntityDiscussionComment) {
|
||||
);
|
||||
}
|
||||
|
||||
function canSeeModeration(comment: EntityDiscussionComment) {
|
||||
return currentUser.value?.id === comment.author?.id || can('discussions.comments.delete-any');
|
||||
}
|
||||
|
||||
function canRetryModeration(comment: EntityDiscussionComment) {
|
||||
return !comment.deleted && comment.moderationStatus !== 'approved' && canSeeModeration(comment);
|
||||
}
|
||||
|
||||
function moderationLabel(status: AiModerationStatus) {
|
||||
const labels: Record<AiModerationStatus, string> = {
|
||||
unreviewed: t('discussion.moderationUnreviewed'),
|
||||
reviewing: t('discussion.moderationReviewing'),
|
||||
approved: t('discussion.moderationApproved'),
|
||||
rejected: t('discussion.moderationRejected'),
|
||||
failed: t('discussion.moderationFailed')
|
||||
};
|
||||
return labels[status];
|
||||
}
|
||||
|
||||
function moderationTone(status: AiModerationStatus) {
|
||||
const tones: Record<AiModerationStatus, 'info' | 'success' | 'warning' | 'danger' | 'neutral'> = {
|
||||
unreviewed: 'neutral',
|
||||
reviewing: 'info',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
failed: 'warning'
|
||||
};
|
||||
return tones[status];
|
||||
}
|
||||
|
||||
function commentAuthorName(comment: EntityDiscussionComment) {
|
||||
return comment.deleted ? t('discussion.deletedComment') : comment.author?.displayName ?? t('discussion.byUnknown');
|
||||
}
|
||||
@@ -190,7 +248,10 @@ async function submitComment() {
|
||||
formError.value = '';
|
||||
|
||||
try {
|
||||
const comment = await api.createEntityDiscussionComment(props.entityType, props.entityId, { body: nextBody });
|
||||
const comment = await api.createEntityDiscussionComment(props.entityType, props.entityId, {
|
||||
body: nextBody,
|
||||
languageCode: selectedLanguageCode.value ?? null
|
||||
});
|
||||
comments.value = [...comments.value, comment];
|
||||
commentTotal.value += 1;
|
||||
body.value = '';
|
||||
@@ -213,7 +274,10 @@ async function submitReply(comment: EntityDiscussionComment) {
|
||||
clearCommentError(key);
|
||||
|
||||
try {
|
||||
const reply = await api.createEntityDiscussionReply(props.entityType, props.entityId, comment.id, { body: nextBody });
|
||||
const reply = await api.createEntityDiscussionReply(props.entityType, props.entityId, comment.id, {
|
||||
body: nextBody,
|
||||
languageCode: selectedLanguageCode.value ?? comment.moderationLanguageCode
|
||||
});
|
||||
comment.replies.push(reply);
|
||||
commentTotal.value += 1;
|
||||
cancelReply(comment.id);
|
||||
@@ -224,6 +288,22 @@ async function submitReply(comment: EntityDiscussionComment) {
|
||||
}
|
||||
}
|
||||
|
||||
async function retryModeration(comment: EntityDiscussionComment) {
|
||||
const key = commentKey(comment.id);
|
||||
moderationBusyId.value = comment.id;
|
||||
clearCommentError(key);
|
||||
|
||||
try {
|
||||
const updated = await api.retryEntityDiscussionModeration(comment.id);
|
||||
comment.moderationStatus = updated.moderationStatus;
|
||||
comment.moderationLanguageCode = updated.moderationLanguageCode;
|
||||
} catch (error) {
|
||||
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.moderationRetryFailed'));
|
||||
} finally {
|
||||
moderationBusyId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function markCommentDeleted(rows: EntityDiscussionComment[], id: number): boolean {
|
||||
for (const comment of rows) {
|
||||
if (comment.id === id) {
|
||||
@@ -272,8 +352,17 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
watch(activeLanguageCode, () => {
|
||||
comments.value = [];
|
||||
nextCursor.value = null;
|
||||
hasMoreComments.value = false;
|
||||
commentTotal.value = 0;
|
||||
void loadDiscussion();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void loadCurrentUser();
|
||||
void loadLanguages();
|
||||
void loadDiscussion();
|
||||
removeAuthListener = onAuthTokenChange(() => {
|
||||
void loadCurrentUser();
|
||||
@@ -294,6 +383,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs id="entity-discussion-language" v-model="activeLanguageCode" :tabs="languageTabs" :label="t('discussion.languages')" />
|
||||
|
||||
<div v-if="!authReady" class="entity-discussion-skeleton" aria-hidden="true">
|
||||
<Skeleton variant="box" height="112px" />
|
||||
</div>
|
||||
@@ -352,6 +443,12 @@ onUnmounted(() => {
|
||||
</RouterLink>
|
||||
<strong v-else>{{ commentAuthorName(comment) }}</strong>
|
||||
<time :datetime="comment.createdAt">{{ formatDateTime(comment.createdAt) }}</time>
|
||||
<StatusBadge
|
||||
v-if="canSeeModeration(comment)"
|
||||
:label="moderationLabel(comment.moderationStatus)"
|
||||
:tone="moderationTone(comment.moderationStatus)"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!comment.deleted" class="entity-discussion-comment__body">{{ comment.body }}</p>
|
||||
|
||||
@@ -366,6 +463,19 @@ onUnmounted(() => {
|
||||
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
|
||||
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.reply') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canRetryModeration(comment)"
|
||||
class="life-icon-button life-icon-button--flat"
|
||||
type="button"
|
||||
:aria-label="t('discussion.moderationRetry')"
|
||||
:disabled="moderationBusyId === comment.id"
|
||||
@click="retryModeration(comment)"
|
||||
>
|
||||
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
||||
<span class="life-action-tooltip" role="tooltip">
|
||||
{{ moderationBusyId === comment.id ? t('discussion.moderationRetrying') : t('discussion.moderationRetry') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canManageComment(comment)"
|
||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||
@@ -427,9 +537,28 @@ onUnmounted(() => {
|
||||
</RouterLink>
|
||||
<strong v-else>{{ commentAuthorName(reply) }}</strong>
|
||||
<time :datetime="reply.createdAt">{{ formatDateTime(reply.createdAt) }}</time>
|
||||
<StatusBadge
|
||||
v-if="canSeeModeration(reply)"
|
||||
:label="moderationLabel(reply.moderationStatus)"
|
||||
:tone="moderationTone(reply.moderationStatus)"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!reply.deleted" class="entity-discussion-comment__body">{{ reply.body }}</p>
|
||||
<div v-if="canManageComment(reply)" class="entity-discussion-comment__actions">
|
||||
<div v-if="canManageComment(reply) || canRetryModeration(reply)" class="entity-discussion-comment__actions">
|
||||
<button
|
||||
v-if="canRetryModeration(reply)"
|
||||
class="life-icon-button life-icon-button--flat"
|
||||
type="button"
|
||||
:aria-label="t('discussion.moderationRetry')"
|
||||
:disabled="moderationBusyId === reply.id"
|
||||
@click="retryModeration(reply)"
|
||||
>
|
||||
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
||||
<span class="life-action-tooltip" role="tooltip">
|
||||
{{ moderationBusyId === reply.id ? t('discussion.moderationRetrying') : t('discussion.moderationRetry') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||
type="button"
|
||||
|
||||
@@ -245,10 +245,13 @@ export interface DailyChecklistItem {
|
||||
|
||||
export type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
|
||||
export type LifeReactionCounts = Record<LifeReactionType, number>;
|
||||
export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed';
|
||||
|
||||
export interface LifePost {
|
||||
id: number;
|
||||
body: string;
|
||||
moderationStatus: AiModerationStatus;
|
||||
moderationLanguageCode: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: UserSummary | null;
|
||||
@@ -271,11 +274,13 @@ export interface LifePostsParams {
|
||||
limit?: number;
|
||||
search?: string;
|
||||
tagId?: string | number;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface CommentPageParams {
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface LifeComment {
|
||||
@@ -284,6 +289,8 @@ export interface LifeComment {
|
||||
parentCommentId: number | null;
|
||||
body: string;
|
||||
deleted: boolean;
|
||||
moderationStatus: AiModerationStatus;
|
||||
moderationLanguageCode: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: UserSummary | null;
|
||||
@@ -552,10 +559,12 @@ export interface DailyChecklistPayload {
|
||||
export interface LifePostPayload {
|
||||
body: string;
|
||||
tagIds: number[];
|
||||
languageCode?: string | null;
|
||||
}
|
||||
|
||||
export interface LifeCommentPayload {
|
||||
body: string;
|
||||
languageCode?: string | null;
|
||||
}
|
||||
|
||||
export type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||
@@ -567,6 +576,8 @@ export interface EntityDiscussionComment {
|
||||
parentCommentId: number | null;
|
||||
body: string;
|
||||
deleted: boolean;
|
||||
moderationStatus: AiModerationStatus;
|
||||
moderationLanguageCode: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: UserSummary | null;
|
||||
@@ -601,6 +612,33 @@ export interface UserCommentActivityPage {
|
||||
|
||||
export interface EntityDiscussionCommentPayload {
|
||||
body: string;
|
||||
languageCode?: string | null;
|
||||
}
|
||||
|
||||
export type AiModerationApiFormat = 'gemini-generate-content' | 'openai-chat-completions';
|
||||
export type AiModerationAuthMode = 'query-key' | 'bearer-token';
|
||||
|
||||
export interface AiModerationSettings {
|
||||
enabled: boolean;
|
||||
apiFormat: AiModerationApiFormat;
|
||||
authMode: AiModerationAuthMode;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
requestsPerMinute: number;
|
||||
apiKeyConfigured: boolean;
|
||||
updatedAt: string;
|
||||
updatedBy: UserSummary | null;
|
||||
}
|
||||
|
||||
export interface AiModerationSettingsPayload {
|
||||
enabled: boolean;
|
||||
apiFormat: AiModerationApiFormat;
|
||||
authMode: AiModerationAuthMode;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
requestsPerMinute: number;
|
||||
apiKey?: string;
|
||||
clearApiKey?: boolean;
|
||||
}
|
||||
|
||||
export function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||
@@ -773,6 +811,9 @@ export const api = {
|
||||
getJson<SystemWording[]>(`/api/admin/system-wordings${buildQuery(params)}`),
|
||||
updateSystemWording: (key: string, payload: { locale: string; value: string }) =>
|
||||
sendJson<SystemWording[]>(`/api/admin/system-wordings/${encodeURIComponent(key)}`, 'PUT', payload),
|
||||
aiModerationSettings: () => getJson<AiModerationSettings>('/api/admin/ai-moderation'),
|
||||
updateAiModerationSettings: (payload: AiModerationSettingsPayload) =>
|
||||
sendJson<AiModerationSettings>('/api/admin/ai-moderation', 'PUT', payload),
|
||||
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
|
||||
verifyEmail: (token: string) =>
|
||||
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
|
||||
@@ -835,12 +876,15 @@ export const api = {
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit,
|
||||
search: params.search?.trim(),
|
||||
tagId: params.tagId
|
||||
tagId: params.tagId,
|
||||
language: params.language
|
||||
})}`
|
||||
),
|
||||
createLifePost: (payload: LifePostPayload) => sendJson<LifePost>('/api/life-posts', 'POST', payload),
|
||||
updateLifePost: (id: string | number, payload: LifePostPayload) =>
|
||||
sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload),
|
||||
retryLifePostModeration: (id: string | number) =>
|
||||
sendJson<LifePost>(`/api/life-posts/${id}/moderation/retry`, 'POST', {}),
|
||||
deleteLifePost: (id: string | number) => deleteJson(`/api/life-posts/${id}`),
|
||||
setLifeReaction: (id: string | number, reactionType: LifeReactionType) =>
|
||||
sendJson<LifePost>(`/api/life-posts/${id}/reaction`, 'PUT', { reactionType }),
|
||||
@@ -851,17 +895,21 @@ export const api = {
|
||||
getJson<LifeCommentsPage>(
|
||||
`/api/life-posts/${postId}/comments${buildQuery({
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit
|
||||
limit: params.limit,
|
||||
language: params.language
|
||||
})}`
|
||||
),
|
||||
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
|
||||
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
|
||||
retryLifeCommentModeration: (id: string | number) =>
|
||||
sendJson<LifeComment>(`/api/life-comments/${id}/moderation/retry`, 'POST', {}),
|
||||
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
|
||||
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number, params: CommentPageParams = {}) =>
|
||||
getJson<EntityDiscussionCommentsPage>(
|
||||
`/api/discussions/${entityType}/${entityId}/comments${buildQuery({
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit
|
||||
limit: params.limit,
|
||||
language: params.language
|
||||
})}`
|
||||
),
|
||||
createEntityDiscussionComment: (
|
||||
@@ -875,6 +923,8 @@ export const api = {
|
||||
commentId: string | number,
|
||||
payload: EntityDiscussionCommentPayload
|
||||
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload),
|
||||
retryEntityDiscussionModeration: (id: string | number) =>
|
||||
sendJson<EntityDiscussionComment>(`/api/discussions/comments/${id}/moderation/retry`, 'POST', {}),
|
||||
deleteEntityDiscussionComment: (id: string | number) => deleteJson(`/api/discussions/comments/${id}`),
|
||||
uploadImage: (
|
||||
entityType: ImageUploadEntityType,
|
||||
|
||||
@@ -761,6 +761,10 @@ button:disabled,
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ai-moderation-form {
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.pokemon-edit-form {
|
||||
height: clamp(420px, calc(100dvh - 188px), 640px);
|
||||
min-height: 0;
|
||||
@@ -1905,6 +1909,13 @@ button:disabled,
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.life-post__moderation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.life-post__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
|
||||
import {
|
||||
api,
|
||||
type AiModerationApiFormat,
|
||||
type AiModerationAuthMode,
|
||||
type AiModerationSettings,
|
||||
type AiModerationSettingsPayload,
|
||||
type AuthUser,
|
||||
type AdminUser,
|
||||
type ConfigType,
|
||||
@@ -53,6 +57,7 @@ type AdminTab =
|
||||
| 'users'
|
||||
| 'roles'
|
||||
| 'permissions'
|
||||
| 'aiModeration'
|
||||
| 'config'
|
||||
| 'languages'
|
||||
| 'wordings'
|
||||
@@ -70,6 +75,7 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
|
||||
users: iconProfile,
|
||||
roles: iconKey,
|
||||
permissions: iconKey,
|
||||
aiModeration: iconAdmin,
|
||||
config: iconAdmin,
|
||||
languages: iconTranslate,
|
||||
wordings: iconTranslate,
|
||||
@@ -105,7 +111,8 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
|
||||
label: t('pages.admin.localizationGroup'),
|
||||
items: [
|
||||
{ key: 'languages', label: t('pages.admin.languages'), permission: 'admin.languages.read' },
|
||||
{ key: 'wordings', label: t('pages.admin.wordings'), permission: 'admin.wordings.read' }
|
||||
{ key: 'wordings', label: t('pages.admin.wordings'), permission: 'admin.wordings.read' },
|
||||
{ key: 'aiModeration', label: t('pages.admin.aiModeration'), permission: 'admin.ai-moderation.read' }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -150,6 +157,7 @@ const itemRows = ref<Item[]>([]);
|
||||
const recipeRows = ref<Recipe[]>([]);
|
||||
const habitatRows = ref<Habitat[]>([]);
|
||||
const wordingRows = ref<SystemWording[]>([]);
|
||||
const aiModerationSettings = ref<AiModerationSettings | null>(null);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const busy = ref(false);
|
||||
const contentLoading = ref(false);
|
||||
@@ -158,6 +166,16 @@ const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, ha
|
||||
const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
|
||||
const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 });
|
||||
const wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] });
|
||||
const aiModerationForm = ref({
|
||||
enabled: true,
|
||||
apiFormat: 'gemini-generate-content' as AiModerationApiFormat,
|
||||
authMode: 'bearer-token' as AiModerationAuthMode,
|
||||
endpoint: 'https://ai.example.com/v1beta',
|
||||
model: 'gemini-2.0-flash-lite',
|
||||
requestsPerMinute: 10,
|
||||
apiKey: '',
|
||||
clearApiKey: false
|
||||
});
|
||||
const userRoleForm = ref({ userId: 0, roleIds: [] as number[] });
|
||||
const roleForm = ref({ id: 0, key: '', name: '', description: '', level: 100, enabled: true });
|
||||
const rolePermissionForm = ref({ roleId: 0, permissionIds: [] as number[] });
|
||||
@@ -255,6 +273,14 @@ const activeWordingSurfaceTab = computed({
|
||||
wordingSurface.value = value === 'frontend' || value === 'backend' || value === 'email' ? value : '';
|
||||
}
|
||||
});
|
||||
const aiModerationApiFormatOptions = computed<Array<{ value: AiModerationApiFormat; label: string }>>(() => [
|
||||
{ value: 'gemini-generate-content', label: t('pages.admin.aiModerationFormatGemini') },
|
||||
{ value: 'openai-chat-completions', label: t('pages.admin.aiModerationFormatOpenAi') }
|
||||
]);
|
||||
const aiModerationAuthModeOptions = computed<Array<{ value: AiModerationAuthMode; label: string }>>(() => [
|
||||
{ value: 'bearer-token', label: t('pages.admin.aiModerationAuthBearer') },
|
||||
{ value: 'query-key', label: t('pages.admin.aiModerationAuthQueryKey') }
|
||||
]);
|
||||
const filteredWordingRows = computed(() =>
|
||||
wordingRows.value.filter((item) => {
|
||||
if (wordingModule.value && item.module !== wordingModule.value) return false;
|
||||
@@ -365,6 +391,19 @@ function resetWordingForm() {
|
||||
wordingForm.value = { key: '', locale: wordingLocale.value || defaultLocale, value: '', defaultValue: '', placeholders: [] };
|
||||
}
|
||||
|
||||
function resetAiModerationForm(settings: AiModerationSettings | null = aiModerationSettings.value) {
|
||||
aiModerationForm.value = {
|
||||
enabled: settings?.enabled ?? true,
|
||||
apiFormat: settings?.apiFormat ?? 'gemini-generate-content',
|
||||
authMode: settings?.authMode ?? 'bearer-token',
|
||||
endpoint: settings?.endpoint ?? 'https://ai.example.com/v1beta',
|
||||
model: settings?.model ?? 'gemini-2.0-flash-lite',
|
||||
requestsPerMinute: settings?.requestsPerMinute ?? 10,
|
||||
apiKey: '',
|
||||
clearApiKey: false
|
||||
};
|
||||
}
|
||||
|
||||
function resetUserRoleForm() {
|
||||
userRoleForm.value = { userId: 0, roleIds: [] };
|
||||
}
|
||||
@@ -781,6 +820,11 @@ async function loadWordings() {
|
||||
wordingRows.value = await api.systemWordings({ locale: wordingLocale.value });
|
||||
}
|
||||
|
||||
async function loadAiModerationSettings() {
|
||||
aiModerationSettings.value = await api.aiModerationSettings();
|
||||
resetAiModerationForm(aiModerationSettings.value);
|
||||
}
|
||||
|
||||
async function reloadWordings() {
|
||||
await run(loadWordings);
|
||||
}
|
||||
@@ -799,6 +843,27 @@ async function saveWording() {
|
||||
});
|
||||
}
|
||||
|
||||
async function saveAiModerationSettings() {
|
||||
await run(async () => {
|
||||
const payload: AiModerationSettingsPayload = {
|
||||
enabled: aiModerationForm.value.enabled,
|
||||
apiFormat: aiModerationForm.value.apiFormat,
|
||||
authMode: aiModerationForm.value.authMode,
|
||||
endpoint: aiModerationForm.value.endpoint,
|
||||
model: aiModerationForm.value.model,
|
||||
requestsPerMinute: aiModerationForm.value.requestsPerMinute,
|
||||
clearApiKey: aiModerationForm.value.clearApiKey
|
||||
};
|
||||
|
||||
if (aiModerationForm.value.apiKey.trim()) {
|
||||
payload.apiKey = aiModerationForm.value.apiKey.trim();
|
||||
}
|
||||
|
||||
aiModerationSettings.value = await api.updateAiModerationSettings(payload);
|
||||
resetAiModerationForm(aiModerationSettings.value);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveUserRoles() {
|
||||
await run(async () => {
|
||||
userRows.value = await api.updateAdminUserRoles(userRoleForm.value.userId, userRoleForm.value.roleIds);
|
||||
@@ -863,6 +928,7 @@ async function loadCurrentTab(showSkeleton = false) {
|
||||
if (activeTab.value === 'permissions') await loadPermissions();
|
||||
if (activeTab.value === 'languages') await loadLanguages();
|
||||
if (activeTab.value === 'wordings') await loadWordings();
|
||||
if (activeTab.value === 'aiModeration') await loadAiModerationSettings();
|
||||
if (activeTab.value === 'checklist') await loadChecklist();
|
||||
if (activeTab.value === 'pokemon') await loadPokemon();
|
||||
if (activeTab.value === 'items') await loadItems();
|
||||
@@ -1335,6 +1401,110 @@ onMounted(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'aiModeration'" class="detail-section">
|
||||
<div class="detail-section__header">
|
||||
<div>
|
||||
<h2>{{ t('pages.admin.aiModeration') }}</h2>
|
||||
<p class="meta-line">
|
||||
{{
|
||||
aiModerationSettings?.apiKeyConfigured
|
||||
? t('pages.admin.aiModerationApiKeyConfigured')
|
||||
: t('pages.admin.aiModerationApiKeyMissing')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="modal-edit-form ai-moderation-form" @submit.prevent="saveAiModerationSettings">
|
||||
<div class="check-row">
|
||||
<label>
|
||||
<input v-model="aiModerationForm.enabled" type="checkbox" :disabled="busy || !can('admin.ai-moderation.update')" />
|
||||
{{ t('pages.admin.aiModerationEnabled') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ai-moderation-format">{{ t('pages.admin.aiModerationFormat') }}</label>
|
||||
<select
|
||||
id="ai-moderation-format"
|
||||
v-model="aiModerationForm.apiFormat"
|
||||
:disabled="busy || !can('admin.ai-moderation.update')"
|
||||
required
|
||||
>
|
||||
<option v-for="option in aiModerationApiFormatOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ai-moderation-auth-mode">{{ t('pages.admin.aiModerationAuthMode') }}</label>
|
||||
<select
|
||||
id="ai-moderation-auth-mode"
|
||||
v-model="aiModerationForm.authMode"
|
||||
:disabled="busy || !can('admin.ai-moderation.update')"
|
||||
required
|
||||
>
|
||||
<option v-for="option in aiModerationAuthModeOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ai-moderation-endpoint">{{ t('pages.admin.aiModerationEndpoint') }}</label>
|
||||
<input
|
||||
id="ai-moderation-endpoint"
|
||||
v-model="aiModerationForm.endpoint"
|
||||
:disabled="busy || !can('admin.ai-moderation.update')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ai-moderation-model">{{ t('pages.admin.aiModerationModel') }}</label>
|
||||
<input
|
||||
id="ai-moderation-model"
|
||||
v-model="aiModerationForm.model"
|
||||
:disabled="busy || !can('admin.ai-moderation.update')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ai-moderation-rpm">{{ t('pages.admin.aiModerationRpm') }}</label>
|
||||
<input
|
||||
id="ai-moderation-rpm"
|
||||
v-model.number="aiModerationForm.requestsPerMinute"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
:disabled="busy || !can('admin.ai-moderation.update')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ai-moderation-api-key">{{ t('pages.admin.aiModerationApiKey') }}</label>
|
||||
<input
|
||||
id="ai-moderation-api-key"
|
||||
v-model="aiModerationForm.apiKey"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:disabled="busy || !can('admin.ai-moderation.update')"
|
||||
/>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<label>
|
||||
<input
|
||||
v-model="aiModerationForm.clearApiKey"
|
||||
type="checkbox"
|
||||
:disabled="busy || !can('admin.ai-moderation.update') || !aiModerationSettings?.apiKeyConfigured"
|
||||
/>
|
||||
{{ t('pages.admin.aiModerationClearApiKey') }}
|
||||
</label>
|
||||
</div>
|
||||
<button v-if="can('admin.ai-moderation.update')" class="ui-button ui-button--primary" type="submit" :disabled="busy">
|
||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
|
||||
<h2>{{ t('pages.admin.pokemonList') }}</h2>
|
||||
<ReorderableList
|
||||
|
||||
@@ -6,6 +6,7 @@ import FilterPanel from '../components/FilterPanel.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusBadge from '../components/StatusBadge.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
@@ -31,7 +32,9 @@ import {
|
||||
getAuthToken,
|
||||
onAuthTokenChange,
|
||||
setAuthToken,
|
||||
type AiModerationStatus,
|
||||
type AuthUser,
|
||||
type Language,
|
||||
type LifeComment,
|
||||
type LifePost,
|
||||
type LifeReactionType,
|
||||
@@ -52,6 +55,7 @@ type LifeCommentPageState = {
|
||||
const { locale, t } = useI18n();
|
||||
const posts = ref<LifePost[]>([]);
|
||||
const lifeTags = ref<NamedEntity[]>([]);
|
||||
const languages = ref<Language[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
@@ -60,6 +64,7 @@ const busy = ref(false);
|
||||
const searchDraft = ref('');
|
||||
const submittedSearch = ref('');
|
||||
const activeTagId = ref('all');
|
||||
const activeLanguageCode = ref('all');
|
||||
const body = ref('');
|
||||
const selectedTagIds = ref<string[]>([]);
|
||||
const editingPostId = ref<number | null>(null);
|
||||
@@ -76,6 +81,8 @@ const commentErrors = ref<Record<string, string>>({});
|
||||
const reactionPickerPostId = ref<number | null>(null);
|
||||
const reactionBusyPostId = ref<number | null>(null);
|
||||
const reactionErrors = ref<Record<number, string>>({});
|
||||
const moderationBusyPostId = ref<number | null>(null);
|
||||
const moderationErrors = ref<Record<number, string>>({});
|
||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
||||
const lifePostPageSize = 20;
|
||||
@@ -91,6 +98,7 @@ const nextCursor = ref<string | null>(null);
|
||||
const hasMorePosts = ref(false);
|
||||
const loadMorePaused = ref(false);
|
||||
const allTagValue = 'all';
|
||||
const allLanguageValue = 'all';
|
||||
|
||||
const reactionOptions = [
|
||||
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
|
||||
@@ -113,10 +121,17 @@ const selectedFeedTagId = computed(() => {
|
||||
const tagId = Number(activeTagId.value);
|
||||
return activeTagId.value === allTagValue || !Number.isInteger(tagId) || tagId <= 0 ? undefined : tagId;
|
||||
});
|
||||
const selectedFeedLanguageCode = computed(() =>
|
||||
activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value
|
||||
);
|
||||
const tagFilterOptions = computed<TabOption[]>(() => [
|
||||
{ value: allTagValue, label: t('pages.life.allTags') },
|
||||
...lifeTags.value.map((tag) => ({ value: String(tag.id), label: tag.name }))
|
||||
]);
|
||||
const languageFilterOptions = computed<TabOption[]>(() => [
|
||||
{ value: allLanguageValue, label: t('pages.life.allLanguages') },
|
||||
...languages.value.map((language) => ({ value: language.code, label: language.name }))
|
||||
]);
|
||||
const postModalTitle = computed(() => (isEditing.value ? t('pages.life.editPost') : t('pages.life.newPost')));
|
||||
const submitLabel = computed(() => {
|
||||
if (busy.value) return isEditing.value ? t('pages.life.updating') : t('pages.life.publishing');
|
||||
@@ -156,6 +171,21 @@ async function loadLifeTags() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLanguages() {
|
||||
try {
|
||||
languages.value = (await api.languages()).filter((language) => language.enabled);
|
||||
|
||||
if (
|
||||
activeLanguageCode.value !== allLanguageValue &&
|
||||
!languages.value.some((language) => language.code === activeLanguageCode.value)
|
||||
) {
|
||||
activeLanguageCode.value = allLanguageValue;
|
||||
}
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPosts() {
|
||||
const requestId = ++postsRequestId;
|
||||
loading.value = true;
|
||||
@@ -166,7 +196,12 @@ async function loadPosts() {
|
||||
loadMorePaused.value = false;
|
||||
|
||||
try {
|
||||
const page = await api.lifePosts({ limit: lifePostPageSize, search: searchQuery.value, tagId: selectedFeedTagId.value });
|
||||
const page = await api.lifePosts({
|
||||
limit: lifePostPageSize,
|
||||
search: searchQuery.value,
|
||||
tagId: selectedFeedTagId.value,
|
||||
language: selectedFeedLanguageCode.value
|
||||
});
|
||||
if (requestId !== postsRequestId) {
|
||||
return;
|
||||
}
|
||||
@@ -202,7 +237,13 @@ async function loadMorePosts() {
|
||||
loadError.value = '';
|
||||
|
||||
try {
|
||||
const page = await api.lifePosts({ cursor, limit: lifePostPageSize, search: searchQuery.value, tagId: selectedFeedTagId.value });
|
||||
const page = await api.lifePosts({
|
||||
cursor,
|
||||
limit: lifePostPageSize,
|
||||
search: searchQuery.value,
|
||||
tagId: selectedFeedTagId.value,
|
||||
language: selectedFeedLanguageCode.value
|
||||
});
|
||||
if (requestId !== postsRequestId) {
|
||||
return;
|
||||
}
|
||||
@@ -233,7 +274,8 @@ function resetForm() {
|
||||
function payload() {
|
||||
return {
|
||||
body: body.value.trim(),
|
||||
tagIds: selectedLifeTagIds()
|
||||
tagIds: selectedLifeTagIds(),
|
||||
languageCode: selectedFeedLanguageCode.value ?? null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,7 +317,9 @@ function matchesCurrentFilters(post: LifePost) {
|
||||
const tagId = selectedFeedTagId.value;
|
||||
const matchesSearch = keyword === '' || post.body.toLowerCase().includes(keyword);
|
||||
const matchesTag = tagId === undefined || post.tags.some((tag) => tag.id === tagId);
|
||||
return matchesSearch && matchesTag;
|
||||
const matchesLanguage =
|
||||
selectedFeedLanguageCode.value === undefined || post.moderationLanguageCode === selectedFeedLanguageCode.value;
|
||||
return matchesSearch && matchesTag && matchesLanguage;
|
||||
}
|
||||
|
||||
function openCreatePostModal() {
|
||||
@@ -412,6 +456,50 @@ function reactionCountLabel(post: LifePost, type: LifeReactionType) {
|
||||
});
|
||||
}
|
||||
|
||||
function moderationLabel(status: AiModerationStatus) {
|
||||
const labels: Record<AiModerationStatus, string> = {
|
||||
unreviewed: t('pages.life.moderationUnreviewed'),
|
||||
reviewing: t('pages.life.moderationReviewing'),
|
||||
approved: t('pages.life.moderationApproved'),
|
||||
rejected: t('pages.life.moderationRejected'),
|
||||
failed: t('pages.life.moderationFailed')
|
||||
};
|
||||
return labels[status];
|
||||
}
|
||||
|
||||
function moderationTone(status: AiModerationStatus) {
|
||||
const tones: Record<AiModerationStatus, 'info' | 'success' | 'warning' | 'danger' | 'neutral'> = {
|
||||
unreviewed: 'neutral',
|
||||
reviewing: 'info',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
failed: 'warning'
|
||||
};
|
||||
return tones[status];
|
||||
}
|
||||
|
||||
function canRetryModeration(post: LifePost) {
|
||||
return post.moderationStatus !== 'approved' && canManage(post);
|
||||
}
|
||||
|
||||
async function retryPostModeration(post: LifePost) {
|
||||
moderationBusyPostId.value = post.id;
|
||||
const nextErrors = { ...moderationErrors.value };
|
||||
delete nextErrors[post.id];
|
||||
moderationErrors.value = nextErrors;
|
||||
|
||||
try {
|
||||
replacePost(await api.retryLifePostModeration(post.id));
|
||||
} catch (error) {
|
||||
moderationErrors.value = {
|
||||
...moderationErrors.value,
|
||||
[post.id]: error instanceof Error && error.message ? error.message : t('pages.life.moderationRetryFailed')
|
||||
};
|
||||
} finally {
|
||||
moderationBusyPostId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function replacePost(updatedPost: LifePost) {
|
||||
if (!matchesCurrentFilters(updatedPost)) {
|
||||
posts.value = posts.value.filter((post) => post.id !== updatedPost.id);
|
||||
@@ -460,7 +548,7 @@ async function loadComments(post: LifePost, reset = false) {
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await api.lifeComments(post.id, { limit: lifeCommentPageSize, cursor });
|
||||
const page = await api.lifeComments(post.id, { limit: lifeCommentPageSize, cursor, language: selectedFeedLanguageCode.value });
|
||||
const nextItems = reset || !existing.loaded ? page.items : mergeComments(existing.items, page.items);
|
||||
setCommentPage(post.id, {
|
||||
items: nextItems,
|
||||
@@ -584,7 +672,7 @@ async function toggleDefaultReaction(post: LifePost) {
|
||||
}
|
||||
|
||||
async function toggleReaction(post: LifePost, reactionType: LifeReactionType) {
|
||||
if (!canUseReactions()) {
|
||||
if (!canUseReactions() || post.moderationStatus !== 'approved') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -656,7 +744,10 @@ async function submitComment(post: LifePost) {
|
||||
clearCommentError(key);
|
||||
|
||||
try {
|
||||
const comment = await api.createLifeComment(post.id, { body: nextBody });
|
||||
const comment = await api.createLifeComment(post.id, {
|
||||
body: nextBody,
|
||||
languageCode: selectedFeedLanguageCode.value ?? post.moderationLanguageCode
|
||||
});
|
||||
const nextTotal = commentCount(post) + 1;
|
||||
post.commentCount = nextTotal;
|
||||
updateCommentPage(post, (page) => ({
|
||||
@@ -686,7 +777,10 @@ async function submitReply(post: LifePost, comment: LifeComment) {
|
||||
clearCommentError(key);
|
||||
|
||||
try {
|
||||
const reply = await api.createLifeCommentReply(post.id, comment.id, { body: nextBody });
|
||||
const reply = await api.createLifeCommentReply(post.id, comment.id, {
|
||||
body: nextBody,
|
||||
languageCode: selectedFeedLanguageCode.value ?? comment.moderationLanguageCode ?? post.moderationLanguageCode
|
||||
});
|
||||
const nextTotal = commentCount(post) + 1;
|
||||
post.commentCount = nextTotal;
|
||||
comment.replies.push(reply);
|
||||
@@ -788,7 +882,13 @@ watch([loadMoreSentinel, hasMorePosts, loading, loadingMore, loadMorePaused], ob
|
||||
watch(activeTagId, () => {
|
||||
void loadPosts();
|
||||
});
|
||||
watch(activeLanguageCode, () => {
|
||||
expandedComments.value = {};
|
||||
commentPages.value = {};
|
||||
void loadPosts();
|
||||
});
|
||||
watch(locale, () => {
|
||||
void loadLanguages();
|
||||
void loadLifeTags();
|
||||
void loadPosts();
|
||||
});
|
||||
@@ -797,6 +897,7 @@ onMounted(() => {
|
||||
document.addEventListener('click', closeReactionPickerFromDocument);
|
||||
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
||||
void loadCurrentUser();
|
||||
void loadLanguages();
|
||||
void loadLifeTags();
|
||||
void loadPosts();
|
||||
removeAuthListener = onAuthTokenChange(() => {
|
||||
@@ -913,6 +1014,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Tabs id="life-language-filter" v-model="activeLanguageCode" :tabs="languageFilterOptions" :label="t('pages.life.languages')" />
|
||||
<Tabs id="life-tag-filter" v-model="activeTagId" :tabs="tagFilterOptions" :label="t('pages.life.tags')" />
|
||||
|
||||
<section class="life-feed" :aria-busy="loading || loadingMore" :aria-label="t('pages.life.kicker')">
|
||||
@@ -964,6 +1066,21 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="life-post__moderation">
|
||||
<StatusBadge :label="moderationLabel(post.moderationStatus)" :tone="moderationTone(post.moderationStatus)" compact />
|
||||
<button
|
||||
v-if="canRetryModeration(post)"
|
||||
class="ui-button ui-button--ghost ui-button--small"
|
||||
type="button"
|
||||
:disabled="moderationBusyPostId === post.id"
|
||||
@click="retryPostModeration(post)"
|
||||
>
|
||||
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
||||
{{ moderationBusyPostId === post.id ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p>
|
||||
|
||||
<p class="life-post__body">{{ post.body }}</p>
|
||||
|
||||
<div v-if="post.tags.length" class="life-post__tags" :aria-label="t('pages.life.tags')">
|
||||
@@ -981,7 +1098,7 @@ onUnmounted(() => {
|
||||
:aria-controls="`life-reactions-${post.id}`"
|
||||
:aria-expanded="reactionPickerPostId === post.id"
|
||||
:aria-label="reactionButtonLabel(post)"
|
||||
:disabled="!canReact || reactionBusyPostId !== null"
|
||||
:disabled="!canReact || post.moderationStatus !== 'approved' || reactionBusyPostId !== null"
|
||||
@click="toggleDefaultReaction(post)"
|
||||
@contextmenu="handleReactionContextMenu($event, post.id)"
|
||||
@keydown="handleReactionKeydown($event, post.id)"
|
||||
@@ -996,7 +1113,7 @@ onUnmounted(() => {
|
||||
:aria-controls="`life-reactions-${post.id}`"
|
||||
:aria-expanded="reactionPickerPostId === post.id"
|
||||
:aria-label="t('pages.life.chooseReaction')"
|
||||
:disabled="!canReact || reactionBusyPostId !== null"
|
||||
:disabled="!canReact || post.moderationStatus !== 'approved' || reactionBusyPostId !== null"
|
||||
@click="toggleReactionPicker(post.id)"
|
||||
@contextmenu="handleReactionContextMenu($event, post.id)"
|
||||
@keydown="handleReactionKeydown($event, post.id)"
|
||||
@@ -1021,7 +1138,7 @@ onUnmounted(() => {
|
||||
type="button"
|
||||
:aria-pressed="post.myReaction === option.type"
|
||||
:aria-label="reactionOptionLabel(post, option.type)"
|
||||
:disabled="isReactionBusy(post.id)"
|
||||
:disabled="post.moderationStatus !== 'approved' || isReactionBusy(post.id)"
|
||||
@click="toggleReaction(post, option.type)"
|
||||
>
|
||||
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
|
||||
@@ -1036,6 +1153,7 @@ onUnmounted(() => {
|
||||
:aria-controls="`life-comments-${post.id}`"
|
||||
:aria-expanded="areCommentsExpanded(post.id)"
|
||||
:aria-label="areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment')"
|
||||
:disabled="post.moderationStatus !== 'approved'"
|
||||
@click="toggleComments(post)"
|
||||
>
|
||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||
@@ -1070,6 +1188,7 @@ onUnmounted(() => {
|
||||
:aria-controls="`life-comments-${post.id}`"
|
||||
:aria-expanded="areCommentsExpanded(post.id)"
|
||||
:aria-label="t('pages.life.commentsCount', { count: commentCount(post) })"
|
||||
:disabled="post.moderationStatus !== 'approved'"
|
||||
@click="toggleComments(post)"
|
||||
>
|
||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||
|
||||
@@ -450,6 +450,8 @@ export const systemWordingMessages = {
|
||||
bodyPlaceholder: 'Share a thought, tip, or discovery...',
|
||||
newPost: 'New Post',
|
||||
tags: 'Tags',
|
||||
languages: 'Languages',
|
||||
allLanguages: 'All languages',
|
||||
allTags: 'All',
|
||||
tagPlaceholder: 'Select tags',
|
||||
searchTags: 'Search tags',
|
||||
@@ -514,6 +516,14 @@ export const systemWordingMessages = {
|
||||
byUnknown: 'Community member',
|
||||
edited: 'Edited',
|
||||
deleteConfirm: 'Delete this post?',
|
||||
moderationUnreviewed: 'Not reviewed',
|
||||
moderationReviewing: 'Reviewing',
|
||||
moderationApproved: 'Approved',
|
||||
moderationRejected: 'Rejected',
|
||||
moderationFailed: 'Review failed',
|
||||
moderationRetry: 'Retry review',
|
||||
moderationRetrying: 'Retrying',
|
||||
moderationRetryFailed: 'Review retry failed',
|
||||
charactersLeft: '{count} characters left'
|
||||
},
|
||||
admin: {
|
||||
@@ -549,6 +559,22 @@ export const systemWordingMessages = {
|
||||
newLanguage: 'New language',
|
||||
editLanguage: 'Edit language',
|
||||
wordings: 'System wordings',
|
||||
aiModeration: 'AI moderation',
|
||||
aiModerationEnabled: 'Enabled',
|
||||
aiModerationFormat: 'API format',
|
||||
aiModerationFormatGemini: 'Gemini generateContent',
|
||||
aiModerationFormatOpenAi: 'OpenAI-compatible chat completions',
|
||||
aiModerationAuthMode: 'Auth mode',
|
||||
aiModerationAuthQueryKey: 'Query key',
|
||||
aiModerationAuthBearer: 'Bearer token',
|
||||
aiModerationEndpoint: 'End Point',
|
||||
aiModerationModel: 'Model',
|
||||
aiModerationRpm: 'Requests per minute',
|
||||
aiModerationApiKey: 'API Key',
|
||||
aiModerationApiKeyConfigured: 'API Key configured',
|
||||
aiModerationApiKeyMissing: 'API Key missing',
|
||||
aiModerationClearApiKey: 'Clear saved API Key',
|
||||
aiModerationSettings: 'AI moderation settings',
|
||||
wordingLocale: 'Locale',
|
||||
wordingModule: 'Module',
|
||||
wordingSurface: 'Surface',
|
||||
@@ -660,6 +686,16 @@ export const systemWordingMessages = {
|
||||
commentFailed: 'Comment failed',
|
||||
replyFailed: 'Reply failed',
|
||||
deleteFailed: 'Delete failed',
|
||||
languages: 'Languages',
|
||||
allLanguages: 'All languages',
|
||||
moderationUnreviewed: 'Not reviewed',
|
||||
moderationReviewing: 'Reviewing',
|
||||
moderationApproved: 'Approved',
|
||||
moderationRejected: 'Rejected',
|
||||
moderationFailed: 'Review failed',
|
||||
moderationRetry: 'Retry review',
|
||||
moderationRetrying: 'Retrying',
|
||||
moderationRetryFailed: 'Review retry failed',
|
||||
loading: 'Loading discussion',
|
||||
loadMore: 'Load more comments',
|
||||
empty: 'No discussion yet',
|
||||
@@ -1235,6 +1271,8 @@ export const systemWordingMessages = {
|
||||
bodyPlaceholder: '分享一段想法、心得或发现……',
|
||||
newPost: 'New Post',
|
||||
tags: '标签',
|
||||
languages: '语言区',
|
||||
allLanguages: '全部语言',
|
||||
allTags: '全部',
|
||||
tagPlaceholder: '选择标签',
|
||||
searchTags: '搜索标签',
|
||||
@@ -1299,6 +1337,14 @@ export const systemWordingMessages = {
|
||||
byUnknown: '社区成员',
|
||||
edited: '已编辑',
|
||||
deleteConfirm: '确认删除这条动态?',
|
||||
moderationUnreviewed: '未审核',
|
||||
moderationReviewing: '审核中',
|
||||
moderationApproved: '审核通过',
|
||||
moderationRejected: '审核不通过',
|
||||
moderationFailed: '审核失败',
|
||||
moderationRetry: '重新审核',
|
||||
moderationRetrying: '重审中',
|
||||
moderationRetryFailed: '重新审核失败',
|
||||
charactersLeft: '还可以输入 {count} 个字符'
|
||||
},
|
||||
admin: {
|
||||
@@ -1334,6 +1380,22 @@ export const systemWordingMessages = {
|
||||
newLanguage: '新增语言',
|
||||
editLanguage: '编辑语言',
|
||||
wordings: '系统文案',
|
||||
aiModeration: 'AI 审核',
|
||||
aiModerationEnabled: '启用',
|
||||
aiModerationFormat: 'API 格式',
|
||||
aiModerationFormatGemini: 'Gemini generateContent',
|
||||
aiModerationFormatOpenAi: 'OpenAI-compatible chat completions',
|
||||
aiModerationAuthMode: '鉴权方式',
|
||||
aiModerationAuthQueryKey: 'Query key',
|
||||
aiModerationAuthBearer: 'Bearer token',
|
||||
aiModerationEndpoint: 'End Point',
|
||||
aiModerationModel: '模型',
|
||||
aiModerationRpm: '每分钟请求数',
|
||||
aiModerationApiKey: 'API Key',
|
||||
aiModerationApiKeyConfigured: 'API Key 已配置',
|
||||
aiModerationApiKeyMissing: 'API Key 未配置',
|
||||
aiModerationClearApiKey: '清除已保存 API Key',
|
||||
aiModerationSettings: 'AI 审核设置',
|
||||
wordingLocale: '语言',
|
||||
wordingModule: '模块',
|
||||
wordingSurface: '端',
|
||||
@@ -1445,6 +1507,16 @@ export const systemWordingMessages = {
|
||||
commentFailed: '评论失败',
|
||||
replyFailed: '回复失败',
|
||||
deleteFailed: '删除失败',
|
||||
languages: '语言区',
|
||||
allLanguages: '全部语言',
|
||||
moderationUnreviewed: '未审核',
|
||||
moderationReviewing: '审核中',
|
||||
moderationApproved: '审核通过',
|
||||
moderationRejected: '审核不通过',
|
||||
moderationFailed: '审核失败',
|
||||
moderationRetry: '重新审核',
|
||||
moderationRetrying: '重审中',
|
||||
moderationRetryFailed: '重新审核失败',
|
||||
loading: '正在加载讨论',
|
||||
loadMore: '加载更多评论',
|
||||
empty: '暂无讨论',
|
||||
|
||||
Reference in New Issue
Block a user