import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts'; import { pool, query, queryOne } from './db.ts'; import { Buffer } from 'node:buffer'; import type { PoolClient } from 'pg'; 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'; type ConfigType = | 'pokemon-types' | 'skills' | 'environments' | 'favorite-things' | 'item-categories' | 'item-usages' | 'acquisition-methods' | 'maps'; 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 PokemonPayload = { id: number; name: string; genus: string; details: string; heightInches: number; weightPounds: number; translations: TranslationInput; typeIds: number[]; stats: PokemonStats; environmentId: number; skillIds: number[]; favoriteThingIds: number[]; skillItemDrops: SkillItemDrop[]; }; type ItemPayload = { name: string; translations: TranslationInput; categoryId: number; usageId: number | null; dyeable: boolean; dualDyeable: boolean; patternEditable: boolean; noRecipe: boolean; acquisitionMethodIds: number[]; tagIds: number[]; }; type RecipePayload = { itemId: number; acquisitionMethodIds: number[]; materials: IdQuantity[]; }; type DailyChecklistPayload = { title: string; translations: TranslationInput; }; type LifePostPayload = { body: string; }; type LifeCommentPayload = { body: string; }; type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks'; type LifeReactionCounts = Record; type LifeCommentRow = { id: number; postId: number; parentCommentId: number | null; body: string; deleted: boolean; createdAt: Date; updatedAt: Date; author: { id: number; displayName: string } | null; }; type LifeComment = LifeCommentRow & { replies: LifeComment[]; }; type LifePostRow = { id: number; body: string; createdAt: Date; createdAtCursor: string; updatedAt: Date; author: { id: number; displayName: string } | null; updatedBy: { id: number; displayName: string } | null; }; type LifePost = Omit & { comments: LifeComment[]; reactionCounts: LifeReactionCounts; myReaction: LifeReactionType | null; }; type LifePostCursor = { createdAt: string; id: number; }; type LifePostsPage = { items: LifePost[]; nextCursor: string | null; hasMore: boolean; }; type HabitatPayload = { name: string; translations: TranslationInput; 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 = { name: string; genus: string; details: string; heightInches: number; weightPounds: number; 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; 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; 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 lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const; 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' } }; const sortableContentDefinitions: Record = { pokemon: { table: 'pokemon', entityType: 'pokemon' }, items: { table: 'items', entityType: 'items' }, recipes: { table: 'recipes', entityType: 'recipes' }, habitats: { table: 'habitats', entityType: 'habitats' } }; 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 sqlLiteral(value: string): string { return `'${value.replaceAll("'", "''")}'`; } 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 = 'Name is required'): 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 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(`${stat.label} must be a non-negative integer`); } 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 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('Record does not exist'); } 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('Language code is invalid'); } const sortOrder = Number(payload.sortOrder ?? 0); return { code, name: cleanName(payload.name, 'Language name is required'), 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('Language code is invalid'); } 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('Default language must be English'); } if (!cleanPayload.enabled && cleanPayload.isDefault) { throw validationError('Default language must be enabled'); } 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('Default language must be English'); } if (!cleanPayload.enabled && cleanPayload.isDefault) { throw validationError('Default language must be enabled'); } 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('Language not found'); } if (!cleanPayload.enabled && current.rows[0].isDefault) { throw validationError('Default language must be enabled'); } if (current.rows[0].isDefault && !cleanPayload.isDefault) { throw validationError('A default language is required'); } 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('Default language cannot be deleted'); } 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('Please select a language'); } 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('Language does not exist'); } 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 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, '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, '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, '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, '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, ${pokemonName} AS name, p.name AS "baseName", ${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", 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 ] = 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) ]); return { pokemonTypes, skills, environments, favoriteThings, itemCategories, itemUsages, acquisitionMethods, itemTags: favoriteThings, maps }; } function cleanDailyChecklistPayload(payload: Record): DailyChecklistPayload { return { title: cleanName(payload.title, 'Please enter a task'), 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('Please select a task'); } 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('Task does not exist'); } 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, 'Please enter a post'); if (body.length > 2000) { throw validationError('Post is too long'); } return { body }; } function cleanLifeCommentPayload(payload: Record): LifeCommentPayload { const body = cleanName(payload.body, 'Please enter a comment'); if (body.length > 1000) { throw validationError('Comment is too long'); } return { body }; } 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('Reaction is invalid'); } return value; } function lifePostProjection(): string { return ` SELECT lp.id, lp.body, 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" 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 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('Cursor is invalid'); } return { createdAt, id }; } catch (error) { if (error instanceof Error && 'statusCode' in error) { throw error; } throw validationError('Cursor is invalid'); } } function encodeLifePostCursor(post: LifePostRow): string { return Buffer.from(JSON.stringify({ createdAt: post.createdAtCursor, id: post.id }), 'utf8').toString('base64url'); } function hydrateLifePost( post: LifePostRow, commentsByPost: Map, countsByPost: Map, myReactionsByPost: Map ): LifePost { return { id: post.id, body: post.body, createdAt: post.createdAt, updatedAt: post.updatedAt, author: post.author, updatedBy: post.updatedBy, comments: commentsByPost.get(post.id) ?? [], 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.created_at AS "createdAt", 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) { comments.set(row.id, { ...row, 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 lifeCommentsForPosts(postIds: number[]): Promise> { const commentsByPost = new Map(); if (postIds.length === 0) { return commentsByPost; } const rows = await query( ` ${lifeCommentProjection('WHERE lc.post_id = ANY($1::integer[])')} ORDER BY lc.created_at, lc.id `, [postIds] ); for (const postId of postIds) { commentsByPost.set(postId, buildLifeCommentTree(rows.filter((comment) => comment.postId === postId))); } return commentsByPost; } 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] ); return row ? { ...row, replies: [] } : null; } export async function listLifePosts(paramsQuery: QueryParams = {}, userId: number | null = null): Promise { const cursor = decodeLifePostCursor(paramsQuery.cursor); const limit = cleanLifePostLimit(paramsQuery.limit); const params: unknown[] = []; let cursorClause = ''; if (cursor) { params.push(cursor.createdAt, cursor.id); cursorClause = ` WHERE (lp.created_at, lp.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer) `; } params.push(limit + 1); const rows = await query( ` ${lifePostProjection()} ${cursorClause} 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 commentsByPost = await lifeCommentsForPosts(postIds); const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId); return { items: posts.map((post) => hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost)), nextCursor: hasMore && posts.length > 0 ? encodeLifePostCursor(posts[posts.length - 1]) : null, hasMore }; } async function getLifePostById(id: number, userId: number | null = null): Promise { const post = await queryOne( ` ${lifePostProjection()} WHERE lp.id = $1 `, [id] ); if (!post) { return null; } const commentsByPost = await lifeCommentsForPosts([post.id]); const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId); return hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost); } export async function createLifePost(payload: Record, userId: number) { const cleanPayload = cleanLifePostPayload(payload); const result = await queryOne<{ id: number }>( ` INSERT INTO life_posts (body, created_by_user_id, updated_by_user_id) VALUES ($1, $2, $2) RETURNING id `, [cleanPayload.body, userId] ); return getLifePostById(result?.id ?? 0, userId); } export async function updateLifePost(id: number, payload: Record, userId: number) { const cleanPayload = cleanLifePostPayload(payload); const result = await queryOne<{ id: number }>( ` UPDATE life_posts SET body = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3 AND created_by_user_id = $2 RETURNING id `, [cleanPayload.body, userId, id] ); return result ? getLifePostById(result.id, userId) : null; } export async function deleteLifePost(id: number, userId: number) { const result = await queryOne<{ id: number }>( ` DELETE FROM life_posts WHERE id = $1 AND created_by_user_id = $2 RETURNING id `, [id, userId] ); return Boolean(result); } export async function setLifePostReaction(postId: number, payload: Record, userId: number) { 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) 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) : null; } export async function deleteLifePostReaction(postId: number, userId: number) { await queryOne<{ postId: number }>( ` DELETE FROM life_post_reactions WHERE post_id = $1 AND user_id = $2 RETURNING post_id AS "postId" `, [postId, userId] ); return getLifePostById(postId, userId); } 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, created_by_user_id) SELECT $1, $2, $3 WHERE EXISTS (SELECT 1 FROM life_posts WHERE id = $1) RETURNING id `, [postId, cleanPayload.body, userId] ); 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, created_by_user_id) SELECT lc.post_id, lc.id, $3, $4 FROM life_post_comments lc WHERE lc.post_id = $1 AND lc.id = $2 AND lc.parent_comment_id IS NULL AND lc.deleted_at IS NULL RETURNING id `, [postId, commentId, cleanPayload.body, userId] ); return result ? getLifeCommentById(result.id) : null; } export async function deleteLifeComment(id: number, userId: number) { 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 created_by_user_id = $2 AND deleted_at IS NULL RETURNING id `, [id, userId] ); return Boolean(result); } 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('Please select a record'); } 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('Please select a record'); } 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 [habitats, itemDrops, favoriteThingItems, editHistory] = await Promise.all([ query( ` SELECT h.id, ${habitatName} AS name, 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 }>( ` SELECT psid.skill_id AS "skillId", i.id, ${itemName} AS name 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, 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.sort_order, c.id, c.name, c.sort_order ORDER BY ${orderByEntity('c')}, ${orderByEntity('i')} `, [id] ), getEditHistory('pokemon', id) ]); const dropsBySkill = itemDrops.reduce((itemsBySkill, item) => { itemsBySkill.set(item.skillId, { id: item.id, name: item.name }); 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, editHistory }; } 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('Choose at least 1 type'); } if (cleanTypeIds.length > 2) { throw validationError('Choose at most 2 types'); } if (skillIds.length > 2) { throw validationError('Choose at most 2 specialities'); } if (favoriteThingIds.length > 6) { throw validationError('Choose at most 6 favourites'); } 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('Drop items must be linked to selected specialities'); } skillItemDrops.set(String(skillId), { skillId, itemId }); } } return { id: requirePositiveInteger(payload.id, 'Pokemon ID is required'), name: cleanName(payload.name, 'Pokemon name is required'), genus: cleanOptionalText(payload.genus), details: cleanOptionalText(payload.details), heightInches: cleanNonNegativeNumber(payload.heightInches, 'Height must be a non-negative number'), weightPounds: cleanNonNegativeNumber(payload.weightPounds, 'Weight must be a non-negative number'), translations: cleanTranslations(payload.translations, ['name', 'details', 'genus']), typeIds, stats: cleanPokemonStats(payload.stats), environmentId: requirePositiveInteger(payload.environmentId, 'Ideal Habitat is required'), skillIds, favoriteThingIds, skillItemDrops: [...skillItemDrops.values()] }; } 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('This speciality cannot have a drop item'); } } 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 sortOrder = await nextSortOrder(client, 'pokemon'); await client.query( ` INSERT INTO pokemon ( id, name, genus, details, height_inches, weight_pounds, environment_id, hp, attack, defense, special_attack, special_defense, speed, 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, $15) `, [ cleanPayload.id, cleanPayload.name, 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, sortOrder, userId ] ); await replacePokemonRelations(client, cleanPayload.id, cleanPayload); await replaceEntityTranslations(client, 'pokemon', cleanPayload.id, cleanPayload.translations, ['name', 'details', 'genus']); await recordEditLog(client, 'pokemon', cleanPayload.id, 'create', userId); return cleanPayload.id; }); return getPokemon(id, locale); } export async function updatePokemon(id: number, payload: Record, userId: number, locale = defaultLocale) { const cleanPayload = cleanPokemonPayload({ ...payload, id }); const before = await getPokemon(id, defaultLocale); const updated = await withTransaction(async (client) => { const result = await client.query( ` UPDATE pokemon SET name = $1, genus = $2, details = $3, height_inches = $4, weight_pounds = $5, environment_id = $6, hp = $7, attack = $8, defense = $9, special_attack = $10, special_defense = $11, speed = $12, updated_by_user_id = $13, updated_at = now() WHERE id = $14 `, [ cleanPayload.name, 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, userId, id ] ); if (result.rowCount === 0) { return false; } 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 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", ${translationsSelect('habitats', 'h.id')} AS translations, ${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')}, 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, 'name', pokemon_rows.name) ORDER BY pokemon_rows.sort_order, pokemon_rows.id) FROM ( SELECT DISTINCT p.id, ${pokemonName} AS name, 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", ${translationsSelect('habitats', 'h.id')} AS translations, ${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')}, 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 FROM habitats h ${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')} WHERE h.id = $1 `, [id] ); if (!habitat) { return null; } const [pokemon, editHistory] = await Promise.all([ query( ` SELECT p.id, ${pokemonName} AS name, 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) ]); return { ...habitat, pokemon, editHistory }; } 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 ?? row.mapId); const selectedTimeOfDays = cleanOptions(row.timeOfDays ?? row.timeOfDay, timeOfDays); const selectedWeathers = cleanOptions(row.weathers ?? row.weather, 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, 'Habitat name is required'), translations: cleanTranslations(payload.translations, ['name']), 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, sort_order, created_by_user_id, updated_by_user_id) VALUES ($1, $2, $3, $3) RETURNING id `, [cleanPayload.name, sortOrder, userId] ); const habitatId = result.rows[0].id; 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, updated_by_user_id = $2, updated_at = now() WHERE id = $3', [cleanPayload.name, userId, id] ); if (result.rowCount === 0) { return false; } 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 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", ${translationsSelect('items', 'i.id')} AS translations, ${auditSelect('i', 'item_created_user', 'item_updated_user')}, 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 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] = 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}, '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}) AS item FROM recipes r JOIN items result_item ON result_item.id = r.item_id ${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')} WHERE r.item_id = $1 `, [id] ), query( ` SELECT r.id, ${resultItemName} AS name, COALESCE(( SELECT json_agg(json_build_object('id', mi.id, 'name', ${materialItemName}, '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, COALESCE(( SELECT json_agg(json_build_object('id', recipe_item.id, 'name', ${recipeItemName}, '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, 'name', ${pokemonName}) 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) ]); return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory }; } function cleanItemPayload(payload: Record): ItemPayload { const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined ? null : requirePositiveInteger(payload.usageId, 'Usage is required'); return { name: cleanName(payload.name, 'Item name is required'), translations: cleanTranslations(payload.translations, ['name']), categoryId: requirePositiveInteger(payload.categoryId, 'Category is required'), usageId, dyeable: Boolean(payload.dyeable), dualDyeable: Boolean(payload.dualDyeable), patternEditable: Boolean(payload.patternEditable), noRecipe: Boolean(payload.noRecipe), acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds), tagIds: cleanIds(payload.tagIds) }; } 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('An item with a recipe cannot be marked as recipe-free'); } } 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, sort_order, created_by_user_id, updated_by_user_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) RETURNING id `, [ cleanPayload.name, cleanPayload.categoryId, cleanPayload.usageId, cleanPayload.dyeable, cleanPayload.dualDyeable, cleanPayload.patternEditable, cleanPayload.noRecipe, sortOrder, userId ] ); const itemId = result.rows[0].id; 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, updated_by_user_id = $8, updated_at = now() WHERE id = $9 `, [ cleanPayload.name, cleanPayload.categoryId, cleanPayload.usageId, cleanPayload.dyeable, cleanPayload.dualDyeable, cleanPayload.patternEditable, cleanPayload.noRecipe, userId, id ] ); if (result.rowCount === 0) { return false; } 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 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 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}, '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}) AS item FROM recipes r JOIN items result_item ON result_item.id = r.item_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, 'Item is required'), 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('Item is required'); } if (result.rows[0].no_recipe) { throw validationError('This item is marked as recipe-free'); } } 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 recordEditLog(client, 'recipes', id, 'delete', userId); return true; }); }