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:
@@ -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>(
|
||||
`
|
||||
${lifeCommentProjection('WHERE lc.parent_comment_id = ANY($1::integer[])')}
|
||||
ORDER BY lc.created_at, lc.id
|
||||
`,
|
||||
[topLevelIds]
|
||||
)
|
||||
? 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 ${replyConditions.join(' AND ')}`)}
|
||||
ORDER BY lc.created_at, lc.id
|
||||
`,
|
||||
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>(
|
||||
`
|
||||
${entityDiscussionCommentProjection('WHERE edc.parent_comment_id = ANY($1::integer[])')}
|
||||
ORDER BY edc.created_at, edc.id
|
||||
`,
|
||||
[topLevelIds]
|
||||
)
|
||||
? 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 ${replyConditions.join(' AND ')}`)}
|
||||
ORDER BY edc.created_at, edc.id
|
||||
`,
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user