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; type DbClient = PoolClient; type TranslationField = 'name' | 'title' | 'details' | 'genus'; type TranslationInput = Record>>; type EntityType = | 'pokemon' | 'pokemon-types' | 'skills' | 'environments' | 'favorite-things' | 'item-categories' | 'item-usages' | 'acquisition-methods' | 'items' | 'maps' | 'habitats' | 'daily-checklist-items' | 'life-tags'; type ConfigType = | 'pokemon-types' | 'skills' | 'environments' | 'favorite-things' | 'item-categories' | 'item-usages' | 'acquisition-methods' | 'maps' | 'life-tags'; type ConfigDefinition = { table: string; entityType: EntityType; hasItemDrop?: 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; 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; type PokemonCsvData = { pokemonRows: CsvRow[]; pokemonByLookup: Map; namesByPokemonId: Map; genusByPokemonId: Map; typesById: Map; 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; tagIds: number[]; 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 & { replies: EntityDiscussionComment[]; }; type EntityDiscussionCommentsPage = { items: EntityDiscussionComment[]; nextCursor: string | null; hasMore: boolean; total: number; }; type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks'; type LifeReactionCounts = Record; 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 & { 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; tags: Array<{ id: number; name: string }>; }; type LifePost = Omit & { commentPreview: LifeComment[]; commentCount: number; reactionCounts: LifeReactionCounts; myReaction: LifeReactionType | null; }; type LifePostCursor = { createdAt: string; id: number; }; 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 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 }>; }; 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 }>; }; 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 } }>; }; type RecipeChangeSource = { item: { name: string }; acquisition_methods: Array<{ name: string }>; materials: Array<{ name: string; quantity: number }>; }; 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 = { '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' } }; const sortableContentDefinitions: Record = { pokemon: { table: 'pokemon', entityType: 'pokemon' }, items: { table: 'items', entityType: 'items' }, recipes: { table: 'recipes', entityType: 'recipes' }, habitats: { table: 'habitats', entityType: 'habitats' } }; const discussionEntityDefinitions: Record = { pokemon: { table: 'pokemon' }, items: { table: 'items' }, recipes: { table: 'recipes' }, habitats: { table: 'habitats' } }; let pokemonCsvDataCache: Promise | 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)) { if (!localePattern.test(locale) || locale === defaultLocale || !fields || typeof fields !== 'object' || Array.isArray(fields)) { continue; } const cleanFields: Partial> = {}; for (const [fieldName, fieldValue] of Object.entries(fields as Record)) { 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 { 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 { 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> { const name = localizedName(entityType, 'o', locale); return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`); } function skillOptions(locale: string): Promise> { 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'); return definition.hasItemDrop ? `c.id, ${name} AS name, c.name AS "baseName", ${translations} AS translations, c.has_item_drop AS "hasItemDrop"` : `c.id, ${name} AS name, c.name AS "baseName", ${translations} AS translations`; } 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 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) : {}; 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; 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(callback: (client: DbClient) => Promise): Promise { 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 { 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 { 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 { const existing = await client.query<{ id: number }>( `SELECT id FROM ${tableName} WHERE id = ANY($1::integer[])`, [ids] ); if (existing.rowCount !== ids.length) { throw validationError('server.validation.recordMissing'); } for (const [index, id] of ids.entries()) { await client.query( ` UPDATE ${tableName} SET sort_order = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3 `, [(index + 1) * 10, userId, id] ); await recordEditLog(client, entityType, id, 'update', userId); } } async function recordEditLog( client: DbClient, entityType: string, entityId: number, action: EditAction, userId: number, changes: EditChange[] = [] ): Promise { 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, 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( ` 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) { 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) { 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) { 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((record, header, index) => { record[header] = values[index] ?? ''; return record; }, {}) ); } async function readPokemonDataFile(fileName: string): Promise { 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 { return rows.reduce((index, row) => { const id = csvInteger(row, fieldName); if (id > 0) { index.set(id, row); } return index; }, new Map()); } async function loadPokemonCsvData(): Promise { 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(); 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 = { 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 pokemonImageCandidateForPath(id: number, path: string): PokemonImage | null { const cleanPath = path.trim(); const candidate = pokemonImageCandidates(id).find((item) => item.path === cleanPath); return candidate ? pokemonImageWithUrl(candidate) : null; } function cleanPokemonImage(value: unknown, pokemonId: 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: `#${pokemonId}`, description: '', source: 'upload' }; } const image = pokemonImageCandidateForPath(pokemonId, path); if (!image) { throw validationError('server.validation.pokemonImagePathInvalid'); } return image; } async function pokemonImageExists(candidate: PokemonImageCandidate): Promise { 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 { 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<{ name: string }>('SELECT name FROM pokemon_types WHERE 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 if (existing.rows[0].name !== name) { 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, [ { label: 'Name', before: existing.rows[0].name, after: name } ]); } 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, userId: number): Promise { 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): Promise { 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 { const search = asString(paramsQuery.search)?.trim() ?? ''; const [data, languages] = await Promise.all([loadPokemonCsvData(), listLanguages()]); return data.pokemonRows .filter((row) => csvInteger(row, 'id') > 0 && pokemonFetchOptionMatches(row, data, languages, locale, search)) .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 }); } } 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> { 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): 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 { 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 { 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); 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 { 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); 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 { 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); 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 { 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 getEditHistory(entityType: string, entityId: number): Promise { 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, lifeTags ] = 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), optionSelect('life_tags', 'life-tags', locale) ]); return { pokemonTypes, skills, environments, favoriteThings, itemCategories, itemUsages, acquisitionMethods, itemTags: favoriteThings, maps, lifeTags }; } function cleanDailyChecklistPayload(payload: Record): 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, 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, userId: number, locale = defaultLocale ) { const cleanPayload = cleanDailyChecklistPayload(payload); 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']); await recordEditLog(client, 'daily-checklist-items', id, 'update', userId); return true; }); return updated ? getDailyChecklistItemById(id, locale) : null; } export async function reorderDailyChecklistItems(payload: Record, 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 }>( 'SELECT id FROM daily_checklist_items WHERE id = ANY($1::integer[])', [ids] ); if (existing.rowCount !== ids.length) { throw validationError('server.validation.taskDoesNotExist'); } for (const [index, id] of ids.entries()) { await client.query( ` UPDATE daily_checklist_items SET sort_order = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3 `, [(index + 1) * 10, userId, id] ); await recordEditLog(client, 'daily-checklist-items', id, 'update', userId); } }); 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): LifePostPayload { const body = cleanName(payload.body, 'server.validation.postRequired'); if (body.length > 2000) { throw validationError('server.validation.postTooLong'); } const tagIds = cleanIds(payload.tagIds); if (tagIds.length === 0) { throw validationError('server.validation.lifeTagRequired'); } return { body, tagIds, languageCode: cleanModerationLanguageCode(payload.languageCode) }; } function cleanLifeCommentPayload(payload: Record): 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 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 tagName = localizedName('life-tags', 'lt', 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", COALESCE(( SELECT json_agg(json_build_object('id', lt.id, 'name', ${tagName}) ORDER BY ${orderByEntity('lt')}) FROM life_post_tags lpt JOIN life_tags lt ON lt.id = lpt.tag_id WHERE lpt.post_id = lp.id ), '[]'::json) AS tags FROM life_posts lp 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 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; const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : ''; const id = Number(cursor.id); if (!createdAt || Number.isNaN(new Date(createdAt).getTime()) || !Number.isInteger(id) || id <= 0) { throw validationError('server.validation.cursorInvalid'); } return { createdAt, id }; } 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 }), '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; 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, commentCountsByPost: Map, countsByPost: Map, myReactionsByPost: Map ): 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, tags: post.tags, commentPreview: commentPreviewByPost.get(post.id) ?? [], commentCount: commentCountsByPost.get(post.id) ?? 0, reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(), myReaction: myReactionsByPost.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(); 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> { const countsByPost = new Map(); 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> { const commentsByPost = new Map(); 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( ` 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 { 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( ` ${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( ` ${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; myReactionsByPost: Map; }> { const countsByPost = new Map(); const myReactionsByPost = new Map(); 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 getLifeCommentById(id: number): Promise { const row = await queryOne( ` ${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 { const cursor = decodeLifePostCursor(paramsQuery.cursor); const limit = cleanLifePostLimit(paramsQuery.limit); const search = asString(paramsQuery.search)?.trim(); const tagIdValue = asString(paramsQuery.tagId)?.trim(); const languageCode = cleanModerationLanguageFilter(paramsQuery.language); const params: unknown[] = []; const conditions: string[] = ['lp.deleted_at IS NULL']; 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 (tagIdValue) { const tagId = requirePositiveInteger(tagIdValue, 'server.validation.tagInvalid'); params.push(tagId); conditions.push(`EXISTS ( SELECT 1 FROM life_post_tags lpt_filter WHERE lpt_filter.post_id = lp.id AND lpt_filter.tag_id = $${params.length} )`); } if (cursor) { params.push(cursor.createdAt, cursor.id); conditions.push(`(lp.created_at, lp.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; params.push(limit + 1); const rows = await query( ` ${lifePostProjection(locale)} ${whereClause} ORDER BY lp.created_at DESC, lp.id DESC 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); return { items: posts.map((post) => hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost)), 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 { return listLifePostsWithFilters(paramsQuery, userId, locale, {}, canViewAll); } async function getPublicProfileUser(userIdValue: number): Promise { const userId = requirePositiveInteger(userIdValue, 'server.validation.recordInvalid'); return queryOne( ` 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 { const user = await getPublicProfileUser(userIdValue); if (!user) { return null; } const stats = await queryOne( ` 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( ` 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 { 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> { const postById = new Map(); 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( ` ${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); for (const post of posts) { postById.set(post.id, hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost)); } return postById; } export async function listUserReactionActivities( userIdValue: number, paramsQuery: QueryParams = {}, viewerUserId: number | null = null, locale = defaultLocale ): Promise { 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 { 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 { const post = await queryOne( ` ${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); return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost); } async function replaceLifePostTags(client: DbClient, postId: number, tagIds: number[]): Promise { await client.query('DELETE FROM life_post_tags WHERE post_id = $1', [postId]); for (const tagId of tagIds) { await client.query( ` INSERT INTO life_post_tags (post_id, tag_id) VALUES ($1, $2) `, [postId, tagId] ); } } export async function createLifePost(payload: Record, userId: number, locale = defaultLocale) { const cleanPayload = cleanLifePostPayload(payload); const id = await withTransaction(async (client) => { const result = await client.query<{ id: number }>( ` INSERT INTO life_posts (body, ai_moderation_status, ai_moderation_language_code, created_by_user_id, updated_by_user_id) VALUES ($1, 'reviewing', NULL, $2, $2) RETURNING id `, [cleanPayload.body, userId] ); const createdId = result.rows[0].id; await replaceLifePostTags(client, createdId, cleanPayload.tagIds); 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, userId: number, locale = defaultLocale, allowAny = false ) { const cleanPayload = cleanLifePostPayload(payload); const updatedId = await withTransaction(async (client) => { const result = await client.query<{ id: number }>( ` UPDATE life_posts SET body = $1, ai_moderation_status = 'reviewing', ai_moderation_language_code = NULL, ai_moderation_content_hash = NULL, ai_moderation_checked_at = NULL, ai_moderation_retry_count = 0, ai_moderation_updated_at = now(), updated_by_user_id = $2, updated_at = now() WHERE id = $3 AND ($4 = true OR created_by_user_id = $2) AND deleted_at IS NULL RETURNING id `, [cleanPayload.body, userId, id, allowAny] ); const resultId = result.rows[0]?.id ?? null; if (resultId === null) { return null; } await replaceLifePostTags(client, resultId, cleanPayload.tagIds); 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, 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 createLifeComment(postId: number, payload: Record, 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, 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): 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, entityType: DiscussionEntityType, entityId: number ): Promise { 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(); 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 { const row = await queryOne( ` ${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 { 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( ` ${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( ` ${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, userId: number ): Promise { 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, userId: number ): Promise { 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 { 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 { 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 { 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, 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 id = await withTransaction(async (client) => { const sortOrder = await nextSortOrder(client, definition.table); const result = definition.hasItemDrop ? await client.query<{ id: number }>( ` INSERT INTO ${definition.table} (name, has_item_drop, sort_order, created_by_user_id, updated_by_user_id) VALUES ($1, $2, $3, $4, $4) RETURNING id `, [name, hasItemDrop, sortOrder, userId] ) : await client.query<{ id: number }>( ` INSERT INTO ${definition.table} (name, sort_order, created_by_user_id, updated_by_user_id) VALUES ($1, $2, $3, $3) RETURNING id `, [name, sortOrder, userId] ); 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, 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, 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 updated = await withTransaction(async (client) => { const result = definition.hasItemDrop ? await client.query( ` UPDATE ${definition.table} SET name = $1, has_item_drop = $2, updated_by_user_id = $3, updated_at = now() WHERE id = $4 `, [name, hasItemDrop, userId, id] ) : await client.query( ` UPDATE ${definition.table} SET name = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3 `, [name, userId, id] ); 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']); await recordEditLog(client, type, id, 'update', userId); 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, userId: number): Promise { 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, userId: number, locale = defaultLocale) { await reorderContent('pokemon', payload, userId); return listPokemon({}, locale); } export async function reorderItems(payload: Record, userId: number, locale = defaultLocale) { await reorderContent('items', payload, userId); return listItems({}, locale); } export async function reorderRecipes(payload: Record, userId: number, locale = defaultLocale) { await reorderContent('recipes', payload, userId); return listRecipes({}, locale); } export async function reorderHabitats(payload: Record, 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()); 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): 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(); 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; 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 { 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, 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, 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): HabitatPayload { const appearances = Array.isArray(payload.pokemonAppearances) ? payload.pokemonAppearances : []; const pokemonAppearances = new Map(); for (const item of appearances) { const row = item as Record; 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 { 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, 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, 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): 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 { 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 { 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, 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, 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): 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 { 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 { 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, 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, 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; }); }