refactor(life): remove life categories and ratings

Drop life_tags and life_post_ratings tables and related schema
Remove category selection and rating UI from Life posts
Simplify Life feed filters and API endpoints
This commit is contained in:
2026-05-07 15:38:32 +08:00
parent e9d356a656
commit a781bc559b
11 changed files with 89 additions and 696 deletions

View File

@@ -32,7 +32,6 @@ CREATE TABLE IF NOT EXISTS entity_translations (
'maps',
'habitats',
'daily-checklist-items',
'life-tags',
'game-versions',
'dish-categories',
'dish-flavors',
@@ -46,6 +45,31 @@ CREATE TABLE IF NOT EXISTS entity_translations (
PRIMARY KEY (entity_type, entity_id, locale, field_name)
);
DELETE FROM entity_translations
WHERE entity_type = 'life-tags';
ALTER TABLE entity_translations
DROP CONSTRAINT IF EXISTS entity_translations_entity_type_check,
ADD CONSTRAINT entity_translations_entity_type_check CHECK (
entity_type IN (
'pokemon',
'pokemon-types',
'skills',
'environments',
'favorite-things',
'acquisition-methods',
'items',
'ancient-artifacts',
'maps',
'habitats',
'daily-checklist-items',
'game-versions',
'dish-categories',
'dish-flavors',
'dishes'
)
);
CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx
ON entity_translations (entity_type, entity_id, field_name, locale);
@@ -266,7 +290,6 @@ VALUES
('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true),
('life.comments.like', 'Like Life comments', 'Like and unlike Life comments.', 'Life', true),
('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', 'Life', true),
('life.ratings.set', 'Set Life ratings', 'Set and remove Life star ratings.', 'Life', true),
('threads.create', 'Create Threads', 'Create forum threads.', 'Threads', true),
('threads.messages.create', 'Create Thread messages', 'Create chat messages inside Threads.', 'Threads', true),
('threads.follow', 'Follow Threads', 'Follow Threads and manage read state.', 'Threads', true),
@@ -288,6 +311,9 @@ ON CONFLICT (key) DO NOTHING;
DELETE FROM permissions
WHERE key = 'pokemon.order';
DELETE FROM permissions
WHERE key = 'life.ratings.set';
INSERT INTO roles (key, name, description, level, enabled, system_role)
VALUES
('owner', 'Owner', 'Highest-level system owner with all permissions.', 1000, true, true),
@@ -377,7 +403,6 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.delete-any',
'life.comments.like',
'life.reactions.set',
'life.ratings.set',
'threads.create',
'threads.messages.create',
'threads.follow',
@@ -461,7 +486,6 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.delete',
'life.comments.like',
'life.reactions.set',
'life.ratings.set',
'threads.create',
'threads.messages.create',
'threads.follow',
@@ -538,7 +562,6 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.delete',
'life.comments.like',
'life.reactions.set',
'life.ratings.set',
'threads.create',
'threads.messages.create',
'threads.follow',
@@ -556,13 +579,6 @@ WHERE r.key = 'member'
)
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = 'life.ratings.set'
WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
@@ -725,18 +741,6 @@ CREATE TABLE IF NOT EXISTS daily_checklist_items (
CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx
ON daily_checklist_items(sort_order, id);
CREATE TABLE IF NOT EXISTS life_tags (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
is_default boolean NOT NULL DEFAULT false,
is_rateable boolean NOT NULL DEFAULT false,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS game_versions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
@@ -754,7 +758,6 @@ CREATE INDEX IF NOT EXISTS game_versions_sort_order_idx
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),
category_id integer REFERENCES life_tags(id) ON DELETE RESTRICT,
game_version_id integer REFERENCES game_versions(id) ON DELETE SET NULL,
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,
@@ -782,14 +785,14 @@ CREATE INDEX IF NOT EXISTS life_posts_user_created_at_idx
ON life_posts(created_by_user_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS life_post_tags (
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
tag_id integer NOT NULL REFERENCES life_tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
DROP INDEX IF EXISTS life_posts_category_idx;
DROP TABLE IF EXISTS life_post_ratings;
DROP TABLE IF EXISTS life_post_tags;
CREATE INDEX IF NOT EXISTS life_post_tags_tag_idx
ON life_post_tags(tag_id, post_id);
ALTER TABLE life_posts
DROP COLUMN IF EXISTS category_id;
DROP TABLE IF EXISTS life_tags;
CREATE TABLE IF NOT EXISTS life_post_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
@@ -847,21 +850,6 @@ CREATE INDEX IF NOT EXISTS life_post_reactions_post_idx
CREATE INDEX IF NOT EXISTS life_post_reactions_user_idx
ON life_post_reactions(user_id, updated_at DESC, post_id DESC);
CREATE TABLE IF NOT EXISTS life_post_ratings (
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rating integer NOT NULL CHECK (rating BETWEEN 1 AND 5),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (post_id, user_id)
);
CREATE INDEX IF NOT EXISTS life_post_ratings_post_idx
ON life_post_ratings(post_id, rating);
CREATE INDEX IF NOT EXISTS life_post_ratings_user_idx
ON life_post_ratings(user_id, updated_at DESC, post_id DESC);
CREATE TABLE IF NOT EXISTS thread_channels (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
@@ -1344,8 +1332,6 @@ CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sor
CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_order, id);
CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id);
CREATE UNIQUE INDEX IF NOT EXISTS pokemon_display_event_item_key ON pokemon(display_id, is_event_item);
CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id);
CREATE UNIQUE INDEX IF NOT EXISTS life_tags_single_default_idx ON life_tags(is_default) WHERE is_default = true;
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
CREATE INDEX IF NOT EXISTS items_sort_order_idx ON items(sort_order, id);
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
@@ -1506,10 +1492,6 @@ CREATE TABLE IF NOT EXISTS notification_ws_tickets (
CREATE INDEX IF NOT EXISTS notification_ws_tickets_user_idx
ON notification_ws_tickets(user_id, expires_at DESC);
CREATE INDEX IF NOT EXISTS life_posts_category_idx
ON life_posts(category_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS life_posts_game_version_idx
ON life_posts(game_version_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL;

View File

@@ -136,7 +136,6 @@ type EntityType =
| 'maps'
| 'habitats'
| 'daily-checklist-items'
| 'life-tags'
| 'game-versions'
| 'dish-categories'
| 'dish-flavors'
@@ -149,7 +148,6 @@ type ConfigType =
| 'favorite-things'
| 'acquisition-methods'
| 'maps'
| 'life-tags'
| 'game-versions'
| 'dish-flavors';
@@ -158,8 +156,6 @@ type ConfigDefinition = {
entityType: EntityType;
hasItemDrop?: boolean;
hasTrading?: boolean;
hasDefault?: boolean;
hasRateable?: boolean;
hasChangeLog?: boolean;
};
type SortableContentType = 'items' | 'ancient-artifacts' | 'recipes' | 'habitats';
@@ -330,7 +326,6 @@ type DailyChecklistPayload = {
type LifePostPayload = {
body: string;
categoryId: number;
gameVersionId: number | null;
languageCode: string | null;
};
@@ -427,10 +422,7 @@ type LifePostRow = {
updatedAt: Date;
author: { id: number; displayName: string } | null;
updatedBy: { id: number; displayName: string } | null;
category: { id: number; name: string; isRateable: boolean } | null;
gameVersion: { id: number; name: string; changeLog: string } | null;
ratingAverage: number | null;
ratingCount: number;
};
type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
@@ -438,16 +430,14 @@ type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
commentCount: number;
reactionCounts: LifeReactionCounts;
myReaction: LifeReactionType | null;
myRating: number | null;
};
type LifePostCursor = {
createdAt: string;
id: number;
ratingAverage?: number;
};
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
type LifePostSort = 'latest' | 'oldest';
type CommentSort = 'oldest' | 'latest' | 'most-liked' | 'most-replied';
type CommentCursor = {
createdAt: string;
@@ -668,8 +658,6 @@ type ConfigChangeSource = {
name: string;
hasItemDrop?: boolean;
hasTrading?: boolean;
isDefault?: boolean;
isRateable?: boolean;
changeLog?: string;
} & TranslationChangeSource;
@@ -738,7 +726,6 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things' },
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
maps: { table: 'maps', entityType: 'maps' },
'life-tags': { table: 'life_tags', entityType: 'life-tags', hasDefault: true, hasRateable: true },
'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true },
'dish-flavors': { table: 'dish_flavors', entityType: 'dish-flavors' }
};
@@ -1081,13 +1068,6 @@ function systemListJsonSql(expression: string, options: readonly SystemListOptio
return `json_build_object('id', ${systemListIdSql(expression, options)}, 'key', ${expression}, 'name', ${systemListNameSql(expression, options, locale)})`;
}
function lifeCategoryOptions(locale: string): Promise<Array<{ id: number; name: string; isDefault: boolean; isRateable: boolean }>> {
const name = localizedName('life-tags', 'lc', locale);
return query(
`SELECT lc.id, ${name} AS name, lc.is_default AS "isDefault", lc.is_rateable AS "isRateable" FROM life_tags lc ORDER BY ${orderByEntity('lc')}`
);
}
function gameVersionOptions(locale: string): Promise<Array<{ id: number; name: string; changeLog: string }>> {
const name = localizedName('game-versions', 'gv', locale);
return query(`SELECT gv.id, ${name} AS name, gv.change_log AS "changeLog" FROM game_versions gv ORDER BY ${orderByEntity('gv')}`);
@@ -1136,12 +1116,6 @@ function configSelect(definition: ConfigDefinition, locale: string): string {
if (definition.hasTrading) {
columns.push(`c.has_trading AS "hasTrading"`);
}
if (definition.hasDefault) {
columns.push(`c.is_default AS "isDefault"`);
}
if (definition.hasRateable) {
columns.push(`c.is_rateable AS "isRateable"`);
}
if (definition.hasChangeLog) {
columns.push(`c.change_log AS "changeLog"`);
}
@@ -2096,7 +2070,7 @@ async function ensurePokemonTypeCatalog(
const changes = configEditChanges(
{ table: 'pokemon_types', entityType: 'pokemon-types' },
existing.rows[0],
{ name, translations, hasItemDrop: false, hasTrading: false, isDefault: false, isRateable: false, changeLog: '' }
{ name, translations, hasItemDrop: false, hasTrading: false, changeLog: '' }
);
if (changes.length) {
await client.query(
@@ -2665,8 +2639,6 @@ function configEditChanges(
translations: TranslationInput;
hasItemDrop: boolean;
hasTrading: boolean;
isDefault: boolean;
isRateable: boolean;
changeLog: string;
}
): EditChange[] {
@@ -2679,12 +2651,6 @@ function configEditChanges(
if (definition.hasTrading) {
pushChange(changes, 'Has trading', boolValue(Boolean(before.hasTrading)), boolValue(after.hasTrading));
}
if (definition.hasDefault) {
pushChange(changes, 'Default category', boolValue(Boolean(before.isDefault)), boolValue(after.isDefault));
}
if (definition.hasRateable) {
pushChange(changes, 'Rateable', boolValue(Boolean(before.isRateable)), boolValue(after.isRateable));
}
if (definition.hasChangeLog) {
pushChange(changes, 'ChangeLog', before.changeLog, after.changeLog);
}
@@ -2782,7 +2748,6 @@ export async function getOptions(locale = defaultLocale) {
favoriteThings,
acquisitionMethods,
maps,
lifeCategories,
gameVersions,
dishFlavors
] = await Promise.all([
@@ -2792,7 +2757,6 @@ export async function getOptions(locale = defaultLocale) {
optionSelect('favorite_things', 'favorite-things', locale),
optionSelect('acquisition_methods', 'acquisition-methods', locale),
optionSelect('maps', 'maps', locale),
lifeCategoryOptions(locale),
gameVersionOptions(locale),
optionSelect('dish_flavors', 'dish-flavors', locale)
]);
@@ -2808,7 +2772,6 @@ export async function getOptions(locale = defaultLocale) {
acquisitionMethods,
itemTags: favoriteThings,
maps,
lifeCategories,
gameVersions,
dishFlavors
};
@@ -2851,8 +2814,6 @@ export async function globalSearch(paramsQuery: QueryParams = {}, locale = defau
const recipeItemName = localizedName('items', 'result_item', locale);
const recipeMaterialName = localizedName('items', 'material_item', locale);
const checklistTitle = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
const lifeCategoryName = localizedName('life-tags', 'lc', locale);
const [pokemon, habitats, items, artifacts, recipes, checklist, life, users] = await Promise.all([
query<GlobalSearchItem>(
`
@@ -2981,10 +2942,9 @@ export async function globalSearch(paramsQuery: QueryParams = {}, locale = defau
LEFT(lp.body, 120) AS title,
'/life/' || lp.id AS url,
NULL AS summary,
${lifeCategoryName} AS meta,
NULL AS meta,
NULL AS image
FROM life_posts lp
LEFT JOIN life_tags lc ON lc.id = lp.category_id
WHERE lp.deleted_at IS NULL
AND lp.ai_moderation_status = 'approved'
AND lp.body ILIKE $1
@@ -3153,12 +3113,10 @@ function cleanLifePostPayload(payload: Record<string, unknown>): LifePostPayload
if (body.length > 2000) {
throw validationError('server.validation.postTooLong');
}
const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.lifeCategoryRequired');
const gameVersionId = optionalPositiveInteger(payload.gameVersionId, 'server.validation.gameVersionInvalid');
return {
body,
categoryId,
gameVersionId,
languageCode: cleanModerationLanguageCode(payload.languageCode)
};
@@ -3203,15 +3161,6 @@ function cleanLifeReactionFilter(value: QueryValue): LifeReactionType | null {
return cleanLifeReactionType(reactionType);
}
function cleanLifeRating(value: unknown): number {
const rating = Number(value);
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
throw validationError('server.validation.ratingInvalid');
}
return rating;
}
function cleanUserCommentActivitySourceFilter(value: QueryValue): UserCommentActivitySource | null {
const source = asString(value);
if (!source) {
@@ -3279,7 +3228,6 @@ function addModerationLanguageCondition(
}
function lifePostProjection(locale = defaultLocale): string {
const categoryName = localizedName('life-tags', 'lc', locale);
const gameVersionName = localizedName('game-versions', 'gv', locale);
return `
@@ -3300,29 +3248,12 @@ function lifePostProjection(locale = defaultLocale): string {
WHEN updated_user.id IS NULL THEN NULL
ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name)
END AS "updatedBy",
CASE
WHEN lc.id IS NULL THEN NULL
ELSE json_build_object('id', lc.id, 'name', ${categoryName}, 'isRateable', lc.is_rateable)
END AS category,
CASE
WHEN gv.id IS NULL THEN NULL
ELSE json_build_object('id', gv.id, 'name', ${gameVersionName}, 'changeLog', gv.change_log)
END AS "gameVersion",
CASE
WHEN rating_stats.rating_count = 0 THEN NULL
ELSE rating_stats.rating_average::double precision
END AS "ratingAverage",
rating_stats.rating_count AS "ratingCount"
END AS "gameVersion"
FROM life_posts lp
LEFT JOIN life_tags lc ON lc.id = lp.category_id
LEFT JOIN game_versions gv ON gv.id = lp.game_version_id
LEFT JOIN LATERAL (
SELECT
ROUND(AVG(lpr.rating)::numeric, 2) AS rating_average,
COUNT(*)::integer AS rating_count
FROM life_post_ratings lpr
WHERE lpr.post_id = lp.id
) rating_stats ON true
LEFT JOIN users created_user ON created_user.id = lp.created_by_user_id
LEFT JOIN users updated_user ON updated_user.id = lp.updated_by_user_id
`;
@@ -3340,18 +3271,7 @@ function cleanLifePostLimit(value: QueryValue): number {
function cleanLifePostSort(value: QueryValue): LifePostSort {
const sort = asString(value);
return sort === 'oldest' || sort === 'top-rated' ? sort : 'latest';
}
function cleanRateableFilter(value: QueryValue): boolean | null {
const rateable = asString(value);
if (rateable === 'true') {
return true;
}
if (rateable === 'false') {
return false;
}
return null;
return sort === 'oldest' ? sort : 'latest';
}
function cleanCommentLimit(value: QueryValue): number {
@@ -3460,19 +3380,17 @@ function decodeLifePostCursor(value: QueryValue): LifePostCursor | null {
const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<LifePostCursor>;
const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
const id = Number(cursor.id);
const ratingAverage = cursor.ratingAverage === undefined ? undefined : Number(cursor.ratingAverage);
if (
!createdAt ||
Number.isNaN(new Date(createdAt).getTime()) ||
!Number.isInteger(id) ||
id <= 0 ||
(ratingAverage !== undefined && (Number.isNaN(ratingAverage) || ratingAverage < 0))
id <= 0
) {
throw validationError('server.validation.cursorInvalid');
}
return { createdAt, id, ratingAverage };
return { createdAt, id };
} catch (error) {
if (error instanceof Error && 'statusCode' in error) {
throw error;
@@ -3482,10 +3400,7 @@ function decodeLifePostCursor(value: QueryValue): LifePostCursor | null {
}
function encodeLifePostCursor(post: LifePostRow): string {
return Buffer.from(
JSON.stringify({ createdAt: post.createdAtCursor, id: post.id, ratingAverage: post.ratingAverage ?? 0 }),
'utf8'
).toString('base64url');
return Buffer.from(JSON.stringify({ createdAt: post.createdAtCursor, id: post.id }), 'utf8').toString('base64url');
}
function encodeProfileCursor(cursor: LifePostCursor): string {
@@ -3560,8 +3475,7 @@ function hydrateLifePost(
commentPreviewByPost: Map<number, LifeComment[]>,
commentCountsByPost: Map<number, number>,
countsByPost: Map<number, LifeReactionCounts>,
myReactionsByPost: Map<number, LifeReactionType>,
myRatingsByPost: Map<number, number>
myReactionsByPost: Map<number, LifeReactionType>
): LifePost {
return {
id: post.id,
@@ -3573,15 +3487,11 @@ function hydrateLifePost(
updatedAt: post.updatedAt,
author: post.author,
updatedBy: post.updatedBy,
category: post.category,
gameVersion: post.gameVersion,
ratingAverage: post.ratingAverage,
ratingCount: post.ratingCount,
commentPreview: commentPreviewByPost.get(post.id) ?? [],
commentCount: commentCountsByPost.get(post.id) ?? 0,
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
myReaction: myReactionsByPost.get(post.id) ?? null,
myRating: myRatingsByPost.get(post.id) ?? null
myReaction: myReactionsByPost.get(post.id) ?? null
};
}
@@ -4004,30 +3914,6 @@ export async function listLifePostReactionUsers(
};
}
async function lifeRatingsForPosts(postIds: number[], userId: number | null): Promise<Map<number, number>> {
const myRatingsByPost = new Map<number, number>();
if (postIds.length === 0 || userId === null) {
return myRatingsByPost;
}
const rows = await query<{ postId: number; rating: number }>(
`
SELECT post_id AS "postId", rating
FROM life_post_ratings
WHERE post_id = ANY($1::integer[])
AND user_id = $2
`,
[postIds, userId]
);
for (const row of rows) {
myRatingsByPost.set(row.postId, row.rating);
}
return myRatingsByPost;
}
async function getLifeCommentById(id: number, userId: number | null = null, canViewAll = false): Promise<LifeComment | null> {
const row = await queryOne<LifeCommentRow>(
`
@@ -4055,9 +3941,7 @@ async function listLifePostsWithFilters(
const limit = cleanLifePostLimit(paramsQuery.limit);
const sort = cleanLifePostSort(paramsQuery.sort);
const search = asString(paramsQuery.search)?.trim();
const categoryIdValue = asString(paramsQuery.categoryId)?.trim();
const gameVersionIdValue = asString(paramsQuery.gameVersionId)?.trim();
const rateable = cleanRateableFilter(paramsQuery.rateable);
const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
const params: unknown[] = [];
const conditions: string[] = ['lp.deleted_at IS NULL'];
@@ -4087,41 +3971,20 @@ async function listLifePostsWithFilters(
conditions.push(`lp.body ILIKE $${params.length}`);
}
if (categoryIdValue) {
const categoryId = requirePositiveInteger(categoryIdValue, 'server.validation.lifeCategoryInvalid');
params.push(categoryId);
conditions.push(`lp.category_id = $${params.length}`);
}
if (gameVersionIdValue && gameVersionIdValue !== 'all') {
const gameVersionId = requirePositiveInteger(gameVersionIdValue, 'server.validation.gameVersionInvalid');
params.push(gameVersionId);
conditions.push(`lp.game_version_id = $${params.length}`);
}
if (rateable !== null) {
params.push(rateable);
conditions.push(`lc.is_rateable = $${params.length}`);
}
if (cursor) {
if (sort === 'top-rated') {
params.push(cursor.ratingAverage ?? 0, cursor.createdAt, cursor.id);
conditions.push(
`(COALESCE(rating_stats.rating_average, 0), lp.created_at, lp.id) < ($${params.length - 2}::numeric, $${params.length - 1}::timestamptz, $${params.length}::integer)`
);
} else {
params.push(cursor.createdAt, cursor.id);
conditions.push(
`(lp.created_at, lp.id) ${sort === 'oldest' ? '>' : '<'} ($${params.length - 1}::timestamptz, $${params.length}::integer)`
);
}
params.push(cursor.createdAt, cursor.id);
conditions.push(
`(lp.created_at, lp.id) ${sort === 'oldest' ? '>' : '<'} ($${params.length - 1}::timestamptz, $${params.length}::integer)`
);
}
const orderClause =
sort === 'top-rated'
? 'ORDER BY COALESCE(rating_stats.rating_average, 0) DESC, lp.created_at DESC, lp.id DESC'
: `ORDER BY lp.created_at ${sort === 'oldest' ? 'ASC' : 'DESC'}, lp.id ${sort === 'oldest' ? 'ASC' : 'DESC'}`;
const orderClause = `ORDER BY lp.created_at ${sort === 'oldest' ? 'ASC' : 'DESC'}, lp.id ${sort === 'oldest' ? 'ASC' : 'DESC'}`;
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
params.push(limit + 1);
const rows = await query<LifePostRow>(
@@ -4140,11 +4003,10 @@ async function listLifePostsWithFilters(
const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds, userId, canViewAll);
const commentCountsByPost = await lifeCommentCountsForPosts(postIds, userId, canViewAll);
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId);
const myRatingsByPost = await lifeRatingsForPosts(postIds, userId);
return {
items: posts.map((post) =>
hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost, myRatingsByPost)
hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost)
),
nextCursor: hasMore && posts.length > 0 ? encodeLifePostCursor(posts[posts.length - 1]) : null,
hasMore
@@ -4429,10 +4291,9 @@ async function hydrateLifePostsById(
const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds, viewerUserId, canViewAll);
const commentCountsByPost = await lifeCommentCountsForPosts(postIds, viewerUserId, canViewAll);
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, viewerUserId);
const myRatingsByPost = await lifeRatingsForPosts(postIds, viewerUserId);
for (const post of posts) {
postById.set(post.id, hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost, myRatingsByPost));
postById.set(post.id, hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost));
}
return postById;
@@ -4693,8 +4554,7 @@ async function getLifePostById(
const commentPreviewByPost = await lifeCommentPreviewForPosts([post.id], userId, canViewAll);
const commentCountsByPost = await lifeCommentCountsForPosts([post.id], userId, canViewAll);
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId);
const myRatingsByPost = await lifeRatingsForPosts([post.id], userId);
return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost, myRatingsByPost);
return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost);
}
export async function getLifePost(
@@ -4707,13 +4567,6 @@ export async function getLifePost(
return getLifePostById(id, userId, locale, { enforceVisibility: true, canViewAll });
}
async function ensureLifeCategory(client: DbClient, categoryId: number): Promise<void> {
const result = await client.query<{ id: number }>('SELECT id FROM life_tags WHERE id = $1', [categoryId]);
if (result.rowCount === 0) {
throw validationError('server.validation.lifeCategoryInvalid');
}
}
async function ensureGameVersion(client: DbClient, gameVersionId: number | null): Promise<void> {
if (gameVersionId === null) {
return;
@@ -4725,43 +4578,28 @@ async function ensureGameVersion(client: DbClient, gameVersionId: number | null)
}
}
async function replaceLifePostCategoryLink(client: DbClient, postId: number, categoryId: number): Promise<void> {
await client.query('DELETE FROM life_post_tags WHERE post_id = $1', [postId]);
await client.query(
`
INSERT INTO life_post_tags (post_id, tag_id)
VALUES ($1, $2)
`,
[postId, categoryId]
);
}
export async function createLifePost(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanLifePostPayload(payload);
const id = await withTransaction(async (client) => {
await ensureLifeCategory(client, cleanPayload.categoryId);
await ensureGameVersion(client, cleanPayload.gameVersionId);
const result = await client.query<{ id: number }>(
`
INSERT INTO life_posts (
body,
category_id,
game_version_id,
ai_moderation_status,
ai_moderation_language_code,
created_by_user_id,
updated_by_user_id
)
VALUES ($1, $2, $3, 'reviewing', NULL, $4, $4)
VALUES ($1, $2, 'reviewing', NULL, $3, $3)
RETURNING id
`,
[cleanPayload.body, cleanPayload.categoryId, cleanPayload.gameVersionId, userId]
[cleanPayload.body, cleanPayload.gameVersionId, userId]
);
const createdId = result.rows[0].id;
await replaceLifePostCategoryLink(client, createdId, cleanPayload.categoryId);
return createdId;
return result.rows[0].id;
});
await requestAiModerationReview({ type: 'life-post', id }, { languageCode: cleanPayload.languageCode, resetRetries: true });
@@ -4778,37 +4616,29 @@ export async function updateLifePost(
const cleanPayload = cleanLifePostPayload(payload);
const updatedId = await withTransaction(async (client) => {
await ensureLifeCategory(client, cleanPayload.categoryId);
await ensureGameVersion(client, cleanPayload.gameVersionId);
const result = await client.query<{ id: number }>(
`
UPDATE life_posts
SET body = $1,
category_id = $2,
game_version_id = $3,
game_version_id = $2,
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 = $4,
updated_by_user_id = $3,
updated_at = now()
WHERE id = $5
AND ($6 = true OR created_by_user_id = $4)
WHERE id = $4
AND ($5 = true OR created_by_user_id = $3)
AND deleted_at IS NULL
RETURNING id
`,
[cleanPayload.body, cleanPayload.categoryId, cleanPayload.gameVersionId, userId, id, allowAny]
[cleanPayload.body, cleanPayload.gameVersionId, userId, id, allowAny]
);
const resultId = result.rows[0]?.id ?? null;
if (resultId === null) {
return null;
}
await replaceLifePostCategoryLink(client, resultId, cleanPayload.categoryId);
return resultId;
return result.rows[0]?.id ?? null;
});
if (updatedId) {
@@ -4916,57 +4746,6 @@ export async function deleteLifePostReaction(postId: number, userId: number, loc
return getLifePostById(postId, userId, locale);
}
export async function setLifePostRating(
postId: number,
payload: Record<string, unknown>,
userId: number,
locale = defaultLocale
) {
const rating = cleanLifeRating(payload.rating);
const result = await queryOne<{ postId: number }>(
`
INSERT INTO life_post_ratings (post_id, user_id, rating)
SELECT $1, $2, $3
FROM life_posts lp
JOIN life_tags lt ON lt.id = lp.category_id
WHERE lp.id = $1
AND lp.deleted_at IS NULL
AND lp.ai_moderation_status = 'approved'
AND lt.is_rateable = true
ON CONFLICT (post_id, user_id)
DO UPDATE SET rating = EXCLUDED.rating, updated_at = now()
RETURNING post_id AS "postId"
`,
[postId, userId, rating]
);
return result ? getLifePostById(result.postId, userId, locale) : null;
}
export async function deleteLifePostRating(postId: number, userId: number, locale = defaultLocale) {
const result = await queryOne<{ postId: number }>(
`
DELETE FROM life_post_ratings
WHERE post_id = $1
AND user_id = $2
AND EXISTS (
SELECT 1
FROM life_posts lp
JOIN life_tags lt ON lt.id = lp.category_id
WHERE lp.id = $1
AND lp.deleted_at IS NULL
AND lp.ai_moderation_status = 'approved'
AND lt.is_rateable = true
)
RETURNING post_id AS "postId"
`,
[postId, userId]
);
return result ? getLifePostById(postId, userId, locale) : null;
}
export async function createLifeComment(postId: number, payload: Record<string, unknown>, userId: number) {
const cleanPayload = cleanLifeCommentPayload(payload);
@@ -5609,18 +5388,10 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
const translations = cleanTranslations(payload.translations, ['name']);
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : false;
const isDefault = definition.hasDefault ? Boolean(payload.isDefault) : false;
const isRateable = definition.hasRateable ? Boolean(payload.isRateable) : false;
const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
const id = await withTransaction(async (client) => {
const sortOrder = await nextSortOrder(client, definition.table);
if (definition.hasDefault && isDefault) {
await client.query(
`UPDATE ${definition.table} SET is_default = false, updated_by_user_id = $1, updated_at = now() WHERE is_default = true`,
[userId]
);
}
const columns = ['name'];
const values: unknown[] = [name];
if (definition.hasItemDrop) {
@@ -5631,14 +5402,6 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
columns.push('has_trading');
values.push(hasTrading);
}
if (definition.hasDefault) {
columns.push('is_default');
values.push(isDefault);
}
if (definition.hasRateable) {
columns.push('is_rateable');
values.push(isRateable);
}
if (definition.hasChangeLog) {
columns.push('change_log');
values.push(changeLog);
@@ -5690,19 +5453,10 @@ export async function updateConfig(
const translations = cleanTranslations(payload.translations, ['name']);
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : false;
const isDefault = definition.hasDefault ? Boolean(payload.isDefault) : false;
const isRateable = definition.hasRateable ? Boolean(payload.isRateable) : false;
const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
const before = await getConfigById(type, id, defaultLocale);
const updated = await withTransaction(async (client) => {
if (definition.hasDefault && isDefault) {
await client.query(
`UPDATE ${definition.table} SET is_default = false, updated_by_user_id = $2, updated_at = now() WHERE id <> $1 AND is_default = true`,
[id, userId]
);
}
const assignments = ['name = $1'];
const values: unknown[] = [name];
if (definition.hasItemDrop) {
@@ -5713,14 +5467,6 @@ export async function updateConfig(
values.push(hasTrading);
assignments.push(`has_trading = $${values.length}`);
}
if (definition.hasDefault) {
values.push(isDefault);
assignments.push(`is_default = $${values.length}`);
}
if (definition.hasRateable) {
values.push(isRateable);
assignments.push(`is_rateable = $${values.length}`);
}
if (definition.hasChangeLog) {
values.push(changeLog);
assignments.push(`change_log = $${values.length}`);
@@ -5768,7 +5514,7 @@ export async function updateConfig(
await replaceEntityTranslations(client, definition.entityType, id, translations, ['name']);
const changes = before
? configEditChanges(definition, before as ConfigChangeSource, { name, translations, hasItemDrop, hasTrading, isDefault, isRateable, changeLog })
? configEditChanges(definition, before as ConfigChangeSource, { name, translations, hasItemDrop, hasTrading, changeLog })
: [];
await recordEditLog(client, type, id, 'update', userId, changes);
return true;

View File

@@ -68,7 +68,6 @@ import {
deleteLifeComment,
deleteLifeCommentLike,
deleteLifePost,
deleteLifePostRating,
deleteLifePostReaction,
deletePokemon,
deleteRecipe,
@@ -133,7 +132,6 @@ import {
retryLifePostModeration,
retryThreadMessageModeration,
restoreLifeComment,
setLifePostRating,
setLifePostReaction,
setEntityDiscussionCommentLike,
setLifeCommentLike,
@@ -1502,26 +1500,6 @@ app.delete('/api/life-posts/:id/reaction', async (request, reply) => {
return post ? post : notFound(reply, request);
});
app.put('/api/life-posts/:id/rating', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.ratings.set', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const post = await setLifePostRating(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return post ? post : notFound(reply, request);
});
app.delete('/api/life-posts/:id/rating', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.ratings.set', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const post = await deleteLifePostRating(Number(id), user.id, requestLocale(request));
return post ? post : notFound(reply, request);
});
app.delete('/api/life-posts/:id', async (request, reply) => {
const user = await requireAnyPermissionWithRateLimits(
request,