Files
pokopiawiki.tootaio.com/backend/src/queries.ts
xiaomai a0e07f101a perf(pokemon): cache fetch options locally to reduce API requests
Add `all` parameter to fetch-options API to retrieve the full list.
Fetch all options once and filter locally in the Pokemon edit view to improve search responsiveness.
2026-05-03 22:34:49 +08:00

5739 lines
188 KiB
TypeScript

import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
import { pool, query, queryOne } from './db.ts';
import {
isUploadImagePath,
linkEntityImageUpload,
listEntityImageUploads,
uploadImageUrl,
uploadPublicBaseUrl
} from './uploads.ts';
import { Buffer } from 'node:buffer';
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;
type QueryParams = Record<string, QueryValue>;
type DbClient = PoolClient;
type TranslationField = 'name' | 'title' | 'details' | 'genus';
type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>;
type EntityType =
| 'pokemon'
| 'pokemon-types'
| 'skills'
| 'environments'
| 'favorite-things'
| 'item-categories'
| 'item-usages'
| 'acquisition-methods'
| 'items'
| 'maps'
| 'habitats'
| 'daily-checklist-items'
| 'life-tags'
| 'game-versions';
type ConfigType =
| 'pokemon-types'
| 'skills'
| 'environments'
| 'favorite-things'
| 'item-categories'
| 'item-usages'
| 'acquisition-methods'
| 'maps'
| 'life-tags'
| 'game-versions';
type ConfigDefinition = {
table: string;
entityType: EntityType;
hasItemDrop?: boolean;
hasDefault?: boolean;
hasRateable?: boolean;
hasChangeLog?: boolean;
};
type SortableContentType = 'pokemon' | 'items' | 'recipes' | 'habitats';
type SortableContentDefinition = {
table: string;
entityType: SortableContentType;
};
type IdQuantity = {
itemId: number;
quantity: number;
};
type SkillItemDrop = {
skillId: number;
itemId: number;
};
type PokemonStats = {
hp: number;
attack: number;
defense: number;
specialAttack: number;
specialDefense: number;
speed: number;
};
type PokemonImage = {
path: string;
url: string;
style: string;
version: string;
variant: string;
description: string;
source?: 'sprite' | 'upload';
};
type EntityImageValue = {
path: string;
url: string;
};
type PokemonImageCandidate = Omit<PokemonImage, 'url'>;
type PokemonImageOptionsResult = {
id: number;
identifier: string;
images: PokemonImage[];
};
type PokemonPayload = {
displayId: number;
isEventItem: boolean;
name: string;
genus: string;
details: string;
heightInches: number;
weightPounds: number;
translations: TranslationInput;
typeIds: number[];
stats: PokemonStats;
environmentId: number;
skillIds: number[];
favoriteThingIds: number[];
skillItemDrops: SkillItemDrop[];
image: PokemonImage | null;
};
type PokemonFetchResult = {
id: number;
identifier: string;
name: string;
genus: string;
heightInches: number;
weightPounds: number;
translations: TranslationInput;
typeIds: number[];
stats: PokemonStats;
};
type PokemonFetchOption = {
id: number;
identifier: string;
name: string;
};
type CsvRow = Record<string, string>;
type PokemonCsvData = {
pokemonRows: CsvRow[];
pokemonByLookup: Map<string, CsvRow>;
namesByPokemonId: Map<number, CsvRow>;
genusByPokemonId: Map<number, CsvRow>;
typesById: Map<number, CsvRow>;
canonicalTypeRows: CsvRow[];
};
type ItemPayload = {
name: string;
translations: TranslationInput;
categoryId: number;
usageId: number | null;
dyeable: boolean;
dualDyeable: boolean;
patternEditable: boolean;
noRecipe: boolean;
isEventItem: boolean;
acquisitionMethodIds: number[];
tagIds: number[];
imagePath: string;
};
type RecipePayload = {
itemId: number;
acquisitionMethodIds: number[];
materials: IdQuantity[];
};
type DailyChecklistPayload = {
title: string;
translations: TranslationInput;
};
type LifePostPayload = {
body: string;
categoryId: number;
gameVersionId: number | null;
languageCode: string | null;
};
type LifeCommentPayload = {
body: string;
languageCode: string | null;
};
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
type DiscussionEntityDefinition = {
table: string;
};
type EntityDiscussionCommentPayload = {
body: string;
languageCode: string | null;
};
type EntityDiscussionCommentRow = {
id: number;
entityType: DiscussionEntityType;
entityId: number;
parentCommentId: number | null;
body: string;
deleted: boolean;
moderationStatus: AiModerationStatus;
moderationLanguageCode: string | null;
createdAt: Date;
createdAtCursor?: string;
updatedAt: Date;
author: { id: number; displayName: string } | null;
};
type EntityDiscussionComment = Omit<EntityDiscussionCommentRow, 'createdAtCursor'> & {
replies: EntityDiscussionComment[];
};
type EntityDiscussionCommentsPage = {
items: EntityDiscussionComment[];
nextCursor: string | null;
hasMore: boolean;
total: number;
};
type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
type LifeReactionCounts = Record<LifeReactionType, number>;
type LifeCommentRow = {
id: number;
postId: number;
parentCommentId: number | null;
body: string;
deleted: boolean;
moderationStatus: AiModerationStatus;
moderationLanguageCode: string | null;
createdAt: Date;
createdAtCursor?: string;
updatedAt: Date;
author: { id: number; displayName: string } | null;
};
type LifeComment = Omit<LifeCommentRow, 'createdAtCursor'> & {
replies: LifeComment[];
};
type LifePostRow = {
id: number;
body: string;
moderationStatus: AiModerationStatus;
moderationLanguageCode: string | null;
createdAt: Date;
createdAtCursor: string;
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'> & {
commentPreview: LifeComment[];
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 LifePostFilters = {
authorId?: number;
};
type LifePostsPage = {
items: LifePost[];
nextCursor: string | null;
hasMore: boolean;
};
type LifeCommentsPage = {
items: LifeComment[];
nextCursor: string | null;
hasMore: boolean;
total: number;
};
type PublicProfileUser = {
id: number;
displayName: string;
joinedAt: Date;
};
type PublicProfileStats = {
wikiEdits: number;
wikiCreates: number;
wikiUpdates: number;
wikiDeletes: number;
imageUploads: number;
lifePosts: number;
lifeComments: number;
lifeReactions: number;
discussionComments: number;
};
type PublicProfileContribution = {
contentType: string;
total: number;
creates: number;
updates: number;
deletes: number;
lastContributedAt: Date | null;
};
type PublicUserProfile = {
user: PublicProfileUser;
stats: PublicProfileStats;
contributions: PublicProfileContribution[];
};
type UserReactionActivity = {
postId: number;
reactionType: LifeReactionType;
reactedAt: Date;
post: LifePost;
};
type UserReactionActivityPage = {
items: UserReactionActivity[];
nextCursor: string | null;
hasMore: boolean;
};
type UserCommentActivitySource = 'life' | 'discussion';
type UserCommentActivity = {
id: number;
source: UserCommentActivitySource;
body: string;
createdAt: Date;
target: {
type: 'life-post' | DiscussionEntityType;
id: number;
title: string;
excerpt: string;
};
};
type UserCommentActivityPage = {
items: UserCommentActivity[];
nextCursor: string | null;
hasMore: boolean;
};
type UserCommentActivityCursor = LifePostCursor & {
source: UserCommentActivitySource;
};
type HabitatPayload = {
name: string;
translations: TranslationInput;
isEventItem: boolean;
imagePath: string;
recipeItems: IdQuantity[];
pokemonAppearances: Array<{
pokemonId: number;
mapId: number;
timeOfDay: string;
weather: string;
rarity: number;
}>;
};
type LanguagePayload = {
code: string;
name: string;
enabled: boolean;
isDefault: boolean;
sortOrder: number;
};
type ValidationError = Error & { statusCode: number };
type EditAction = 'create' | 'update' | 'delete';
type EditChange = {
label: string;
before: string;
after: string;
};
type EditHistoryEntry = {
action: EditAction;
changes: EditChange[];
createdAt: Date;
user: { id: number; displayName: string } | null;
};
type TranslationChangeSource = {
translations?: TranslationInput | null;
};
type PokemonChangeSource = {
displayId: number;
isEventItem: boolean;
name: string;
genus: string;
details: string;
heightInches: number;
weightPounds: number;
image: PokemonImage | null;
types: Array<{ name: string }>;
stats: PokemonStats;
environment: { name: string };
skills: Array<{ name: string; itemDrop?: { name: string } | null }>;
favorite_things: Array<{ name: string }>;
} & TranslationChangeSource;
type ItemChangeSource = {
name: string;
isEventItem: boolean;
image: EntityImageValue | null;
category: { name: string };
usage: { name: string } | null;
customization: { dyeable: boolean; dualDyeable: boolean; patternEditable: boolean };
noRecipe: boolean;
acquisitionMethods: Array<{ name: string }>;
tags: Array<{ name: string }>;
} & TranslationChangeSource;
type HabitatChangeSource = {
name: string;
isEventItem: boolean;
image: EntityImageValue | null;
recipe: Array<{ name: string; quantity: number }>;
pokemon: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }>;
} & TranslationChangeSource;
type RecipeChangeSource = {
item: { name: string };
acquisition_methods: Array<{ name: string }>;
materials: Array<{ name: string; quantity: number }>;
};
type DailyChecklistChangeSource = {
title: string;
} & TranslationChangeSource;
type ConfigChangeSource = {
name: string;
hasItemDrop?: boolean;
isDefault?: boolean;
isRateable?: boolean;
changeLog?: string;
} & TranslationChangeSource;
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天'];
const defaultLocale = 'en';
const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
const defaultLifePostLimit = 20;
const maxLifePostLimit = 50;
const defaultCommentLimit = 20;
const maxCommentLimit = 50;
const lifeCommentPreviewLimit = 2;
const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const;
const pokemonTypeIconIds = new Set(Array.from({ length: 19 }, (_value, index) => index + 1));
const pokemonSpriteBaseUrl = 'https://pokesprite.tootaio.com';
const pokemonSpriteRequestTimeoutMs = 2500;
const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
{ key: 'hp', label: 'HP' },
{ key: 'attack', label: 'Attack' },
{ key: 'defense', label: 'Defense' },
{ key: 'specialAttack', label: 'Special Attack' },
{ key: 'specialDefense', label: 'Special Defense' },
{ key: 'speed', label: 'Speed' }
];
const configDefinitions: Record<ConfigType, ConfigDefinition> = {
'pokemon-types': { table: 'pokemon_types', entityType: 'pokemon-types' },
skills: { table: 'skills', entityType: 'skills', hasItemDrop: true },
environments: { table: 'environments', entityType: 'environments' },
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things' },
'item-categories': { table: 'item_categories', entityType: 'item-categories' },
'item-usages': { table: 'item_usages', entityType: 'item-usages' },
'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 }
};
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
pokemon: { table: 'pokemon', entityType: 'pokemon' },
items: { table: 'items', entityType: 'items' },
recipes: { table: 'recipes', entityType: 'recipes' },
habitats: { table: 'habitats', entityType: 'habitats' }
};
const discussionEntityDefinitions: Record<DiscussionEntityType, DiscussionEntityDefinition> = {
pokemon: { table: 'pokemon' },
items: { table: 'items' },
recipes: { table: 'recipes' },
habitats: { table: 'habitats' }
};
let pokemonCsvDataCache: Promise<PokemonCsvData> | null = null;
function asString(value: QueryValue): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
export function cleanLocale(value: unknown): string {
const locale = typeof value === 'string' ? value.trim() : '';
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("'", "''")}'`;
}
function uploadedImageJson(pathExpression: string): string {
return `
CASE WHEN ${pathExpression} <> '' THEN json_build_object(
'path', ${pathExpression},
'url', ${sqlLiteral(uploadPublicBaseUrl)} || ${pathExpression}
) ELSE NULL END
`;
}
function pokemonImageJson(alias: string): string {
return `
CASE
WHEN ${alias}.image_path LIKE '/sprites/%' THEN json_build_object(
'path', ${alias}.image_path,
'url', ${sqlLiteral(pokemonSpriteBaseUrl)} || ${alias}.image_path,
'style', ${alias}.image_style,
'version', ${alias}.image_version,
'variant', ${alias}.image_variant,
'description', ${alias}.image_description,
'source', 'sprite'
)
WHEN ${alias}.image_path <> '' THEN json_build_object(
'path', ${alias}.image_path,
'url', ${sqlLiteral(uploadPublicBaseUrl)} || ${alias}.image_path,
'style', 'Upload',
'version', 'Community upload',
'variant', ${alias}.name,
'description', '',
'source', 'upload'
)
ELSE NULL
END
`;
}
function imagePathLabel(path: string | null | undefined): string {
const cleanPath = path?.trim() ?? '';
if (cleanPath === '') {
return '';
}
const parts = cleanPath.split('/');
return parts.length >= 3 ? `${parts[1]} / ${parts[2]}` : cleanPath;
}
function localizedField(
entityType: EntityType,
entityIdExpression: string,
baseExpression: string,
fieldName: TranslationField,
locale: string
): string {
const entity = sqlLiteral(entityType);
const field = sqlLiteral(fieldName);
const requestedLocale = sqlLiteral(cleanLocale(locale));
const defaultLocaleSql = sqlLiteral(defaultLocale);
return `
COALESCE(
(
SELECT et.value
FROM entity_translations et
WHERE et.entity_type = ${entity}
AND et.entity_id = ${entityIdExpression}
AND et.locale = ${requestedLocale}
AND et.field_name = ${field}
),
(
SELECT et.value
FROM entity_translations et
WHERE et.entity_type = ${entity}
AND et.entity_id = ${entityIdExpression}
AND et.locale = ${defaultLocaleSql}
AND et.field_name = ${field}
),
${baseExpression}
)
`;
}
function localizedName(entityType: EntityType, entityAlias: string, locale: string): string {
return localizedField(entityType, `${entityAlias}.id`, `${entityAlias}.name`, 'name', locale);
}
function translationsSelect(entityType: EntityType, entityIdExpression: string): string {
return `
COALESCE((
SELECT jsonb_object_agg(locale, fields)
FROM (
SELECT locale, jsonb_object_agg(field_name, value) AS fields
FROM entity_translations
WHERE entity_type = ${sqlLiteral(entityType)}
AND entity_id = ${entityIdExpression}
GROUP BY locale
) translation_rows
), '{}'::jsonb)
`;
}
function cleanTranslations(value: unknown, allowedFields: TranslationField[]): TranslationInput {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const translations: TranslationInput = {};
const allowedFieldSet = new Set(allowedFields);
for (const [locale, fields] of Object.entries(value as Record<string, unknown>)) {
if (!localePattern.test(locale) || locale === defaultLocale || !fields || typeof fields !== 'object' || Array.isArray(fields)) {
continue;
}
const cleanFields: Partial<Record<TranslationField, string>> = {};
for (const [fieldName, fieldValue] of Object.entries(fields as Record<string, unknown>)) {
if (!allowedFieldSet.has(fieldName as TranslationField) || typeof fieldValue !== 'string') {
continue;
}
const cleanValue = fieldValue.trim();
if (cleanValue !== '') {
cleanFields[fieldName as TranslationField] = cleanValue;
}
}
if (Object.keys(cleanFields).length > 0) {
translations[locale] = cleanFields;
}
}
return translations;
}
async function replaceEntityTranslations(
client: DbClient,
entityType: EntityType,
entityId: number,
translations: TranslationInput,
fields: TranslationField[]
): Promise<void> {
await client.query(
`
DELETE FROM entity_translations
WHERE entity_type = $1
AND entity_id = $2
AND field_name = ANY($3::text[])
`,
[entityType, entityId, fields]
);
for (const [locale, translatedFields] of Object.entries(translations)) {
for (const fieldName of fields) {
const value = translatedFields[fieldName];
if (typeof value !== 'string' || value.trim() === '') {
continue;
}
await client.query(
`
INSERT INTO entity_translations (entity_type, entity_id, locale, field_name, value)
VALUES ($1, $2, $3, $4, $5)
`,
[entityType, entityId, locale, fieldName, value.trim()]
);
}
}
}
async function deleteEntityTranslations(client: DbClient, entityType: EntityType, entityId: number): Promise<void> {
await client.query('DELETE FROM entity_translations WHERE entity_type = $1 AND entity_id = $2', [entityType, entityId]);
}
function optionSelect(
tableName: string,
entityType: EntityType,
locale: string
): Promise<Array<{ id: number; name: string }>> {
const name = localizedName(entityType, 'o', locale);
return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`);
}
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')}`);
}
function skillOptions(locale: string): Promise<Array<{ id: number; name: string; hasItemDrop: boolean }>> {
const name = localizedName('skills', 's', locale);
return query(`SELECT s.id, ${name} AS name, s.has_item_drop AS "hasItemDrop" FROM skills s ORDER BY ${orderByEntity('s')}`);
}
function auditSelect(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string {
return `
${entityAlias}.created_at AS "createdAt",
${entityAlias}.updated_at AS "updatedAt",
CASE
WHEN ${createdAlias}.id IS NULL THEN NULL
ELSE json_build_object('id', ${createdAlias}.id, 'displayName', ${createdAlias}.display_name)
END AS "createdBy",
CASE
WHEN ${updatedAlias}.id IS NULL THEN NULL
ELSE json_build_object('id', ${updatedAlias}.id, 'displayName', ${updatedAlias}.display_name)
END AS "updatedBy"
`;
}
function auditJoins(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string {
return `
LEFT JOIN users ${createdAlias} ON ${createdAlias}.id = ${entityAlias}.created_by_user_id
LEFT JOIN users ${updatedAlias} ON ${updatedAlias}.id = ${entityAlias}.updated_by_user_id
`;
}
function configOrder(): string {
return orderByEntity('c');
}
function configSelect(definition: ConfigDefinition, locale: string): string {
const name = localizedName(definition.entityType, 'c', locale);
const translations = translationsSelect(definition.entityType, 'c.id');
const columns = [`c.id`, `${name} AS name`, `c.name AS "baseName"`, `${translations} AS translations`];
if (definition.hasItemDrop) {
columns.push(`c.has_item_drop AS "hasItemDrop"`);
}
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"`);
}
return columns.join(', ');
}
function validationError(message: string): ValidationError {
const error = new Error(message) as ValidationError;
error.statusCode = 400;
return error;
}
function requirePositiveInteger(value: unknown, message: string): number {
const numberValue = Number(value);
if (!Number.isInteger(numberValue) || numberValue <= 0) {
throw validationError(message);
}
return numberValue;
}
function optionalPositiveInteger(value: unknown, message: string): number | null {
if (value === undefined || value === null || value === '') {
return null;
}
return requirePositiveInteger(value, message);
}
function cleanName(value: unknown, message = 'server.validation.nameRequired'): string {
if (typeof value !== 'string' || value.trim() === '') {
throw validationError(message);
}
return value.trim();
}
function cleanOptionalText(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats'): string {
const imagePath = cleanOptionalText(value);
if (imagePath === '') {
return '';
}
if (!isUploadImagePath(imagePath) || !imagePath.startsWith(`${entityType}/`)) {
throw validationError('server.validation.imagePathInvalid');
}
return imagePath;
}
function cleanIds(value: unknown): number[] {
if (!Array.isArray(value)) {
return [];
}
return [...new Set(value.map((item) => Number(item)).filter((item) => Number.isInteger(item) && item > 0))];
}
function cleanIdValues(value: unknown): number[] {
return cleanIds(Array.isArray(value) ? value : [value]);
}
function cleanPokemonStats(value: unknown): PokemonStats {
const row = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
return pokemonStatLabels.reduce((stats, stat) => {
const numberValue = Number(row[stat.key] ?? 0);
if (!Number.isInteger(numberValue) || numberValue < 0) {
throw validationError('server.validation.statNonNegative');
}
return { ...stats, [stat.key]: numberValue };
}, {} as PokemonStats);
}
function cleanNonNegativeNumber(value: unknown, message: string): number {
const numberValue = Number(value ?? 0);
if (!Number.isFinite(numberValue) || numberValue < 0) {
throw validationError(message);
}
return numberValue;
}
function cleanQuantities(value: unknown): IdQuantity[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => {
const row = item as Partial<IdQuantity>;
return {
itemId: Number(row.itemId),
quantity: Number(row.quantity)
};
})
.filter((item) => Number.isInteger(item.itemId) && item.itemId > 0 && Number.isInteger(item.quantity) && item.quantity > 0);
}
function cleanOptions(value: unknown, allowedValues: string[]): string[] {
const values = Array.isArray(value) ? value : [value];
return [...new Set(values.map((item) => String(item ?? '')).filter((item) => allowedValues.includes(item)))];
}
function orderByEntity(entityAlias: string): string {
return `${entityAlias}.sort_order, ${entityAlias}.id`;
}
async function withTransaction<T>(callback: (client: DbClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async function nextSortOrder(client: DbClient, tableName: string): Promise<number> {
const result = await client.query<{ sortOrder: number }>(
`SELECT COALESCE(MAX(sort_order), 0) + 10 AS "sortOrder" FROM ${tableName}`
);
return result.rows[0]?.sortOrder ?? 10;
}
async function nextPokemonInternalId(client: DbClient, displayId: number, isEventItem: boolean): Promise<number> {
if (isEventItem) {
const result = await client.query<{ id: number }>(
'SELECT COALESCE(MAX(id), 999999) + 1 AS id FROM pokemon WHERE id >= 1000000'
);
const nextId = result.rows[0]?.id ?? 1000000;
return nextId === displayId ? nextId + 1 : nextId;
}
if (!isEventItem) {
const preferredId = await client.query<{ id: number }>('SELECT id FROM pokemon WHERE id = $1', [displayId]);
if (preferredId.rowCount === 0) {
return displayId;
}
}
const result = await client.query<{ id: number }>(
'SELECT COALESCE(MAX(id), 0) + 1 AS id FROM pokemon WHERE id <> $1',
[displayId]
);
const nextId = result.rows[0]?.id ?? 1;
return nextId === displayId ? nextId + 1 : nextId;
}
async function reorderTableRows(
client: DbClient,
tableName: string,
entityType: string,
ids: number[],
userId: number
): Promise<void> {
const existing = await client.query<{ id: number; sortOrder: number }>(
`SELECT id, sort_order AS "sortOrder" FROM ${tableName} WHERE id = ANY($1::integer[])`,
[ids]
);
if (existing.rowCount !== ids.length) {
throw validationError('server.validation.recordMissing');
}
const sortOrders = new Map(existing.rows.map((row) => [row.id, row.sortOrder]));
for (const [index, id] of ids.entries()) {
const nextSortOrder = (index + 1) * 10;
const previousSortOrder = sortOrders.get(id);
if (previousSortOrder === nextSortOrder) {
continue;
}
await client.query(
`
UPDATE ${tableName}
SET sort_order = $1, updated_by_user_id = $2, updated_at = now()
WHERE id = $3
`,
[nextSortOrder, userId, id]
);
const changes: EditChange[] = [];
pushChange(changes, 'Sort order', String(previousSortOrder), String(nextSortOrder));
await recordEditLog(client, entityType, id, 'update', userId, changes);
}
}
async function recordEditLog(
client: DbClient,
entityType: string,
entityId: number,
action: EditAction,
userId: number,
changes: EditChange[] = []
): Promise<void> {
await client.query(
`
INSERT INTO wiki_edit_logs (entity_type, entity_id, action, user_id, changes)
VALUES ($1, $2, $3, $4, $5::jsonb)
`,
[entityType, entityId, action, userId, JSON.stringify(changes)]
);
}
function cleanLanguagePayload(payload: Record<string, unknown>, requireCode: boolean): LanguagePayload {
const code = typeof payload.code === 'string' ? payload.code.trim() : '';
if (requireCode && !localePattern.test(code)) {
throw validationError('server.validation.languageCodeInvalid');
}
const sortOrder = Number(payload.sortOrder ?? 0);
return {
code,
name: cleanName(payload.name, 'server.validation.languageNameRequired'),
enabled: payload.enabled !== false,
isDefault: Boolean(payload.isDefault),
sortOrder: Number.isInteger(sortOrder) && sortOrder >= 0 ? sortOrder : 0
};
}
function requireLanguageCode(value: unknown): string {
const code = typeof value === 'string' ? value.trim() : '';
if (!localePattern.test(code)) {
throw validationError('server.validation.languageCodeInvalid');
}
return code;
}
export async function listLanguages(includeDisabled = false) {
return query<LanguagePayload>(
`
SELECT code, name, enabled, is_default AS "isDefault", sort_order AS "sortOrder"
FROM languages
${includeDisabled ? '' : 'WHERE enabled = true'}
ORDER BY sort_order, name
`
);
}
export async function createLanguage(payload: Record<string, unknown>) {
const cleanPayload = cleanLanguagePayload(payload, true);
if (cleanPayload.isDefault && cleanPayload.code !== defaultLocale) {
throw validationError('server.validation.defaultLanguageMustBeEnglish');
}
if (!cleanPayload.enabled && cleanPayload.isDefault) {
throw validationError('server.validation.defaultLanguageMustBeEnabled');
}
await withTransaction(async (client) => {
if (cleanPayload.isDefault) {
await client.query('UPDATE languages SET is_default = false');
}
await client.query(
`
INSERT INTO languages (code, name, enabled, is_default, sort_order)
VALUES ($1, $2, $3, $4, $5)
`,
[cleanPayload.code, cleanPayload.name, cleanPayload.enabled, cleanPayload.isDefault, cleanPayload.sortOrder]
);
});
return listLanguages(true);
}
export async function updateLanguage(code: string, payload: Record<string, unknown>) {
const locale = requireLanguageCode(code);
const cleanPayload = cleanLanguagePayload({ ...payload, code: locale }, false);
if (cleanPayload.isDefault && locale !== defaultLocale) {
throw validationError('server.validation.defaultLanguageMustBeEnglish');
}
if (!cleanPayload.enabled && cleanPayload.isDefault) {
throw validationError('server.validation.defaultLanguageMustBeEnabled');
}
await withTransaction(async (client) => {
const current = await client.query<{ isDefault: boolean }>(
'SELECT is_default AS "isDefault" FROM languages WHERE code = $1',
[locale]
);
if (current.rowCount === 0) {
throw validationError('server.validation.languageNotFound');
}
if (!cleanPayload.enabled && current.rows[0].isDefault) {
throw validationError('server.validation.defaultLanguageMustBeEnabled');
}
if (current.rows[0].isDefault && !cleanPayload.isDefault) {
throw validationError('server.validation.defaultLanguageRequired');
}
if (cleanPayload.isDefault) {
await client.query('UPDATE languages SET is_default = false WHERE code <> $1', [locale]);
}
await client.query(
`
UPDATE languages
SET name = $1,
enabled = $2,
is_default = $3,
sort_order = $4
WHERE code = $5
`,
[cleanPayload.name, cleanPayload.enabled, cleanPayload.isDefault, cleanPayload.sortOrder, locale]
);
});
return listLanguages(true);
}
export async function deleteLanguage(code: string) {
const locale = requireLanguageCode(code);
if (locale === defaultLocale) {
throw validationError('server.validation.defaultLanguageCannotBeDeleted');
}
return withTransaction(async (client) => {
const result = await client.query<{ isDefault: boolean }>(
'DELETE FROM languages WHERE code = $1 AND is_default = false RETURNING is_default AS "isDefault"',
[locale]
);
return (result.rowCount ?? 0) > 0;
});
}
export async function reorderLanguages(payload: Record<string, unknown>) {
const codes = Array.isArray(payload.codes) ? payload.codes.map(requireLanguageCode) : [];
if (codes.length === 0) {
throw validationError('server.validation.selectLanguage');
}
await withTransaction(async (client) => {
const existing = await client.query<{ code: string }>(
'SELECT code FROM languages WHERE code = ANY($1::text[])',
[codes]
);
if (existing.rowCount !== codes.length) {
throw validationError('server.validation.languageDoesNotExist');
}
for (const [index, code] of codes.entries()) {
await client.query(
`
UPDATE languages
SET sort_order = $1
WHERE code = $2
`,
[(index + 1) * 10, code]
);
}
});
return listLanguages(true);
}
function parseCsv(content: string, fileName: string): CsvRow[] {
const rows: string[][] = [];
let row: string[] = [];
let cell = '';
let inQuotes = false;
for (let index = 0; index < content.length; index += 1) {
const char = content[index];
if (inQuotes) {
if (char === '"' && content[index + 1] === '"') {
cell += '"';
index += 1;
} else if (char === '"') {
inQuotes = false;
} else {
cell += char;
}
continue;
}
if (char === '"') {
inQuotes = true;
} else if (char === ',') {
row.push(cell);
cell = '';
} else if (char === '\n') {
row.push(cell);
if (row.some((value) => value !== '')) {
rows.push(row);
}
row = [];
cell = '';
} else if (char !== '\r') {
cell += char;
}
}
if (cell !== '' || row.length > 0) {
row.push(cell);
if (row.some((value) => value !== '')) {
rows.push(row);
}
}
const headers = rows[0]?.map((header) => header.replace(/^\uFEFF/, ''));
if (!headers?.length) {
throw validationError('server.validation.pokemonDataFileEmpty');
}
return rows.slice(1).map((values) =>
headers.reduce<CsvRow>((record, header, index) => {
record[header] = values[index] ?? '';
return record;
}, {})
);
}
async function readPokemonDataFile(fileName: string): Promise<string> {
const sourceDir = dirname(fileURLToPath(import.meta.url));
const directories = [
process.env.POKOPIA_DATA_DIR ? resolve(process.env.POKOPIA_DATA_DIR) : '',
resolve(process.cwd(), 'data'),
resolve(process.cwd(), '..', 'data'),
resolve(sourceDir, '..', 'data'),
resolve(sourceDir, '..', '..', 'data')
].filter(Boolean);
const uniqueDirectories = [...new Set(directories)];
for (const directory of uniqueDirectories) {
try {
return await readFile(resolve(directory, fileName), 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
}
throw validationError('server.validation.pokemonDataFileUnavailable');
}
function csvInteger(row: CsvRow, fieldName: string): number {
const value = Number(row[fieldName]);
return Number.isInteger(value) ? value : 0;
}
function csvNumber(row: CsvRow, fieldName: string): number {
const value = Number(row[fieldName]);
return Number.isFinite(value) ? value : 0;
}
function csvText(row: CsvRow, fieldName: string): string {
return row[fieldName]?.trim() ?? '';
}
function indexRowsByNumber(rows: CsvRow[], fieldName: string): Map<number, CsvRow> {
return rows.reduce((index, row) => {
const id = csvInteger(row, fieldName);
if (id > 0) {
index.set(id, row);
}
return index;
}, new Map<number, CsvRow>());
}
async function loadPokemonCsvData(): Promise<PokemonCsvData> {
if (!pokemonCsvDataCache) {
pokemonCsvDataCache = (async () => {
const [pokemonContent, namesContent, genusContent, typesContent] = await Promise.all([
readPokemonDataFile('pokemon_data.csv'),
readPokemonDataFile('localized_pokemon_name.csv'),
readPokemonDataFile('localized_pokemon_genus.csv'),
readPokemonDataFile('localized_type_name.csv')
]);
const pokemonRows = parseCsv(pokemonContent, 'pokemon_data.csv');
const typeRows = parseCsv(typesContent, 'localized_type_name.csv');
const pokemonByLookup = new Map<string, CsvRow>();
for (const row of pokemonRows) {
const id = csvInteger(row, 'id');
const identifier = csvText(row, 'identifier').toLowerCase();
if (id > 0) {
pokemonByLookup.set(String(id), row);
}
if (identifier) {
pokemonByLookup.set(identifier, row);
}
}
return {
pokemonRows,
pokemonByLookup,
namesByPokemonId: indexRowsByNumber(parseCsv(namesContent, 'localized_pokemon_name.csv'), 'pokemon_species_id'),
genusByPokemonId: indexRowsByNumber(parseCsv(genusContent, 'localized_pokemon_genus.csv'), 'pokemon_species_id'),
typesById: indexRowsByNumber(typeRows, 'type_id'),
canonicalTypeRows: typeRows.filter((row) => pokemonTypeIconIds.has(csvInteger(row, 'type_id')))
};
})();
}
return pokemonCsvDataCache;
}
function pokemonDataLookupKey(value: unknown): string {
const rawValue = typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim() : '';
if (rawValue === '') {
throw validationError('server.validation.pokemonIdentifierRequired');
}
const numericValue = Number(rawValue);
if (Number.isInteger(numericValue) && numericValue > 0) {
return String(numericValue);
}
return rawValue.toLowerCase();
}
function languageCsvColumn(code: string): string | null {
const [language, region = ''] = code.split('-');
const languageKey = language.toLowerCase();
const regionKey = region.toUpperCase();
const directColumns: Record<string, string> = {
de: 'de',
en: 'en',
es: 'es',
fr: 'fr',
it: 'it',
ja: 'ja',
ko: 'ko'
};
if (languageKey === 'zh') {
return ['HK', 'MO', 'TW'].includes(regionKey) ? 'zh_hant' : 'zh_hans';
}
return directColumns[languageKey] ?? null;
}
function localizedCsvText(row: CsvRow, code: string): string {
const column = languageCsvColumn(code);
return column ? csvText(row, column) : '';
}
function defaultLanguage(languages: LanguagePayload[]): LanguagePayload | undefined {
return languages.find((language) => language.isDefault) ?? languages.find((language) => language.code === defaultLocale) ?? languages[0];
}
function defaultCsvText(row: CsvRow, languages: LanguagePayload[], fallback: string): string {
const defaultCode = defaultLanguage(languages)?.code ?? defaultLocale;
return localizedCsvText(row, defaultCode) || localizedCsvText(row, defaultLocale) || fallback;
}
function pokemonSpriteUrl(path: string): string {
return `${pokemonSpriteBaseUrl}${path}`;
}
function pokemonImageWithUrl(candidate: PokemonImageCandidate): PokemonImage {
return { ...candidate, url: pokemonSpriteUrl(candidate.path), source: 'sprite' };
}
function pokemonImageCandidates(id: number): PokemonImageCandidate[] {
return [
{
path: `/sprites/pokemon/other/official-artwork/${id}.png`,
style: 'Official artwork',
version: 'Official artwork',
variant: 'Default',
description: 'Large official artwork'
},
{
path: `/sprites/pokemon/other/official-artwork/shiny/${id}.png`,
style: 'Official artwork',
version: 'Official artwork',
variant: 'Shiny',
description: 'Large shiny official artwork'
},
{
path: `/sprites/pokemon/other/home/${id}.png`,
style: 'Pokemon HOME',
version: 'HOME',
variant: 'Default',
description: 'Modern HOME render'
},
{
path: `/sprites/pokemon/other/home/shiny/${id}.png`,
style: 'Pokemon HOME',
version: 'HOME',
variant: 'Shiny',
description: 'Modern shiny HOME render'
},
{
path: `/sprites/pokemon/other/home/female/${id}.png`,
style: 'Pokemon HOME',
version: 'HOME',
variant: 'Female',
description: 'Modern female HOME render'
},
{
path: `/sprites/pokemon/other/home/shiny/female/${id}.png`,
style: 'Pokemon HOME',
version: 'HOME',
variant: 'Shiny female',
description: 'Modern shiny female HOME render'
},
{
path: `/sprites/pokemon/other/dream-world/${id}.svg`,
style: 'Dream World',
version: 'Dream World',
variant: 'Default',
description: 'Dream World SVG artwork'
},
{
path: `/sprites/pokemon/other/dream-world/female/${id}.svg`,
style: 'Dream World',
version: 'Dream World',
variant: 'Female',
description: 'Dream World female SVG artwork'
},
{
path: `/sprites/pokemon/other/showdown/${id}.gif`,
style: 'Pokemon Showdown',
version: 'Showdown',
variant: 'Front animated',
description: 'Animated front battle sprite'
},
{
path: `/sprites/pokemon/other/showdown/shiny/${id}.gif`,
style: 'Pokemon Showdown',
version: 'Showdown',
variant: 'Shiny front animated',
description: 'Animated shiny front battle sprite'
},
{
path: `/sprites/pokemon/other/showdown/female/${id}.gif`,
style: 'Pokemon Showdown',
version: 'Showdown',
variant: 'Female front animated',
description: 'Animated female front battle sprite'
},
{
path: `/sprites/pokemon/other/showdown/back/${id}.gif`,
style: 'Pokemon Showdown',
version: 'Showdown',
variant: 'Back animated',
description: 'Animated back battle sprite'
},
{
path: `/sprites/pokemon/${id}.png`,
style: 'Default sprite',
version: 'PokeAPI',
variant: 'Front',
description: 'Compact front sprite'
},
{
path: `/sprites/pokemon/shiny/${id}.png`,
style: 'Default sprite',
version: 'PokeAPI',
variant: 'Shiny front',
description: 'Compact shiny front sprite'
},
{
path: `/sprites/pokemon/female/${id}.png`,
style: 'Default sprite',
version: 'PokeAPI',
variant: 'Female front',
description: 'Compact female front sprite'
},
{
path: `/sprites/pokemon/back/${id}.png`,
style: 'Default sprite',
version: 'PokeAPI',
variant: 'Back',
description: 'Compact back sprite'
},
{
path: `/sprites/pokemon/back/shiny/${id}.png`,
style: 'Default sprite',
version: 'PokeAPI',
variant: 'Shiny back',
description: 'Compact shiny back sprite'
},
{
path: `/sprites/pokemon/versions/generation-v/black-white/animated/${id}.gif`,
style: 'Game version',
version: 'Black / White',
variant: 'Animated front',
description: 'Generation V animated sprite'
},
{
path: `/sprites/pokemon/versions/generation-v/black-white/animated/shiny/${id}.gif`,
style: 'Game version',
version: 'Black / White',
variant: 'Animated shiny',
description: 'Generation V animated shiny sprite'
},
{
path: `/sprites/pokemon/versions/generation-v/black-white/${id}.png`,
style: 'Game version',
version: 'Black / White',
variant: 'Front',
description: 'Generation V front sprite'
},
{
path: `/sprites/pokemon/versions/generation-vi/x-y/${id}.png`,
style: 'Game version',
version: 'X / Y',
variant: 'Front',
description: 'Generation VI front sprite'
},
{
path: `/sprites/pokemon/versions/generation-vii/ultra-sun-ultra-moon/${id}.png`,
style: 'Game version',
version: 'Ultra Sun / Ultra Moon',
variant: 'Front',
description: 'Generation VII front sprite'
},
{
path: `/sprites/pokemon/versions/generation-ix/scarlet-violet/${id}.png`,
style: 'Game version',
version: 'Scarlet / Violet',
variant: 'Front',
description: 'Generation IX front sprite'
},
{
path: `/sprites/pokemon/versions/generation-iii/emerald/${id}.png`,
style: 'Game version',
version: 'Emerald',
variant: 'Front',
description: 'Generation III front sprite'
},
{
path: `/sprites/pokemon/versions/generation-i/red-blue/${id}.png`,
style: 'Game version',
version: 'Red / Blue',
variant: 'Front',
description: 'Generation I front sprite'
}
];
}
function pokemonImageLabel(image: PokemonImage | null | undefined): string {
if (!image) {
return '';
}
return image.source === 'upload' || isUploadImagePath(image.path) ? imagePathLabel(image.path) : `${image.style} - ${image.version} - ${image.variant}`;
}
function pokemonImageDataIdFromPath(path: string): number | null {
const match = path.match(/^\/sprites\/pokemon\/(?:.+\/)?([1-9]\d*)\.(?:png|gif|svg)$/);
if (!match) {
return null;
}
const id = Number(match[1]);
return Number.isSafeInteger(id) ? id : null;
}
function pokemonImageCandidateForPath(path: string): PokemonImage | null {
const cleanPath = path.trim();
const id = pokemonImageDataIdFromPath(cleanPath);
if (!id) {
return null;
}
const candidate = pokemonImageCandidates(id).find((item) => item.path === cleanPath);
return candidate ? pokemonImageWithUrl(candidate) : null;
}
function cleanPokemonImage(value: unknown, displayId: number): PokemonImage | null {
const path = typeof value === 'string' ? value.trim() : '';
if (path === '') {
return null;
}
if (isUploadImagePath(path)) {
if (!path.startsWith('pokemon/')) {
throw validationError('server.validation.imagePathInvalid');
}
return {
path,
url: uploadImageUrl(path),
style: 'Upload',
version: 'Community upload',
variant: `#${displayId}`,
description: '',
source: 'upload'
};
}
const image = pokemonImageCandidateForPath(path);
if (!image) {
throw validationError('server.validation.pokemonImagePathInvalid');
}
return image;
}
async function pokemonImageExists(candidate: PokemonImageCandidate): Promise<boolean> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), pokemonSpriteRequestTimeoutMs);
try {
const response = await fetch(pokemonSpriteUrl(candidate.path), { method: 'HEAD', signal: controller.signal });
return response.ok;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
function assignTranslation(translations: TranslationInput, locale: string, fieldName: TranslationField, value: string): void {
if (!value) {
return;
}
translations[locale] = {
...(translations[locale] ?? {}),
[fieldName]: value
};
}
function localizedCsvTranslations(
rows: Array<{ row: CsvRow; fieldName: TranslationField }>,
languages: LanguagePayload[]
): TranslationInput {
const translations: TranslationInput = {};
const defaultCode = defaultLanguage(languages)?.code ?? defaultLocale;
for (const language of languages) {
if (language.code === defaultCode) {
continue;
}
for (const { row, fieldName } of rows) {
assignTranslation(translations, language.code, fieldName, localizedCsvText(row, language.code));
}
}
return cleanTranslations(translations, rows.map((row) => row.fieldName));
}
function fetchedPokemonStats(row: CsvRow): PokemonStats {
return {
hp: csvInteger(row, 'hp'),
attack: csvInteger(row, 'atk'),
defense: csvInteger(row, 'def'),
specialAttack: csvInteger(row, 'sp_atk'),
specialDefense: csvInteger(row, 'sp_def'),
speed: csvInteger(row, 'spd')
};
}
function fetchedPokemonTypeIds(row: CsvRow, data: PokemonCsvData): number[] {
const typeIds = [csvInteger(row, 'type_1_id'), csvInteger(row, 'type_2_id')].filter((typeId) => typeId > 0);
if (typeIds.length === 0 || typeIds.some((typeId) => !data.typesById.has(typeId) || !pokemonTypeIconIds.has(typeId))) {
throw validationError('server.validation.pokemonTypeDataUnavailable');
}
return typeIds;
}
async function ensurePokemonTypeCatalog(
client: DbClient,
data: PokemonCsvData,
languages: LanguagePayload[],
userId: number
): Promise<void> {
for (const row of data.canonicalTypeRows) {
const typeId = csvInteger(row, 'type_id');
const name = defaultCsvText(row, languages, csvText(row, 'identifier'));
const translations = localizedCsvTranslations([{ row, fieldName: 'name' }], languages);
const existing = await client.query<ConfigChangeSource>(
`
SELECT pt.name, ${translationsSelect('pokemon-types', 'pt.id')} AS translations
FROM pokemon_types pt
WHERE pt.id = $1
`,
[typeId]
);
if (existing.rowCount === 0) {
await client.query(
`
INSERT INTO pokemon_types (
id,
name,
sort_order,
created_by_user_id,
updated_by_user_id
)
VALUES ($1, $2, $3, $4, $4)
`,
[typeId, name, typeId * 10, userId]
);
await recordEditLog(client, 'pokemon-types', typeId, 'create', userId);
} else {
const changes = configEditChanges(
{ table: 'pokemon_types', entityType: 'pokemon-types' },
existing.rows[0],
{ name, translations, hasItemDrop: false, isDefault: false, isRateable: false, changeLog: '' }
);
if (changes.length) {
await client.query(
`
UPDATE pokemon_types
SET name = $1,
updated_by_user_id = $2,
updated_at = now()
WHERE id = $3
`,
[name, userId, typeId]
);
await recordEditLog(client, 'pokemon-types', typeId, 'update', userId, changes);
}
}
await replaceEntityTranslations(client, 'pokemon-types', typeId, translations, ['name']);
}
await client.query(
`
SELECT setval(
pg_get_serial_sequence('pokemon_types', 'id'),
GREATEST((SELECT COALESCE(MAX(id), 1) FROM pokemon_types), 1),
true
)
`
);
}
export async function fetchPokemonData(payload: Record<string, unknown>, userId: number): Promise<PokemonFetchResult> {
const lookupKey = pokemonDataLookupKey(payload.identifier);
const [data, languages] = await Promise.all([loadPokemonCsvData(), listLanguages()]);
const pokemonRow = data.pokemonByLookup.get(lookupKey);
if (!pokemonRow) {
throw validationError('server.validation.pokemonDataNotFound');
}
const id = csvInteger(pokemonRow, 'id');
const nameRow = data.namesByPokemonId.get(id) ?? pokemonRow;
const genusRow = data.genusByPokemonId.get(id) ?? pokemonRow;
const identifier = csvText(pokemonRow, 'identifier');
const typeIds = fetchedPokemonTypeIds(pokemonRow, data);
await withTransaction((client) => ensurePokemonTypeCatalog(client, data, languages, userId));
return {
id,
identifier,
name: defaultCsvText(nameRow, languages, identifier),
genus: defaultCsvText(genusRow, languages, ''),
heightInches: Math.round(csvNumber(pokemonRow, 'height_m') * 39.37007874015748 * 100) / 100,
weightPounds: Math.round(csvNumber(pokemonRow, 'weight_kg') * 2.2046226218 * 10) / 10,
translations: localizedCsvTranslations(
[
{ row: nameRow, fieldName: 'name' },
{ row: genusRow, fieldName: 'genus' }
],
languages
),
typeIds,
stats: fetchedPokemonStats(pokemonRow)
};
}
export async function fetchPokemonImageOptions(payload: Record<string, unknown>): Promise<PokemonImageOptionsResult> {
const lookupKey = pokemonDataLookupKey(payload.identifier);
const data = await loadPokemonCsvData();
const pokemonRow = data.pokemonByLookup.get(lookupKey);
if (!pokemonRow) {
throw validationError('server.validation.pokemonDataNotFound');
}
const id = csvInteger(pokemonRow, 'id');
const images = (
await Promise.all(
pokemonImageCandidates(id).map(async (candidate) => (await pokemonImageExists(candidate) ? pokemonImageWithUrl(candidate) : null))
)
).filter((image): image is PokemonImage => image !== null);
return {
id,
identifier: csvText(pokemonRow, 'identifier'),
images
};
}
function pokemonFetchOption(row: CsvRow, data: PokemonCsvData, languages: LanguagePayload[], locale: string): PokemonFetchOption {
const id = csvInteger(row, 'id');
const identifier = csvText(row, 'identifier');
const nameRow = data.namesByPokemonId.get(id) ?? row;
return {
id,
identifier,
name: localizedCsvText(nameRow, cleanLocale(locale)) || defaultCsvText(nameRow, languages, identifier)
};
}
function pokemonFetchOptionMatches(
row: CsvRow,
data: PokemonCsvData,
languages: LanguagePayload[],
locale: string,
search: string
): boolean {
if (!search) {
return true;
}
const id = csvInteger(row, 'id');
const identifier = csvText(row, 'identifier');
const nameRow = data.namesByPokemonId.get(id) ?? row;
const defaultCode = defaultLanguage(languages)?.code ?? defaultLocale;
const searchFields = [
String(id),
identifier,
localizedCsvText(nameRow, cleanLocale(locale)),
localizedCsvText(nameRow, defaultCode),
localizedCsvText(nameRow, defaultLocale)
];
const keyword = search.toLowerCase();
return searchFields.some((field) => field.toLowerCase().includes(keyword));
}
export async function listPokemonFetchOptions(paramsQuery: QueryParams, locale = defaultLocale): Promise<PokemonFetchOption[]> {
const search = asString(paramsQuery.search)?.trim() ?? '';
const includeAll = asString(paramsQuery.all) === 'true';
const [data, languages] = await Promise.all([loadPokemonCsvData(), listLanguages()]);
const rows = data.pokemonRows.filter(
(row) => csvInteger(row, 'id') > 0 && pokemonFetchOptionMatches(row, data, languages, locale, search)
);
return (includeAll ? rows : rows.slice(0, 20)).map((row) => pokemonFetchOption(row, data, languages, locale));
}
function displayValue(value: string | null | undefined): string {
const cleanValue = value?.trim() ?? '';
return cleanValue === '' ? 'None' : cleanValue;
}
function pushChange(changes: EditChange[], label: string, before: string | null | undefined, after: string | null | undefined): void {
const beforeValue = displayValue(before);
const afterValue = displayValue(after);
if (beforeValue !== afterValue) {
changes.push({ label, before: beforeValue, after: afterValue });
}
}
const translationChangeLabels: Record<TranslationField, string> = {
name: 'Name',
title: 'Title',
details: 'Details',
genus: 'Genus'
};
function translationFieldValue(
translations: TranslationInput | null | undefined,
locale: string,
field: TranslationField
): string | null {
const value = translations?.[locale]?.[field];
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
}
function pushTranslationChanges(
changes: EditChange[],
before: TranslationInput | null | undefined,
after: TranslationInput,
fields: TranslationField[]
): void {
const locales = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after)])]
.filter((locale) => locale !== defaultLocale)
.sort((a, b) => a.localeCompare(b));
for (const locale of locales) {
for (const field of fields) {
pushChange(
changes,
`${translationChangeLabels[field]} (${locale})`,
translationFieldValue(before, locale, field),
translationFieldValue(after, locale, field)
);
}
}
}
function boolValue(value: boolean): string {
return value ? 'Yes' : 'No';
}
function namedListValue(items: Array<{ name: string }> | null | undefined): string {
if (!items?.length) {
return 'None';
}
return [...new Set(items.map((item) => item.name))]
.sort((a, b) => a.localeCompare(b))
.join(' / ');
}
function quantityListValue(items: Array<{ name: string; quantity: number }> | null | undefined): string {
if (!items?.length) {
return 'None';
}
return items
.map((item) => ({ name: item.name, value: `${item.name} x${item.quantity}` }))
.sort((a, b) => a.name.localeCompare(b.name))
.map((item) => item.value)
.join(' / ');
}
function skillDropListValue(skills: Array<{ name: string; itemDrop?: { name: string } | null }> | null | undefined): string {
const rows = skills
?.filter((skill) => skill.itemDrop)
.map((skill) => `${skill.name}: ${skill.itemDrop?.name}`)
.sort((a, b) => a.localeCompare(b)) ?? [];
return rows.length ? rows.join(' / ') : 'None';
}
function pokemonStatsValue(stats: PokemonStats | null | undefined): string {
return pokemonStatLabels.map((stat) => `${stat.label}: ${stats?.[stat.key] ?? 0}`).join(' / ');
}
function roundMeasure(value: number, precision: number): number {
const scale = 10 ** precision;
return Math.round(value * scale) / scale;
}
function formatFixedMeasure(value: number, precision: number): string {
return value.toFixed(precision);
}
function feetInchesValue(inches: number): string {
const totalInches = Math.round(inches);
const feet = Math.floor(totalInches / 12);
const remainingInches = totalInches - feet * 12;
return `${feet}'${remainingInches}"`;
}
function pokemonHeightValue(inches: number | null | undefined): string {
const value = inches ?? 0;
return `${feetInchesValue(value)} / ${formatFixedMeasure(roundMeasure(value * 0.0254, 2), 2)} m`;
}
function pokemonWeightValue(pounds: number | null | undefined): string {
const value = pounds ?? 0;
return `${formatFixedMeasure(roundMeasure(value, 1), 1)} lb / ${formatFixedMeasure(roundMeasure(value * 0.45359237, 2), 2)} kg`;
}
function appearanceListValue(
rows: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }> | null | undefined
): string {
if (!rows?.length) {
return 'None';
}
return rows
.map((row) => `${row.name}: ${row.time_of_day} / ${row.weather} / ${row.rarity} stars / ${row.map.name}`)
.sort((a, b) => a.localeCompare(b))
.join(' / ');
}
async function entityNameMap(client: DbClient, tableName: string, ids: number[]): Promise<Map<number, string>> {
const uniqueIds = [...new Set(ids)].filter((id) => Number.isInteger(id) && id > 0);
if (!uniqueIds.length) {
return new Map();
}
const result = await client.query<{ id: number; name: string }>(
`SELECT id, name FROM ${tableName} WHERE id = ANY($1::integer[])`,
[uniqueIds]
);
return new Map(result.rows.map((row) => [row.id, row.name]));
}
function namesFromIds(ids: number[], namesById: Map<number, string>): string {
const names = [...new Set(ids)]
.map((id) => namesById.get(id))
.filter((name): name is string => Boolean(name))
.sort((a, b) => a.localeCompare(b));
return names.length ? names.join(' / ') : 'None';
}
async function quantityPayloadValue(client: DbClient, rows: IdQuantity[]): Promise<string> {
const namesById = await entityNameMap(client, 'items', rows.map((row) => row.itemId));
return quantityListValue(
rows
.map((row) => {
const name = namesById.get(row.itemId);
return name ? { name, quantity: row.quantity } : null;
})
.filter((row): row is { name: string; quantity: number } => row !== null)
);
}
async function pokemonEditChanges(
client: DbClient,
before: PokemonChangeSource,
after: PokemonPayload
): Promise<EditChange[]> {
const changes: EditChange[] = [];
const environmentNames = await entityNameMap(client, 'environments', [after.environmentId]);
const typeNames = await entityNameMap(client, 'pokemon_types', after.typeIds);
const skillNames = await entityNameMap(client, 'skills', after.skillIds);
const favoriteThingNames = await entityNameMap(client, 'favorite_things', after.favoriteThingIds);
const dropSkillNames = await entityNameMap(client, 'skills', after.skillItemDrops.map((drop) => drop.skillId));
const dropItemNames = await entityNameMap(client, 'items', after.skillItemDrops.map((drop) => drop.itemId));
const afterDrops = after.skillItemDrops
.map((drop) => {
const skillName = dropSkillNames.get(drop.skillId);
const itemName = dropItemNames.get(drop.itemId);
return skillName && itemName ? `${skillName}: ${itemName}` : null;
})
.filter((drop): drop is string => drop !== null)
.sort((a, b) => a.localeCompare(b))
.join(' / ');
pushChange(changes, 'Name', before.name, after.name);
pushChange(changes, 'Pokemon ID', String(before.displayId), String(after.displayId));
pushChange(changes, 'Event item', boolValue(before.isEventItem), boolValue(after.isEventItem));
pushChange(changes, 'Genus', before.genus, after.genus);
pushChange(changes, 'Details', before.details, after.details);
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'genus', 'details']);
pushChange(changes, 'Height', pokemonHeightValue(before.heightInches), pokemonHeightValue(after.heightInches));
pushChange(changes, 'Weight', pokemonWeightValue(before.weightPounds), pokemonWeightValue(after.weightPounds));
pushChange(changes, 'Image', pokemonImageLabel(before.image), pokemonImageLabel(after.image));
pushChange(changes, 'Types', namedListValue(before.types), namesFromIds(after.typeIds, typeNames));
pushChange(changes, 'Stats', pokemonStatsValue(before.stats), pokemonStatsValue(after.stats));
pushChange(changes, 'Ideal Habitat', before.environment.name, environmentNames.get(after.environmentId));
pushChange(changes, 'Specialities', namedListValue(before.skills), namesFromIds(after.skillIds, skillNames));
pushChange(changes, 'Favourites', namedListValue(before.favorite_things), namesFromIds(after.favoriteThingIds, favoriteThingNames));
pushChange(changes, 'Speciality drops', skillDropListValue(before.skills), afterDrops);
return changes;
}
async function itemEditChanges(
client: DbClient,
before: ItemChangeSource,
after: ItemPayload
): Promise<EditChange[]> {
const changes: EditChange[] = [];
const categoryNames = await entityNameMap(client, 'item_categories', [after.categoryId]);
const usageNames = await entityNameMap(client, 'item_usages', after.usageId ? [after.usageId] : []);
const methodNames = await entityNameMap(client, 'acquisition_methods', after.acquisitionMethodIds);
const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
pushChange(changes, 'Name', before.name, after.name);
pushTranslationChanges(changes, before.translations, after.translations, ['name']);
pushChange(changes, 'Event item', boolValue(before.isEventItem), boolValue(after.isEventItem));
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
pushChange(changes, 'Category', before.category.name, categoryNames.get(after.categoryId));
pushChange(changes, 'Usage', before.usage?.name, after.usageId ? usageNames.get(after.usageId) : null);
pushChange(changes, 'Dyeable', boolValue(before.customization.dyeable), boolValue(after.dyeable));
pushChange(changes, 'Dual dyeable', boolValue(before.customization.dualDyeable), boolValue(after.dualDyeable));
pushChange(changes, 'Pattern editable', boolValue(before.customization.patternEditable), boolValue(after.patternEditable));
pushChange(changes, 'No recipe', boolValue(before.noRecipe), boolValue(after.noRecipe));
pushChange(changes, 'Acquisition methods', namedListValue(before.acquisitionMethods), namesFromIds(after.acquisitionMethodIds, methodNames));
pushChange(changes, 'Tags', namedListValue(before.tags), namesFromIds(after.tagIds, tagNames));
return changes;
}
async function habitatEditChanges(
client: DbClient,
before: HabitatChangeSource,
after: HabitatPayload
): Promise<EditChange[]> {
const changes: EditChange[] = [];
const pokemonNames = await entityNameMap(client, 'pokemon', after.pokemonAppearances.map((row) => row.pokemonId));
const mapNames = await entityNameMap(client, 'maps', after.pokemonAppearances.map((row) => row.mapId));
const afterAppearances = after.pokemonAppearances
.map((row) => {
const pokemonName = pokemonNames.get(row.pokemonId);
const mapName = mapNames.get(row.mapId);
return pokemonName && mapName ? `${pokemonName}: ${row.timeOfDay} / ${row.weather} / ${row.rarity} stars / ${mapName}` : null;
})
.filter((row): row is string => row !== null)
.sort((a, b) => a.localeCompare(b))
.join(' / ');
pushChange(changes, 'Name', before.name, after.name);
pushTranslationChanges(changes, before.translations, after.translations, ['name']);
pushChange(changes, 'Event item', boolValue(before.isEventItem), boolValue(after.isEventItem));
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
pushChange(changes, 'Recipe', quantityListValue(before.recipe), await quantityPayloadValue(client, after.recipeItems));
pushChange(changes, 'Possible Pokemon', appearanceListValue(before.pokemon), afterAppearances);
return changes;
}
async function recipeEditChanges(
client: DbClient,
before: RecipeChangeSource,
after: RecipePayload
): Promise<EditChange[]> {
const changes: EditChange[] = [];
const itemNames = await entityNameMap(client, 'items', [after.itemId]);
const methodNames = await entityNameMap(client, 'acquisition_methods', after.acquisitionMethodIds);
pushChange(changes, 'Item', before.item.name, itemNames.get(after.itemId));
pushChange(changes, 'Acquisition methods', namedListValue(before.acquisition_methods), namesFromIds(after.acquisitionMethodIds, methodNames));
pushChange(changes, 'Materials', quantityListValue(before.materials), await quantityPayloadValue(client, after.materials));
return changes;
}
function dailyChecklistEditChanges(before: DailyChecklistChangeSource, after: DailyChecklistPayload): EditChange[] {
const changes: EditChange[] = [];
pushChange(changes, 'Title', before.title, after.title);
pushTranslationChanges(changes, before.translations, after.translations, ['title']);
return changes;
}
function configEditChanges(
definition: ConfigDefinition,
before: ConfigChangeSource,
after: {
name: string;
translations: TranslationInput;
hasItemDrop: boolean;
isDefault: boolean;
isRateable: boolean;
changeLog: string;
}
): EditChange[] {
const changes: EditChange[] = [];
pushChange(changes, 'Name', before.name, after.name);
pushTranslationChanges(changes, before.translations, after.translations, ['name']);
if (definition.hasItemDrop) {
pushChange(changes, 'Has item drop', boolValue(Boolean(before.hasItemDrop)), boolValue(after.hasItemDrop));
}
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);
}
return changes;
}
function getEditHistory(entityType: string, entityId: number): Promise<EditHistoryEntry[]> {
return query(
`
SELECT
l.action,
COALESCE(l.changes, '[]'::jsonb) AS changes,
l.created_at AS "createdAt",
CASE
WHEN u.id IS NULL THEN NULL
ELSE json_build_object('id', u.id, 'displayName', u.display_name)
END AS user
FROM wiki_edit_logs l
LEFT JOIN users u ON u.id = l.user_id
WHERE l.entity_type = $1
AND l.entity_id = $2
ORDER BY l.created_at DESC, l.id DESC
`,
[entityType, entityId]
);
}
function pokemonProjection(locale: string): string {
const pokemonName = localizedName('pokemon', 'p', locale);
const pokemonGenus = localizedField('pokemon', 'p.id', 'p.genus', 'genus', locale);
const pokemonDetails = localizedField('pokemon', 'p.id', 'p.details', 'details', locale);
const typeName = localizedName('pokemon-types', 'pt', locale);
const environmentName = localizedName('environments', 'e', locale);
const skillName = localizedName('skills', 's', locale);
const favoriteThingName = localizedName('favorite-things', 'ft', locale);
return `
SELECT
p.id,
p.display_id AS "displayId",
${pokemonName} AS name,
p.name AS "baseName",
p.is_event_item AS "isEventItem",
${pokemonGenus} AS genus,
p.genus AS "baseGenus",
${pokemonDetails} AS details,
p.details AS "baseDetails",
p.height_inches AS "heightInches",
round((p.height_inches * 0.0254)::numeric, 2)::double precision AS "heightMeters",
p.weight_pounds AS "weightPounds",
round((p.weight_pounds * 0.45359237)::numeric, 2)::double precision AS "weightKg",
${pokemonImageJson('p')} AS image,
json_build_object(
'hp', p.hp,
'attack', p.attack,
'defense', p.defense,
'specialAttack', p.special_attack,
'specialDefense', p.special_defense,
'speed', p.speed
) AS stats,
${translationsSelect('pokemon', 'p.id')} AS translations,
${auditSelect('p', 'pokemon_created_user', 'pokemon_updated_user')},
json_build_object('id', e.id, 'name', ${environmentName}) AS environment,
COALESCE((
SELECT json_agg(json_build_object('id', pt.id, 'name', ${typeName}) ORDER BY ppt.slot_order)
FROM pokemon_pokemon_types ppt
JOIN pokemon_types pt ON pt.id = ppt.type_id
WHERE ppt.pokemon_id = p.id
), '[]'::json) AS types,
COALESCE((
SELECT json_agg(json_build_object('id', s.id, 'name', ${skillName}, 'hasItemDrop', s.has_item_drop) ORDER BY ${orderByEntity('s')})
FROM pokemon_skills ps
JOIN skills s ON s.id = ps.skill_id
WHERE ps.pokemon_id = p.id
), '[]'::json) AS skills,
COALESCE((
SELECT json_agg(json_build_object('id', ft.id, 'name', ${favoriteThingName}) ORDER BY ${orderByEntity('ft')})
FROM pokemon_favorite_things pft
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
WHERE pft.pokemon_id = p.id
), '[]'::json) AS favorite_things
FROM pokemon p
JOIN environments e ON e.id = p.environment_id
${auditJoins('p', 'pokemon_created_user', 'pokemon_updated_user')}
`;
}
export async function getOptions(locale = defaultLocale) {
const [
pokemonTypes,
skills,
environments,
favoriteThings,
itemCategories,
itemUsages,
acquisitionMethods,
maps,
lifeCategories,
gameVersions
] = await Promise.all([
optionSelect('pokemon_types', 'pokemon-types', locale),
skillOptions(locale),
optionSelect('environments', 'environments', locale),
optionSelect('favorite_things', 'favorite-things', locale),
optionSelect('item_categories', 'item-categories', locale),
optionSelect('item_usages', 'item-usages', locale),
optionSelect('acquisition_methods', 'acquisition-methods', locale),
optionSelect('maps', 'maps', locale),
lifeCategoryOptions(locale),
gameVersionOptions(locale)
]);
return {
pokemonTypes,
skills,
environments,
favoriteThings,
itemCategories,
itemUsages,
acquisitionMethods,
itemTags: favoriteThings,
maps,
lifeCategories,
gameVersions
};
}
function cleanDailyChecklistPayload(payload: Record<string, unknown>): DailyChecklistPayload {
return {
title: cleanName(payload.title, 'server.validation.taskRequired'),
translations: cleanTranslations(payload.translations, ['title'])
};
}
export async function listDailyChecklistItems(locale = defaultLocale) {
const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
return query(
`
SELECT c.id, ${title} AS title, c.title AS "baseTitle", ${translationsSelect('daily-checklist-items', 'c.id')} AS translations
FROM daily_checklist_items c
ORDER BY c.sort_order, c.id
`
);
}
async function getDailyChecklistItemById(id: number, locale = defaultLocale) {
const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
return queryOne(
`
SELECT c.id, ${title} AS title, c.title AS "baseTitle", ${translationsSelect('daily-checklist-items', 'c.id')} AS translations
FROM daily_checklist_items c
WHERE c.id = $1
`,
[id]
);
}
export async function createDailyChecklistItem(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanDailyChecklistPayload(payload);
const id = await withTransaction(async (client) => {
const orderResult = await client.query<{ sortOrder: number }>(
'SELECT COALESCE(MAX(sort_order), 0) + 10 AS "sortOrder" FROM daily_checklist_items'
);
const sortOrder = orderResult.rows[0]?.sortOrder ?? 10;
const result = await client.query<{ id: number }>(
`
INSERT INTO daily_checklist_items (title, sort_order, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $3, $3)
RETURNING id
`,
[cleanPayload.title, sortOrder, userId]
);
const createdId = result.rows[0].id;
await replaceEntityTranslations(client, 'daily-checklist-items', createdId, cleanPayload.translations, ['title']);
await recordEditLog(client, 'daily-checklist-items', createdId, 'create', userId);
return createdId;
});
return getDailyChecklistItemById(id, locale);
}
export async function updateDailyChecklistItem(
id: number,
payload: Record<string, unknown>,
userId: number,
locale = defaultLocale
) {
const cleanPayload = cleanDailyChecklistPayload(payload);
const before = await getDailyChecklistItemById(id, defaultLocale);
const updated = await withTransaction(async (client) => {
const result = await client.query(
`
UPDATE daily_checklist_items
SET title = $1, updated_by_user_id = $2, updated_at = now()
WHERE id = $3
`,
[cleanPayload.title, userId, id]
);
if (result.rowCount === 0) {
return false;
}
await replaceEntityTranslations(client, 'daily-checklist-items', id, cleanPayload.translations, ['title']);
const changes = before ? dailyChecklistEditChanges(before as DailyChecklistChangeSource, cleanPayload) : [];
await recordEditLog(client, 'daily-checklist-items', id, 'update', userId, changes);
return true;
});
return updated ? getDailyChecklistItemById(id, locale) : null;
}
export async function reorderDailyChecklistItems(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const ids = cleanIds(payload.ids);
if (ids.length === 0) {
throw validationError('server.validation.selectTask');
}
await withTransaction(async (client) => {
const existing = await client.query<{ id: number; sortOrder: number }>(
'SELECT id, sort_order AS "sortOrder" FROM daily_checklist_items WHERE id = ANY($1::integer[])',
[ids]
);
if (existing.rowCount !== ids.length) {
throw validationError('server.validation.taskDoesNotExist');
}
const sortOrders = new Map(existing.rows.map((row) => [row.id, row.sortOrder]));
for (const [index, id] of ids.entries()) {
const nextSortOrder = (index + 1) * 10;
const previousSortOrder = sortOrders.get(id);
if (previousSortOrder === nextSortOrder) {
continue;
}
await client.query(
`
UPDATE daily_checklist_items
SET sort_order = $1, updated_by_user_id = $2, updated_at = now()
WHERE id = $3
`,
[nextSortOrder, userId, id]
);
const changes: EditChange[] = [];
pushChange(changes, 'Sort order', String(previousSortOrder), String(nextSortOrder));
await recordEditLog(client, 'daily-checklist-items', id, 'update', userId, changes);
}
});
return listDailyChecklistItems(locale);
}
export async function deleteDailyChecklistItem(id: number, userId: number) {
return withTransaction(async (client) => {
const result = await client.query<{ id: number }>('DELETE FROM daily_checklist_items WHERE id = $1 RETURNING id', [id]);
if (result.rowCount === 0) {
return false;
}
await deleteEntityTranslations(client, 'daily-checklist-items', id);
await recordEditLog(client, 'daily-checklist-items', id, 'delete', userId);
return true;
});
}
function cleanLifePostPayload(payload: Record<string, unknown>): LifePostPayload {
const body = cleanName(payload.body, 'server.validation.postRequired');
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)
};
}
function cleanLifeCommentPayload(payload: Record<string, unknown>): LifeCommentPayload {
const body = cleanName(payload.body, 'server.validation.commentRequired');
if (body.length > 1000) {
throw validationError('server.validation.commentTooLong');
}
return { body, languageCode: cleanModerationLanguageCode(payload.languageCode) };
}
function emptyLifeReactionCounts(): LifeReactionCounts {
return {
like: 0,
helpful: 0,
fun: 0,
thanks: 0
};
}
function isLifeReactionType(value: unknown): value is LifeReactionType {
return typeof value === 'string' && lifeReactionTypes.includes(value as LifeReactionType);
}
function cleanLifeReactionType(value: unknown): LifeReactionType {
if (!isLifeReactionType(value)) {
throw validationError('server.validation.reactionInvalid');
}
return value;
}
function cleanLifeReactionFilter(value: QueryValue): LifeReactionType | null {
const reactionType = asString(value);
if (!reactionType) {
return 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) {
return null;
}
if (source !== 'life' && source !== 'discussion') {
throw validationError('server.validation.invalidField');
}
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 categoryName = localizedName('life-tags', 'lc', locale);
const gameVersionName = localizedName('game-versions', 'gv', locale);
return `
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",
CASE
WHEN created_user.id IS NULL THEN NULL
ELSE json_build_object('id', created_user.id, 'displayName', created_user.display_name)
END AS author,
CASE
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"
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
`;
}
function cleanLifePostLimit(value: QueryValue): number {
const rawLimit = asString(value);
if (rawLimit === undefined || rawLimit === '') {
return defaultLifePostLimit;
}
const limit = Number(rawLimit);
return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxLifePostLimit) : defaultLifePostLimit;
}
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;
}
function cleanCommentLimit(value: QueryValue): number {
const rawLimit = asString(value);
if (rawLimit === undefined || rawLimit === '') {
return defaultCommentLimit;
}
const limit = Number(rawLimit);
return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxCommentLimit) : defaultCommentLimit;
}
function decodeLifePostCursor(value: QueryValue): LifePostCursor | null {
const rawCursor = asString(value);
if (!rawCursor) {
return null;
}
try {
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))
) {
throw validationError('server.validation.cursorInvalid');
}
return { createdAt, id, ratingAverage };
} catch (error) {
if (error instanceof Error && 'statusCode' in error) {
throw error;
}
throw validationError('server.validation.cursorInvalid');
}
}
function encodeLifePostCursor(post: LifePostRow): string {
return Buffer.from(
JSON.stringify({ createdAt: post.createdAtCursor, id: post.id, ratingAverage: post.ratingAverage ?? 0 }),
'utf8'
).toString('base64url');
}
function encodeProfileCursor(cursor: LifePostCursor): string {
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
}
function decodeUserCommentActivityCursor(value: QueryValue): UserCommentActivityCursor | null {
const rawCursor = asString(value);
if (!rawCursor) {
return null;
}
try {
const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<UserCommentActivityCursor>;
const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
const id = Number(cursor.id);
const source = cursor.source;
if (
!createdAt ||
Number.isNaN(new Date(createdAt).getTime()) ||
!Number.isInteger(id) ||
id <= 0 ||
(source !== 'life' && source !== 'discussion')
) {
throw validationError('server.validation.cursorInvalid');
}
return { createdAt, id, source };
} catch (error) {
if (error instanceof Error && 'statusCode' in error) {
throw error;
}
throw validationError('server.validation.cursorInvalid');
}
}
function encodeUserCommentActivityCursor(cursor: UserCommentActivityCursor): string {
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
}
function hydrateLifePost(
post: LifePostRow,
commentPreviewByPost: Map<number, LifeComment[]>,
commentCountsByPost: Map<number, number>,
countsByPost: Map<number, LifeReactionCounts>,
myReactionsByPost: Map<number, LifeReactionType>,
myRatingsByPost: Map<number, number>
): LifePost {
return {
id: post.id,
body: post.body,
moderationStatus: post.moderationStatus,
moderationLanguageCode: post.moderationLanguageCode,
createdAt: post.createdAt,
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
};
}
function lifeCommentProjection(whereClause: string): string {
return `
SELECT
lc.id,
lc.post_id AS "postId",
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",
CASE
WHEN lc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL
ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name)
END AS author
FROM life_post_comments lc
LEFT JOIN users comment_user ON comment_user.id = lc.created_by_user_id
${whereClause}
`;
}
function buildLifeCommentTree(rows: LifeCommentRow[]): LifeComment[] {
const comments = new Map<number, LifeComment>();
const topLevelComments: LifeComment[] = [];
for (const row of rows) {
const { createdAtCursor: _createdAtCursor, ...comment } = row;
comments.set(row.id, { ...comment, replies: [] });
}
for (const comment of comments.values()) {
if (comment.parentCommentId === null) {
topLevelComments.push(comment);
continue;
}
const parent = comments.get(comment.parentCommentId);
if (parent?.parentCommentId === null) {
parent.replies.push(comment);
} else {
topLevelComments.push(comment);
}
}
return topLevelComments;
}
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);
}
if (postIds.length === 0) {
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 lc
WHERE ${conditions.join(' AND ')}
GROUP BY post_id
`,
params
);
for (const row of rows) {
countsByPost.set(row.postId, row.total);
}
return countsByPost;
}
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 (
SELECT id
FROM (
SELECT
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 ${previewConditions.join(' AND ')}
) ranked
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
`,
params
);
for (const postId of postIds) {
commentsByPost.set(postId, buildLifeCommentTree(rows.filter((comment) => comment.postId === postId)));
}
return commentsByPost;
}
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 lp
WHERE ${postConditions.join(' AND ')}
) AS exists
`,
postParams
);
if (exists?.exists !== true) {
return null;
}
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);
topLevelConditions.push(`(lc.created_at, lc.id) > ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
}
params.push(limit + 1);
const topLevelRows = await query<LifeCommentRow>(
`
${lifeCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`)}
ORDER BY lc.created_at, lc.id
LIMIT $${params.length}
`,
params
);
const hasMore = topLevelRows.length > limit;
const topLevelComments = hasMore ? topLevelRows.slice(0, limit) : topLevelRows;
const topLevelIds = topLevelComments.map((comment) => comment.id);
const replyRows = topLevelIds.length
? 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 lc
WHERE ${totalConditions.join(' AND ')}
`,
totalParams
);
return {
items: buildLifeCommentTree([...topLevelComments, ...replyRows]),
nextCursor:
hasMore && topLevelComments.length > 0
? encodeProfileCursor({
createdAt:
topLevelComments[topLevelComments.length - 1].createdAtCursor ??
topLevelComments[topLevelComments.length - 1].createdAt.toISOString(),
id: topLevelComments[topLevelComments.length - 1].id
})
: null,
hasMore,
total: total?.total ?? 0
};
}
async function lifeReactionsForPosts(
postIds: number[],
userId: number | null
): Promise<{
countsByPost: Map<number, LifeReactionCounts>;
myReactionsByPost: Map<number, LifeReactionType>;
}> {
const countsByPost = new Map<number, LifeReactionCounts>();
const myReactionsByPost = new Map<number, LifeReactionType>();
for (const postId of postIds) {
countsByPost.set(postId, emptyLifeReactionCounts());
}
if (postIds.length === 0) {
return { countsByPost, myReactionsByPost };
}
const countRows = await query<{ postId: number; reactionType: LifeReactionType; count: number }>(
`
SELECT
post_id AS "postId",
reaction_type AS "reactionType",
COUNT(*)::integer AS count
FROM life_post_reactions
WHERE post_id = ANY($1::integer[])
GROUP BY post_id, reaction_type
`,
[postIds]
);
for (const row of countRows) {
const counts = countsByPost.get(row.postId);
if (counts && isLifeReactionType(row.reactionType)) {
counts[row.reactionType] = row.count;
}
}
if (userId !== null) {
const myRows = await query<{ postId: number; reactionType: LifeReactionType }>(
`
SELECT post_id AS "postId", reaction_type AS "reactionType"
FROM life_post_reactions
WHERE post_id = ANY($1::integer[])
AND user_id = $2
`,
[postIds, userId]
);
for (const row of myRows) {
if (isLifeReactionType(row.reactionType)) {
myReactionsByPost.set(row.postId, row.reactionType);
}
}
}
return { countsByPost, myReactionsByPost };
}
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): Promise<LifeComment | null> {
const row = await queryOne<LifeCommentRow>(
`
${lifeCommentProjection('WHERE lc.id = $1')}
`,
[id]
);
if (!row) {
return null;
}
const { createdAtCursor: _createdAtCursor, ...comment } = row;
return { ...comment, replies: [] };
}
async function listLifePostsWithFilters(
paramsQuery: QueryParams = {},
userId: number | null = null,
locale = defaultLocale,
filters: LifePostFilters = {},
canViewAll = false
): Promise<LifePostsPage> {
const cursor = decodeLifePostCursor(paramsQuery.cursor);
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'];
if (filters.authorId !== undefined) {
params.push(filters.authorId);
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}`);
}
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)`
);
}
}
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 whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
params.push(limit + 1);
const rows = await query<LifePostRow>(
`
${lifePostProjection(locale)}
${whereClause}
${orderClause}
LIMIT $${params.length}
`,
params
);
const hasMore = rows.length > limit;
const posts = hasMore ? rows.slice(0, limit) : rows;
const postIds = posts.map((post) => post.id);
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)
),
nextCursor: hasMore && posts.length > 0 ? encodeLifePostCursor(posts[posts.length - 1]) : null,
hasMore
};
}
export async function listLifePosts(
paramsQuery: QueryParams = {},
userId: number | null = null,
locale = defaultLocale,
canViewAll = false
): Promise<LifePostsPage> {
return listLifePostsWithFilters(paramsQuery, userId, locale, {}, canViewAll);
}
async function getPublicProfileUser(userIdValue: number): Promise<PublicProfileUser | null> {
const userId = requirePositiveInteger(userIdValue, 'server.validation.recordInvalid');
return queryOne<PublicProfileUser>(
`
SELECT
id,
display_name AS "displayName",
created_at AS "joinedAt"
FROM users
WHERE id = $1
`,
[userId]
);
}
function publicContributionType(entityType: string): string {
return entityType === 'daily-checklist-items' ? 'daily-checklist' : entityType;
}
export async function getPublicUserProfile(userIdValue: number): Promise<PublicUserProfile | null> {
const user = await getPublicProfileUser(userIdValue);
if (!user) {
return null;
}
const stats = await queryOne<PublicProfileStats>(
`
SELECT
COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1), 0) AS "wikiEdits",
COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1 AND action = 'create'), 0) AS "wikiCreates",
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
AND ai_moderation_status = 'approved'
), 0) AS "lifePosts",
COALESCE((
SELECT COUNT(*)::integer
FROM life_post_comments lc
JOIN life_posts lp ON lp.id = lc.post_id
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
FROM life_post_reactions lpr
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]
);
const contributions = await query<PublicProfileContribution>(
`
SELECT
entity_type AS "contentType",
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE action = 'create')::integer AS creates,
COUNT(*) FILTER (WHERE action = 'update')::integer AS updates,
COUNT(*) FILTER (WHERE action = 'delete')::integer AS deletes,
MAX(created_at) AS "lastContributedAt"
FROM wiki_edit_logs
WHERE user_id = $1
GROUP BY entity_type
ORDER BY total DESC, "lastContributedAt" DESC, entity_type
`,
[user.id]
);
return {
user,
stats: stats ?? {
wikiEdits: 0,
wikiCreates: 0,
wikiUpdates: 0,
wikiDeletes: 0,
imageUploads: 0,
lifePosts: 0,
lifeComments: 0,
lifeReactions: 0,
discussionComments: 0
},
contributions: contributions.map((item) => ({
...item,
contentType: publicContributionType(item.contentType)
}))
};
}
export async function listUserLifePosts(
userIdValue: number,
paramsQuery: QueryParams = {},
viewerUserId: number | null = null,
locale = defaultLocale,
canViewAll = false
): Promise<LifePostsPage | null> {
const user = await getPublicProfileUser(userIdValue);
if (!user) {
return null;
}
return listLifePostsWithFilters(paramsQuery, viewerUserId, locale, { authorId: user.id }, canViewAll);
}
async function hydrateLifePostsById(
postIds: number[],
viewerUserId: number | null,
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 ${conditions.join(' AND ')}
`,
params
);
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));
}
return postById;
}
export async function listUserReactionActivities(
userIdValue: number,
paramsQuery: QueryParams = {},
viewerUserId: number | null = null,
locale = defaultLocale
): Promise<UserReactionActivityPage | null> {
const user = await getPublicProfileUser(userIdValue);
if (!user) {
return null;
}
const cursor = decodeLifePostCursor(paramsQuery.cursor);
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', "lp.ai_moderation_status = 'approved'"];
if (reactionType) {
params.push(reactionType);
conditions.push(`lpr.reaction_type = $${params.length}`);
}
if (cursor) {
params.push(cursor.createdAt, cursor.id);
conditions.push(`(lpr.updated_at, lpr.post_id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
}
params.push(limit + 1);
const rows = await query<{
postId: number;
reactionType: LifeReactionType;
reactedAt: Date;
reactedAtCursor: string;
}>(
`
SELECT
lpr.post_id AS "postId",
lpr.reaction_type AS "reactionType",
lpr.updated_at AS "reactedAt",
lpr.updated_at::text AS "reactedAtCursor"
FROM life_post_reactions lpr
JOIN life_posts lp ON lp.id = lpr.post_id
WHERE ${conditions.join(' AND ')}
ORDER BY lpr.updated_at DESC, lpr.post_id DESC
LIMIT $${params.length}
`,
params
);
const hasMore = rows.length > limit;
const activities = hasMore ? rows.slice(0, limit) : rows;
const postById = await hydrateLifePostsById(
activities.map((activity) => activity.postId),
viewerUserId,
locale
);
return {
items: activities.flatMap((activity) => {
const post = postById.get(activity.postId);
return post
? [
{
postId: activity.postId,
reactionType: activity.reactionType,
reactedAt: activity.reactedAt,
post
}
]
: [];
}),
nextCursor:
hasMore && activities.length > 0
? encodeProfileCursor({
createdAt: activities[activities.length - 1].reactedAtCursor,
id: activities[activities.length - 1].postId
})
: null,
hasMore
};
}
export async function listUserCommentActivities(
userIdValue: number,
paramsQuery: QueryParams = {},
locale = defaultLocale
): Promise<UserCommentActivityPage | null> {
const user = await getPublicProfileUser(userIdValue);
if (!user) {
return null;
}
const cursor = decodeUserCommentActivityCursor(paramsQuery.cursor);
const limit = cleanLifePostLimit(paramsQuery.limit);
const sourceFilter = cleanUserCommentActivitySourceFilter(paramsQuery.source);
const pokemonName = localizedName('pokemon', 'p', locale);
const itemName = localizedName('items', 'i', locale);
const recipeItemName = localizedName('items', 'recipe_item', locale);
const habitatName = localizedName('habitats', 'h', locale);
const params: unknown[] = [user.id];
const outerConditions: string[] = [];
if (sourceFilter) {
params.push(sourceFilter);
outerConditions.push(`source = $${params.length}`);
}
if (cursor) {
params.push(cursor.createdAt, cursor.source, cursor.id);
outerConditions.push(
`(created_at, source, id) < ($${params.length - 2}::timestamptz, $${params.length - 1}::text, $${params.length}::integer)`
);
}
params.push(limit + 1);
const outerWhere = outerConditions.length ? `WHERE ${outerConditions.join(' AND ')}` : '';
const rows = await query<{
id: number;
source: UserCommentActivitySource;
body: string;
createdAt: Date;
createdAtCursor: string;
targetType: 'life-post' | DiscussionEntityType;
targetId: number;
targetTitle: string;
targetExcerpt: string;
}>(
`
WITH activity AS (
SELECT
'life'::text AS source,
lc.id,
lc.body,
lc.created_at,
lc.created_at::text AS cursor_at,
'life-post'::text AS target_type,
lp.id AS target_id,
COALESCE(post_user.display_name, '') AS target_title,
lp.body AS target_excerpt
FROM life_post_comments lc
JOIN life_posts lp ON lp.id = lc.post_id
LEFT JOIN users post_user ON post_user.id = lp.created_by_user_id
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
SELECT
'discussion'::text AS source,
edc.id,
edc.body,
edc.created_at,
edc.created_at::text AS cursor_at,
edc.entity_type AS target_type,
edc.entity_id AS target_id,
COALESCE(
CASE edc.entity_type
WHEN 'pokemon' THEN ${pokemonName}
WHEN 'items' THEN ${itemName}
WHEN 'recipes' THEN ${recipeItemName}
WHEN 'habitats' THEN ${habitatName}
ELSE ''
END,
''
) AS target_title,
''::text AS target_excerpt
FROM entity_discussion_comments edc
LEFT JOIN pokemon p ON edc.entity_type = 'pokemon' AND p.id = edc.entity_id
LEFT JOIN items i ON edc.entity_type = 'items' AND i.id = edc.entity_id
LEFT JOIN recipes r ON edc.entity_type = 'recipes' AND r.id = edc.entity_id
LEFT JOIN items recipe_item ON recipe_item.id = r.item_id
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,
id,
body,
created_at AS "createdAt",
cursor_at AS "createdAtCursor",
target_type AS "targetType",
target_id AS "targetId",
target_title AS "targetTitle",
target_excerpt AS "targetExcerpt"
FROM activity
${outerWhere}
ORDER BY created_at DESC, source DESC, id DESC
LIMIT $${params.length}
`,
params
);
const hasMore = rows.length > limit;
const activities = hasMore ? rows.slice(0, limit) : rows;
return {
items: activities.map((activity) => ({
id: activity.id,
source: activity.source,
body: activity.body,
createdAt: activity.createdAt,
target: {
type: activity.targetType,
id: activity.targetId,
title: activity.targetTitle,
excerpt: activity.targetExcerpt
}
})),
nextCursor:
hasMore && activities.length > 0
? encodeUserCommentActivityCursor({
createdAt: activities[activities.length - 1].createdAtCursor,
id: activities[activities.length - 1].id,
source: activities[activities.length - 1].source
})
: null,
hasMore
};
}
async function getLifePostById(id: number, userId: number | null = null, locale = defaultLocale): Promise<LifePost | null> {
const post = await queryOne<LifePostRow>(
`
${lifePostProjection(locale)}
WHERE lp.id = $1
AND lp.deleted_at IS NULL
`,
[id]
);
if (!post) {
return null;
}
const commentPreviewByPost = await lifeCommentPreviewForPosts([post.id], userId, false);
const commentCountsByPost = await lifeCommentCountsForPosts([post.id], userId, false);
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId);
const myRatingsByPost = await lifeRatingsForPosts([post.id], userId);
return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost, myRatingsByPost);
}
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;
}
const result = await client.query<{ id: number }>('SELECT id FROM game_versions WHERE id = $1', [gameVersionId]);
if (result.rowCount === 0) {
throw validationError('server.validation.gameVersionInvalid');
}
}
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)
RETURNING id
`,
[cleanPayload.body, cleanPayload.categoryId, cleanPayload.gameVersionId, userId]
);
const createdId = result.rows[0].id;
await replaceLifePostCategoryLink(client, createdId, cleanPayload.categoryId);
return createdId;
});
await requestAiModerationReview({ type: 'life-post', id }, { languageCode: cleanPayload.languageCode, resetRetries: true });
return getLifePostById(id, userId, locale);
}
export async function updateLifePost(
id: number,
payload: Record<string, unknown>,
userId: number,
locale = defaultLocale,
allowAny = false
) {
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,
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_at = now()
WHERE id = $5
AND ($6 = true OR created_by_user_id = $4)
AND deleted_at IS NULL
RETURNING id
`,
[cleanPayload.body, cleanPayload.categoryId, 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;
});
if (updatedId) {
await requestAiModerationReview(
{ type: 'life-post', id: updatedId },
{ languageCode: cleanPayload.languageCode, resetRetries: true }
);
}
return updatedId ? getLifePostById(updatedId, userId, locale) : null;
}
export async function deleteLifePost(id: number, userId: number, allowAny = false) {
const result = await queryOne<{ id: number }>(
`
UPDATE life_posts
SET deleted_at = now(),
deleted_by_user_id = $2,
updated_by_user_id = $2,
updated_at = now()
WHERE id = $1
AND ($3 = true OR created_by_user_id = $2)
AND deleted_at IS NULL
RETURNING id
`,
[id, userId, allowAny]
);
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>,
userId: number,
locale = defaultLocale
) {
const reactionType = cleanLifeReactionType(payload.reactionType);
const result = await queryOne<{ postId: number }>(
`
INSERT INTO life_post_reactions (post_id, user_id, reaction_type)
SELECT $1, $2, $3
WHERE EXISTS (
SELECT 1
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()
RETURNING post_id AS "postId"
`,
[postId, userId, reactionType]
);
return result ? getLifePostById(result.postId, userId, locale) : null;
}
export async function deleteLifePostReaction(postId: number, userId: number, locale = defaultLocale) {
await queryOne<{ postId: number }>(
`
DELETE FROM life_post_reactions
WHERE post_id = $1
AND user_id = $2
AND EXISTS (
SELECT 1
FROM life_posts
WHERE id = $1
AND deleted_at IS NULL
AND ai_moderation_status = 'approved'
)
RETURNING post_id AS "postId"
`,
[postId, userId]
);
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);
const result = await queryOne<{ id: number }>(
`
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;
}
export async function createLifeCommentReply(
postId: number,
commentId: number,
payload: Record<string, unknown>,
userId: number
) {
const cleanPayload = cleanLifeCommentPayload(payload);
const result = await queryOne<{ id: number }>(
`
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;
}
export async function deleteLifeComment(id: number, userId: number, allowAny = false) {
const result = await queryOne<{ id: number }>(
`
UPDATE life_post_comments
SET deleted_at = now(), deleted_by_user_id = $2, updated_at = now()
WHERE id = $1
AND ($3 = true OR created_by_user_id = $2)
AND deleted_at IS NULL
RETURNING id
`,
[id, userId, allowAny]
);
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');
}
return value as DiscussionEntityType;
}
function cleanEntityDiscussionCommentPayload(payload: Record<string, unknown>): EntityDiscussionCommentPayload {
const body = cleanName(payload.body, 'server.validation.commentRequired');
if (body.length > 1000) {
throw validationError('server.validation.commentTooLong');
}
return { body, languageCode: cleanModerationLanguageCode(payload.languageCode) };
}
async function entityDiscussionExists(
client: Pick<DbClient, 'query'>,
entityType: DiscussionEntityType,
entityId: number
): Promise<boolean> {
const definition = discussionEntityDefinitions[entityType];
const result = await client.query<{ exists: boolean }>(
`SELECT EXISTS (SELECT 1 FROM ${definition.table} WHERE id = $1) AS exists`,
[entityId]
);
return result.rows[0]?.exists === true;
}
function entityDiscussionCommentProjection(whereClause: string): string {
return `
SELECT
edc.id,
edc.entity_type AS "entityType",
edc.entity_id AS "entityId",
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",
CASE
WHEN edc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL
ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name)
END AS author
FROM entity_discussion_comments edc
LEFT JOIN users comment_user ON comment_user.id = edc.created_by_user_id
${whereClause}
`;
}
function buildEntityDiscussionCommentTree(rows: EntityDiscussionCommentRow[]): EntityDiscussionComment[] {
const comments = new Map<number, EntityDiscussionComment>();
const topLevelComments: EntityDiscussionComment[] = [];
for (const row of rows) {
const { createdAtCursor: _createdAtCursor, ...comment } = row;
comments.set(row.id, { ...comment, replies: [] });
}
for (const comment of comments.values()) {
if (comment.parentCommentId === null) {
topLevelComments.push(comment);
continue;
}
const parent = comments.get(comment.parentCommentId);
if (parent?.parentCommentId === null) {
parent.replies.push(comment);
} else {
topLevelComments.push(comment);
}
}
return topLevelComments;
}
async function getEntityDiscussionCommentById(id: number): Promise<EntityDiscussionComment | null> {
const row = await queryOne<EntityDiscussionCommentRow>(
`
${entityDiscussionCommentProjection('WHERE edc.id = $1')}
`,
[id]
);
if (!row) {
return null;
}
const { createdAtCursor: _createdAtCursor, ...comment } = row;
return { ...comment, replies: [] };
}
export async function listEntityDiscussionComments(
entityTypeValue: string,
entityIdValue: number,
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;
}
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);
topLevelConditions.push(`(edc.created_at, edc.id) > ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
}
params.push(limit + 1);
const topLevelRows = await query<EntityDiscussionCommentRow>(
`
${entityDiscussionCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`)}
ORDER BY edc.created_at, edc.id
LIMIT $${params.length}
`,
params
);
const hasMore = topLevelRows.length > limit;
const topLevelComments = hasMore ? topLevelRows.slice(0, limit) : topLevelRows;
const topLevelIds = topLevelComments.map((comment) => comment.id);
const replyRows = topLevelIds.length
? 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 edc
WHERE ${totalConditions.join(' AND ')}
`,
totalParams
);
return {
items: buildEntityDiscussionCommentTree([...topLevelComments, ...replyRows]),
nextCursor:
hasMore && topLevelComments.length > 0
? encodeProfileCursor({
createdAt:
topLevelComments[topLevelComments.length - 1].createdAtCursor ??
topLevelComments[topLevelComments.length - 1].createdAt.toISOString(),
id: topLevelComments[topLevelComments.length - 1].id
})
: null,
hasMore,
total: total?.total ?? 0
};
}
export async function createEntityDiscussionComment(
entityTypeValue: string,
entityIdValue: number,
payload: Record<string, unknown>,
userId: number
): Promise<EntityDiscussionComment | null> {
const entityType = cleanDiscussionEntityType(entityTypeValue);
const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid');
const cleanPayload = cleanEntityDiscussionCommentPayload(payload);
const id = await withTransaction(async (client) => {
if (!(await entityDiscussionExists(client, entityType, entityId))) {
return null;
}
const result = await client.query<{ id: number }>(
`
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]
);
return result.rows[0].id;
});
if (id) {
await requestAiModerationReview(
{ type: 'discussion-comment', id },
{ languageCode: cleanPayload.languageCode, resetRetries: true }
);
}
return id ? getEntityDiscussionCommentById(id) : null;
}
export async function createEntityDiscussionReply(
entityTypeValue: string,
entityIdValue: number,
commentIdValue: number,
payload: Record<string, unknown>,
userId: number
): Promise<EntityDiscussionComment | null> {
const entityType = cleanDiscussionEntityType(entityTypeValue);
const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid');
const commentId = requirePositiveInteger(commentIdValue, 'server.validation.commentInvalid');
const cleanPayload = cleanEntityDiscussionCommentPayload(payload);
const id = await withTransaction(async (client) => {
if (!(await entityDiscussionExists(client, entityType, entityId))) {
return null;
}
const result = await client.query<{ id: number }>(
`
INSERT INTO entity_discussion_comments (
entity_type,
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, '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]
);
return result.rows[0]?.id ?? null;
});
if (id) {
await requestAiModerationReview(
{ type: 'discussion-comment', id },
{ languageCode: cleanPayload.languageCode, resetRetries: true }
);
}
return id ? getEntityDiscussionCommentById(id) : null;
}
export async function deleteEntityDiscussionComment(id: number, userId: number, allowAny = false): Promise<boolean> {
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
const result = await queryOne<{ id: number }>(
`
UPDATE entity_discussion_comments
SET deleted_at = now(),
deleted_by_user_id = $2,
updated_at = now()
WHERE id = $1
AND ($3 = true OR created_by_user_id = $2)
AND deleted_at IS NULL
RETURNING id
`,
[commentId, userId, allowAny]
);
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,
entityId: number
): Promise<void> {
await client.query(
`
DELETE FROM entity_discussion_comments
WHERE entity_type = $1
AND entity_id = $2
`,
[entityType, entityId]
);
}
export function isConfigType(type: string): type is ConfigType {
return Object.hasOwn(configDefinitions, type);
}
export async function listConfig(type: ConfigType, locale = defaultLocale) {
const definition = configDefinitions[type];
return query(
`
SELECT ${configSelect(definition, locale)}, ${auditSelect('c')}
FROM ${definition.table} c
${auditJoins('c')}
ORDER BY ${configOrder()}
`
);
}
async function getConfigById(type: ConfigType, id: number, locale = defaultLocale) {
const definition = configDefinitions[type];
return queryOne(
`
SELECT ${configSelect(definition, locale)}, ${auditSelect('c')}
FROM ${definition.table} c
${auditJoins('c')}
WHERE c.id = $1
`,
[id]
);
}
export async function createConfig(type: ConfigType, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const definition = configDefinitions[type];
const name = cleanName(payload.name);
const translations = cleanTranslations(payload.translations, ['name']);
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : 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) {
columns.push('has_item_drop');
values.push(hasItemDrop);
}
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);
}
columns.push('sort_order', 'created_by_user_id', 'updated_by_user_id');
values.push(sortOrder, userId, userId);
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
const result = await client.query<{ id: number }>(
`
INSERT INTO ${definition.table} (${columns.join(', ')})
VALUES (${placeholders})
RETURNING id
`,
values
);
const createdId = result.rows[0].id;
await replaceEntityTranslations(client, definition.entityType, createdId, translations, ['name']);
await recordEditLog(client, type, createdId, 'create', userId);
return createdId;
});
return getConfigById(type, id, locale);
}
export async function reorderConfig(type: ConfigType, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const definition = configDefinitions[type];
const ids = cleanIds(payload.ids);
if (ids.length === 0) {
throw validationError('server.validation.selectRecord');
}
await withTransaction(async (client) => {
await reorderTableRows(client, definition.table, type, ids, userId);
});
return listConfig(type, locale);
}
export async function updateConfig(
type: ConfigType,
id: number,
payload: Record<string, unknown>,
userId: number,
locale = defaultLocale
) {
const definition = configDefinitions[type];
const name = cleanName(payload.name);
const translations = cleanTranslations(payload.translations, ['name']);
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : 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) {
values.push(hasItemDrop);
assignments.push(`has_item_drop = $${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}`);
}
values.push(userId);
assignments.push(`updated_by_user_id = $${values.length}`, 'updated_at = now()');
values.push(id);
const result = await client.query(
`
UPDATE ${definition.table}
SET ${assignments.join(', ')}
WHERE id = $${values.length}
`,
values
);
if (result.rowCount === 0) {
return false;
}
if (definition.hasItemDrop && !hasItemDrop) {
await client.query('DELETE FROM pokemon_skill_item_drops WHERE skill_id = $1', [id]);
}
await replaceEntityTranslations(client, definition.entityType, id, translations, ['name']);
const changes = before
? configEditChanges(definition, before as ConfigChangeSource, { name, translations, hasItemDrop, isDefault, isRateable, changeLog })
: [];
await recordEditLog(client, type, id, 'update', userId, changes);
return true;
});
return updated ? getConfigById(type, id, locale) : null;
}
export async function deleteConfig(type: ConfigType, id: number, userId: number) {
const definition = configDefinitions[type];
return withTransaction(async (client) => {
const result = await client.query<{ id: number }>(`DELETE FROM ${definition.table} WHERE id = $1 RETURNING id`, [id]);
if (result.rowCount === 0) {
return false;
}
await deleteEntityTranslations(client, definition.entityType, id);
await recordEditLog(client, type, id, 'delete', userId);
return true;
});
}
async function reorderContent(type: SortableContentType, payload: Record<string, unknown>, userId: number): Promise<void> {
const definition = sortableContentDefinitions[type];
const ids = cleanIds(payload.ids);
if (ids.length === 0) {
throw validationError('server.validation.selectRecord');
}
await withTransaction(async (client) => {
await reorderTableRows(client, definition.table, definition.entityType, ids, userId);
});
}
export async function reorderPokemon(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
await reorderContent('pokemon', payload, userId);
return listPokemon({}, locale);
}
export async function reorderItems(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
await reorderContent('items', payload, userId);
return listItems({}, locale);
}
export async function reorderRecipes(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
await reorderContent('recipes', payload, userId);
return listRecipes({}, locale);
}
export async function reorderHabitats(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
await reorderContent('habitats', payload, userId);
return listHabitats(locale);
}
export async function listPokemon(paramsQuery: QueryParams, locale = defaultLocale) {
const params: unknown[] = [];
const conditions: string[] = [];
const search = asString(paramsQuery.search)?.trim();
const environmentId = Number(asString(paramsQuery.environmentId));
const skillIds = parseIdList(asString(paramsQuery.skillIds));
const favoriteThingIds = parseIdList(asString(paramsQuery.favoriteThingIds));
if (search) {
params.push(`%${search}%`);
conditions.push(`${localizedName('pokemon', 'p', locale)} ILIKE $${params.length}`);
}
if (Number.isInteger(environmentId) && environmentId > 0) {
params.push(environmentId);
conditions.push(`p.environment_id = $${params.length}`);
}
const skillFilter = sqlForRelationFilter(
skillIds,
parseMatchMode(asString(paramsQuery.skillMode)),
'pokemon_skills',
'pokemon_id',
'skill_id',
'p.id',
params
);
if (skillFilter) {
conditions.push(skillFilter);
}
const favoriteThingFilter = sqlForRelationFilter(
favoriteThingIds,
parseMatchMode(asString(paramsQuery.favoriteThingMode)),
'pokemon_favorite_things',
'pokemon_id',
'favorite_thing_id',
'p.id',
params
);
if (favoriteThingFilter) {
conditions.push(favoriteThingFilter);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
return query(`${pokemonProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('p')}`, params);
}
export async function getPokemon(id: number, locale = defaultLocale) {
const pokemon = await queryOne(`${pokemonProjection(locale)} WHERE p.id = $1`, [id]);
if (!pokemon) {
return null;
}
const habitatName = localizedName('habitats', 'h', locale);
const mapName = localizedName('maps', 'm', locale);
const itemName = localizedName('items', 'i', locale);
const categoryName = localizedName('item-categories', 'c', locale);
const tagName = localizedName('favorite-things', 'ft', locale);
const relatedPokemonName = localizedName('pokemon', 'related_pokemon', locale);
const relatedEnvironmentName = localizedName('environments', 'related_environment', locale);
const relatedSkillName = localizedName('skills', 'related_skill', locale);
const relatedFavoriteThingName = localizedName('favorite-things', 'related_favorite_thing', locale);
const [habitats, itemDrops, favoriteThingItems, relatedPokemon, editHistory, imageHistory] = await Promise.all([
query(
`
SELECT
h.id,
${habitatName} AS name,
${uploadedImageJson('h.image_path')} AS image,
hp.time_of_day,
hp.weather,
hp.rarity,
json_build_object('id', m.id, 'name', ${mapName}) AS map
FROM habitat_pokemon hp
JOIN habitats h ON h.id = hp.habitat_id
JOIN maps m ON m.id = hp.map_id
WHERE hp.pokemon_id = $1
ORDER BY ${orderByEntity('h')}, hp.rarity, ${orderByEntity('m')}
`,
[id]
),
query<{ skillId: number; id: number; name: string; image: EntityImageValue | null }>(
`
SELECT psid.skill_id AS "skillId", i.id, ${itemName} AS name, ${uploadedImageJson('i.image_path')} AS image
FROM pokemon_skill_item_drops psid
JOIN skills s ON s.id = psid.skill_id
JOIN items i ON i.id = psid.item_id
WHERE psid.pokemon_id = $1
AND s.has_item_drop = true
ORDER BY ${orderByEntity('s')}, ${orderByEntity('i')}
`,
[id]
),
query(
`
SELECT
i.id,
${itemName} AS name,
${uploadedImageJson('i.image_path')} AS image,
json_build_object('id', c.id, 'name', ${categoryName}) AS category,
json_agg(json_build_object('id', ft.id, 'name', ${tagName}) ORDER BY ${orderByEntity('ft')}) AS tags
FROM pokemon_favorite_things pft
JOIN item_favorite_things ift ON ift.favorite_thing_id = pft.favorite_thing_id
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
JOIN items i ON i.id = ift.item_id
JOIN item_categories c ON c.id = i.category_id
WHERE pft.pokemon_id = $1
GROUP BY i.id, i.name, i.image_path, i.sort_order, c.id, c.name, c.sort_order
ORDER BY ${orderByEntity('c')}, ${orderByEntity('i')}
`,
[id]
),
query(
`
WITH current_pokemon AS (
SELECT p.id, p.environment_id
FROM pokemon p
WHERE p.id = $1
),
current_favourites AS (
SELECT pft.favorite_thing_id
FROM pokemon_favorite_things pft
WHERE pft.pokemon_id = $1
),
scored_pokemon AS (
SELECT
related_pokemon.id,
related_pokemon.sort_order,
(related_pokemon.environment_id = current_pokemon.environment_id) AS "environmentMatches",
COUNT(current_favourites.favorite_thing_id)::integer AS "favoriteThingMatchCount"
FROM current_pokemon
JOIN pokemon related_pokemon ON related_pokemon.id <> current_pokemon.id
LEFT JOIN pokemon_favorite_things related_pokemon_favourite
ON related_pokemon_favourite.pokemon_id = related_pokemon.id
LEFT JOIN current_favourites
ON current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id
GROUP BY related_pokemon.id, related_pokemon.sort_order, related_pokemon.environment_id, current_pokemon.environment_id
HAVING related_pokemon.environment_id = current_pokemon.environment_id
OR COUNT(current_favourites.favorite_thing_id) > 0
)
SELECT
related_pokemon.id,
related_pokemon.display_id AS "displayId",
${relatedPokemonName} AS name,
related_pokemon.is_event_item AS "isEventItem",
${pokemonImageJson('related_pokemon')} AS image,
json_build_object('id', related_environment.id, 'name', ${relatedEnvironmentName}) AS environment,
COALESCE((
SELECT json_agg(
json_build_object(
'id', related_skill.id,
'name', ${relatedSkillName},
'hasItemDrop', related_skill.has_item_drop
)
ORDER BY ${orderByEntity('related_skill')}
)
FROM pokemon_skills related_pokemon_skill
JOIN skills related_skill ON related_skill.id = related_pokemon_skill.skill_id
WHERE related_pokemon_skill.pokemon_id = related_pokemon.id
), '[]'::json) AS skills,
COALESCE((
SELECT json_agg(
json_build_object(
'id', related_favorite_thing.id,
'name', ${relatedFavoriteThingName},
'matches', EXISTS (
SELECT 1
FROM current_favourites
WHERE current_favourites.favorite_thing_id = related_favorite_thing.id
)
)
ORDER BY ${orderByEntity('related_favorite_thing')}
)
FROM pokemon_favorite_things related_pokemon_favourite
JOIN favorite_things related_favorite_thing ON related_favorite_thing.id = related_pokemon_favourite.favorite_thing_id
WHERE related_pokemon_favourite.pokemon_id = related_pokemon.id
), '[]'::json) AS favorite_things
FROM scored_pokemon
JOIN pokemon related_pokemon ON related_pokemon.id = scored_pokemon.id
JOIN environments related_environment ON related_environment.id = related_pokemon.environment_id
ORDER BY scored_pokemon."environmentMatches" DESC, scored_pokemon."favoriteThingMatchCount" DESC, scored_pokemon.sort_order, related_pokemon.id
`,
[id]
),
getEditHistory('pokemon', id),
listEntityImageUploads('pokemon', id)
]);
const dropsBySkill = itemDrops.reduce((itemsBySkill, item) => {
itemsBySkill.set(item.skillId, { id: item.id, name: item.name, image: item.image });
return itemsBySkill;
}, new Map<number, { id: number; name: string; image: EntityImageValue | null }>());
const skills = Array.isArray(pokemon.skills)
? pokemon.skills.map((skill: { id: number; name: string }) => ({
...skill,
itemDrop: dropsBySkill.get(skill.id) ?? null
}))
: [];
return { ...pokemon, skills, habitats, favoriteThingItems, relatedPokemon, editHistory, imageHistory };
}
function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
const cleanTypeIds = cleanIds(payload.typeIds);
const typeIds = cleanTypeIds.slice(0, 2);
const skillIds = cleanIds(payload.skillIds);
const favoriteThingIds = cleanIds(payload.favoriteThingIds);
const selectedSkillIds = new Set(skillIds);
const skillItemDrops = new Map<string, SkillItemDrop>();
if (typeIds.length === 0) {
throw validationError('server.validation.typeMin');
}
if (cleanTypeIds.length > 2) {
throw validationError('server.validation.typeMax');
}
if (skillIds.length > 2) {
throw validationError('server.validation.skillMax');
}
if (favoriteThingIds.length > 6) {
throw validationError('server.validation.favoriteMax');
}
if (Array.isArray(payload.skillItemDrops)) {
for (const item of payload.skillItemDrops) {
const row = item as Record<string, unknown>;
const skillId = Number(row.skillId);
const itemId = Number(row.itemId);
if (!Number.isInteger(itemId) || itemId <= 0) {
continue;
}
if (!Number.isInteger(skillId) || skillId <= 0 || !selectedSkillIds.has(skillId)) {
throw validationError('server.validation.dropItemSelectedSkill');
}
skillItemDrops.set(String(skillId), { skillId, itemId });
}
}
const displayId = requirePositiveInteger(payload.displayId, 'server.validation.pokemonIdRequired');
return {
displayId,
isEventItem: Boolean(payload.isEventItem),
name: cleanName(payload.name, 'server.validation.pokemonNameRequired'),
genus: cleanOptionalText(payload.genus),
details: cleanOptionalText(payload.details),
heightInches: cleanNonNegativeNumber(payload.heightInches, 'server.validation.heightNonNegative'),
weightPounds: cleanNonNegativeNumber(payload.weightPounds, 'server.validation.weightNonNegative'),
translations: cleanTranslations(payload.translations, ['name', 'details', 'genus']),
typeIds,
stats: cleanPokemonStats(payload.stats),
environmentId: requirePositiveInteger(payload.environmentId, 'server.validation.environmentRequired'),
skillIds,
favoriteThingIds,
skillItemDrops: [...skillItemDrops.values()],
image: cleanPokemonImage(payload.imagePath, displayId)
};
}
async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload): Promise<void> {
await client.query('DELETE FROM pokemon_skill_item_drops WHERE pokemon_id = $1', [pokemonId]);
await client.query('DELETE FROM pokemon_pokemon_types WHERE pokemon_id = $1', [pokemonId]);
await client.query('DELETE FROM pokemon_skills WHERE pokemon_id = $1', [pokemonId]);
await client.query('DELETE FROM pokemon_favorite_things WHERE pokemon_id = $1', [pokemonId]);
for (const [index, typeId] of payload.typeIds.entries()) {
await client.query('INSERT INTO pokemon_pokemon_types (pokemon_id, type_id, slot_order) VALUES ($1, $2, $3)', [
pokemonId,
typeId,
index + 1
]);
}
for (const skillId of payload.skillIds) {
await client.query('INSERT INTO pokemon_skills (pokemon_id, skill_id) VALUES ($1, $2)', [pokemonId, skillId]);
}
for (const favoriteThingId of payload.favoriteThingIds) {
await client.query('INSERT INTO pokemon_favorite_things (pokemon_id, favorite_thing_id) VALUES ($1, $2)', [
pokemonId,
favoriteThingId
]);
}
if (payload.skillItemDrops.length > 0) {
const allowedDrops = await client.query<{ id: number }>(
'SELECT id FROM skills WHERE id = ANY($1::integer[]) AND has_item_drop = true',
[payload.skillItemDrops.map((drop) => drop.skillId)]
);
const allowedDropSkillIds = new Set(allowedDrops.rows.map((row) => row.id));
if (payload.skillItemDrops.some((drop) => !allowedDropSkillIds.has(drop.skillId))) {
throw validationError('server.validation.skillNoDrop');
}
}
for (const drop of payload.skillItemDrops) {
await client.query(
'INSERT INTO pokemon_skill_item_drops (pokemon_id, skill_id, item_id) VALUES ($1, $2, $3)',
[pokemonId, drop.skillId, drop.itemId]
);
}
}
export async function createPokemon(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanPokemonPayload(payload);
const id = await withTransaction(async (client) => {
const pokemonId = await nextPokemonInternalId(client, cleanPayload.displayId, cleanPayload.isEventItem);
const sortOrder = await nextSortOrder(client, 'pokemon');
await client.query(
`
INSERT INTO pokemon (
id,
display_id,
name,
is_event_item,
genus,
details,
height_inches,
weight_pounds,
environment_id,
hp,
attack,
defense,
special_attack,
special_defense,
speed,
image_path,
image_style,
image_version,
image_variant,
image_description,
sort_order,
created_by_user_id,
updated_by_user_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $22)
`,
[
pokemonId,
cleanPayload.displayId,
cleanPayload.name,
cleanPayload.isEventItem,
cleanPayload.genus,
cleanPayload.details,
cleanPayload.heightInches,
cleanPayload.weightPounds,
cleanPayload.environmentId,
cleanPayload.stats.hp,
cleanPayload.stats.attack,
cleanPayload.stats.defense,
cleanPayload.stats.specialAttack,
cleanPayload.stats.specialDefense,
cleanPayload.stats.speed,
cleanPayload.image?.path ?? '',
cleanPayload.image?.style ?? '',
cleanPayload.image?.version ?? '',
cleanPayload.image?.variant ?? '',
cleanPayload.image?.description ?? '',
sortOrder,
userId
]
);
await linkEntityImageUpload(client, 'pokemon', pokemonId, cleanPayload.image?.path, cleanPayload.name);
await replacePokemonRelations(client, pokemonId, cleanPayload);
await replaceEntityTranslations(client, 'pokemon', pokemonId, cleanPayload.translations, ['name', 'details', 'genus']);
await recordEditLog(client, 'pokemon', pokemonId, 'create', userId);
return pokemonId;
});
return getPokemon(id, locale);
}
export async function updatePokemon(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanPokemonPayload(payload);
const before = await getPokemon(id, defaultLocale);
const updated = await withTransaction(async (client) => {
const result = await client.query(
`
UPDATE pokemon
SET
display_id = $1,
name = $2,
is_event_item = $3,
genus = $4,
details = $5,
height_inches = $6,
weight_pounds = $7,
environment_id = $8,
hp = $9,
attack = $10,
defense = $11,
special_attack = $12,
special_defense = $13,
speed = $14,
image_path = $15,
image_style = $16,
image_version = $17,
image_variant = $18,
image_description = $19,
updated_by_user_id = $20,
updated_at = now()
WHERE id = $21
`,
[
cleanPayload.displayId,
cleanPayload.name,
cleanPayload.isEventItem,
cleanPayload.genus,
cleanPayload.details,
cleanPayload.heightInches,
cleanPayload.weightPounds,
cleanPayload.environmentId,
cleanPayload.stats.hp,
cleanPayload.stats.attack,
cleanPayload.stats.defense,
cleanPayload.stats.specialAttack,
cleanPayload.stats.specialDefense,
cleanPayload.stats.speed,
cleanPayload.image?.path ?? '',
cleanPayload.image?.style ?? '',
cleanPayload.image?.version ?? '',
cleanPayload.image?.variant ?? '',
cleanPayload.image?.description ?? '',
userId,
id
]
);
if (result.rowCount === 0) {
return false;
}
await linkEntityImageUpload(client, 'pokemon', id, cleanPayload.image?.path, cleanPayload.name);
await replacePokemonRelations(client, id, cleanPayload);
await replaceEntityTranslations(client, 'pokemon', id, cleanPayload.translations, ['name', 'details', 'genus']);
const changes = before ? await pokemonEditChanges(client, before as unknown as PokemonChangeSource, cleanPayload) : [];
await recordEditLog(client, 'pokemon', id, 'update', userId, changes);
return true;
});
return updated ? getPokemon(id, locale) : null;
}
export async function deletePokemon(id: number, userId: number) {
return withTransaction(async (client) => {
const result = await client.query<{ id: number }>('DELETE FROM pokemon WHERE id = $1 RETURNING id', [id]);
if (result.rowCount === 0) {
return false;
}
await deleteEntityDiscussionCommentsForEntity(client, 'pokemon', id);
await deleteEntityTranslations(client, 'pokemon', id);
await recordEditLog(client, 'pokemon', id, 'delete', userId);
return true;
});
}
export async function listHabitats(locale = defaultLocale) {
const habitatName = localizedName('habitats', 'h', locale);
const itemName = localizedName('items', 'i', locale);
const pokemonName = localizedName('pokemon', 'p', locale);
return query(`
SELECT
h.id,
${habitatName} AS name,
h.name AS "baseName",
h.is_event_item AS "isEventItem",
${translationsSelect('habitats', 'h.id')} AS translations,
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
${uploadedImageJson('h.image_path')} AS image,
COALESCE((
SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${orderByEntity('i')})
FROM habitat_recipe_items hri
JOIN items i ON i.id = hri.item_id
WHERE hri.habitat_id = h.id
), '[]'::json) AS recipe,
COALESCE((
SELECT json_agg(
json_build_object(
'id', pokemon_rows.id,
'displayId', pokemon_rows.display_id,
'name', pokemon_rows.name,
'isEventItem', pokemon_rows.is_event_item
)
ORDER BY pokemon_rows.sort_order, pokemon_rows.id
)
FROM (
SELECT DISTINCT p.id, p.display_id, ${pokemonName} AS name, p.is_event_item, p.sort_order
FROM habitat_pokemon hp
JOIN pokemon p ON p.id = hp.pokemon_id
WHERE hp.habitat_id = h.id
) pokemon_rows
), '[]'::json) AS pokemon
FROM habitats h
${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')}
ORDER BY ${orderByEntity('h')}
`);
}
export async function getHabitat(id: number, locale = defaultLocale) {
const habitatName = localizedName('habitats', 'h', locale);
const itemName = localizedName('items', 'i', locale);
const pokemonName = localizedName('pokemon', 'p', locale);
const mapName = localizedName('maps', 'm', locale);
const habitat = await queryOne(
`
SELECT
h.id,
${habitatName} AS name,
h.name AS "baseName",
h.is_event_item AS "isEventItem",
${translationsSelect('habitats', 'h.id')} AS translations,
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
${uploadedImageJson('h.image_path')} AS image,
COALESCE((
SELECT json_agg(
json_build_object(
'id', i.id,
'name', ${itemName},
'image', ${uploadedImageJson('i.image_path')},
'quantity', hri.quantity
)
ORDER BY ${orderByEntity('i')}
)
FROM habitat_recipe_items hri
JOIN items i ON i.id = hri.item_id
WHERE hri.habitat_id = h.id
), '[]'::json) AS recipe
FROM habitats h
${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')}
WHERE h.id = $1
`,
[id]
);
if (!habitat) {
return null;
}
const [pokemon, editHistory, imageHistory] = await Promise.all([
query(
`
SELECT
p.id,
p.display_id AS "displayId",
${pokemonName} AS name,
p.is_event_item AS "isEventItem",
${pokemonImageJson('p')} AS image,
hp.time_of_day,
hp.weather,
hp.rarity,
json_build_object('id', m.id, 'name', ${mapName}) AS map
FROM habitat_pokemon hp
JOIN pokemon p ON p.id = hp.pokemon_id
JOIN maps m ON m.id = hp.map_id
WHERE hp.habitat_id = $1
ORDER BY hp.rarity, ${orderByEntity('p')}, ${orderByEntity('m')}
`,
[id]
),
getEditHistory('habitats', id),
listEntityImageUploads('habitats', id)
]);
return { ...habitat, pokemon, editHistory, imageHistory };
}
function cleanHabitatPayload(payload: Record<string, unknown>): HabitatPayload {
const appearances = Array.isArray(payload.pokemonAppearances) ? payload.pokemonAppearances : [];
const pokemonAppearances = new Map<string, HabitatPayload['pokemonAppearances'][number]>();
for (const item of appearances) {
const row = item as Record<string, unknown>;
const pokemonId = Number(row.pokemonId);
const mapIds = cleanIdValues(row.mapIds);
const selectedTimeOfDays = cleanOptions(row.timeOfDays, timeOfDays);
const selectedWeathers = cleanOptions(row.weathers, weathers);
const rarity = Number(row.rarity);
if (!Number.isInteger(pokemonId) || pokemonId <= 0 || !Number.isInteger(rarity) || rarity < 1 || rarity > 3) {
continue;
}
for (const mapId of mapIds) {
for (const timeOfDay of selectedTimeOfDays) {
for (const weather of selectedWeathers) {
pokemonAppearances.set(`${pokemonId}:${mapId}:${timeOfDay}:${weather}`, {
pokemonId,
mapId,
timeOfDay,
weather,
rarity
});
}
}
}
}
return {
name: cleanName(payload.name, 'server.validation.habitatNameRequired'),
translations: cleanTranslations(payload.translations, ['name']),
isEventItem: Boolean(payload.isEventItem),
imagePath: cleanUploadImagePath(payload.imagePath, 'habitats'),
recipeItems: cleanQuantities(payload.recipeItems),
pokemonAppearances: [...pokemonAppearances.values()]
};
}
async function replaceHabitatRelations(client: DbClient, habitatId: number, payload: HabitatPayload): Promise<void> {
await client.query('DELETE FROM habitat_recipe_items WHERE habitat_id = $1', [habitatId]);
await client.query('DELETE FROM habitat_pokemon WHERE habitat_id = $1', [habitatId]);
for (const item of payload.recipeItems) {
await client.query('INSERT INTO habitat_recipe_items (habitat_id, item_id, quantity) VALUES ($1, $2, $3)', [
habitatId,
item.itemId,
item.quantity
]);
}
for (const item of payload.pokemonAppearances) {
await client.query(
`
INSERT INTO habitat_pokemon (habitat_id, pokemon_id, map_id, time_of_day, weather, rarity)
VALUES ($1, $2, $3, $4, $5, $6)
`,
[habitatId, item.pokemonId, item.mapId, item.timeOfDay, item.weather, item.rarity]
);
}
}
export async function createHabitat(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanHabitatPayload(payload);
const id = await withTransaction(async (client) => {
const sortOrder = await nextSortOrder(client, 'habitats');
const result = await client.query<{ id: number }>(
`
INSERT INTO habitats (name, is_event_item, image_path, sort_order, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $3, $4, $5, $5)
RETURNING id
`,
[cleanPayload.name, cleanPayload.isEventItem, cleanPayload.imagePath, sortOrder, userId]
);
const habitatId = result.rows[0].id;
await linkEntityImageUpload(client, 'habitats', habitatId, cleanPayload.imagePath, cleanPayload.name);
await replaceHabitatRelations(client, habitatId, cleanPayload);
await replaceEntityTranslations(client, 'habitats', habitatId, cleanPayload.translations, ['name']);
await recordEditLog(client, 'habitats', habitatId, 'create', userId);
return habitatId;
});
return getHabitat(id, locale);
}
export async function updateHabitat(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanHabitatPayload(payload);
const before = await getHabitat(id, defaultLocale);
const updated = await withTransaction(async (client) => {
const result = await client.query(
'UPDATE habitats SET name = $1, is_event_item = $2, image_path = $3, updated_by_user_id = $4, updated_at = now() WHERE id = $5',
[cleanPayload.name, cleanPayload.isEventItem, cleanPayload.imagePath, userId, id]
);
if (result.rowCount === 0) {
return false;
}
await linkEntityImageUpload(client, 'habitats', id, cleanPayload.imagePath, cleanPayload.name);
await replaceHabitatRelations(client, id, cleanPayload);
await replaceEntityTranslations(client, 'habitats', id, cleanPayload.translations, ['name']);
const changes = before ? await habitatEditChanges(client, before as unknown as HabitatChangeSource, cleanPayload) : [];
await recordEditLog(client, 'habitats', id, 'update', userId, changes);
return true;
});
return updated ? getHabitat(id, locale) : null;
}
export async function deleteHabitat(id: number, userId: number) {
return withTransaction(async (client) => {
const result = await client.query<{ id: number }>('DELETE FROM habitats WHERE id = $1 RETURNING id', [id]);
if (result.rowCount === 0) {
return false;
}
await deleteEntityDiscussionCommentsForEntity(client, 'habitats', id);
await deleteEntityTranslations(client, 'habitats', id);
await recordEditLog(client, 'habitats', id, 'delete', userId);
return true;
});
}
function itemProjection(locale: string): string {
const itemName = localizedName('items', 'i', locale);
const categoryName = localizedName('item-categories', 'c', locale);
const usageName = localizedName('item-usages', 'u', locale);
const tagName = localizedName('favorite-things', 't', locale);
return `
SELECT
i.id,
${itemName} AS name,
i.name AS "baseName",
i.is_event_item AS "isEventItem",
${translationsSelect('items', 'i.id')} AS translations,
${auditSelect('i', 'item_created_user', 'item_updated_user')},
${uploadedImageJson('i.image_path')} AS image,
json_build_object('id', c.id, 'name', ${categoryName}) AS category,
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'name', ${usageName}) END AS usage,
json_build_object(
'dyeable', i.dyeable,
'dualDyeable', i.dual_dyeable,
'patternEditable', i.pattern_editable
) AS customization,
i.no_recipe AS "noRecipe",
COALESCE((
SELECT json_agg(json_build_object('id', t.id, 'name', ${tagName}) ORDER BY ${orderByEntity('t')})
FROM item_favorite_things ift
JOIN favorite_things t ON t.id = ift.favorite_thing_id
WHERE ift.item_id = i.id
), '[]'::json) AS tags,
CASE
WHEN item_recipe.id IS NULL THEN NULL
ELSE json_build_object(
'id', item_recipe.id,
'createdAt', item_recipe.created_at,
'updatedAt', item_recipe.updated_at,
'createdBy', CASE
WHEN recipe_created_user.id IS NULL THEN NULL
ELSE json_build_object('id', recipe_created_user.id, 'displayName', recipe_created_user.display_name)
END,
'updatedBy', CASE
WHEN recipe_updated_user.id IS NULL THEN NULL
ELSE json_build_object('id', recipe_updated_user.id, 'displayName', recipe_updated_user.display_name)
END
)
END AS recipe
FROM items i
JOIN item_categories c ON c.id = i.category_id
LEFT JOIN item_usages u ON u.id = i.usage_id
LEFT JOIN recipes item_recipe ON item_recipe.item_id = i.id
LEFT JOIN users recipe_created_user ON recipe_created_user.id = item_recipe.created_by_user_id
LEFT JOIN users recipe_updated_user ON recipe_updated_user.id = item_recipe.updated_by_user_id
${auditJoins('i', 'item_created_user', 'item_updated_user')}
`;
}
export async function listItems(paramsQuery: QueryParams, locale = defaultLocale) {
const params: unknown[] = [];
const conditions: string[] = [];
const categoryId = Number(asString(paramsQuery.categoryId));
const usageId = Number(asString(paramsQuery.usageId));
const tagIds = parseIdList(asString(paramsQuery.tagIds));
const search = asString(paramsQuery.search)?.trim();
const recipeOrder = asString(paramsQuery.recipeOrder) === '1';
if (search) {
params.push(`%${search}%`);
conditions.push(`${localizedName('items', 'i', locale)} ILIKE $${params.length}`);
}
if (Number.isInteger(categoryId) && categoryId > 0) {
params.push(categoryId);
conditions.push(`i.category_id = $${params.length}`);
}
if (Number.isInteger(usageId) && usageId > 0) {
params.push(usageId);
conditions.push(`i.usage_id = $${params.length}`);
}
const tagFilter = sqlForRelationFilter(
tagIds,
'any',
'item_favorite_things',
'item_id',
'favorite_thing_id',
'i.id',
params
);
if (tagFilter) {
conditions.push(tagFilter);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const orderClause = recipeOrder
? `ORDER BY CASE WHEN item_recipe.id IS NULL THEN 1 ELSE 0 END, item_recipe.sort_order, item_recipe.id, ${orderByEntity('i')}`
: `ORDER BY ${orderByEntity('i')}`;
return query(`${itemProjection(locale)} ${whereClause} ${orderClause}`, params);
}
export async function getItem(id: number, locale = defaultLocale) {
const item = await queryOne(`${itemProjection(locale)} WHERE i.id = $1`, [id]);
if (!item) {
return null;
}
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
const resultItemName = localizedName('items', 'result_item', locale);
const resultItemCategoryName = localizedName('item-categories', 'result_category', locale);
const resultItemUsageName = localizedName('item-usages', 'result_usage', locale);
const materialItemName = localizedName('items', 'mi', locale);
const habitatName = localizedName('habitats', 'h', locale);
const recipeItemName = localizedName('items', 'recipe_item', locale);
const pokemonName = localizedName('pokemon', 'p', locale);
const skillName = localizedName('skills', 's', locale);
const [acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory, imageHistory] = await Promise.all([
query(
`
SELECT am.id, ${acquisitionMethodName} AS name
FROM item_acquisition_methods iam
JOIN acquisition_methods am ON am.id = iam.acquisition_method_id
WHERE iam.item_id = $1
ORDER BY ${orderByEntity('am')}
`,
[id]
),
queryOne(
`
SELECT
r.id,
${resultItemName} AS name,
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
COALESCE((
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${orderByEntity('am')})
FROM recipe_acquisition_methods ram
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
WHERE ram.recipe_id = r.id
), '[]'::json) AS acquisition_methods,
COALESCE((
SELECT json_agg(
json_build_object(
'id', mi.id,
'name', ${materialItemName},
'image', ${uploadedImageJson('mi.image_path')},
'quantity', rm.quantity
)
ORDER BY ${orderByEntity('mi')}
)
FROM recipe_materials rm
JOIN items mi ON mi.id = rm.item_id
WHERE rm.recipe_id = r.id
), '[]'::json) AS materials,
json_build_object(
'id', result_item.id,
'name', ${resultItemName},
'image', ${uploadedImageJson('result_item.image_path')},
'category', json_build_object('id', result_category.id, 'name', ${resultItemCategoryName}),
'usage', CASE
WHEN result_usage.id IS NULL THEN NULL
ELSE json_build_object('id', result_usage.id, 'name', ${resultItemUsageName})
END
) AS item
FROM recipes r
JOIN items result_item ON result_item.id = r.item_id
JOIN item_categories result_category ON result_category.id = result_item.category_id
LEFT JOIN item_usages result_usage ON result_usage.id = result_item.usage_id
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
WHERE r.item_id = $1
`,
[id]
),
query(
`
SELECT
r.id,
${resultItemName} AS name,
${uploadedImageJson('result_item.image_path')} AS image,
COALESCE((
SELECT json_agg(
json_build_object(
'id', mi.id,
'name', ${materialItemName},
'image', ${uploadedImageJson('mi.image_path')},
'quantity', recipe_material.quantity
)
ORDER BY ${orderByEntity('mi')}
)
FROM recipe_materials recipe_material
JOIN items mi ON mi.id = recipe_material.item_id
WHERE recipe_material.recipe_id = r.id
), '[]'::json) AS materials
FROM recipe_materials used_material
JOIN recipes r ON r.id = used_material.recipe_id
JOIN items result_item ON result_item.id = r.item_id
WHERE used_material.item_id = $1
ORDER BY ${orderByEntity('r')}
`,
[id]
),
query(
`
SELECT
h.id,
${habitatName} AS name,
${uploadedImageJson('h.image_path')} AS image,
COALESCE((
SELECT json_agg(
json_build_object(
'id', recipe_item.id,
'name', ${recipeItemName},
'image', ${uploadedImageJson('recipe_item.image_path')},
'quantity', recipe_item_row.quantity
)
ORDER BY ${orderByEntity('recipe_item')}
)
FROM habitat_recipe_items recipe_item_row
JOIN items recipe_item ON recipe_item.id = recipe_item_row.item_id
WHERE recipe_item_row.habitat_id = h.id
), '[]'::json) AS recipe
FROM habitat_recipe_items used_item
JOIN habitats h ON h.id = used_item.habitat_id
WHERE used_item.item_id = $1
ORDER BY ${orderByEntity('h')}
`,
[id]
),
query(
`
SELECT
json_build_object(
'id', p.id,
'displayId', p.display_id,
'name', ${pokemonName},
'isEventItem', p.is_event_item,
'image', ${pokemonImageJson('p')}
) AS pokemon,
json_build_object('id', s.id, 'name', ${skillName}) AS skill
FROM pokemon_skill_item_drops psid
JOIN pokemon p ON p.id = psid.pokemon_id
JOIN skills s ON s.id = psid.skill_id
WHERE psid.item_id = $1
AND s.has_item_drop = true
ORDER BY ${orderByEntity('p')}, ${orderByEntity('s')}
`,
[id]
),
getEditHistory('items', id),
listEntityImageUploads('items', id)
]);
return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory, imageHistory };
}
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined
? null
: requirePositiveInteger(payload.usageId, 'server.validation.usageRequired');
return {
name: cleanName(payload.name, 'server.validation.itemNameRequired'),
translations: cleanTranslations(payload.translations, ['name']),
categoryId: requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired'),
usageId,
dyeable: Boolean(payload.dyeable),
dualDyeable: Boolean(payload.dualDyeable),
patternEditable: Boolean(payload.patternEditable),
noRecipe: Boolean(payload.noRecipe),
isEventItem: Boolean(payload.isEventItem),
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
tagIds: cleanIds(payload.tagIds),
imagePath: cleanUploadImagePath(payload.imagePath, 'items')
};
}
async function ensureItemCanDisableRecipe(client: DbClient, itemId: number, noRecipe: boolean): Promise<void> {
if (!noRecipe) {
return;
}
const result = await client.query('SELECT 1 FROM recipes WHERE item_id = $1', [itemId]);
if (result.rowCount && result.rowCount > 0) {
throw validationError('server.validation.recipeFreeWithRecipe');
}
}
async function replaceItemRelations(client: DbClient, itemId: number, payload: ItemPayload): Promise<void> {
await client.query('DELETE FROM item_acquisition_methods WHERE item_id = $1', [itemId]);
await client.query('DELETE FROM item_favorite_things WHERE item_id = $1', [itemId]);
for (const methodId of payload.acquisitionMethodIds) {
await client.query('INSERT INTO item_acquisition_methods (item_id, acquisition_method_id) VALUES ($1, $2)', [
itemId,
methodId
]);
}
for (const tagId of payload.tagIds) {
await client.query('INSERT INTO item_favorite_things (item_id, favorite_thing_id) VALUES ($1, $2)', [
itemId,
tagId
]);
}
}
export async function createItem(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanItemPayload(payload);
const id = await withTransaction(async (client) => {
const sortOrder = await nextSortOrder(client, 'items');
const result = await client.query<{ id: number }>(
`
INSERT INTO items (
name,
category_id,
usage_id,
dyeable,
dual_dyeable,
pattern_editable,
no_recipe,
is_event_item,
image_path,
sort_order,
created_by_user_id,
updated_by_user_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)
RETURNING id
`,
[
cleanPayload.name,
cleanPayload.categoryId,
cleanPayload.usageId,
cleanPayload.dyeable,
cleanPayload.dualDyeable,
cleanPayload.patternEditable,
cleanPayload.noRecipe,
cleanPayload.isEventItem,
cleanPayload.imagePath,
sortOrder,
userId
]
);
const itemId = result.rows[0].id;
await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name);
await replaceItemRelations(client, itemId, cleanPayload);
await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name']);
await recordEditLog(client, 'items', itemId, 'create', userId);
return itemId;
});
return getItem(id, locale);
}
export async function updateItem(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanItemPayload(payload);
const before = await getItem(id, defaultLocale);
const updated = await withTransaction(async (client) => {
await ensureItemCanDisableRecipe(client, id, cleanPayload.noRecipe);
const result = await client.query(
`
UPDATE items
SET name = $1,
category_id = $2,
usage_id = $3,
dyeable = $4,
dual_dyeable = $5,
pattern_editable = $6,
no_recipe = $7,
is_event_item = $8,
image_path = $9,
updated_by_user_id = $10,
updated_at = now()
WHERE id = $11
`,
[
cleanPayload.name,
cleanPayload.categoryId,
cleanPayload.usageId,
cleanPayload.dyeable,
cleanPayload.dualDyeable,
cleanPayload.patternEditable,
cleanPayload.noRecipe,
cleanPayload.isEventItem,
cleanPayload.imagePath,
userId,
id
]
);
if (result.rowCount === 0) {
return false;
}
await linkEntityImageUpload(client, 'items', id, cleanPayload.imagePath, cleanPayload.name);
await replaceItemRelations(client, id, cleanPayload);
await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name']);
const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : [];
await recordEditLog(client, 'items', id, 'update', userId, changes);
return true;
});
return updated ? getItem(id, locale) : null;
}
export async function deleteItem(id: number, userId: number) {
return withTransaction(async (client) => {
const result = await client.query<{ id: number }>('DELETE FROM items WHERE id = $1 RETURNING id', [id]);
if (result.rowCount === 0) {
return false;
}
await deleteEntityDiscussionCommentsForEntity(client, 'items', id);
await deleteEntityTranslations(client, 'items', id);
await recordEditLog(client, 'items', id, 'delete', userId);
return true;
});
}
export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaultLocale) {
const params: unknown[] = [];
const conditions: string[] = [];
const categoryId = Number(asString(paramsQuery.categoryId));
const resultItemName = localizedName('items', 'result_item', locale);
const materialItemName = localizedName('items', 'i', locale);
if (Number.isInteger(categoryId) && categoryId > 0) {
params.push(categoryId);
conditions.push(`result_item.category_id = $${params.length}`);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
return query(`
SELECT
r.id,
${resultItemName} AS name,
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
COALESCE((
SELECT json_agg(json_build_object('id', i.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${orderByEntity('i')})
FROM recipe_materials rm
JOIN items i ON i.id = rm.item_id
WHERE rm.recipe_id = r.id
), '[]'::json) AS materials
FROM recipes r
JOIN items result_item ON result_item.id = r.item_id
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
${whereClause}
ORDER BY ${orderByEntity('r')}
`, params);
}
export async function getRecipe(id: number, locale = defaultLocale) {
const resultItemName = localizedName('items', 'result_item', locale);
const resultItemCategoryName = localizedName('item-categories', 'result_category', locale);
const resultItemUsageName = localizedName('item-usages', 'result_usage', locale);
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
const materialItemName = localizedName('items', 'i', locale);
const recipe = await queryOne(
`
SELECT
r.id,
${resultItemName} AS name,
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
COALESCE((
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${orderByEntity('am')})
FROM recipe_acquisition_methods ram
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
WHERE ram.recipe_id = r.id
), '[]'::json) AS acquisition_methods,
COALESCE((
SELECT json_agg(
json_build_object(
'id', i.id,
'name', ${materialItemName},
'image', ${uploadedImageJson('i.image_path')},
'quantity', rm.quantity
)
ORDER BY ${orderByEntity('i')}
)
FROM recipe_materials rm
JOIN items i ON i.id = rm.item_id
WHERE rm.recipe_id = r.id
), '[]'::json) AS materials,
json_build_object(
'id', result_item.id,
'name', ${resultItemName},
'image', ${uploadedImageJson('result_item.image_path')},
'category', json_build_object('id', result_category.id, 'name', ${resultItemCategoryName}),
'usage', CASE
WHEN result_usage.id IS NULL THEN NULL
ELSE json_build_object('id', result_usage.id, 'name', ${resultItemUsageName})
END
) AS item
FROM recipes r
JOIN items result_item ON result_item.id = r.item_id
JOIN item_categories result_category ON result_category.id = result_item.category_id
LEFT JOIN item_usages result_usage ON result_usage.id = result_item.usage_id
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
WHERE r.id = $1
`,
[id]
);
if (!recipe) {
return null;
}
const editHistory = await getEditHistory('recipes', id);
return { ...recipe, editHistory };
}
function cleanRecipePayload(payload: Record<string, unknown>): RecipePayload {
return {
itemId: requirePositiveInteger(payload.itemId, 'server.validation.itemRequired'),
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
materials: cleanQuantities(payload.materials)
};
}
async function replaceRecipeRelations(client: DbClient, recipeId: number, payload: RecipePayload): Promise<void> {
await client.query('DELETE FROM recipe_acquisition_methods WHERE recipe_id = $1', [recipeId]);
await client.query('DELETE FROM recipe_materials WHERE recipe_id = $1', [recipeId]);
for (const methodId of payload.acquisitionMethodIds) {
await client.query('INSERT INTO recipe_acquisition_methods (recipe_id, acquisition_method_id) VALUES ($1, $2)', [
recipeId,
methodId
]);
}
for (const material of payload.materials) {
await client.query('INSERT INTO recipe_materials (recipe_id, item_id, quantity) VALUES ($1, $2, $3)', [
recipeId,
material.itemId,
material.quantity
]);
}
}
async function ensureItemCanHaveRecipe(client: DbClient, itemId: number): Promise<void> {
const result = await client.query<{ no_recipe: boolean }>('SELECT no_recipe FROM items WHERE id = $1', [itemId]);
if (result.rowCount === 0) {
throw validationError('server.validation.itemRequired');
}
if (result.rows[0].no_recipe) {
throw validationError('server.validation.recipeFreeItem');
}
}
export async function createRecipe(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanRecipePayload(payload);
const id = await withTransaction(async (client) => {
await ensureItemCanHaveRecipe(client, cleanPayload.itemId);
const sortOrder = await nextSortOrder(client, 'recipes');
const result = await client.query<{ id: number }>(
`
INSERT INTO recipes (item_id, sort_order, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $3, $3)
RETURNING id
`,
[cleanPayload.itemId, sortOrder, userId]
);
const recipeId = result.rows[0].id;
await replaceRecipeRelations(client, recipeId, cleanPayload);
await recordEditLog(client, 'recipes', recipeId, 'create', userId);
return recipeId;
});
return getRecipe(id, locale);
}
export async function updateRecipe(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanRecipePayload(payload);
const before = await getRecipe(id, defaultLocale);
const updated = await withTransaction(async (client) => {
await ensureItemCanHaveRecipe(client, cleanPayload.itemId);
const result = await client.query(
'UPDATE recipes SET item_id = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3',
[cleanPayload.itemId, userId, id]
);
if (result.rowCount === 0) {
return false;
}
await replaceRecipeRelations(client, id, cleanPayload);
const changes = before ? await recipeEditChanges(client, before as unknown as RecipeChangeSource, cleanPayload) : [];
await recordEditLog(client, 'recipes', id, 'update', userId, changes);
return true;
});
return updated ? getRecipe(id, locale) : null;
}
export async function deleteRecipe(id: number, userId: number) {
return withTransaction(async (client) => {
const result = await client.query<{ id: number }>('DELETE FROM recipes WHERE id = $1 RETURNING id', [id]);
if (result.rowCount === 0) {
return false;
}
await deleteEntityDiscussionCommentsForEntity(client, 'recipes', id);
await recordEditLog(client, 'recipes', id, 'delete', userId);
return true;
});
}