Files
pokopiawiki.tootaio.com/backend/src/queries.ts
xiaomai 18baf7b513 feat(moderation): add AI moderation for user-generated content
Add AI moderation settings, caching, and status tracking
Require AI approval for Life Posts, Comments, and Discussions
Implement language filtering and moderation status UI
Add retry mechanism for failed moderation checks
2026-05-03 17:08:51 +08:00

5318 lines
173 KiB
TypeScript

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