Completely remove deleted comments and their replies from lists, previews, and counts. Hide the "approved" moderation status badge to reduce visual clutter.
7037 lines
236 KiB
TypeScript
7037 lines
236 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';
|
|
import { createLifePostReactionNotification } from './notifications.ts';
|
|
|
|
type QueryValue = string | string[] | undefined;
|
|
|
|
type QueryParams = Record<string, QueryValue>;
|
|
|
|
type DbClient = PoolClient;
|
|
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
|
|
type DataToolScopeSummary = {
|
|
scope: DataToolScope;
|
|
count: number;
|
|
};
|
|
type DataToolRows = Record<string, unknown>[];
|
|
type DataToolScopeData = Record<string, DataToolRows | undefined>;
|
|
type DataToolsBundle = {
|
|
version: 1;
|
|
exportedAt: string;
|
|
scopes: DataToolScope[];
|
|
data: Partial<Record<DataToolScope, DataToolScopeData>>;
|
|
};
|
|
type GlobalSearchGroupType =
|
|
| 'pokemon'
|
|
| 'habitats'
|
|
| 'items'
|
|
| 'ancient-artifacts'
|
|
| 'recipes'
|
|
| 'daily-checklist'
|
|
| 'life';
|
|
type GlobalSearchItem = {
|
|
id: number;
|
|
type: GlobalSearchGroupType;
|
|
title: string;
|
|
url: string;
|
|
summary: string | null;
|
|
meta: string | null;
|
|
image: EntityImageValue | PokemonImage | null;
|
|
};
|
|
type GlobalSearchGroup = {
|
|
type: GlobalSearchGroupType;
|
|
items: GlobalSearchItem[];
|
|
};
|
|
type GlobalSearchResults = {
|
|
query: string;
|
|
groups: GlobalSearchGroup[];
|
|
};
|
|
|
|
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'
|
|
| 'ancient-artifacts'
|
|
| 'maps'
|
|
| 'habitats'
|
|
| 'daily-checklist-items'
|
|
| 'life-tags'
|
|
| 'game-versions';
|
|
|
|
type ConfigType =
|
|
| 'pokemon-types'
|
|
| 'skills'
|
|
| 'environments'
|
|
| 'favorite-things'
|
|
| 'acquisition-methods'
|
|
| 'maps'
|
|
| 'life-tags'
|
|
| 'game-versions';
|
|
|
|
type ConfigDefinition = {
|
|
table: string;
|
|
entityType: EntityType;
|
|
hasItemDrop?: boolean;
|
|
hasDefault?: boolean;
|
|
hasRateable?: boolean;
|
|
hasChangeLog?: boolean;
|
|
};
|
|
type SortableContentType = 'pokemon' | 'items' | 'ancient-artifacts' | '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 = {
|
|
dataId: number | null;
|
|
dataIdentifier: string;
|
|
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 = {
|
|
displayId: number;
|
|
name: string;
|
|
details: string;
|
|
translations: TranslationInput;
|
|
categoryId: number;
|
|
categoryKey: string;
|
|
usageId: number | null;
|
|
usageKey: string | null;
|
|
dyeable: boolean;
|
|
dualDyeable: boolean;
|
|
patternEditable: boolean;
|
|
noRecipe: boolean;
|
|
isEventItem: boolean;
|
|
acquisitionMethodIds: number[];
|
|
tagIds: number[];
|
|
imagePath: string;
|
|
};
|
|
|
|
type AncientArtifactPayload = {
|
|
displayId: number;
|
|
name: string;
|
|
details: string;
|
|
translations: TranslationInput;
|
|
categoryId: number;
|
|
categoryKey: string;
|
|
tagIds: number[];
|
|
imagePath: string;
|
|
};
|
|
|
|
type RecipePayload = {
|
|
itemId: number;
|
|
acquisitionMethodIds: number[];
|
|
materials: IdQuantity[];
|
|
};
|
|
|
|
type DailyChecklistPayload = {
|
|
title: string;
|
|
translations: TranslationInput;
|
|
};
|
|
|
|
type LifePostPayload = {
|
|
body: string;
|
|
categoryId: number;
|
|
gameVersionId: number | null;
|
|
languageCode: string | null;
|
|
};
|
|
|
|
type LifeCommentPayload = {
|
|
body: string;
|
|
languageCode: string | null;
|
|
};
|
|
|
|
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'ancient-artifacts';
|
|
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;
|
|
moderationReason: 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 LifeReactionUser = {
|
|
user: { id: number; displayName: string };
|
|
reactionType: LifeReactionType;
|
|
reactedAt: Date;
|
|
};
|
|
type LifeReactionUsersPage = {
|
|
items: LifeReactionUser[];
|
|
nextCursor: string | null;
|
|
hasMore: boolean;
|
|
total: number;
|
|
};
|
|
type LifeReactionUserCursor = {
|
|
reactedAt: string;
|
|
userId: number;
|
|
};
|
|
|
|
type LifeCommentRow = {
|
|
id: number;
|
|
postId: number;
|
|
parentCommentId: number | null;
|
|
body: string;
|
|
deleted: boolean;
|
|
moderationStatus: AiModerationStatus;
|
|
moderationLanguageCode: string | null;
|
|
moderationReason: 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;
|
|
moderationReason: string | null;
|
|
createdAt: Date;
|
|
createdAtCursor: string;
|
|
updatedAt: Date;
|
|
author: { id: number; displayName: string } | null;
|
|
updatedBy: { id: number; displayName: string } | null;
|
|
category: { id: number; name: string; isRateable: boolean } | null;
|
|
gameVersion: { id: number; name: string; changeLog: string } | null;
|
|
ratingAverage: number | null;
|
|
ratingCount: number;
|
|
};
|
|
|
|
type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
|
|
commentPreview: LifeComment[];
|
|
commentCount: number;
|
|
reactionCounts: LifeReactionCounts;
|
|
myReaction: LifeReactionType | null;
|
|
myRating: number | null;
|
|
};
|
|
|
|
type LifePostCursor = {
|
|
createdAt: string;
|
|
id: number;
|
|
ratingAverage?: number;
|
|
};
|
|
|
|
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
|
|
|
|
type LifePostFilters = {
|
|
authorId?: number;
|
|
};
|
|
|
|
type LifePostsPage = {
|
|
items: LifePost[];
|
|
nextCursor: string | null;
|
|
hasMore: boolean;
|
|
};
|
|
|
|
type LifeCommentsPage = {
|
|
items: LifeComment[];
|
|
nextCursor: string | null;
|
|
hasMore: boolean;
|
|
total: number;
|
|
};
|
|
|
|
type PublicProfileUser = {
|
|
id: number;
|
|
displayName: string;
|
|
joinedAt: Date;
|
|
};
|
|
|
|
type PublicProfileStats = {
|
|
wikiEdits: number;
|
|
wikiCreates: number;
|
|
wikiUpdates: number;
|
|
wikiDeletes: number;
|
|
imageUploads: number;
|
|
lifePosts: number;
|
|
lifeComments: number;
|
|
lifeReactions: number;
|
|
discussionComments: number;
|
|
};
|
|
|
|
type PublicProfileContribution = {
|
|
contentType: string;
|
|
total: number;
|
|
creates: number;
|
|
updates: number;
|
|
deletes: number;
|
|
lastContributedAt: Date | null;
|
|
};
|
|
|
|
type PublicUserProfile = {
|
|
user: PublicProfileUser;
|
|
stats: PublicProfileStats;
|
|
contributions: PublicProfileContribution[];
|
|
};
|
|
|
|
type UserReactionActivity = {
|
|
postId: number;
|
|
reactionType: LifeReactionType;
|
|
reactedAt: Date;
|
|
post: LifePost;
|
|
};
|
|
|
|
type UserReactionActivityPage = {
|
|
items: UserReactionActivity[];
|
|
nextCursor: string | null;
|
|
hasMore: boolean;
|
|
};
|
|
|
|
type UserCommentActivitySource = 'life' | 'discussion';
|
|
|
|
type UserCommentActivity = {
|
|
id: number;
|
|
source: UserCommentActivitySource;
|
|
body: string;
|
|
createdAt: Date;
|
|
target: {
|
|
type: 'life-post' | DiscussionEntityType;
|
|
id: number;
|
|
title: string;
|
|
excerpt: string;
|
|
};
|
|
};
|
|
|
|
type UserCommentActivityPage = {
|
|
items: UserCommentActivity[];
|
|
nextCursor: string | null;
|
|
hasMore: boolean;
|
|
};
|
|
|
|
type UserCommentActivityCursor = LifePostCursor & {
|
|
source: UserCommentActivitySource;
|
|
};
|
|
|
|
type HabitatPayload = {
|
|
name: string;
|
|
translations: TranslationInput;
|
|
isEventItem: boolean;
|
|
imagePath: string;
|
|
recipeItems: IdQuantity[];
|
|
pokemonAppearances: Array<{
|
|
pokemonId: number;
|
|
mapId: number;
|
|
timeOfDay: string;
|
|
weather: string;
|
|
rarity: number;
|
|
}>;
|
|
};
|
|
|
|
type LanguagePayload = {
|
|
code: string;
|
|
name: string;
|
|
enabled: boolean;
|
|
isDefault: boolean;
|
|
sortOrder: number;
|
|
};
|
|
|
|
type ValidationError = Error & { statusCode: number };
|
|
type EditAction = 'create' | 'update' | 'delete';
|
|
type EditChange = {
|
|
label: string;
|
|
before: string;
|
|
after: string;
|
|
};
|
|
type EditHistoryEntry = {
|
|
action: EditAction;
|
|
changes: EditChange[];
|
|
createdAt: Date;
|
|
user: { id: number; displayName: string } | null;
|
|
};
|
|
type TranslationChangeSource = {
|
|
translations?: TranslationInput | null;
|
|
};
|
|
type PokemonChangeSource = {
|
|
displayId: number;
|
|
isEventItem: boolean;
|
|
name: string;
|
|
genus: string;
|
|
details: string;
|
|
heightInches: number;
|
|
weightPounds: number;
|
|
image: PokemonImage | null;
|
|
types: Array<{ name: string }>;
|
|
stats: PokemonStats;
|
|
environment: { name: string };
|
|
skills: Array<{ name: string; itemDrop?: { name: string } | null }>;
|
|
favorite_things: Array<{ name: string }>;
|
|
} & TranslationChangeSource;
|
|
type ItemChangeSource = {
|
|
displayId: number;
|
|
name: string;
|
|
details: string;
|
|
isEventItem: boolean;
|
|
image: EntityImageValue | null;
|
|
category: { name: string };
|
|
usage: { name: string } | null;
|
|
customization: { dyeable: boolean; dualDyeable: boolean; patternEditable: boolean };
|
|
noRecipe: boolean;
|
|
acquisitionMethods: Array<{ name: string }>;
|
|
tags: Array<{ name: string }>;
|
|
} & TranslationChangeSource;
|
|
type AncientArtifactChangeSource = {
|
|
displayId: number;
|
|
name: string;
|
|
details: string;
|
|
image: EntityImageValue | null;
|
|
category: { name: string };
|
|
tags: Array<{ name: string }>;
|
|
} & TranslationChangeSource;
|
|
type HabitatChangeSource = {
|
|
name: string;
|
|
isEventItem: boolean;
|
|
image: EntityImageValue | null;
|
|
recipe: Array<{ name: string; quantity: number }>;
|
|
pokemon: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }>;
|
|
} & TranslationChangeSource;
|
|
type RecipeChangeSource = {
|
|
item: { name: string };
|
|
acquisition_methods: Array<{ name: string }>;
|
|
materials: Array<{ name: string; quantity: number }>;
|
|
};
|
|
type DailyChecklistChangeSource = {
|
|
title: string;
|
|
} & TranslationChangeSource;
|
|
type ConfigChangeSource = {
|
|
name: string;
|
|
hasItemDrop?: boolean;
|
|
isDefault?: boolean;
|
|
isRateable?: boolean;
|
|
changeLog?: string;
|
|
} & TranslationChangeSource;
|
|
|
|
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
|
const weathers = ['晴天', '阴天', '雨天'];
|
|
const defaultLocale = 'en';
|
|
const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
|
|
const defaultLifePostLimit = 20;
|
|
const maxLifePostLimit = 50;
|
|
const defaultCommentLimit = 20;
|
|
const maxCommentLimit = 50;
|
|
const lifeCommentPreviewLimit = 2;
|
|
const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const;
|
|
const pokemonTypeIconIds = new Set(Array.from({ length: 19 }, (_value, index) => index + 1));
|
|
const pokemonSpriteBaseUrl = 'https://pokesprite.tootaio.com';
|
|
const pokemonSpriteRequestTimeoutMs = 2500;
|
|
const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
|
|
{ key: 'hp', label: 'HP' },
|
|
{ key: 'attack', label: 'Attack' },
|
|
{ key: 'defense', label: 'Defense' },
|
|
{ key: 'specialAttack', label: 'Special Attack' },
|
|
{ key: 'specialDefense', label: 'Special Defense' },
|
|
{ key: 'speed', label: 'Speed' }
|
|
];
|
|
|
|
type SystemListOption = {
|
|
id: number;
|
|
key: string;
|
|
labels: Record<typeof defaultLocale | 'zh-CN', string>;
|
|
};
|
|
|
|
const itemCategoryOptions = [
|
|
{ id: 1, key: 'furniture', labels: { en: 'Furniture', 'zh-CN': '家具' } },
|
|
{ id: 2, key: 'misc', labels: { en: 'Misc', 'zh-CN': '杂项' } },
|
|
{ id: 3, key: 'outdoor', labels: { en: 'Outdoor', 'zh-CN': '户外' } },
|
|
{ id: 4, key: 'utilities', labels: { en: 'Utilities', 'zh-CN': '实用工具' } },
|
|
{ id: 5, key: 'buildings', labels: { en: 'Buildings', 'zh-CN': '建筑' } },
|
|
{ id: 6, key: 'blocks', labels: { en: 'Blocks', 'zh-CN': '方块' } },
|
|
{ id: 7, key: 'kits', labels: { en: 'Kits', 'zh-CN': '套件' } },
|
|
{ id: 8, key: 'nature', labels: { en: 'Nature', 'zh-CN': '自然' } },
|
|
{ id: 9, key: 'food', labels: { en: 'Food', 'zh-CN': '食物' } },
|
|
{ id: 10, key: 'materials', labels: { en: 'Materials', 'zh-CN': '材料' } },
|
|
{ id: 11, key: 'key-items', labels: { en: 'Key Items', 'zh-CN': '关键物品' } },
|
|
{ id: 12, key: 'other', labels: { en: 'Other', 'zh-CN': '其他' } }
|
|
] as const satisfies readonly SystemListOption[];
|
|
|
|
const itemUsageOptions = [
|
|
{ id: 1, key: 'decoration', labels: { en: 'Decoration', 'zh-CN': '装饰' } },
|
|
{ id: 2, key: 'relaxation', labels: { en: 'Relaxation', 'zh-CN': '休闲' } },
|
|
{ id: 3, key: 'toy', labels: { en: 'Toy', 'zh-CN': '玩具' } },
|
|
{ id: 4, key: 'road', labels: { en: 'Road', 'zh-CN': '道路' } }
|
|
] as const satisfies readonly SystemListOption[];
|
|
|
|
const ancientArtifactCategoryOptions = [
|
|
{ id: 1, key: 'lost-relics-l', labels: { en: 'Lost Relics (L)', 'zh-CN': 'Lost Relics (L)' } },
|
|
{ id: 2, key: 'lost-relics-s', labels: { en: 'Lost Relics (S)', 'zh-CN': 'Lost Relics (S)' } },
|
|
{ id: 3, key: 'fossils', labels: { en: 'Fossils', 'zh-CN': '化石' } }
|
|
] as const satisfies readonly SystemListOption[];
|
|
|
|
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' },
|
|
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
|
|
maps: { table: 'maps', entityType: 'maps' },
|
|
'life-tags': { table: 'life_tags', entityType: 'life-tags', hasDefault: true, hasRateable: true },
|
|
'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true }
|
|
};
|
|
|
|
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
|
|
pokemon: { table: 'pokemon', entityType: 'pokemon' },
|
|
items: { table: 'items', entityType: 'items' },
|
|
'ancient-artifacts': { table: 'ancient_artifacts', entityType: 'ancient-artifacts' },
|
|
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' },
|
|
'ancient-artifacts': { table: 'ancient_artifacts' }
|
|
};
|
|
|
|
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 systemListLabel(option: SystemListOption, locale: string): string {
|
|
const clean = cleanLocale(locale) as keyof SystemListOption['labels'];
|
|
return option.labels[clean] ?? option.labels[defaultLocale];
|
|
}
|
|
|
|
function systemListOptions(options: readonly SystemListOption[], locale: string): Array<{ id: number; key: string; name: string }> {
|
|
return options.map((option) => ({ id: option.id, key: option.key, name: systemListLabel(option, locale) }));
|
|
}
|
|
|
|
function systemListOptionById(
|
|
options: readonly SystemListOption[],
|
|
id: number,
|
|
message: string
|
|
): SystemListOption {
|
|
const option = options.find((item) => item.id === id);
|
|
if (!option) {
|
|
throw validationError(message);
|
|
}
|
|
return option;
|
|
}
|
|
|
|
function systemListOptionByKey(options: readonly SystemListOption[], key: string | null | undefined): SystemListOption | null {
|
|
return options.find((item) => item.key === key) ?? null;
|
|
}
|
|
|
|
function systemListNameByKey(options: readonly SystemListOption[], key: string | null | undefined, locale = defaultLocale): string | null {
|
|
const option = systemListOptionByKey(options, key);
|
|
return option ? systemListLabel(option, locale) : null;
|
|
}
|
|
|
|
function systemListIdSql(expression: string, options: readonly SystemListOption[]): string {
|
|
const cases = options.map((option) => `WHEN ${sqlLiteral(option.key)} THEN ${option.id}`).join(' ');
|
|
return `CASE ${expression} ${cases} ELSE NULL END`;
|
|
}
|
|
|
|
function systemListNameSql(expression: string, options: readonly SystemListOption[], locale: string): string {
|
|
const cases = options
|
|
.map((option) => `WHEN ${sqlLiteral(option.key)} THEN ${sqlLiteral(systemListLabel(option, locale))}`)
|
|
.join(' ');
|
|
return `CASE ${expression} ${cases} ELSE '' END`;
|
|
}
|
|
|
|
function systemListJsonSql(expression: string, options: readonly SystemListOption[], locale: string): string {
|
|
return `json_build_object('id', ${systemListIdSql(expression, options)}, 'key', ${expression}, 'name', ${systemListNameSql(expression, options, locale)})`;
|
|
}
|
|
|
|
function lifeCategoryOptions(locale: string): Promise<Array<{ id: number; name: string; isDefault: boolean; isRateable: boolean }>> {
|
|
const name = localizedName('life-tags', 'lc', locale);
|
|
return query(
|
|
`SELECT lc.id, ${name} AS name, lc.is_default AS "isDefault", lc.is_rateable AS "isRateable" FROM life_tags lc ORDER BY ${orderByEntity('lc')}`
|
|
);
|
|
}
|
|
|
|
function gameVersionOptions(locale: string): Promise<Array<{ id: number; name: string; changeLog: string }>> {
|
|
const name = localizedName('game-versions', 'gv', locale);
|
|
return query(`SELECT gv.id, ${name} AS name, gv.change_log AS "changeLog" FROM game_versions gv ORDER BY ${orderByEntity('gv')}`);
|
|
}
|
|
|
|
function skillOptions(locale: string): Promise<Array<{ id: number; name: string; hasItemDrop: boolean }>> {
|
|
const name = localizedName('skills', 's', locale);
|
|
return query(`SELECT s.id, ${name} AS name, s.has_item_drop AS "hasItemDrop" FROM skills s ORDER BY ${orderByEntity('s')}`);
|
|
}
|
|
|
|
function auditSelect(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string {
|
|
return `
|
|
${entityAlias}.created_at AS "createdAt",
|
|
${entityAlias}.updated_at AS "updatedAt",
|
|
CASE
|
|
WHEN ${createdAlias}.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', ${createdAlias}.id, 'displayName', ${createdAlias}.display_name)
|
|
END AS "createdBy",
|
|
CASE
|
|
WHEN ${updatedAlias}.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', ${updatedAlias}.id, 'displayName', ${updatedAlias}.display_name)
|
|
END AS "updatedBy"
|
|
`;
|
|
}
|
|
|
|
function auditJoins(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string {
|
|
return `
|
|
LEFT JOIN users ${createdAlias} ON ${createdAlias}.id = ${entityAlias}.created_by_user_id
|
|
LEFT JOIN users ${updatedAlias} ON ${updatedAlias}.id = ${entityAlias}.updated_by_user_id
|
|
`;
|
|
}
|
|
|
|
function configOrder(): string {
|
|
return orderByEntity('c');
|
|
}
|
|
|
|
function configSelect(definition: ConfigDefinition, locale: string): string {
|
|
const name = localizedName(definition.entityType, 'c', locale);
|
|
const translations = translationsSelect(definition.entityType, 'c.id');
|
|
const columns = [`c.id`, `${name} AS name`, `c.name AS "baseName"`, `${translations} AS translations`];
|
|
if (definition.hasItemDrop) {
|
|
columns.push(`c.has_item_drop AS "hasItemDrop"`);
|
|
}
|
|
if (definition.hasDefault) {
|
|
columns.push(`c.is_default AS "isDefault"`);
|
|
}
|
|
if (definition.hasRateable) {
|
|
columns.push(`c.is_rateable AS "isRateable"`);
|
|
}
|
|
if (definition.hasChangeLog) {
|
|
columns.push(`c.change_log AS "changeLog"`);
|
|
}
|
|
return columns.join(', ');
|
|
}
|
|
|
|
function validationError(message: string): ValidationError {
|
|
const error = new Error(message) as ValidationError;
|
|
error.statusCode = 400;
|
|
return error;
|
|
}
|
|
|
|
function requirePositiveInteger(value: unknown, message: string): number {
|
|
const numberValue = Number(value);
|
|
if (!Number.isInteger(numberValue) || numberValue <= 0) {
|
|
throw validationError(message);
|
|
}
|
|
return numberValue;
|
|
}
|
|
|
|
function optionalPositiveInteger(value: unknown, message: string): number | null {
|
|
if (value === undefined || value === null || value === '') {
|
|
return null;
|
|
}
|
|
|
|
return requirePositiveInteger(value, message);
|
|
}
|
|
|
|
function cleanName(value: unknown, message = 'server.validation.nameRequired'): string {
|
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
throw validationError(message);
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function cleanOptionalText(value: unknown): string {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats' | 'ancient-artifacts'): 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,
|
|
dataId: number | null
|
|
): Promise<number> {
|
|
if (dataId !== null) {
|
|
return dataId;
|
|
}
|
|
|
|
const result = await client.query<{ id: number }>(
|
|
'SELECT COALESCE(MAX(id), 999999) + 1 AS id FROM pokemon WHERE id >= 1000000'
|
|
);
|
|
return result.rows[0]?.id ?? 1000000;
|
|
}
|
|
|
|
async function reorderTableRows(
|
|
client: DbClient,
|
|
tableName: string,
|
|
entityType: string,
|
|
ids: number[],
|
|
userId: number
|
|
): Promise<void> {
|
|
const existing = await client.query<{ id: number; sortOrder: number }>(
|
|
`SELECT id, sort_order AS "sortOrder" FROM ${tableName} WHERE id = ANY($1::integer[])`,
|
|
[ids]
|
|
);
|
|
|
|
if (existing.rowCount !== ids.length) {
|
|
throw validationError('server.validation.recordMissing');
|
|
}
|
|
|
|
const sortOrders = new Map(existing.rows.map((row) => [row.id, row.sortOrder]));
|
|
for (const [index, id] of ids.entries()) {
|
|
const nextSortOrder = (index + 1) * 10;
|
|
const previousSortOrder = sortOrders.get(id);
|
|
if (previousSortOrder === nextSortOrder) {
|
|
continue;
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
UPDATE ${tableName}
|
|
SET sort_order = $1, updated_by_user_id = $2, updated_at = now()
|
|
WHERE id = $3
|
|
`,
|
|
[nextSortOrder, userId, id]
|
|
);
|
|
const changes: EditChange[] = [];
|
|
pushChange(changes, 'Sort order', String(previousSortOrder), String(nextSortOrder));
|
|
await recordEditLog(client, entityType, id, 'update', userId, changes);
|
|
}
|
|
}
|
|
|
|
async function recordEditLog(
|
|
client: DbClient,
|
|
entityType: string,
|
|
entityId: number,
|
|
action: EditAction,
|
|
userId: number,
|
|
changes: EditChange[] = []
|
|
): Promise<void> {
|
|
await client.query(
|
|
`
|
|
INSERT INTO wiki_edit_logs (entity_type, entity_id, action, user_id, changes)
|
|
VALUES ($1, $2, $3, $4, $5::jsonb)
|
|
`,
|
|
[entityType, entityId, action, userId, JSON.stringify(changes)]
|
|
);
|
|
}
|
|
|
|
function cleanLanguagePayload(payload: Record<string, unknown>, requireCode: boolean): LanguagePayload {
|
|
const code = typeof payload.code === 'string' ? payload.code.trim() : '';
|
|
if (requireCode && !localePattern.test(code)) {
|
|
throw validationError('server.validation.languageCodeInvalid');
|
|
}
|
|
|
|
const sortOrder = Number(payload.sortOrder ?? 0);
|
|
|
|
return {
|
|
code,
|
|
name: cleanName(payload.name, 'server.validation.languageNameRequired'),
|
|
enabled: payload.enabled !== false,
|
|
isDefault: Boolean(payload.isDefault),
|
|
sortOrder: Number.isInteger(sortOrder) && sortOrder >= 0 ? sortOrder : 0
|
|
};
|
|
}
|
|
|
|
function requireLanguageCode(value: unknown): string {
|
|
const code = typeof value === 'string' ? value.trim() : '';
|
|
if (!localePattern.test(code)) {
|
|
throw validationError('server.validation.languageCodeInvalid');
|
|
}
|
|
return code;
|
|
}
|
|
|
|
export async function listLanguages(includeDisabled = false) {
|
|
return query<LanguagePayload>(
|
|
`
|
|
SELECT code, name, enabled, is_default AS "isDefault", sort_order AS "sortOrder"
|
|
FROM languages
|
|
${includeDisabled ? '' : 'WHERE enabled = true'}
|
|
ORDER BY sort_order, name
|
|
`
|
|
);
|
|
}
|
|
|
|
export async function createLanguage(payload: Record<string, unknown>) {
|
|
const cleanPayload = cleanLanguagePayload(payload, true);
|
|
if (cleanPayload.isDefault && cleanPayload.code !== defaultLocale) {
|
|
throw validationError('server.validation.defaultLanguageMustBeEnglish');
|
|
}
|
|
if (!cleanPayload.enabled && cleanPayload.isDefault) {
|
|
throw validationError('server.validation.defaultLanguageMustBeEnabled');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
if (cleanPayload.isDefault) {
|
|
await client.query('UPDATE languages SET is_default = false');
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
INSERT INTO languages (code, name, enabled, is_default, sort_order)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
`,
|
|
[cleanPayload.code, cleanPayload.name, cleanPayload.enabled, cleanPayload.isDefault, cleanPayload.sortOrder]
|
|
);
|
|
});
|
|
|
|
return listLanguages(true);
|
|
}
|
|
|
|
export async function updateLanguage(code: string, payload: Record<string, unknown>) {
|
|
const locale = requireLanguageCode(code);
|
|
const cleanPayload = cleanLanguagePayload({ ...payload, code: locale }, false);
|
|
if (cleanPayload.isDefault && locale !== defaultLocale) {
|
|
throw validationError('server.validation.defaultLanguageMustBeEnglish');
|
|
}
|
|
if (!cleanPayload.enabled && cleanPayload.isDefault) {
|
|
throw validationError('server.validation.defaultLanguageMustBeEnabled');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
const current = await client.query<{ isDefault: boolean }>(
|
|
'SELECT is_default AS "isDefault" FROM languages WHERE code = $1',
|
|
[locale]
|
|
);
|
|
|
|
if (current.rowCount === 0) {
|
|
throw validationError('server.validation.languageNotFound');
|
|
}
|
|
|
|
if (!cleanPayload.enabled && current.rows[0].isDefault) {
|
|
throw validationError('server.validation.defaultLanguageMustBeEnabled');
|
|
}
|
|
|
|
if (current.rows[0].isDefault && !cleanPayload.isDefault) {
|
|
throw validationError('server.validation.defaultLanguageRequired');
|
|
}
|
|
|
|
if (cleanPayload.isDefault) {
|
|
await client.query('UPDATE languages SET is_default = false WHERE code <> $1', [locale]);
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
UPDATE languages
|
|
SET name = $1,
|
|
enabled = $2,
|
|
is_default = $3,
|
|
sort_order = $4
|
|
WHERE code = $5
|
|
`,
|
|
[cleanPayload.name, cleanPayload.enabled, cleanPayload.isDefault, cleanPayload.sortOrder, locale]
|
|
);
|
|
});
|
|
|
|
return listLanguages(true);
|
|
}
|
|
|
|
export async function deleteLanguage(code: string) {
|
|
const locale = requireLanguageCode(code);
|
|
if (locale === defaultLocale) {
|
|
throw validationError('server.validation.defaultLanguageCannotBeDeleted');
|
|
}
|
|
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ isDefault: boolean }>(
|
|
'DELETE FROM languages WHERE code = $1 AND is_default = false RETURNING is_default AS "isDefault"',
|
|
[locale]
|
|
);
|
|
return (result.rowCount ?? 0) > 0;
|
|
});
|
|
}
|
|
|
|
export async function reorderLanguages(payload: Record<string, unknown>) {
|
|
const codes = Array.isArray(payload.codes) ? payload.codes.map(requireLanguageCode) : [];
|
|
if (codes.length === 0) {
|
|
throw validationError('server.validation.selectLanguage');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
const existing = await client.query<{ code: string }>(
|
|
'SELECT code FROM languages WHERE code = ANY($1::text[])',
|
|
[codes]
|
|
);
|
|
|
|
if (existing.rowCount !== codes.length) {
|
|
throw validationError('server.validation.languageDoesNotExist');
|
|
}
|
|
|
|
for (const [index, code] of codes.entries()) {
|
|
await client.query(
|
|
`
|
|
UPDATE languages
|
|
SET sort_order = $1
|
|
WHERE code = $2
|
|
`,
|
|
[(index + 1) * 10, code]
|
|
);
|
|
}
|
|
});
|
|
|
|
return listLanguages(true);
|
|
}
|
|
|
|
function parseCsv(content: string, fileName: string): CsvRow[] {
|
|
const rows: string[][] = [];
|
|
let row: string[] = [];
|
|
let cell = '';
|
|
let inQuotes = false;
|
|
|
|
for (let index = 0; index < content.length; index += 1) {
|
|
const char = content[index];
|
|
|
|
if (inQuotes) {
|
|
if (char === '"' && content[index + 1] === '"') {
|
|
cell += '"';
|
|
index += 1;
|
|
} else if (char === '"') {
|
|
inQuotes = false;
|
|
} else {
|
|
cell += char;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (char === '"') {
|
|
inQuotes = true;
|
|
} else if (char === ',') {
|
|
row.push(cell);
|
|
cell = '';
|
|
} else if (char === '\n') {
|
|
row.push(cell);
|
|
if (row.some((value) => value !== '')) {
|
|
rows.push(row);
|
|
}
|
|
row = [];
|
|
cell = '';
|
|
} else if (char !== '\r') {
|
|
cell += char;
|
|
}
|
|
}
|
|
|
|
if (cell !== '' || row.length > 0) {
|
|
row.push(cell);
|
|
if (row.some((value) => value !== '')) {
|
|
rows.push(row);
|
|
}
|
|
}
|
|
|
|
const headers = rows[0]?.map((header) => header.replace(/^\uFEFF/, ''));
|
|
if (!headers?.length) {
|
|
throw validationError('server.validation.pokemonDataFileEmpty');
|
|
}
|
|
|
|
return rows.slice(1).map((values) =>
|
|
headers.reduce<CsvRow>((record, header, index) => {
|
|
record[header] = values[index] ?? '';
|
|
return record;
|
|
}, {})
|
|
);
|
|
}
|
|
|
|
async function readPokemonDataFile(fileName: string): Promise<string> {
|
|
const sourceDir = dirname(fileURLToPath(import.meta.url));
|
|
const directories = [
|
|
process.env.POKOPIA_DATA_DIR ? resolve(process.env.POKOPIA_DATA_DIR) : '',
|
|
resolve(process.cwd(), 'data'),
|
|
resolve(process.cwd(), '..', 'data'),
|
|
resolve(sourceDir, '..', 'data'),
|
|
resolve(sourceDir, '..', '..', 'data')
|
|
].filter(Boolean);
|
|
const uniqueDirectories = [...new Set(directories)];
|
|
|
|
for (const directory of uniqueDirectories) {
|
|
try {
|
|
return await readFile(resolve(directory, fileName), 'utf8');
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
throw validationError('server.validation.pokemonDataFileUnavailable');
|
|
}
|
|
|
|
function csvInteger(row: CsvRow, fieldName: string): number {
|
|
const value = Number(row[fieldName]);
|
|
return Number.isInteger(value) ? value : 0;
|
|
}
|
|
|
|
function csvNumber(row: CsvRow, fieldName: string): number {
|
|
const value = Number(row[fieldName]);
|
|
return Number.isFinite(value) ? value : 0;
|
|
}
|
|
|
|
function csvText(row: CsvRow, fieldName: string): string {
|
|
return row[fieldName]?.trim() ?? '';
|
|
}
|
|
|
|
function indexRowsByNumber(rows: CsvRow[], fieldName: string): Map<number, CsvRow> {
|
|
return rows.reduce((index, row) => {
|
|
const id = csvInteger(row, fieldName);
|
|
if (id > 0) {
|
|
index.set(id, row);
|
|
}
|
|
return index;
|
|
}, new Map<number, CsvRow>());
|
|
}
|
|
|
|
async function loadPokemonCsvData(): Promise<PokemonCsvData> {
|
|
if (!pokemonCsvDataCache) {
|
|
pokemonCsvDataCache = (async () => {
|
|
const [pokemonContent, namesContent, genusContent, typesContent] = await Promise.all([
|
|
readPokemonDataFile('pokemon_data.csv'),
|
|
readPokemonDataFile('localized_pokemon_name.csv'),
|
|
readPokemonDataFile('localized_pokemon_genus.csv'),
|
|
readPokemonDataFile('localized_type_name.csv')
|
|
]);
|
|
const pokemonRows = parseCsv(pokemonContent, 'pokemon_data.csv');
|
|
const typeRows = parseCsv(typesContent, 'localized_type_name.csv');
|
|
const pokemonByLookup = new Map<string, CsvRow>();
|
|
|
|
for (const row of pokemonRows) {
|
|
const id = csvInteger(row, 'id');
|
|
const identifier = csvText(row, 'identifier').toLowerCase();
|
|
if (id > 0) {
|
|
pokemonByLookup.set(String(id), row);
|
|
}
|
|
if (identifier) {
|
|
pokemonByLookup.set(identifier, row);
|
|
}
|
|
}
|
|
|
|
return {
|
|
pokemonRows,
|
|
pokemonByLookup,
|
|
namesByPokemonId: indexRowsByNumber(parseCsv(namesContent, 'localized_pokemon_name.csv'), 'pokemon_species_id'),
|
|
genusByPokemonId: indexRowsByNumber(parseCsv(genusContent, 'localized_pokemon_genus.csv'), 'pokemon_species_id'),
|
|
typesById: indexRowsByNumber(typeRows, 'type_id'),
|
|
canonicalTypeRows: typeRows.filter((row) => pokemonTypeIconIds.has(csvInteger(row, 'type_id')))
|
|
};
|
|
})();
|
|
}
|
|
|
|
return pokemonCsvDataCache;
|
|
}
|
|
|
|
function pokemonDataLookupKey(value: unknown): string {
|
|
const rawValue = typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim() : '';
|
|
if (rawValue === '') {
|
|
throw validationError('server.validation.pokemonIdentifierRequired');
|
|
}
|
|
|
|
const numericValue = Number(rawValue);
|
|
if (Number.isInteger(numericValue) && numericValue > 0) {
|
|
return String(numericValue);
|
|
}
|
|
|
|
return rawValue.toLowerCase();
|
|
}
|
|
|
|
function languageCsvColumn(code: string): string | null {
|
|
const [language, region = ''] = code.split('-');
|
|
const languageKey = language.toLowerCase();
|
|
const regionKey = region.toUpperCase();
|
|
const directColumns: Record<string, string> = {
|
|
de: 'de',
|
|
en: 'en',
|
|
es: 'es',
|
|
fr: 'fr',
|
|
it: 'it',
|
|
ja: 'ja',
|
|
ko: 'ko'
|
|
};
|
|
|
|
if (languageKey === 'zh') {
|
|
return ['HK', 'MO', 'TW'].includes(regionKey) ? 'zh_hant' : 'zh_hans';
|
|
}
|
|
|
|
return directColumns[languageKey] ?? null;
|
|
}
|
|
|
|
function localizedCsvText(row: CsvRow, code: string): string {
|
|
const column = languageCsvColumn(code);
|
|
return column ? csvText(row, column) : '';
|
|
}
|
|
|
|
function defaultLanguage(languages: LanguagePayload[]): LanguagePayload | undefined {
|
|
return languages.find((language) => language.isDefault) ?? languages.find((language) => language.code === defaultLocale) ?? languages[0];
|
|
}
|
|
|
|
function defaultCsvText(row: CsvRow, languages: LanguagePayload[], fallback: string): string {
|
|
const defaultCode = defaultLanguage(languages)?.code ?? defaultLocale;
|
|
return localizedCsvText(row, defaultCode) || localizedCsvText(row, defaultLocale) || fallback;
|
|
}
|
|
|
|
function pokemonSpriteUrl(path: string): string {
|
|
return `${pokemonSpriteBaseUrl}${path}`;
|
|
}
|
|
|
|
function pokemonImageWithUrl(candidate: PokemonImageCandidate): PokemonImage {
|
|
return { ...candidate, url: pokemonSpriteUrl(candidate.path), source: 'sprite' };
|
|
}
|
|
|
|
function pokemonImageCandidates(id: number): PokemonImageCandidate[] {
|
|
return [
|
|
{
|
|
path: `/sprites/pokemon/other/official-artwork/${id}.png`,
|
|
style: 'Official artwork',
|
|
version: 'Official artwork',
|
|
variant: 'Default',
|
|
description: 'Large official artwork'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/official-artwork/shiny/${id}.png`,
|
|
style: 'Official artwork',
|
|
version: 'Official artwork',
|
|
variant: 'Shiny',
|
|
description: 'Large shiny official artwork'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/home/${id}.png`,
|
|
style: 'Pokemon HOME',
|
|
version: 'HOME',
|
|
variant: 'Default',
|
|
description: 'Modern HOME render'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/home/shiny/${id}.png`,
|
|
style: 'Pokemon HOME',
|
|
version: 'HOME',
|
|
variant: 'Shiny',
|
|
description: 'Modern shiny HOME render'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/home/female/${id}.png`,
|
|
style: 'Pokemon HOME',
|
|
version: 'HOME',
|
|
variant: 'Female',
|
|
description: 'Modern female HOME render'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/home/shiny/female/${id}.png`,
|
|
style: 'Pokemon HOME',
|
|
version: 'HOME',
|
|
variant: 'Shiny female',
|
|
description: 'Modern shiny female HOME render'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/dream-world/${id}.svg`,
|
|
style: 'Dream World',
|
|
version: 'Dream World',
|
|
variant: 'Default',
|
|
description: 'Dream World SVG artwork'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/dream-world/female/${id}.svg`,
|
|
style: 'Dream World',
|
|
version: 'Dream World',
|
|
variant: 'Female',
|
|
description: 'Dream World female SVG artwork'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/showdown/${id}.gif`,
|
|
style: 'Pokemon Showdown',
|
|
version: 'Showdown',
|
|
variant: 'Front animated',
|
|
description: 'Animated front battle sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/showdown/shiny/${id}.gif`,
|
|
style: 'Pokemon Showdown',
|
|
version: 'Showdown',
|
|
variant: 'Shiny front animated',
|
|
description: 'Animated shiny front battle sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/showdown/female/${id}.gif`,
|
|
style: 'Pokemon Showdown',
|
|
version: 'Showdown',
|
|
variant: 'Female front animated',
|
|
description: 'Animated female front battle sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/showdown/back/${id}.gif`,
|
|
style: 'Pokemon Showdown',
|
|
version: 'Showdown',
|
|
variant: 'Back animated',
|
|
description: 'Animated back battle sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/${id}.png`,
|
|
style: 'Default sprite',
|
|
version: 'PokeAPI',
|
|
variant: 'Front',
|
|
description: 'Compact front sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/shiny/${id}.png`,
|
|
style: 'Default sprite',
|
|
version: 'PokeAPI',
|
|
variant: 'Shiny front',
|
|
description: 'Compact shiny front sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/female/${id}.png`,
|
|
style: 'Default sprite',
|
|
version: 'PokeAPI',
|
|
variant: 'Female front',
|
|
description: 'Compact female front sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/back/${id}.png`,
|
|
style: 'Default sprite',
|
|
version: 'PokeAPI',
|
|
variant: 'Back',
|
|
description: 'Compact back sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/back/shiny/${id}.png`,
|
|
style: 'Default sprite',
|
|
version: 'PokeAPI',
|
|
variant: 'Shiny back',
|
|
description: 'Compact shiny back sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/versions/generation-v/black-white/animated/${id}.gif`,
|
|
style: 'Game version',
|
|
version: 'Black / White',
|
|
variant: 'Animated front',
|
|
description: 'Generation V animated sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/versions/generation-v/black-white/animated/shiny/${id}.gif`,
|
|
style: 'Game version',
|
|
version: 'Black / White',
|
|
variant: 'Animated shiny',
|
|
description: 'Generation V animated shiny sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/versions/generation-v/black-white/${id}.png`,
|
|
style: 'Game version',
|
|
version: 'Black / White',
|
|
variant: 'Front',
|
|
description: 'Generation V front sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/versions/generation-vi/x-y/${id}.png`,
|
|
style: 'Game version',
|
|
version: 'X / Y',
|
|
variant: 'Front',
|
|
description: 'Generation VI front sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/versions/generation-vii/ultra-sun-ultra-moon/${id}.png`,
|
|
style: 'Game version',
|
|
version: 'Ultra Sun / Ultra Moon',
|
|
variant: 'Front',
|
|
description: 'Generation VII front sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/versions/generation-ix/scarlet-violet/${id}.png`,
|
|
style: 'Game version',
|
|
version: 'Scarlet / Violet',
|
|
variant: 'Front',
|
|
description: 'Generation IX front sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/versions/generation-iii/emerald/${id}.png`,
|
|
style: 'Game version',
|
|
version: 'Emerald',
|
|
variant: 'Front',
|
|
description: 'Generation III front sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/versions/generation-i/red-blue/${id}.png`,
|
|
style: 'Game version',
|
|
version: 'Red / Blue',
|
|
variant: 'Front',
|
|
description: 'Generation I front sprite'
|
|
}
|
|
];
|
|
}
|
|
|
|
function pokemonImageLabel(image: PokemonImage | null | undefined): string {
|
|
if (!image) {
|
|
return '';
|
|
}
|
|
return image.source === 'upload' || isUploadImagePath(image.path) ? imagePathLabel(image.path) : `${image.style} - ${image.version} - ${image.variant}`;
|
|
}
|
|
|
|
function pokemonImageDataIdFromPath(path: string): number | null {
|
|
const match = path.match(/^\/sprites\/pokemon\/(?:.+\/)?([1-9]\d*)\.(?:png|gif|svg)$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const id = Number(match[1]);
|
|
return Number.isSafeInteger(id) ? id : null;
|
|
}
|
|
|
|
function pokemonImageCandidateForPath(path: string): PokemonImage | null {
|
|
const cleanPath = path.trim();
|
|
const id = pokemonImageDataIdFromPath(cleanPath);
|
|
if (!id) {
|
|
return null;
|
|
}
|
|
|
|
const candidate = pokemonImageCandidates(id).find((item) => item.path === cleanPath);
|
|
return candidate ? pokemonImageWithUrl(candidate) : null;
|
|
}
|
|
|
|
function cleanPokemonImage(value: unknown, displayId: number): PokemonImage | null {
|
|
const path = typeof value === 'string' ? value.trim() : '';
|
|
if (path === '') {
|
|
return null;
|
|
}
|
|
|
|
if (isUploadImagePath(path)) {
|
|
if (!path.startsWith('pokemon/')) {
|
|
throw validationError('server.validation.imagePathInvalid');
|
|
}
|
|
return {
|
|
path,
|
|
url: uploadImageUrl(path),
|
|
style: 'Upload',
|
|
version: 'Community upload',
|
|
variant: `#${displayId}`,
|
|
description: '',
|
|
source: 'upload'
|
|
};
|
|
}
|
|
|
|
const image = pokemonImageCandidateForPath(path);
|
|
if (!image) {
|
|
throw validationError('server.validation.pokemonImagePathInvalid');
|
|
}
|
|
return image;
|
|
}
|
|
|
|
async function pokemonImageExists(candidate: PokemonImageCandidate): Promise<boolean> {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), pokemonSpriteRequestTimeoutMs);
|
|
|
|
try {
|
|
const response = await fetch(pokemonSpriteUrl(candidate.path), { method: 'HEAD', signal: controller.signal });
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function assignTranslation(translations: TranslationInput, locale: string, fieldName: TranslationField, value: string): void {
|
|
if (!value) {
|
|
return;
|
|
}
|
|
|
|
translations[locale] = {
|
|
...(translations[locale] ?? {}),
|
|
[fieldName]: value
|
|
};
|
|
}
|
|
|
|
function localizedCsvTranslations(
|
|
rows: Array<{ row: CsvRow; fieldName: TranslationField }>,
|
|
languages: LanguagePayload[]
|
|
): TranslationInput {
|
|
const translations: TranslationInput = {};
|
|
const defaultCode = defaultLanguage(languages)?.code ?? defaultLocale;
|
|
|
|
for (const language of languages) {
|
|
if (language.code === defaultCode) {
|
|
continue;
|
|
}
|
|
|
|
for (const { row, fieldName } of rows) {
|
|
assignTranslation(translations, language.code, fieldName, localizedCsvText(row, language.code));
|
|
}
|
|
}
|
|
|
|
return cleanTranslations(translations, rows.map((row) => row.fieldName));
|
|
}
|
|
|
|
function fetchedPokemonStats(row: CsvRow): PokemonStats {
|
|
return {
|
|
hp: csvInteger(row, 'hp'),
|
|
attack: csvInteger(row, 'atk'),
|
|
defense: csvInteger(row, 'def'),
|
|
specialAttack: csvInteger(row, 'sp_atk'),
|
|
specialDefense: csvInteger(row, 'sp_def'),
|
|
speed: csvInteger(row, 'spd')
|
|
};
|
|
}
|
|
|
|
function fetchedPokemonTypeIds(row: CsvRow, data: PokemonCsvData): number[] {
|
|
const typeIds = [csvInteger(row, 'type_1_id'), csvInteger(row, 'type_2_id')].filter((typeId) => typeId > 0);
|
|
|
|
if (typeIds.length === 0 || typeIds.some((typeId) => !data.typesById.has(typeId) || !pokemonTypeIconIds.has(typeId))) {
|
|
throw validationError('server.validation.pokemonTypeDataUnavailable');
|
|
}
|
|
|
|
return typeIds;
|
|
}
|
|
|
|
async function ensurePokemonTypeCatalog(
|
|
client: DbClient,
|
|
data: PokemonCsvData,
|
|
languages: LanguagePayload[],
|
|
userId: number
|
|
): Promise<void> {
|
|
for (const row of data.canonicalTypeRows) {
|
|
const typeId = csvInteger(row, 'type_id');
|
|
const name = defaultCsvText(row, languages, csvText(row, 'identifier'));
|
|
const translations = localizedCsvTranslations([{ row, fieldName: 'name' }], languages);
|
|
const existing = await client.query<ConfigChangeSource>(
|
|
`
|
|
SELECT pt.name, ${translationsSelect('pokemon-types', 'pt.id')} AS translations
|
|
FROM pokemon_types pt
|
|
WHERE pt.id = $1
|
|
`,
|
|
[typeId]
|
|
);
|
|
|
|
if (existing.rowCount === 0) {
|
|
await client.query(
|
|
`
|
|
INSERT INTO pokemon_types (
|
|
id,
|
|
name,
|
|
sort_order,
|
|
created_by_user_id,
|
|
updated_by_user_id
|
|
)
|
|
VALUES ($1, $2, $3, $4, $4)
|
|
`,
|
|
[typeId, name, typeId * 10, userId]
|
|
);
|
|
await recordEditLog(client, 'pokemon-types', typeId, 'create', userId);
|
|
} else {
|
|
const changes = configEditChanges(
|
|
{ table: 'pokemon_types', entityType: 'pokemon-types' },
|
|
existing.rows[0],
|
|
{ name, translations, hasItemDrop: false, isDefault: false, isRateable: false, changeLog: '' }
|
|
);
|
|
if (changes.length) {
|
|
await client.query(
|
|
`
|
|
UPDATE pokemon_types
|
|
SET name = $1,
|
|
updated_by_user_id = $2,
|
|
updated_at = now()
|
|
WHERE id = $3
|
|
`,
|
|
[name, userId, typeId]
|
|
);
|
|
await recordEditLog(client, 'pokemon-types', typeId, 'update', userId, changes);
|
|
}
|
|
}
|
|
|
|
await replaceEntityTranslations(client, 'pokemon-types', typeId, translations, ['name']);
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
SELECT setval(
|
|
pg_get_serial_sequence('pokemon_types', 'id'),
|
|
GREATEST((SELECT COALESCE(MAX(id), 1) FROM pokemon_types), 1),
|
|
true
|
|
)
|
|
`
|
|
);
|
|
}
|
|
|
|
export async function fetchPokemonData(payload: Record<string, unknown>, userId: number): Promise<PokemonFetchResult> {
|
|
const lookupKey = pokemonDataLookupKey(payload.identifier);
|
|
const [data, languages] = await Promise.all([loadPokemonCsvData(), listLanguages()]);
|
|
const pokemonRow = data.pokemonByLookup.get(lookupKey);
|
|
|
|
if (!pokemonRow) {
|
|
throw validationError('server.validation.pokemonDataNotFound');
|
|
}
|
|
|
|
const id = csvInteger(pokemonRow, 'id');
|
|
const nameRow = data.namesByPokemonId.get(id) ?? pokemonRow;
|
|
const genusRow = data.genusByPokemonId.get(id) ?? pokemonRow;
|
|
const identifier = csvText(pokemonRow, 'identifier');
|
|
const typeIds = fetchedPokemonTypeIds(pokemonRow, data);
|
|
|
|
await withTransaction((client) => ensurePokemonTypeCatalog(client, data, languages, userId));
|
|
|
|
return {
|
|
id,
|
|
identifier,
|
|
name: defaultCsvText(nameRow, languages, identifier),
|
|
genus: defaultCsvText(genusRow, languages, ''),
|
|
heightInches: Math.round(csvNumber(pokemonRow, 'height_m') * 39.37007874015748 * 100) / 100,
|
|
weightPounds: Math.round(csvNumber(pokemonRow, 'weight_kg') * 2.2046226218 * 10) / 10,
|
|
translations: localizedCsvTranslations(
|
|
[
|
|
{ row: nameRow, fieldName: 'name' },
|
|
{ row: genusRow, fieldName: 'genus' }
|
|
],
|
|
languages
|
|
),
|
|
typeIds,
|
|
stats: fetchedPokemonStats(pokemonRow)
|
|
};
|
|
}
|
|
|
|
export async function fetchPokemonImageOptions(payload: Record<string, unknown>): Promise<PokemonImageOptionsResult> {
|
|
const lookupKey = pokemonDataLookupKey(payload.identifier);
|
|
const data = await loadPokemonCsvData();
|
|
const pokemonRow = data.pokemonByLookup.get(lookupKey);
|
|
|
|
if (!pokemonRow) {
|
|
throw validationError('server.validation.pokemonDataNotFound');
|
|
}
|
|
|
|
const id = csvInteger(pokemonRow, 'id');
|
|
const images = (
|
|
await Promise.all(
|
|
pokemonImageCandidates(id).map(async (candidate) => (await pokemonImageExists(candidate) ? pokemonImageWithUrl(candidate) : null))
|
|
)
|
|
).filter((image): image is PokemonImage => image !== null);
|
|
|
|
return {
|
|
id,
|
|
identifier: csvText(pokemonRow, 'identifier'),
|
|
images
|
|
};
|
|
}
|
|
|
|
function pokemonFetchOption(row: CsvRow, data: PokemonCsvData, languages: LanguagePayload[], locale: string): PokemonFetchOption {
|
|
const id = csvInteger(row, 'id');
|
|
const identifier = csvText(row, 'identifier');
|
|
const nameRow = data.namesByPokemonId.get(id) ?? row;
|
|
|
|
return {
|
|
id,
|
|
identifier,
|
|
name: localizedCsvText(nameRow, cleanLocale(locale)) || defaultCsvText(nameRow, languages, identifier)
|
|
};
|
|
}
|
|
|
|
function pokemonFetchOptionMatches(
|
|
row: CsvRow,
|
|
data: PokemonCsvData,
|
|
languages: LanguagePayload[],
|
|
locale: string,
|
|
search: string
|
|
): boolean {
|
|
if (!search) {
|
|
return true;
|
|
}
|
|
|
|
const id = csvInteger(row, 'id');
|
|
const identifier = csvText(row, 'identifier');
|
|
const nameRow = data.namesByPokemonId.get(id) ?? row;
|
|
const defaultCode = defaultLanguage(languages)?.code ?? defaultLocale;
|
|
const searchFields = [
|
|
String(id),
|
|
identifier,
|
|
localizedCsvText(nameRow, cleanLocale(locale)),
|
|
localizedCsvText(nameRow, defaultCode),
|
|
localizedCsvText(nameRow, defaultLocale)
|
|
];
|
|
const keyword = search.toLowerCase();
|
|
|
|
return searchFields.some((field) => field.toLowerCase().includes(keyword));
|
|
}
|
|
|
|
export async function listPokemonFetchOptions(paramsQuery: QueryParams, locale = defaultLocale): Promise<PokemonFetchOption[]> {
|
|
const search = asString(paramsQuery.search)?.trim() ?? '';
|
|
const includeAll = asString(paramsQuery.all) === 'true';
|
|
const [data, languages] = await Promise.all([loadPokemonCsvData(), listLanguages()]);
|
|
const rows = data.pokemonRows.filter(
|
|
(row) => csvInteger(row, 'id') > 0 && pokemonFetchOptionMatches(row, data, languages, locale, search)
|
|
);
|
|
|
|
return (includeAll ? rows : rows.slice(0, 20)).map((row) => pokemonFetchOption(row, data, languages, locale));
|
|
}
|
|
|
|
function displayValue(value: string | null | undefined): string {
|
|
const cleanValue = value?.trim() ?? '';
|
|
return cleanValue === '' ? 'None' : cleanValue;
|
|
}
|
|
|
|
function pushChange(changes: EditChange[], label: string, before: string | null | undefined, after: string | null | undefined): void {
|
|
const beforeValue = displayValue(before);
|
|
const afterValue = displayValue(after);
|
|
|
|
if (beforeValue !== afterValue) {
|
|
changes.push({ label, before: beforeValue, after: afterValue });
|
|
}
|
|
}
|
|
|
|
const translationChangeLabels: Record<TranslationField, string> = {
|
|
name: 'Name',
|
|
title: 'Title',
|
|
details: 'Details',
|
|
genus: 'Genus'
|
|
};
|
|
|
|
function translationFieldValue(
|
|
translations: TranslationInput | null | undefined,
|
|
locale: string,
|
|
field: TranslationField
|
|
): string | null {
|
|
const value = translations?.[locale]?.[field];
|
|
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
|
}
|
|
|
|
function pushTranslationChanges(
|
|
changes: EditChange[],
|
|
before: TranslationInput | null | undefined,
|
|
after: TranslationInput,
|
|
fields: TranslationField[]
|
|
): void {
|
|
const locales = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after)])]
|
|
.filter((locale) => locale !== defaultLocale)
|
|
.sort((a, b) => a.localeCompare(b));
|
|
|
|
for (const locale of locales) {
|
|
for (const field of fields) {
|
|
pushChange(
|
|
changes,
|
|
`${translationChangeLabels[field]} (${locale})`,
|
|
translationFieldValue(before, locale, field),
|
|
translationFieldValue(after, locale, field)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function boolValue(value: boolean): string {
|
|
return value ? 'Yes' : 'No';
|
|
}
|
|
|
|
function namedListValue(items: Array<{ name: string }> | null | undefined): string {
|
|
if (!items?.length) {
|
|
return 'None';
|
|
}
|
|
|
|
return [...new Set(items.map((item) => item.name))]
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.join(' / ');
|
|
}
|
|
|
|
function quantityListValue(items: Array<{ name: string; quantity: number }> | null | undefined): string {
|
|
if (!items?.length) {
|
|
return 'None';
|
|
}
|
|
|
|
return items
|
|
.map((item) => ({ name: item.name, value: `${item.name} x${item.quantity}` }))
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
.map((item) => item.value)
|
|
.join(' / ');
|
|
}
|
|
|
|
function skillDropListValue(skills: Array<{ name: string; itemDrop?: { name: string } | null }> | null | undefined): string {
|
|
const rows = skills
|
|
?.filter((skill) => skill.itemDrop)
|
|
.map((skill) => `${skill.name}: ${skill.itemDrop?.name}`)
|
|
.sort((a, b) => a.localeCompare(b)) ?? [];
|
|
|
|
return rows.length ? rows.join(' / ') : 'None';
|
|
}
|
|
|
|
function pokemonStatsValue(stats: PokemonStats | null | undefined): string {
|
|
return pokemonStatLabels.map((stat) => `${stat.label}: ${stats?.[stat.key] ?? 0}`).join(' / ');
|
|
}
|
|
|
|
function roundMeasure(value: number, precision: number): number {
|
|
const scale = 10 ** precision;
|
|
return Math.round(value * scale) / scale;
|
|
}
|
|
|
|
function formatFixedMeasure(value: number, precision: number): string {
|
|
return value.toFixed(precision);
|
|
}
|
|
|
|
function feetInchesValue(inches: number): string {
|
|
const totalInches = Math.round(inches);
|
|
const feet = Math.floor(totalInches / 12);
|
|
const remainingInches = totalInches - feet * 12;
|
|
return `${feet}'${remainingInches}"`;
|
|
}
|
|
|
|
function pokemonHeightValue(inches: number | null | undefined): string {
|
|
const value = inches ?? 0;
|
|
return `${feetInchesValue(value)} / ${formatFixedMeasure(roundMeasure(value * 0.0254, 2), 2)} m`;
|
|
}
|
|
|
|
function pokemonWeightValue(pounds: number | null | undefined): string {
|
|
const value = pounds ?? 0;
|
|
return `${formatFixedMeasure(roundMeasure(value, 1), 1)} lb / ${formatFixedMeasure(roundMeasure(value * 0.45359237, 2), 2)} kg`;
|
|
}
|
|
|
|
function appearanceListValue(
|
|
rows: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }> | null | undefined
|
|
): string {
|
|
if (!rows?.length) {
|
|
return 'None';
|
|
}
|
|
|
|
return rows
|
|
.map((row) => `${row.name}: ${row.time_of_day} / ${row.weather} / ${row.rarity} stars / ${row.map.name}`)
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.join(' / ');
|
|
}
|
|
|
|
async function entityNameMap(client: DbClient, tableName: string, ids: number[]): Promise<Map<number, string>> {
|
|
const uniqueIds = [...new Set(ids)].filter((id) => Number.isInteger(id) && id > 0);
|
|
if (!uniqueIds.length) {
|
|
return new Map();
|
|
}
|
|
|
|
const result = await client.query<{ id: number; name: string }>(
|
|
`SELECT id, name FROM ${tableName} WHERE id = ANY($1::integer[])`,
|
|
[uniqueIds]
|
|
);
|
|
|
|
return new Map(result.rows.map((row) => [row.id, row.name]));
|
|
}
|
|
|
|
function namesFromIds(ids: number[], namesById: Map<number, string>): string {
|
|
const names = [...new Set(ids)]
|
|
.map((id) => namesById.get(id))
|
|
.filter((name): name is string => Boolean(name))
|
|
.sort((a, b) => a.localeCompare(b));
|
|
|
|
return names.length ? names.join(' / ') : 'None';
|
|
}
|
|
|
|
async function quantityPayloadValue(client: DbClient, rows: IdQuantity[]): Promise<string> {
|
|
const namesById = await entityNameMap(client, 'items', rows.map((row) => row.itemId));
|
|
return quantityListValue(
|
|
rows
|
|
.map((row) => {
|
|
const name = namesById.get(row.itemId);
|
|
return name ? { name, quantity: row.quantity } : null;
|
|
})
|
|
.filter((row): row is { name: string; quantity: number } => row !== null)
|
|
);
|
|
}
|
|
|
|
async function pokemonEditChanges(
|
|
client: DbClient,
|
|
before: PokemonChangeSource,
|
|
after: PokemonPayload
|
|
): Promise<EditChange[]> {
|
|
const changes: EditChange[] = [];
|
|
const environmentNames = await entityNameMap(client, 'environments', [after.environmentId]);
|
|
const typeNames = await entityNameMap(client, 'pokemon_types', after.typeIds);
|
|
const skillNames = await entityNameMap(client, 'skills', after.skillIds);
|
|
const favoriteThingNames = await entityNameMap(client, 'favorite_things', after.favoriteThingIds);
|
|
const dropSkillNames = await entityNameMap(client, 'skills', after.skillItemDrops.map((drop) => drop.skillId));
|
|
const dropItemNames = await entityNameMap(client, 'items', after.skillItemDrops.map((drop) => drop.itemId));
|
|
const afterDrops = after.skillItemDrops
|
|
.map((drop) => {
|
|
const skillName = dropSkillNames.get(drop.skillId);
|
|
const itemName = dropItemNames.get(drop.itemId);
|
|
return skillName && itemName ? `${skillName}: ${itemName}` : null;
|
|
})
|
|
.filter((drop): drop is string => drop !== null)
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.join(' / ');
|
|
|
|
pushChange(changes, 'Name', before.name, after.name);
|
|
pushChange(changes, 'Pokopia ID', String(before.displayId), String(after.displayId));
|
|
pushChange(changes, 'Event Pokemon', boolValue(before.isEventItem), boolValue(after.isEventItem));
|
|
pushChange(changes, 'Genus', before.genus, after.genus);
|
|
pushChange(changes, 'Details', before.details, after.details);
|
|
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'genus', 'details']);
|
|
pushChange(changes, 'Height', pokemonHeightValue(before.heightInches), pokemonHeightValue(after.heightInches));
|
|
pushChange(changes, 'Weight', pokemonWeightValue(before.weightPounds), pokemonWeightValue(after.weightPounds));
|
|
pushChange(changes, 'Image', pokemonImageLabel(before.image), pokemonImageLabel(after.image));
|
|
pushChange(changes, 'Types', namedListValue(before.types), namesFromIds(after.typeIds, typeNames));
|
|
pushChange(changes, 'Stats', pokemonStatsValue(before.stats), pokemonStatsValue(after.stats));
|
|
pushChange(changes, 'Ideal Habitat', before.environment.name, environmentNames.get(after.environmentId));
|
|
pushChange(changes, 'Specialities', namedListValue(before.skills), namesFromIds(after.skillIds, skillNames));
|
|
pushChange(changes, 'Favourites', namedListValue(before.favorite_things), namesFromIds(after.favoriteThingIds, favoriteThingNames));
|
|
pushChange(changes, 'Speciality drops', skillDropListValue(before.skills), afterDrops);
|
|
|
|
return changes;
|
|
}
|
|
|
|
async function itemEditChanges(
|
|
client: DbClient,
|
|
before: ItemChangeSource,
|
|
after: ItemPayload
|
|
): Promise<EditChange[]> {
|
|
const changes: EditChange[] = [];
|
|
const 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, 'Display ID', String(before.displayId), String(after.displayId));
|
|
pushChange(changes, 'Description', before.details, after.details);
|
|
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'details']);
|
|
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, systemListNameByKey(itemCategoryOptions, after.categoryKey));
|
|
pushChange(changes, 'Usage', before.usage?.name, systemListNameByKey(itemUsageOptions, after.usageKey));
|
|
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 ancientArtifactEditChanges(
|
|
client: DbClient,
|
|
before: AncientArtifactChangeSource,
|
|
after: AncientArtifactPayload
|
|
): Promise<EditChange[]> {
|
|
const changes: EditChange[] = [];
|
|
const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
|
|
|
|
pushChange(changes, 'Name', before.name, after.name);
|
|
pushChange(changes, 'Display ID', String(before.displayId), String(after.displayId));
|
|
pushChange(changes, 'Description', before.details, after.details);
|
|
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'details']);
|
|
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
|
|
pushChange(changes, 'Category', before.category.name, systemListNameByKey(ancientArtifactCategoryOptions, after.categoryKey));
|
|
pushChange(changes, 'Tags', namedListValue(before.tags), namesFromIds(after.tagIds, tagNames));
|
|
return changes;
|
|
}
|
|
|
|
async function habitatEditChanges(
|
|
client: DbClient,
|
|
before: HabitatChangeSource,
|
|
after: HabitatPayload
|
|
): Promise<EditChange[]> {
|
|
const changes: EditChange[] = [];
|
|
const pokemonNames = await entityNameMap(client, 'pokemon', after.pokemonAppearances.map((row) => row.pokemonId));
|
|
const mapNames = await entityNameMap(client, 'maps', after.pokemonAppearances.map((row) => row.mapId));
|
|
const afterAppearances = after.pokemonAppearances
|
|
.map((row) => {
|
|
const pokemonName = pokemonNames.get(row.pokemonId);
|
|
const mapName = mapNames.get(row.mapId);
|
|
return pokemonName && mapName ? `${pokemonName}: ${row.timeOfDay} / ${row.weather} / ${row.rarity} stars / ${mapName}` : null;
|
|
})
|
|
.filter((row): row is string => row !== null)
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.join(' / ');
|
|
|
|
pushChange(changes, 'Name', before.name, after.name);
|
|
pushTranslationChanges(changes, before.translations, after.translations, ['name']);
|
|
pushChange(changes, 'Event Habitat', boolValue(before.isEventItem), boolValue(after.isEventItem));
|
|
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
|
|
pushChange(changes, 'Recipe', quantityListValue(before.recipe), await quantityPayloadValue(client, after.recipeItems));
|
|
pushChange(changes, 'Possible Pokemon', appearanceListValue(before.pokemon), afterAppearances);
|
|
|
|
return changes;
|
|
}
|
|
|
|
async function recipeEditChanges(
|
|
client: DbClient,
|
|
before: RecipeChangeSource,
|
|
after: RecipePayload
|
|
): Promise<EditChange[]> {
|
|
const changes: EditChange[] = [];
|
|
const itemNames = await entityNameMap(client, 'items', [after.itemId]);
|
|
const methodNames = await entityNameMap(client, 'acquisition_methods', after.acquisitionMethodIds);
|
|
|
|
pushChange(changes, 'Item', before.item.name, itemNames.get(after.itemId));
|
|
pushChange(changes, 'Acquisition methods', namedListValue(before.acquisition_methods), namesFromIds(after.acquisitionMethodIds, methodNames));
|
|
pushChange(changes, 'Materials', quantityListValue(before.materials), await quantityPayloadValue(client, after.materials));
|
|
|
|
return changes;
|
|
}
|
|
|
|
function dailyChecklistEditChanges(before: DailyChecklistChangeSource, after: DailyChecklistPayload): EditChange[] {
|
|
const changes: EditChange[] = [];
|
|
pushChange(changes, 'Title', before.title, after.title);
|
|
pushTranslationChanges(changes, before.translations, after.translations, ['title']);
|
|
return changes;
|
|
}
|
|
|
|
function configEditChanges(
|
|
definition: ConfigDefinition,
|
|
before: ConfigChangeSource,
|
|
after: {
|
|
name: string;
|
|
translations: TranslationInput;
|
|
hasItemDrop: boolean;
|
|
isDefault: boolean;
|
|
isRateable: boolean;
|
|
changeLog: string;
|
|
}
|
|
): EditChange[] {
|
|
const changes: EditChange[] = [];
|
|
pushChange(changes, 'Name', before.name, after.name);
|
|
pushTranslationChanges(changes, before.translations, after.translations, ['name']);
|
|
if (definition.hasItemDrop) {
|
|
pushChange(changes, 'Has item drop', boolValue(Boolean(before.hasItemDrop)), boolValue(after.hasItemDrop));
|
|
}
|
|
if (definition.hasDefault) {
|
|
pushChange(changes, 'Default category', boolValue(Boolean(before.isDefault)), boolValue(after.isDefault));
|
|
}
|
|
if (definition.hasRateable) {
|
|
pushChange(changes, 'Rateable', boolValue(Boolean(before.isRateable)), boolValue(after.isRateable));
|
|
}
|
|
if (definition.hasChangeLog) {
|
|
pushChange(changes, 'ChangeLog', before.changeLog, after.changeLog);
|
|
}
|
|
return changes;
|
|
}
|
|
|
|
function getEditHistory(entityType: string, entityId: number): Promise<EditHistoryEntry[]> {
|
|
return query(
|
|
`
|
|
SELECT
|
|
l.action,
|
|
COALESCE(l.changes, '[]'::jsonb) AS changes,
|
|
l.created_at AS "createdAt",
|
|
CASE
|
|
WHEN u.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', u.id, 'displayName', u.display_name)
|
|
END AS user
|
|
FROM wiki_edit_logs l
|
|
LEFT JOIN users u ON u.id = l.user_id
|
|
WHERE l.entity_type = $1
|
|
AND l.entity_id = $2
|
|
ORDER BY l.created_at DESC, l.id DESC
|
|
`,
|
|
[entityType, entityId]
|
|
);
|
|
}
|
|
|
|
function pokemonProjection(locale: string): string {
|
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
|
const pokemonGenus = localizedField('pokemon', 'p.id', 'p.genus', 'genus', locale);
|
|
const pokemonDetails = localizedField('pokemon', 'p.id', 'p.details', 'details', locale);
|
|
const typeName = localizedName('pokemon-types', 'pt', locale);
|
|
const environmentName = localizedName('environments', 'e', locale);
|
|
const skillName = localizedName('skills', 's', locale);
|
|
const favoriteThingName = localizedName('favorite-things', 'ft', locale);
|
|
|
|
return `
|
|
SELECT
|
|
p.id,
|
|
p.data_id AS "dataId",
|
|
p.data_identifier AS "dataIdentifier",
|
|
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,
|
|
acquisitionMethods,
|
|
maps,
|
|
lifeCategories,
|
|
gameVersions
|
|
] = await Promise.all([
|
|
optionSelect('pokemon_types', 'pokemon-types', locale),
|
|
skillOptions(locale),
|
|
optionSelect('environments', 'environments', locale),
|
|
optionSelect('favorite_things', 'favorite-things', locale),
|
|
optionSelect('acquisition_methods', 'acquisition-methods', locale),
|
|
optionSelect('maps', 'maps', locale),
|
|
lifeCategoryOptions(locale),
|
|
gameVersionOptions(locale)
|
|
]);
|
|
|
|
return {
|
|
pokemonTypes,
|
|
skills,
|
|
environments,
|
|
favoriteThings,
|
|
itemCategories: systemListOptions(itemCategoryOptions, locale),
|
|
itemUsages: systemListOptions(itemUsageOptions, locale),
|
|
ancientArtifactCategories: systemListOptions(ancientArtifactCategoryOptions, locale),
|
|
acquisitionMethods,
|
|
itemTags: favoriteThings,
|
|
maps,
|
|
lifeCategories,
|
|
gameVersions
|
|
};
|
|
}
|
|
|
|
function cleanDailyChecklistPayload(payload: Record<string, unknown>): DailyChecklistPayload {
|
|
return {
|
|
title: cleanName(payload.title, 'server.validation.taskRequired'),
|
|
translations: cleanTranslations(payload.translations, ['title'])
|
|
};
|
|
}
|
|
|
|
export async function listDailyChecklistItems(locale = defaultLocale) {
|
|
const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
|
|
return query(
|
|
`
|
|
SELECT c.id, ${title} AS title, c.title AS "baseTitle", ${translationsSelect('daily-checklist-items', 'c.id')} AS translations
|
|
FROM daily_checklist_items c
|
|
ORDER BY c.sort_order, c.id
|
|
`
|
|
);
|
|
}
|
|
|
|
export async function globalSearch(paramsQuery: QueryParams = {}, locale = defaultLocale): Promise<GlobalSearchResults> {
|
|
const search = asString(paramsQuery.query)?.trim() ?? asString(paramsQuery.search)?.trim() ?? '';
|
|
if (!search) {
|
|
return { query: '', groups: [] };
|
|
}
|
|
|
|
const pattern = `%${search}%`;
|
|
const limit = 5;
|
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
|
const habitatName = localizedName('habitats', 'h', locale);
|
|
const itemName = localizedName('items', 'i', locale);
|
|
const itemCategoryName = systemListJsonSql('i.category_key', itemCategoryOptions, locale);
|
|
const artifactName = localizedName('ancient-artifacts', 'a', locale);
|
|
const artifactCategoryName = systemListJsonSql('a.category_key', ancientArtifactCategoryOptions, locale);
|
|
const recipeItemName = localizedName('items', 'result_item', locale);
|
|
const recipeMaterialName = localizedName('items', 'material_item', locale);
|
|
const checklistTitle = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
|
|
const lifeCategoryName = localizedName('life-tags', 'lc', locale);
|
|
|
|
const [pokemon, habitats, items, artifacts, recipes, checklist, life] = await Promise.all([
|
|
query<GlobalSearchItem>(
|
|
`
|
|
SELECT
|
|
p.id,
|
|
'pokemon' AS type,
|
|
${pokemonName} AS title,
|
|
'/pokemon/' || p.id AS url,
|
|
NULLIF(p.genus, '') AS summary,
|
|
'#' || p.display_id::text AS meta,
|
|
${pokemonImageJson('p')} AS image
|
|
FROM pokemon p
|
|
WHERE ${pokemonName} ILIKE $1
|
|
ORDER BY ${orderByEntity('p')}
|
|
LIMIT $2
|
|
`,
|
|
[pattern, limit]
|
|
),
|
|
query<GlobalSearchItem>(
|
|
`
|
|
SELECT
|
|
h.id,
|
|
'habitats' AS type,
|
|
${habitatName} AS title,
|
|
'/habitats/' || h.id AS url,
|
|
NULL AS summary,
|
|
NULL AS meta,
|
|
${uploadedImageJson('h.image_path')} AS image
|
|
FROM habitats h
|
|
WHERE ${habitatName} ILIKE $1
|
|
ORDER BY ${orderByEntity('h')}
|
|
LIMIT $2
|
|
`,
|
|
[pattern, limit]
|
|
),
|
|
query<GlobalSearchItem>(
|
|
`
|
|
SELECT
|
|
i.id,
|
|
'items' AS type,
|
|
${itemName} AS title,
|
|
'/items/' || i.id AS url,
|
|
NULLIF(i.details, '') AS summary,
|
|
(${itemCategoryName}->>'name') AS meta,
|
|
${uploadedImageJson('i.image_path')} AS image
|
|
FROM items i
|
|
WHERE ${itemName} ILIKE $1
|
|
ORDER BY i.display_id, ${orderByEntity('i')}
|
|
LIMIT $2
|
|
`,
|
|
[pattern, limit]
|
|
),
|
|
query<GlobalSearchItem>(
|
|
`
|
|
SELECT
|
|
a.id,
|
|
'ancient-artifacts' AS type,
|
|
${artifactName} AS title,
|
|
'/ancient-artifacts/' || a.id AS url,
|
|
NULLIF(a.details, '') AS summary,
|
|
(${artifactCategoryName}->>'name') AS meta,
|
|
${uploadedImageJson('a.image_path')} AS image
|
|
FROM ancient_artifacts a
|
|
WHERE ${artifactName} ILIKE $1
|
|
ORDER BY a.display_id, ${orderByEntity('a')}
|
|
LIMIT $2
|
|
`,
|
|
[pattern, limit]
|
|
),
|
|
query<GlobalSearchItem>(
|
|
`
|
|
SELECT
|
|
r.id,
|
|
'recipes' AS type,
|
|
${recipeItemName} AS title,
|
|
'/recipes/' || r.id AS url,
|
|
(
|
|
SELECT string_agg(material_rows.name, ' / ' ORDER BY material_rows.name)
|
|
FROM (
|
|
SELECT DISTINCT ${recipeMaterialName} AS name
|
|
FROM recipe_materials rm
|
|
JOIN items material_item ON material_item.id = rm.item_id
|
|
WHERE rm.recipe_id = r.id
|
|
) material_rows
|
|
) AS summary,
|
|
NULL AS meta,
|
|
${uploadedImageJson('result_item.image_path')} AS image
|
|
FROM recipes r
|
|
JOIN items result_item ON result_item.id = r.item_id
|
|
WHERE ${recipeItemName} ILIKE $1
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM recipe_materials rm
|
|
JOIN items material_item ON material_item.id = rm.item_id
|
|
WHERE rm.recipe_id = r.id
|
|
AND ${recipeMaterialName} ILIKE $1
|
|
)
|
|
ORDER BY ${orderByEntity('r')}
|
|
LIMIT $2
|
|
`,
|
|
[pattern, limit]
|
|
),
|
|
query<GlobalSearchItem>(
|
|
`
|
|
SELECT
|
|
c.id,
|
|
'daily-checklist' AS type,
|
|
${checklistTitle} AS title,
|
|
'/checklist' AS url,
|
|
NULL AS summary,
|
|
NULL AS meta,
|
|
NULL AS image
|
|
FROM daily_checklist_items c
|
|
WHERE ${checklistTitle} ILIKE $1
|
|
ORDER BY ${orderByEntity('c')}
|
|
LIMIT $2
|
|
`,
|
|
[pattern, limit]
|
|
),
|
|
query<GlobalSearchItem>(
|
|
`
|
|
SELECT
|
|
lp.id,
|
|
'life' AS type,
|
|
LEFT(lp.body, 120) AS title,
|
|
'/life/' || lp.id AS url,
|
|
NULL AS summary,
|
|
${lifeCategoryName} AS meta,
|
|
NULL AS image
|
|
FROM life_posts lp
|
|
LEFT JOIN life_tags lc ON lc.id = lp.category_id
|
|
WHERE lp.deleted_at IS NULL
|
|
AND lp.ai_moderation_status = 'approved'
|
|
AND lp.body ILIKE $1
|
|
ORDER BY lp.created_at DESC, lp.id DESC
|
|
LIMIT $2
|
|
`,
|
|
[pattern, limit]
|
|
)
|
|
]);
|
|
|
|
const groups: GlobalSearchGroup[] = [
|
|
{ type: 'pokemon', items: pokemon },
|
|
{ type: 'habitats', items: habitats },
|
|
{ type: 'items', items: items },
|
|
{ type: 'ancient-artifacts', items: artifacts },
|
|
{ type: 'recipes', items: recipes },
|
|
{ type: 'daily-checklist', items: checklist },
|
|
{ type: 'life', items: life }
|
|
];
|
|
|
|
return { query: search, groups: groups.filter((group) => group.items.length > 0) };
|
|
}
|
|
|
|
async function getDailyChecklistItemById(id: number, locale = defaultLocale) {
|
|
const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
|
|
return queryOne(
|
|
`
|
|
SELECT c.id, ${title} AS title, c.title AS "baseTitle", ${translationsSelect('daily-checklist-items', 'c.id')} AS translations
|
|
FROM daily_checklist_items c
|
|
WHERE c.id = $1
|
|
`,
|
|
[id]
|
|
);
|
|
}
|
|
|
|
export async function createDailyChecklistItem(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanDailyChecklistPayload(payload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
const orderResult = await client.query<{ sortOrder: number }>(
|
|
'SELECT COALESCE(MAX(sort_order), 0) + 10 AS "sortOrder" FROM daily_checklist_items'
|
|
);
|
|
const sortOrder = orderResult.rows[0]?.sortOrder ?? 10;
|
|
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO daily_checklist_items (title, sort_order, created_by_user_id, updated_by_user_id)
|
|
VALUES ($1, $2, $3, $3)
|
|
RETURNING id
|
|
`,
|
|
[cleanPayload.title, sortOrder, userId]
|
|
);
|
|
|
|
const createdId = result.rows[0].id;
|
|
await replaceEntityTranslations(client, 'daily-checklist-items', createdId, cleanPayload.translations, ['title']);
|
|
await recordEditLog(client, 'daily-checklist-items', createdId, 'create', userId);
|
|
return createdId;
|
|
});
|
|
|
|
return getDailyChecklistItemById(id, locale);
|
|
}
|
|
|
|
export async function updateDailyChecklistItem(
|
|
id: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number,
|
|
locale = defaultLocale
|
|
) {
|
|
const cleanPayload = cleanDailyChecklistPayload(payload);
|
|
const before = await getDailyChecklistItemById(id, defaultLocale);
|
|
|
|
const updated = await withTransaction(async (client) => {
|
|
const result = await client.query(
|
|
`
|
|
UPDATE daily_checklist_items
|
|
SET title = $1, updated_by_user_id = $2, updated_at = now()
|
|
WHERE id = $3
|
|
`,
|
|
[cleanPayload.title, userId, id]
|
|
);
|
|
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await replaceEntityTranslations(client, 'daily-checklist-items', id, cleanPayload.translations, ['title']);
|
|
const changes = before ? dailyChecklistEditChanges(before as DailyChecklistChangeSource, cleanPayload) : [];
|
|
await recordEditLog(client, 'daily-checklist-items', id, 'update', userId, changes);
|
|
return true;
|
|
});
|
|
|
|
return updated ? getDailyChecklistItemById(id, locale) : null;
|
|
}
|
|
|
|
export async function reorderDailyChecklistItems(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const ids = cleanIds(payload.ids);
|
|
if (ids.length === 0) {
|
|
throw validationError('server.validation.selectTask');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
const existing = await client.query<{ id: number; sortOrder: number }>(
|
|
'SELECT id, sort_order AS "sortOrder" FROM daily_checklist_items WHERE id = ANY($1::integer[])',
|
|
[ids]
|
|
);
|
|
|
|
if (existing.rowCount !== ids.length) {
|
|
throw validationError('server.validation.taskDoesNotExist');
|
|
}
|
|
|
|
const sortOrders = new Map(existing.rows.map((row) => [row.id, row.sortOrder]));
|
|
for (const [index, id] of ids.entries()) {
|
|
const nextSortOrder = (index + 1) * 10;
|
|
const previousSortOrder = sortOrders.get(id);
|
|
if (previousSortOrder === nextSortOrder) {
|
|
continue;
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
UPDATE daily_checklist_items
|
|
SET sort_order = $1, updated_by_user_id = $2, updated_at = now()
|
|
WHERE id = $3
|
|
`,
|
|
[nextSortOrder, userId, id]
|
|
);
|
|
const changes: EditChange[] = [];
|
|
pushChange(changes, 'Sort order', String(previousSortOrder), String(nextSortOrder));
|
|
await recordEditLog(client, 'daily-checklist-items', id, 'update', userId, changes);
|
|
}
|
|
});
|
|
|
|
return listDailyChecklistItems(locale);
|
|
}
|
|
|
|
export async function deleteDailyChecklistItem(id: number, userId: number) {
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ id: number }>('DELETE FROM daily_checklist_items WHERE id = $1 RETURNING id', [id]);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await deleteEntityTranslations(client, 'daily-checklist-items', id);
|
|
await recordEditLog(client, 'daily-checklist-items', id, 'delete', userId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function cleanLifePostPayload(payload: Record<string, unknown>): LifePostPayload {
|
|
const body = cleanName(payload.body, 'server.validation.postRequired');
|
|
if (body.length > 2000) {
|
|
throw validationError('server.validation.postTooLong');
|
|
}
|
|
const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.lifeCategoryRequired');
|
|
const gameVersionId = optionalPositiveInteger(payload.gameVersionId, 'server.validation.gameVersionInvalid');
|
|
|
|
return {
|
|
body,
|
|
categoryId,
|
|
gameVersionId,
|
|
languageCode: cleanModerationLanguageCode(payload.languageCode)
|
|
};
|
|
}
|
|
|
|
function cleanLifeCommentPayload(payload: Record<string, unknown>): LifeCommentPayload {
|
|
const body = cleanName(payload.body, 'server.validation.commentRequired');
|
|
if (body.length > 1000) {
|
|
throw validationError('server.validation.commentTooLong');
|
|
}
|
|
|
|
return { body, languageCode: cleanModerationLanguageCode(payload.languageCode) };
|
|
}
|
|
|
|
function emptyLifeReactionCounts(): LifeReactionCounts {
|
|
return {
|
|
like: 0,
|
|
helpful: 0,
|
|
fun: 0,
|
|
thanks: 0
|
|
};
|
|
}
|
|
|
|
function isLifeReactionType(value: unknown): value is LifeReactionType {
|
|
return typeof value === 'string' && lifeReactionTypes.includes(value as LifeReactionType);
|
|
}
|
|
|
|
function cleanLifeReactionType(value: unknown): LifeReactionType {
|
|
if (!isLifeReactionType(value)) {
|
|
throw validationError('server.validation.reactionInvalid');
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function cleanLifeReactionFilter(value: QueryValue): LifeReactionType | null {
|
|
const reactionType = asString(value);
|
|
if (!reactionType) {
|
|
return null;
|
|
}
|
|
|
|
return cleanLifeReactionType(reactionType);
|
|
}
|
|
|
|
function cleanLifeRating(value: unknown): number {
|
|
const rating = Number(value);
|
|
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
|
|
throw validationError('server.validation.ratingInvalid');
|
|
}
|
|
|
|
return rating;
|
|
}
|
|
|
|
function cleanUserCommentActivitySourceFilter(value: QueryValue): UserCommentActivitySource | null {
|
|
const source = asString(value);
|
|
if (!source) {
|
|
return null;
|
|
}
|
|
|
|
if (source !== 'life' && source !== 'discussion') {
|
|
throw validationError('server.validation.invalidField');
|
|
}
|
|
|
|
return source;
|
|
}
|
|
|
|
function cleanModerationLanguageFilter(value: QueryValue): string | null {
|
|
return cleanModerationLanguageCode(asString(value));
|
|
}
|
|
|
|
function addModerationVisibilityCondition(
|
|
conditions: string[],
|
|
params: unknown[],
|
|
alias: string,
|
|
ownerColumn: string,
|
|
userId: number | null,
|
|
canViewAll: boolean
|
|
): void {
|
|
if (canViewAll) {
|
|
return;
|
|
}
|
|
|
|
if (userId !== null) {
|
|
params.push(userId);
|
|
conditions.push(`(${alias}.ai_moderation_status = 'approved' OR ${ownerColumn} = $${params.length})`);
|
|
return;
|
|
}
|
|
|
|
conditions.push(`${alias}.ai_moderation_status = 'approved'`);
|
|
}
|
|
|
|
function addModerationLanguageCondition(
|
|
conditions: string[],
|
|
params: unknown[],
|
|
alias: string,
|
|
languageCode: string | null
|
|
): void {
|
|
if (!languageCode) {
|
|
return;
|
|
}
|
|
|
|
params.push(languageCode);
|
|
conditions.push(`${alias}.ai_moderation_language_code = $${params.length}`);
|
|
}
|
|
|
|
function lifePostProjection(locale = defaultLocale): string {
|
|
const categoryName = localizedName('life-tags', 'lc', locale);
|
|
const gameVersionName = localizedName('game-versions', 'gv', locale);
|
|
|
|
return `
|
|
SELECT
|
|
lp.id,
|
|
lp.body,
|
|
lp.ai_moderation_status AS "moderationStatus",
|
|
lp.ai_moderation_language_code AS "moderationLanguageCode",
|
|
lp.ai_moderation_reason AS "moderationReason",
|
|
lp.created_at AS "createdAt",
|
|
lp.created_at::text AS "createdAtCursor",
|
|
lp.updated_at AS "updatedAt",
|
|
CASE
|
|
WHEN created_user.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', created_user.id, 'displayName', created_user.display_name)
|
|
END AS author,
|
|
CASE
|
|
WHEN updated_user.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name)
|
|
END AS "updatedBy",
|
|
CASE
|
|
WHEN lc.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', lc.id, 'name', ${categoryName}, 'isRateable', lc.is_rateable)
|
|
END AS category,
|
|
CASE
|
|
WHEN gv.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', gv.id, 'name', ${gameVersionName}, 'changeLog', gv.change_log)
|
|
END AS "gameVersion",
|
|
CASE
|
|
WHEN rating_stats.rating_count = 0 THEN NULL
|
|
ELSE rating_stats.rating_average::double precision
|
|
END AS "ratingAverage",
|
|
rating_stats.rating_count AS "ratingCount"
|
|
FROM life_posts lp
|
|
LEFT JOIN life_tags lc ON lc.id = lp.category_id
|
|
LEFT JOIN game_versions gv ON gv.id = lp.game_version_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT
|
|
ROUND(AVG(lpr.rating)::numeric, 2) AS rating_average,
|
|
COUNT(*)::integer AS rating_count
|
|
FROM life_post_ratings lpr
|
|
WHERE lpr.post_id = lp.id
|
|
) rating_stats ON true
|
|
LEFT JOIN users created_user ON created_user.id = lp.created_by_user_id
|
|
LEFT JOIN users updated_user ON updated_user.id = lp.updated_by_user_id
|
|
`;
|
|
}
|
|
|
|
function cleanLifePostLimit(value: QueryValue): number {
|
|
const rawLimit = asString(value);
|
|
if (rawLimit === undefined || rawLimit === '') {
|
|
return defaultLifePostLimit;
|
|
}
|
|
|
|
const limit = Number(rawLimit);
|
|
return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxLifePostLimit) : defaultLifePostLimit;
|
|
}
|
|
|
|
function cleanLifePostSort(value: QueryValue): LifePostSort {
|
|
const sort = asString(value);
|
|
return sort === 'oldest' || sort === 'top-rated' ? sort : 'latest';
|
|
}
|
|
|
|
function cleanRateableFilter(value: QueryValue): boolean | null {
|
|
const rateable = asString(value);
|
|
if (rateable === 'true') {
|
|
return true;
|
|
}
|
|
if (rateable === 'false') {
|
|
return false;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function cleanCommentLimit(value: QueryValue): number {
|
|
const rawLimit = asString(value);
|
|
if (rawLimit === undefined || rawLimit === '') {
|
|
return defaultCommentLimit;
|
|
}
|
|
|
|
const limit = Number(rawLimit);
|
|
return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxCommentLimit) : defaultCommentLimit;
|
|
}
|
|
|
|
function decodeLifePostCursor(value: QueryValue): LifePostCursor | null {
|
|
const rawCursor = asString(value);
|
|
if (!rawCursor) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<LifePostCursor>;
|
|
const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
|
|
const id = Number(cursor.id);
|
|
const ratingAverage = cursor.ratingAverage === undefined ? undefined : Number(cursor.ratingAverage);
|
|
|
|
if (
|
|
!createdAt ||
|
|
Number.isNaN(new Date(createdAt).getTime()) ||
|
|
!Number.isInteger(id) ||
|
|
id <= 0 ||
|
|
(ratingAverage !== undefined && (Number.isNaN(ratingAverage) || ratingAverage < 0))
|
|
) {
|
|
throw validationError('server.validation.cursorInvalid');
|
|
}
|
|
|
|
return { createdAt, id, ratingAverage };
|
|
} catch (error) {
|
|
if (error instanceof Error && 'statusCode' in error) {
|
|
throw error;
|
|
}
|
|
throw validationError('server.validation.cursorInvalid');
|
|
}
|
|
}
|
|
|
|
function encodeLifePostCursor(post: LifePostRow): string {
|
|
return Buffer.from(
|
|
JSON.stringify({ createdAt: post.createdAtCursor, id: post.id, ratingAverage: post.ratingAverage ?? 0 }),
|
|
'utf8'
|
|
).toString('base64url');
|
|
}
|
|
|
|
function encodeProfileCursor(cursor: LifePostCursor): string {
|
|
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
|
|
}
|
|
|
|
function decodeLifeReactionUserCursor(value: QueryValue): LifeReactionUserCursor | null {
|
|
const rawCursor = asString(value);
|
|
if (!rawCursor) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<LifeReactionUserCursor>;
|
|
const reactedAt = typeof cursor.reactedAt === 'string' ? cursor.reactedAt : '';
|
|
const userId = Number(cursor.userId);
|
|
|
|
if (!reactedAt || Number.isNaN(new Date(reactedAt).getTime()) || !Number.isInteger(userId) || userId <= 0) {
|
|
throw validationError('server.validation.cursorInvalid');
|
|
}
|
|
|
|
return { reactedAt, userId };
|
|
} catch (error) {
|
|
if (error instanceof Error && 'statusCode' in error) {
|
|
throw error;
|
|
}
|
|
throw validationError('server.validation.cursorInvalid');
|
|
}
|
|
}
|
|
|
|
function encodeLifeReactionUserCursor(cursor: LifeReactionUserCursor): string {
|
|
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
|
|
}
|
|
|
|
function decodeUserCommentActivityCursor(value: QueryValue): UserCommentActivityCursor | null {
|
|
const rawCursor = asString(value);
|
|
if (!rawCursor) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<UserCommentActivityCursor>;
|
|
const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
|
|
const id = Number(cursor.id);
|
|
const source = cursor.source;
|
|
|
|
if (
|
|
!createdAt ||
|
|
Number.isNaN(new Date(createdAt).getTime()) ||
|
|
!Number.isInteger(id) ||
|
|
id <= 0 ||
|
|
(source !== 'life' && source !== 'discussion')
|
|
) {
|
|
throw validationError('server.validation.cursorInvalid');
|
|
}
|
|
|
|
return { createdAt, id, source };
|
|
} catch (error) {
|
|
if (error instanceof Error && 'statusCode' in error) {
|
|
throw error;
|
|
}
|
|
throw validationError('server.validation.cursorInvalid');
|
|
}
|
|
}
|
|
|
|
function encodeUserCommentActivityCursor(cursor: UserCommentActivityCursor): string {
|
|
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
|
|
}
|
|
|
|
function hydrateLifePost(
|
|
post: LifePostRow,
|
|
commentPreviewByPost: Map<number, LifeComment[]>,
|
|
commentCountsByPost: Map<number, number>,
|
|
countsByPost: Map<number, LifeReactionCounts>,
|
|
myReactionsByPost: Map<number, LifeReactionType>,
|
|
myRatingsByPost: Map<number, number>
|
|
): LifePost {
|
|
return {
|
|
id: post.id,
|
|
body: post.body,
|
|
moderationStatus: post.moderationStatus,
|
|
moderationLanguageCode: post.moderationLanguageCode,
|
|
moderationReason: post.moderationReason,
|
|
createdAt: post.createdAt,
|
|
updatedAt: post.updatedAt,
|
|
author: post.author,
|
|
updatedBy: post.updatedBy,
|
|
category: post.category,
|
|
gameVersion: post.gameVersion,
|
|
ratingAverage: post.ratingAverage,
|
|
ratingCount: post.ratingCount,
|
|
commentPreview: commentPreviewByPost.get(post.id) ?? [],
|
|
commentCount: commentCountsByPost.get(post.id) ?? 0,
|
|
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
|
|
myReaction: myReactionsByPost.get(post.id) ?? null,
|
|
myRating: myRatingsByPost.get(post.id) ?? null
|
|
};
|
|
}
|
|
|
|
function lifeCommentProjection(whereClause: string): string {
|
|
return `
|
|
SELECT
|
|
lc.id,
|
|
lc.post_id AS "postId",
|
|
lc.parent_comment_id AS "parentCommentId",
|
|
CASE WHEN lc.deleted_at IS NULL THEN lc.body ELSE '' END AS body,
|
|
lc.deleted_at IS NOT NULL AS deleted,
|
|
lc.ai_moderation_status AS "moderationStatus",
|
|
lc.ai_moderation_language_code AS "moderationLanguageCode",
|
|
lc.ai_moderation_reason AS "moderationReason",
|
|
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 addVisibleLifeCommentCondition(conditions: string[]): void {
|
|
conditions.push('lc.deleted_at IS NULL');
|
|
conditions.push(`
|
|
(
|
|
lc.parent_comment_id IS NULL
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM life_post_comments parent_comment
|
|
WHERE parent_comment.id = lc.parent_comment_id
|
|
AND parent_comment.deleted_at IS NULL
|
|
)
|
|
)
|
|
`);
|
|
}
|
|
|
|
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[])'];
|
|
addVisibleLifeCommentCondition(conditions);
|
|
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'];
|
|
addVisibleLifeCommentCondition(previewConditions);
|
|
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'];
|
|
addVisibleLifeCommentCondition(topLevelConditions);
|
|
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[])'];
|
|
addVisibleLifeCommentCondition(replyConditions);
|
|
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'];
|
|
addVisibleLifeCommentCondition(totalConditions);
|
|
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 };
|
|
}
|
|
|
|
export async function listLifePostReactionUsers(
|
|
postIdValue: number,
|
|
paramsQuery: QueryParams = {},
|
|
userId: number | null = null,
|
|
canViewAll = false
|
|
): Promise<LifeReactionUsersPage | null> {
|
|
const postId = requirePositiveInteger(postIdValue, 'server.validation.recordInvalid');
|
|
const cursor = decodeLifeReactionUserCursor(paramsQuery.cursor);
|
|
const limit = cleanLifePostLimit(paramsQuery.limit);
|
|
const reactionType = cleanLifeReactionFilter(paramsQuery.reactionType);
|
|
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 conditions = ['lpr.post_id = $1'];
|
|
if (reactionType) {
|
|
params.push(reactionType);
|
|
conditions.push(`lpr.reaction_type = $${params.length}`);
|
|
}
|
|
if (cursor) {
|
|
params.push(cursor.reactedAt, cursor.userId);
|
|
conditions.push(`(lpr.updated_at, lpr.user_id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
|
|
}
|
|
|
|
params.push(limit + 1);
|
|
const rows = await query<{
|
|
userId: number;
|
|
displayName: string;
|
|
reactionType: LifeReactionType;
|
|
reactedAt: Date;
|
|
reactedAtCursor: string;
|
|
}>(
|
|
`
|
|
SELECT
|
|
u.id AS "userId",
|
|
u.display_name AS "displayName",
|
|
lpr.reaction_type AS "reactionType",
|
|
lpr.updated_at AS "reactedAt",
|
|
lpr.updated_at::text AS "reactedAtCursor"
|
|
FROM life_post_reactions lpr
|
|
JOIN users u ON u.id = lpr.user_id
|
|
WHERE ${conditions.join(' AND ')}
|
|
ORDER BY lpr.updated_at DESC, lpr.user_id DESC
|
|
LIMIT $${params.length}
|
|
`,
|
|
params
|
|
);
|
|
const hasMore = rows.length > limit;
|
|
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
const totalParams: unknown[] = [postId];
|
|
const totalConditions = ['post_id = $1'];
|
|
if (reactionType) {
|
|
totalParams.push(reactionType);
|
|
totalConditions.push(`reaction_type = $${totalParams.length}`);
|
|
}
|
|
const total = await queryOne<{ total: number }>(
|
|
`
|
|
SELECT COUNT(*)::integer AS total
|
|
FROM life_post_reactions
|
|
WHERE ${totalConditions.join(' AND ')}
|
|
`,
|
|
totalParams
|
|
);
|
|
|
|
return {
|
|
items: items.map((item) => ({
|
|
user: { id: item.userId, displayName: item.displayName },
|
|
reactionType: item.reactionType,
|
|
reactedAt: item.reactedAt
|
|
})),
|
|
nextCursor:
|
|
hasMore && items.length > 0
|
|
? encodeLifeReactionUserCursor({
|
|
reactedAt: items[items.length - 1].reactedAtCursor,
|
|
userId: items[items.length - 1].userId
|
|
})
|
|
: null,
|
|
hasMore,
|
|
total: total?.total ?? 0
|
|
};
|
|
}
|
|
|
|
async function lifeRatingsForPosts(postIds: number[], userId: number | null): Promise<Map<number, number>> {
|
|
const myRatingsByPost = new Map<number, number>();
|
|
|
|
if (postIds.length === 0 || userId === null) {
|
|
return myRatingsByPost;
|
|
}
|
|
|
|
const rows = await query<{ postId: number; rating: number }>(
|
|
`
|
|
SELECT post_id AS "postId", rating
|
|
FROM life_post_ratings
|
|
WHERE post_id = ANY($1::integer[])
|
|
AND user_id = $2
|
|
`,
|
|
[postIds, userId]
|
|
);
|
|
|
|
for (const row of rows) {
|
|
myRatingsByPost.set(row.postId, row.rating);
|
|
}
|
|
|
|
return myRatingsByPost;
|
|
}
|
|
|
|
async function getLifeCommentById(id: number): Promise<LifeComment | null> {
|
|
const row = await queryOne<LifeCommentRow>(
|
|
`
|
|
${lifeCommentProjection('WHERE lc.id = $1')}
|
|
`,
|
|
[id]
|
|
);
|
|
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
const { createdAtCursor: _createdAtCursor, ...comment } = row;
|
|
return { ...comment, replies: [] };
|
|
}
|
|
|
|
async function listLifePostsWithFilters(
|
|
paramsQuery: QueryParams = {},
|
|
userId: number | null = null,
|
|
locale = defaultLocale,
|
|
filters: LifePostFilters = {},
|
|
canViewAll = false
|
|
): Promise<LifePostsPage> {
|
|
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
|
const limit = cleanLifePostLimit(paramsQuery.limit);
|
|
const sort = cleanLifePostSort(paramsQuery.sort);
|
|
const search = asString(paramsQuery.search)?.trim();
|
|
const categoryIdValue = asString(paramsQuery.categoryId)?.trim();
|
|
const gameVersionIdValue = asString(paramsQuery.gameVersionId)?.trim();
|
|
const rateable = cleanRateableFilter(paramsQuery.rateable);
|
|
const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
|
|
const params: unknown[] = [];
|
|
const conditions: string[] = ['lp.deleted_at IS NULL'];
|
|
|
|
if (filters.authorId !== undefined) {
|
|
params.push(filters.authorId);
|
|
conditions.push(`lp.created_by_user_id = $${params.length}`);
|
|
}
|
|
|
|
addModerationVisibilityCondition(conditions, params, 'lp', 'lp.created_by_user_id', userId, canViewAll);
|
|
addModerationLanguageCondition(conditions, params, 'lp', languageCode);
|
|
|
|
if (search) {
|
|
params.push(`%${search}%`);
|
|
conditions.push(`lp.body ILIKE $${params.length}`);
|
|
}
|
|
|
|
if (categoryIdValue) {
|
|
const categoryId = requirePositiveInteger(categoryIdValue, 'server.validation.lifeCategoryInvalid');
|
|
params.push(categoryId);
|
|
conditions.push(`lp.category_id = $${params.length}`);
|
|
}
|
|
|
|
if (gameVersionIdValue && gameVersionIdValue !== 'all') {
|
|
const gameVersionId = requirePositiveInteger(gameVersionIdValue, 'server.validation.gameVersionInvalid');
|
|
params.push(gameVersionId);
|
|
conditions.push(`lp.game_version_id = $${params.length}`);
|
|
}
|
|
|
|
if (rateable !== null) {
|
|
params.push(rateable);
|
|
conditions.push(`lc.is_rateable = $${params.length}`);
|
|
}
|
|
|
|
if (cursor) {
|
|
if (sort === 'top-rated') {
|
|
params.push(cursor.ratingAverage ?? 0, cursor.createdAt, cursor.id);
|
|
conditions.push(
|
|
`(COALESCE(rating_stats.rating_average, 0), lp.created_at, lp.id) < ($${params.length - 2}::numeric, $${params.length - 1}::timestamptz, $${params.length}::integer)`
|
|
);
|
|
} else {
|
|
params.push(cursor.createdAt, cursor.id);
|
|
conditions.push(
|
|
`(lp.created_at, lp.id) ${sort === 'oldest' ? '>' : '<'} ($${params.length - 1}::timestamptz, $${params.length}::integer)`
|
|
);
|
|
}
|
|
}
|
|
|
|
const orderClause =
|
|
sort === 'top-rated'
|
|
? 'ORDER BY COALESCE(rating_stats.rating_average, 0) DESC, lp.created_at DESC, lp.id DESC'
|
|
: `ORDER BY lp.created_at ${sort === 'oldest' ? 'ASC' : 'DESC'}, lp.id ${sort === 'oldest' ? 'ASC' : 'DESC'}`;
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
params.push(limit + 1);
|
|
const rows = await query<LifePostRow>(
|
|
`
|
|
${lifePostProjection(locale)}
|
|
${whereClause}
|
|
${orderClause}
|
|
LIMIT $${params.length}
|
|
`,
|
|
params
|
|
);
|
|
const hasMore = rows.length > limit;
|
|
const posts = hasMore ? rows.slice(0, limit) : rows;
|
|
|
|
const postIds = posts.map((post) => post.id);
|
|
const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds, userId, canViewAll);
|
|
const commentCountsByPost = await lifeCommentCountsForPosts(postIds, userId, canViewAll);
|
|
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId);
|
|
const myRatingsByPost = await lifeRatingsForPosts(postIds, userId);
|
|
|
|
return {
|
|
items: posts.map((post) =>
|
|
hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost, myRatingsByPost)
|
|
),
|
|
nextCursor: hasMore && posts.length > 0 ? encodeLifePostCursor(posts[posts.length - 1]) : null,
|
|
hasMore
|
|
};
|
|
}
|
|
|
|
export async function listLifePosts(
|
|
paramsQuery: QueryParams = {},
|
|
userId: number | null = null,
|
|
locale = defaultLocale,
|
|
canViewAll = false
|
|
): Promise<LifePostsPage> {
|
|
return listLifePostsWithFilters(paramsQuery, userId, locale, {}, canViewAll);
|
|
}
|
|
|
|
async function getPublicProfileUser(userIdValue: number): Promise<PublicProfileUser | null> {
|
|
const userId = requirePositiveInteger(userIdValue, 'server.validation.recordInvalid');
|
|
return queryOne<PublicProfileUser>(
|
|
`
|
|
SELECT
|
|
id,
|
|
display_name AS "displayName",
|
|
created_at AS "joinedAt"
|
|
FROM users
|
|
WHERE id = $1
|
|
`,
|
|
[userId]
|
|
);
|
|
}
|
|
|
|
function publicContributionType(entityType: string): string {
|
|
return entityType === 'daily-checklist-items' ? 'daily-checklist' : entityType;
|
|
}
|
|
|
|
export async function getPublicUserProfile(userIdValue: number): Promise<PublicUserProfile | null> {
|
|
const user = await getPublicProfileUser(userIdValue);
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
const stats = await queryOne<PublicProfileStats>(
|
|
`
|
|
SELECT
|
|
COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1), 0) AS "wikiEdits",
|
|
COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1 AND action = 'create'), 0) AS "wikiCreates",
|
|
COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1 AND action = 'update'), 0) AS "wikiUpdates",
|
|
COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1 AND action = 'delete'), 0) AS "wikiDeletes",
|
|
COALESCE((SELECT COUNT(*)::integer FROM entity_image_uploads WHERE created_by_user_id = $1), 0) AS "imageUploads",
|
|
COALESCE((
|
|
SELECT COUNT(*)::integer
|
|
FROM life_posts
|
|
WHERE created_by_user_id = $1
|
|
AND deleted_at IS NULL
|
|
AND ai_moderation_status = 'approved'
|
|
), 0) AS "lifePosts",
|
|
COALESCE((
|
|
SELECT COUNT(*)::integer
|
|
FROM life_post_comments lc
|
|
JOIN life_posts lp ON lp.id = lc.post_id
|
|
WHERE lc.created_by_user_id = $1
|
|
AND lc.deleted_at IS NULL
|
|
AND lp.deleted_at IS NULL
|
|
AND lc.ai_moderation_status = 'approved'
|
|
AND lp.ai_moderation_status = 'approved'
|
|
), 0) AS "lifeComments",
|
|
COALESCE((
|
|
SELECT COUNT(*)::integer
|
|
FROM life_post_reactions lpr
|
|
JOIN life_posts lp ON lp.id = lpr.post_id
|
|
WHERE lpr.user_id = $1
|
|
AND lp.deleted_at IS NULL
|
|
AND lp.ai_moderation_status = 'approved'
|
|
), 0) AS "lifeReactions",
|
|
COALESCE((
|
|
SELECT COUNT(*)::integer
|
|
FROM entity_discussion_comments
|
|
WHERE created_by_user_id = $1
|
|
AND deleted_at IS NULL
|
|
AND ai_moderation_status = 'approved'
|
|
), 0) AS "discussionComments"
|
|
`,
|
|
[user.id]
|
|
);
|
|
|
|
const contributions = await query<PublicProfileContribution>(
|
|
`
|
|
SELECT
|
|
entity_type AS "contentType",
|
|
COUNT(*)::integer AS total,
|
|
COUNT(*) FILTER (WHERE action = 'create')::integer AS creates,
|
|
COUNT(*) FILTER (WHERE action = 'update')::integer AS updates,
|
|
COUNT(*) FILTER (WHERE action = 'delete')::integer AS deletes,
|
|
MAX(created_at) AS "lastContributedAt"
|
|
FROM wiki_edit_logs
|
|
WHERE user_id = $1
|
|
GROUP BY entity_type
|
|
ORDER BY total DESC, "lastContributedAt" DESC, entity_type
|
|
`,
|
|
[user.id]
|
|
);
|
|
|
|
return {
|
|
user,
|
|
stats: stats ?? {
|
|
wikiEdits: 0,
|
|
wikiCreates: 0,
|
|
wikiUpdates: 0,
|
|
wikiDeletes: 0,
|
|
imageUploads: 0,
|
|
lifePosts: 0,
|
|
lifeComments: 0,
|
|
lifeReactions: 0,
|
|
discussionComments: 0
|
|
},
|
|
contributions: contributions.map((item) => ({
|
|
...item,
|
|
contentType: publicContributionType(item.contentType)
|
|
}))
|
|
};
|
|
}
|
|
|
|
export async function listUserLifePosts(
|
|
userIdValue: number,
|
|
paramsQuery: QueryParams = {},
|
|
viewerUserId: number | null = null,
|
|
locale = defaultLocale,
|
|
canViewAll = false
|
|
): Promise<LifePostsPage | null> {
|
|
const user = await getPublicProfileUser(userIdValue);
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
return listLifePostsWithFilters(paramsQuery, viewerUserId, locale, { authorId: user.id }, canViewAll);
|
|
}
|
|
|
|
async function hydrateLifePostsById(
|
|
postIds: number[],
|
|
viewerUserId: number | null,
|
|
locale: string,
|
|
canViewAll = false
|
|
): Promise<Map<number, LifePost>> {
|
|
const postById = new Map<number, LifePost>();
|
|
if (postIds.length === 0) {
|
|
return postById;
|
|
}
|
|
|
|
const params: unknown[] = [postIds];
|
|
const conditions = ['lp.id = ANY($1::integer[])', 'lp.deleted_at IS NULL'];
|
|
addModerationVisibilityCondition(conditions, params, 'lp', 'lp.created_by_user_id', viewerUserId, canViewAll);
|
|
const posts = await query<LifePostRow>(
|
|
`
|
|
${lifePostProjection(locale)}
|
|
WHERE ${conditions.join(' AND ')}
|
|
`,
|
|
params
|
|
);
|
|
const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds, viewerUserId, canViewAll);
|
|
const commentCountsByPost = await lifeCommentCountsForPosts(postIds, viewerUserId, canViewAll);
|
|
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, viewerUserId);
|
|
const myRatingsByPost = await lifeRatingsForPosts(postIds, viewerUserId);
|
|
|
|
for (const post of posts) {
|
|
postById.set(post.id, hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost, myRatingsByPost));
|
|
}
|
|
|
|
return postById;
|
|
}
|
|
|
|
export async function listUserReactionActivities(
|
|
userIdValue: number,
|
|
paramsQuery: QueryParams = {},
|
|
viewerUserId: number | null = null,
|
|
locale = defaultLocale
|
|
): Promise<UserReactionActivityPage | null> {
|
|
const user = await getPublicProfileUser(userIdValue);
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
|
const limit = cleanLifePostLimit(paramsQuery.limit);
|
|
const reactionType = cleanLifeReactionFilter(paramsQuery.reactionType);
|
|
const params: unknown[] = [user.id];
|
|
const conditions = ['lpr.user_id = $1', 'lp.deleted_at IS NULL', "lp.ai_moderation_status = 'approved'"];
|
|
|
|
if (reactionType) {
|
|
params.push(reactionType);
|
|
conditions.push(`lpr.reaction_type = $${params.length}`);
|
|
}
|
|
|
|
if (cursor) {
|
|
params.push(cursor.createdAt, cursor.id);
|
|
conditions.push(`(lpr.updated_at, lpr.post_id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
|
|
}
|
|
|
|
params.push(limit + 1);
|
|
const rows = await query<{
|
|
postId: number;
|
|
reactionType: LifeReactionType;
|
|
reactedAt: Date;
|
|
reactedAtCursor: string;
|
|
}>(
|
|
`
|
|
SELECT
|
|
lpr.post_id AS "postId",
|
|
lpr.reaction_type AS "reactionType",
|
|
lpr.updated_at AS "reactedAt",
|
|
lpr.updated_at::text AS "reactedAtCursor"
|
|
FROM life_post_reactions lpr
|
|
JOIN life_posts lp ON lp.id = lpr.post_id
|
|
WHERE ${conditions.join(' AND ')}
|
|
ORDER BY lpr.updated_at DESC, lpr.post_id DESC
|
|
LIMIT $${params.length}
|
|
`,
|
|
params
|
|
);
|
|
const hasMore = rows.length > limit;
|
|
const activities = hasMore ? rows.slice(0, limit) : rows;
|
|
const postById = await hydrateLifePostsById(
|
|
activities.map((activity) => activity.postId),
|
|
viewerUserId,
|
|
locale
|
|
);
|
|
|
|
return {
|
|
items: activities.flatMap((activity) => {
|
|
const post = postById.get(activity.postId);
|
|
return post
|
|
? [
|
|
{
|
|
postId: activity.postId,
|
|
reactionType: activity.reactionType,
|
|
reactedAt: activity.reactedAt,
|
|
post
|
|
}
|
|
]
|
|
: [];
|
|
}),
|
|
nextCursor:
|
|
hasMore && activities.length > 0
|
|
? encodeProfileCursor({
|
|
createdAt: activities[activities.length - 1].reactedAtCursor,
|
|
id: activities[activities.length - 1].postId
|
|
})
|
|
: null,
|
|
hasMore
|
|
};
|
|
}
|
|
|
|
export async function listUserCommentActivities(
|
|
userIdValue: number,
|
|
paramsQuery: QueryParams = {},
|
|
locale = defaultLocale
|
|
): Promise<UserCommentActivityPage | null> {
|
|
const user = await getPublicProfileUser(userIdValue);
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
const cursor = decodeUserCommentActivityCursor(paramsQuery.cursor);
|
|
const limit = cleanLifePostLimit(paramsQuery.limit);
|
|
const sourceFilter = cleanUserCommentActivitySourceFilter(paramsQuery.source);
|
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
|
const itemName = localizedName('items', 'i', locale);
|
|
const recipeItemName = localizedName('items', 'recipe_item', locale);
|
|
const habitatName = localizedName('habitats', 'h', locale);
|
|
const artifactName = localizedName('ancient-artifacts', 'a', 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}
|
|
WHEN 'ancient-artifacts' THEN ${artifactName}
|
|
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
|
|
LEFT JOIN ancient_artifacts a ON edc.entity_type = 'ancient-artifacts' AND a.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,
|
|
options: { enforceVisibility?: boolean; canViewAll?: boolean } = {}
|
|
): Promise<LifePost | null> {
|
|
const params: unknown[] = [id];
|
|
const conditions = ['lp.id = $1', 'lp.deleted_at IS NULL'];
|
|
|
|
if (options.enforceVisibility) {
|
|
addModerationVisibilityCondition(conditions, params, 'lp', 'lp.created_by_user_id', userId, options.canViewAll === true);
|
|
}
|
|
|
|
const post = await queryOne<LifePostRow>(
|
|
`
|
|
${lifePostProjection(locale)}
|
|
WHERE ${conditions.join(' AND ')}
|
|
`,
|
|
params
|
|
);
|
|
|
|
if (!post) {
|
|
return null;
|
|
}
|
|
|
|
const canViewAll = options.canViewAll === true;
|
|
const commentPreviewByPost = await lifeCommentPreviewForPosts([post.id], userId, canViewAll);
|
|
const commentCountsByPost = await lifeCommentCountsForPosts([post.id], userId, canViewAll);
|
|
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId);
|
|
const myRatingsByPost = await lifeRatingsForPosts([post.id], userId);
|
|
return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost, myRatingsByPost);
|
|
}
|
|
|
|
export async function getLifePost(
|
|
idValue: number,
|
|
userId: number | null = null,
|
|
locale = defaultLocale,
|
|
canViewAll = false
|
|
): Promise<LifePost | null> {
|
|
const id = requirePositiveInteger(idValue, 'server.validation.recordInvalid');
|
|
return getLifePostById(id, userId, locale, { enforceVisibility: true, canViewAll });
|
|
}
|
|
|
|
async function ensureLifeCategory(client: DbClient, categoryId: number): Promise<void> {
|
|
const result = await client.query<{ id: number }>('SELECT id FROM life_tags WHERE id = $1', [categoryId]);
|
|
if (result.rowCount === 0) {
|
|
throw validationError('server.validation.lifeCategoryInvalid');
|
|
}
|
|
}
|
|
|
|
async function ensureGameVersion(client: DbClient, gameVersionId: number | null): Promise<void> {
|
|
if (gameVersionId === null) {
|
|
return;
|
|
}
|
|
|
|
const result = await client.query<{ id: number }>('SELECT id FROM game_versions WHERE id = $1', [gameVersionId]);
|
|
if (result.rowCount === 0) {
|
|
throw validationError('server.validation.gameVersionInvalid');
|
|
}
|
|
}
|
|
|
|
async function replaceLifePostCategoryLink(client: DbClient, postId: number, categoryId: number): Promise<void> {
|
|
await client.query('DELETE FROM life_post_tags WHERE post_id = $1', [postId]);
|
|
await client.query(
|
|
`
|
|
INSERT INTO life_post_tags (post_id, tag_id)
|
|
VALUES ($1, $2)
|
|
`,
|
|
[postId, categoryId]
|
|
);
|
|
}
|
|
|
|
export async function createLifePost(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanLifePostPayload(payload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
await ensureLifeCategory(client, cleanPayload.categoryId);
|
|
await ensureGameVersion(client, cleanPayload.gameVersionId);
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO life_posts (
|
|
body,
|
|
category_id,
|
|
game_version_id,
|
|
ai_moderation_status,
|
|
ai_moderation_language_code,
|
|
created_by_user_id,
|
|
updated_by_user_id
|
|
)
|
|
VALUES ($1, $2, $3, 'reviewing', NULL, $4, $4)
|
|
RETURNING id
|
|
`,
|
|
[cleanPayload.body, cleanPayload.categoryId, cleanPayload.gameVersionId, userId]
|
|
);
|
|
|
|
const createdId = result.rows[0].id;
|
|
await replaceLifePostCategoryLink(client, createdId, cleanPayload.categoryId);
|
|
return createdId;
|
|
});
|
|
|
|
await requestAiModerationReview({ type: 'life-post', id }, { languageCode: cleanPayload.languageCode, resetRetries: true });
|
|
return getLifePostById(id, userId, locale);
|
|
}
|
|
|
|
export async function updateLifePost(
|
|
id: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number,
|
|
locale = defaultLocale,
|
|
allowAny = false
|
|
) {
|
|
const cleanPayload = cleanLifePostPayload(payload);
|
|
|
|
const updatedId = await withTransaction(async (client) => {
|
|
await ensureLifeCategory(client, cleanPayload.categoryId);
|
|
await ensureGameVersion(client, cleanPayload.gameVersionId);
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
UPDATE life_posts
|
|
SET body = $1,
|
|
category_id = $2,
|
|
game_version_id = $3,
|
|
ai_moderation_status = 'reviewing',
|
|
ai_moderation_language_code = NULL,
|
|
ai_moderation_content_hash = NULL,
|
|
ai_moderation_checked_at = NULL,
|
|
ai_moderation_retry_count = 0,
|
|
ai_moderation_updated_at = now(),
|
|
updated_by_user_id = $4,
|
|
updated_at = now()
|
|
WHERE id = $5
|
|
AND ($6 = true OR created_by_user_id = $4)
|
|
AND deleted_at IS NULL
|
|
RETURNING id
|
|
`,
|
|
[cleanPayload.body, cleanPayload.categoryId, cleanPayload.gameVersionId, userId, id, allowAny]
|
|
);
|
|
|
|
const resultId = result.rows[0]?.id ?? null;
|
|
if (resultId === null) {
|
|
return null;
|
|
}
|
|
|
|
await replaceLifePostCategoryLink(client, resultId, cleanPayload.categoryId);
|
|
return resultId;
|
|
});
|
|
|
|
if (updatedId) {
|
|
await requestAiModerationReview(
|
|
{ type: 'life-post', id: updatedId },
|
|
{ languageCode: cleanPayload.languageCode, resetRetries: true }
|
|
);
|
|
}
|
|
|
|
return updatedId ? getLifePostById(updatedId, userId, locale) : null;
|
|
}
|
|
|
|
export async function deleteLifePost(id: number, userId: number, allowAny = false) {
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
UPDATE life_posts
|
|
SET deleted_at = now(),
|
|
deleted_by_user_id = $2,
|
|
updated_by_user_id = $2,
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
AND ($3 = true OR created_by_user_id = $2)
|
|
AND deleted_at IS NULL
|
|
RETURNING id
|
|
`,
|
|
[id, userId, allowAny]
|
|
);
|
|
|
|
return Boolean(result);
|
|
}
|
|
|
|
export async function retryLifePostModeration(id: number, userId: number, locale = defaultLocale, allowAny = false) {
|
|
const postId = requirePositiveInteger(id, 'server.validation.recordInvalid');
|
|
const row = await queryOne<{ id: number }>(
|
|
`
|
|
SELECT id
|
|
FROM life_posts
|
|
WHERE id = $1
|
|
AND ($3 = true OR created_by_user_id = $2)
|
|
AND deleted_at IS NULL
|
|
`,
|
|
[postId, userId, allowAny]
|
|
);
|
|
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
await requestAiModerationReview({ type: 'life-post', id: postId }, { incrementRetries: true });
|
|
return getLifePostById(postId, userId, locale);
|
|
}
|
|
|
|
export async function setLifePostReaction(
|
|
postId: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number,
|
|
locale = defaultLocale
|
|
) {
|
|
const reactionType = cleanLifeReactionType(payload.reactionType);
|
|
|
|
const result = await queryOne<{ postId: number }>(
|
|
`
|
|
INSERT INTO life_post_reactions (post_id, user_id, reaction_type)
|
|
SELECT $1, $2, $3
|
|
WHERE EXISTS (
|
|
SELECT 1
|
|
FROM life_posts
|
|
WHERE id = $1
|
|
AND deleted_at IS NULL
|
|
AND ai_moderation_status = 'approved'
|
|
)
|
|
ON CONFLICT (post_id, user_id)
|
|
DO UPDATE SET reaction_type = EXCLUDED.reaction_type, updated_at = now()
|
|
RETURNING post_id AS "postId"
|
|
`,
|
|
[postId, userId, reactionType]
|
|
);
|
|
|
|
if (result) {
|
|
await createLifePostReactionNotification(result.postId, userId);
|
|
}
|
|
|
|
return result ? getLifePostById(result.postId, userId, locale) : null;
|
|
}
|
|
|
|
export async function deleteLifePostReaction(postId: number, userId: number, locale = defaultLocale) {
|
|
await queryOne<{ postId: number }>(
|
|
`
|
|
DELETE FROM life_post_reactions
|
|
WHERE post_id = $1
|
|
AND user_id = $2
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM life_posts
|
|
WHERE id = $1
|
|
AND deleted_at IS NULL
|
|
AND ai_moderation_status = 'approved'
|
|
)
|
|
RETURNING post_id AS "postId"
|
|
`,
|
|
[postId, userId]
|
|
);
|
|
|
|
return getLifePostById(postId, userId, locale);
|
|
}
|
|
|
|
export async function setLifePostRating(
|
|
postId: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number,
|
|
locale = defaultLocale
|
|
) {
|
|
const rating = cleanLifeRating(payload.rating);
|
|
|
|
const result = await queryOne<{ postId: number }>(
|
|
`
|
|
INSERT INTO life_post_ratings (post_id, user_id, rating)
|
|
SELECT $1, $2, $3
|
|
FROM life_posts lp
|
|
JOIN life_tags lt ON lt.id = lp.category_id
|
|
WHERE lp.id = $1
|
|
AND lp.deleted_at IS NULL
|
|
AND lp.ai_moderation_status = 'approved'
|
|
AND lt.is_rateable = true
|
|
ON CONFLICT (post_id, user_id)
|
|
DO UPDATE SET rating = EXCLUDED.rating, updated_at = now()
|
|
RETURNING post_id AS "postId"
|
|
`,
|
|
[postId, userId, rating]
|
|
);
|
|
|
|
return result ? getLifePostById(result.postId, userId, locale) : null;
|
|
}
|
|
|
|
export async function deleteLifePostRating(postId: number, userId: number, locale = defaultLocale) {
|
|
const result = await queryOne<{ postId: number }>(
|
|
`
|
|
DELETE FROM life_post_ratings
|
|
WHERE post_id = $1
|
|
AND user_id = $2
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM life_posts lp
|
|
JOIN life_tags lt ON lt.id = lp.category_id
|
|
WHERE lp.id = $1
|
|
AND lp.deleted_at IS NULL
|
|
AND lp.ai_moderation_status = 'approved'
|
|
AND lt.is_rateable = true
|
|
)
|
|
RETURNING post_id AS "postId"
|
|
`,
|
|
[postId, userId]
|
|
);
|
|
|
|
return result ? getLifePostById(postId, userId, locale) : null;
|
|
}
|
|
|
|
export async function createLifeComment(postId: number, payload: Record<string, unknown>, userId: number) {
|
|
const cleanPayload = cleanLifeCommentPayload(payload);
|
|
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
INSERT INTO life_post_comments (post_id, body, ai_moderation_status, ai_moderation_language_code, created_by_user_id)
|
|
SELECT $1, $2, 'reviewing', NULL, $3
|
|
WHERE EXISTS (
|
|
SELECT 1
|
|
FROM life_posts
|
|
WHERE id = $1
|
|
AND deleted_at IS NULL
|
|
AND ai_moderation_status = 'approved'
|
|
)
|
|
RETURNING id
|
|
`,
|
|
[postId, cleanPayload.body, userId]
|
|
);
|
|
|
|
if (result) {
|
|
await requestAiModerationReview(
|
|
{ type: 'life-comment', id: result.id },
|
|
{ languageCode: cleanPayload.languageCode, resetRetries: true }
|
|
);
|
|
}
|
|
|
|
return result ? getLifeCommentById(result.id) : null;
|
|
}
|
|
|
|
export async function createLifeCommentReply(
|
|
postId: number,
|
|
commentId: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number
|
|
) {
|
|
const cleanPayload = cleanLifeCommentPayload(payload);
|
|
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
INSERT INTO life_post_comments (
|
|
post_id,
|
|
parent_comment_id,
|
|
body,
|
|
ai_moderation_status,
|
|
ai_moderation_language_code,
|
|
created_by_user_id
|
|
)
|
|
SELECT lc.post_id, lc.id, $3, 'reviewing', NULL, $4
|
|
FROM life_post_comments lc
|
|
JOIN life_posts lp ON lp.id = lc.post_id
|
|
WHERE lc.post_id = $1
|
|
AND lc.id = $2
|
|
AND lc.parent_comment_id IS NULL
|
|
AND lc.deleted_at IS NULL
|
|
AND lc.ai_moderation_status = 'approved'
|
|
AND lp.deleted_at IS NULL
|
|
AND lp.ai_moderation_status = 'approved'
|
|
RETURNING id
|
|
`,
|
|
[postId, commentId, cleanPayload.body, userId]
|
|
);
|
|
|
|
if (result) {
|
|
await requestAiModerationReview(
|
|
{ type: 'life-comment', id: result.id },
|
|
{ languageCode: cleanPayload.languageCode, resetRetries: true }
|
|
);
|
|
}
|
|
|
|
return result ? getLifeCommentById(result.id) : null;
|
|
}
|
|
|
|
export async function deleteLifeComment(id: number, userId: number, allowAny = false) {
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
UPDATE life_post_comments
|
|
SET deleted_at = now(), deleted_by_user_id = $2, updated_at = now()
|
|
WHERE id = $1
|
|
AND ($3 = true OR created_by_user_id = $2)
|
|
AND deleted_at IS NULL
|
|
RETURNING id
|
|
`,
|
|
[id, userId, allowAny]
|
|
);
|
|
|
|
return Boolean(result);
|
|
}
|
|
|
|
export async function retryLifeCommentModeration(id: number, userId: number, allowAny = false) {
|
|
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
|
const row = await queryOne<{ id: number }>(
|
|
`
|
|
SELECT id
|
|
FROM life_post_comments
|
|
WHERE id = $1
|
|
AND ($3 = true OR created_by_user_id = $2)
|
|
AND deleted_at IS NULL
|
|
`,
|
|
[commentId, userId, allowAny]
|
|
);
|
|
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
await requestAiModerationReview({ type: 'life-comment', id: commentId }, { incrementRetries: true });
|
|
return getLifeCommentById(commentId);
|
|
}
|
|
|
|
function cleanDiscussionEntityType(value: unknown): DiscussionEntityType {
|
|
if (typeof value !== 'string' || !Object.hasOwn(discussionEntityDefinitions, value)) {
|
|
throw validationError('server.validation.entityTypeInvalid');
|
|
}
|
|
|
|
return value as DiscussionEntityType;
|
|
}
|
|
|
|
function cleanEntityDiscussionCommentPayload(payload: Record<string, unknown>): EntityDiscussionCommentPayload {
|
|
const body = cleanName(payload.body, 'server.validation.commentRequired');
|
|
if (body.length > 1000) {
|
|
throw validationError('server.validation.commentTooLong');
|
|
}
|
|
|
|
return { body, languageCode: cleanModerationLanguageCode(payload.languageCode) };
|
|
}
|
|
|
|
async function entityDiscussionExists(
|
|
client: Pick<DbClient, 'query'>,
|
|
entityType: DiscussionEntityType,
|
|
entityId: number
|
|
): Promise<boolean> {
|
|
const definition = discussionEntityDefinitions[entityType];
|
|
const result = await client.query<{ exists: boolean }>(
|
|
`SELECT EXISTS (SELECT 1 FROM ${definition.table} WHERE id = $1) AS exists`,
|
|
[entityId]
|
|
);
|
|
|
|
return result.rows[0]?.exists === true;
|
|
}
|
|
|
|
function entityDiscussionCommentProjection(whereClause: string): string {
|
|
return `
|
|
SELECT
|
|
edc.id,
|
|
edc.entity_type AS "entityType",
|
|
edc.entity_id AS "entityId",
|
|
edc.parent_comment_id AS "parentCommentId",
|
|
CASE WHEN edc.deleted_at IS NULL THEN edc.body ELSE '' END AS body,
|
|
edc.deleted_at IS NOT NULL AS deleted,
|
|
edc.ai_moderation_status AS "moderationStatus",
|
|
edc.ai_moderation_language_code AS "moderationLanguageCode",
|
|
edc.ai_moderation_reason AS "moderationReason",
|
|
edc.created_at AS "createdAt",
|
|
edc.created_at::text AS "createdAtCursor",
|
|
edc.updated_at AS "updatedAt",
|
|
CASE
|
|
WHEN edc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name)
|
|
END AS author
|
|
FROM entity_discussion_comments edc
|
|
LEFT JOIN users comment_user ON comment_user.id = edc.created_by_user_id
|
|
${whereClause}
|
|
`;
|
|
}
|
|
|
|
function buildEntityDiscussionCommentTree(rows: EntityDiscussionCommentRow[]): EntityDiscussionComment[] {
|
|
const comments = new Map<number, EntityDiscussionComment>();
|
|
const topLevelComments: EntityDiscussionComment[] = [];
|
|
|
|
for (const row of rows) {
|
|
const { createdAtCursor: _createdAtCursor, ...comment } = row;
|
|
comments.set(row.id, { ...comment, replies: [] });
|
|
}
|
|
|
|
for (const comment of comments.values()) {
|
|
if (comment.parentCommentId === null) {
|
|
topLevelComments.push(comment);
|
|
continue;
|
|
}
|
|
|
|
const parent = comments.get(comment.parentCommentId);
|
|
if (parent?.parentCommentId === null) {
|
|
parent.replies.push(comment);
|
|
} else {
|
|
topLevelComments.push(comment);
|
|
}
|
|
}
|
|
|
|
return topLevelComments;
|
|
}
|
|
|
|
async function getEntityDiscussionCommentById(id: number): Promise<EntityDiscussionComment | null> {
|
|
const row = await queryOne<EntityDiscussionCommentRow>(
|
|
`
|
|
${entityDiscussionCommentProjection('WHERE edc.id = $1')}
|
|
`,
|
|
[id]
|
|
);
|
|
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
const { createdAtCursor: _createdAtCursor, ...comment } = row;
|
|
return { ...comment, replies: [] };
|
|
}
|
|
|
|
export async function listEntityDiscussionComments(
|
|
entityTypeValue: string,
|
|
entityIdValue: number,
|
|
paramsQuery: QueryParams = {},
|
|
userId: number | null = null,
|
|
canViewAll = false
|
|
): Promise<EntityDiscussionCommentsPage | null> {
|
|
const entityType = cleanDiscussionEntityType(entityTypeValue);
|
|
const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid');
|
|
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
|
const limit = cleanCommentLimit(paramsQuery.limit);
|
|
const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
|
|
|
|
if (!(await entityDiscussionExists(pool, entityType, entityId))) {
|
|
return null;
|
|
}
|
|
|
|
const params: unknown[] = [entityType, entityId];
|
|
const topLevelConditions = ['edc.entity_type = $1', 'edc.entity_id = $2', 'edc.parent_comment_id IS NULL'];
|
|
addModerationVisibilityCondition(topLevelConditions, params, 'edc', 'edc.created_by_user_id', userId, canViewAll);
|
|
addModerationLanguageCondition(topLevelConditions, params, 'edc', languageCode);
|
|
|
|
if (cursor) {
|
|
params.push(cursor.createdAt, cursor.id);
|
|
topLevelConditions.push(`(edc.created_at, edc.id) > ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
|
|
}
|
|
|
|
params.push(limit + 1);
|
|
const topLevelRows = await query<EntityDiscussionCommentRow>(
|
|
`
|
|
${entityDiscussionCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`)}
|
|
ORDER BY edc.created_at, edc.id
|
|
LIMIT $${params.length}
|
|
`,
|
|
params
|
|
);
|
|
const hasMore = topLevelRows.length > limit;
|
|
const topLevelComments = hasMore ? topLevelRows.slice(0, limit) : topLevelRows;
|
|
const topLevelIds = topLevelComments.map((comment) => comment.id);
|
|
const replyRows = topLevelIds.length
|
|
? await (async () => {
|
|
const replyParams: unknown[] = [topLevelIds];
|
|
const replyConditions = ['edc.parent_comment_id = ANY($1::integer[])'];
|
|
addModerationVisibilityCondition(replyConditions, replyParams, 'edc', 'edc.created_by_user_id', userId, canViewAll);
|
|
addModerationLanguageCondition(replyConditions, replyParams, 'edc', languageCode);
|
|
return query<EntityDiscussionCommentRow>(
|
|
`
|
|
${entityDiscussionCommentProjection(`WHERE ${replyConditions.join(' AND ')}`)}
|
|
ORDER BY edc.created_at, edc.id
|
|
`,
|
|
replyParams
|
|
);
|
|
})()
|
|
: [];
|
|
const totalParams: unknown[] = [entityType, entityId];
|
|
const totalConditions = ['edc.entity_type = $1', 'edc.entity_id = $2'];
|
|
addModerationVisibilityCondition(totalConditions, totalParams, 'edc', 'edc.created_by_user_id', userId, canViewAll);
|
|
addModerationLanguageCondition(totalConditions, totalParams, 'edc', languageCode);
|
|
const total = await queryOne<{ total: number }>(
|
|
`
|
|
SELECT COUNT(*)::integer AS total
|
|
FROM entity_discussion_comments edc
|
|
WHERE ${totalConditions.join(' AND ')}
|
|
`,
|
|
totalParams
|
|
);
|
|
|
|
return {
|
|
items: buildEntityDiscussionCommentTree([...topLevelComments, ...replyRows]),
|
|
nextCursor:
|
|
hasMore && topLevelComments.length > 0
|
|
? encodeProfileCursor({
|
|
createdAt:
|
|
topLevelComments[topLevelComments.length - 1].createdAtCursor ??
|
|
topLevelComments[topLevelComments.length - 1].createdAt.toISOString(),
|
|
id: topLevelComments[topLevelComments.length - 1].id
|
|
})
|
|
: null,
|
|
hasMore,
|
|
total: total?.total ?? 0
|
|
};
|
|
}
|
|
|
|
export async function createEntityDiscussionComment(
|
|
entityTypeValue: string,
|
|
entityIdValue: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number
|
|
): Promise<EntityDiscussionComment | null> {
|
|
const entityType = cleanDiscussionEntityType(entityTypeValue);
|
|
const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid');
|
|
const cleanPayload = cleanEntityDiscussionCommentPayload(payload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
if (!(await entityDiscussionExists(client, entityType, entityId))) {
|
|
return null;
|
|
}
|
|
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO entity_discussion_comments (
|
|
entity_type,
|
|
entity_id,
|
|
body,
|
|
ai_moderation_status,
|
|
ai_moderation_language_code,
|
|
created_by_user_id
|
|
)
|
|
VALUES ($1, $2, $3, 'reviewing', NULL, $4)
|
|
RETURNING id
|
|
`,
|
|
[entityType, entityId, cleanPayload.body, userId]
|
|
);
|
|
|
|
return result.rows[0].id;
|
|
});
|
|
|
|
if (id) {
|
|
await requestAiModerationReview(
|
|
{ type: 'discussion-comment', id },
|
|
{ languageCode: cleanPayload.languageCode, resetRetries: true }
|
|
);
|
|
}
|
|
|
|
return id ? getEntityDiscussionCommentById(id) : null;
|
|
}
|
|
|
|
export async function createEntityDiscussionReply(
|
|
entityTypeValue: string,
|
|
entityIdValue: number,
|
|
commentIdValue: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number
|
|
): Promise<EntityDiscussionComment | null> {
|
|
const entityType = cleanDiscussionEntityType(entityTypeValue);
|
|
const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid');
|
|
const commentId = requirePositiveInteger(commentIdValue, 'server.validation.commentInvalid');
|
|
const cleanPayload = cleanEntityDiscussionCommentPayload(payload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
if (!(await entityDiscussionExists(client, entityType, entityId))) {
|
|
return null;
|
|
}
|
|
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO entity_discussion_comments (
|
|
entity_type,
|
|
entity_id,
|
|
parent_comment_id,
|
|
body,
|
|
ai_moderation_status,
|
|
ai_moderation_language_code,
|
|
created_by_user_id
|
|
)
|
|
SELECT edc.entity_type, edc.entity_id, edc.id, $4, 'reviewing', NULL, $5
|
|
FROM entity_discussion_comments edc
|
|
WHERE edc.entity_type = $1
|
|
AND edc.entity_id = $2
|
|
AND edc.id = $3
|
|
AND edc.parent_comment_id IS NULL
|
|
AND edc.deleted_at IS NULL
|
|
AND edc.ai_moderation_status = 'approved'
|
|
RETURNING id
|
|
`,
|
|
[entityType, entityId, commentId, cleanPayload.body, userId]
|
|
);
|
|
|
|
return result.rows[0]?.id ?? null;
|
|
});
|
|
|
|
if (id) {
|
|
await requestAiModerationReview(
|
|
{ type: 'discussion-comment', id },
|
|
{ languageCode: cleanPayload.languageCode, resetRetries: true }
|
|
);
|
|
}
|
|
|
|
return id ? getEntityDiscussionCommentById(id) : null;
|
|
}
|
|
|
|
export async function deleteEntityDiscussionComment(id: number, userId: number, allowAny = false): Promise<boolean> {
|
|
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
UPDATE entity_discussion_comments
|
|
SET deleted_at = now(),
|
|
deleted_by_user_id = $2,
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
AND ($3 = true OR created_by_user_id = $2)
|
|
AND deleted_at IS NULL
|
|
RETURNING id
|
|
`,
|
|
[commentId, userId, allowAny]
|
|
);
|
|
|
|
return Boolean(result);
|
|
}
|
|
|
|
export async function retryEntityDiscussionCommentModeration(
|
|
id: number,
|
|
userId: number,
|
|
allowAny = false
|
|
): Promise<EntityDiscussionComment | null> {
|
|
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
|
const row = await queryOne<{ id: number }>(
|
|
`
|
|
SELECT id
|
|
FROM entity_discussion_comments
|
|
WHERE id = $1
|
|
AND ($3 = true OR created_by_user_id = $2)
|
|
AND deleted_at IS NULL
|
|
`,
|
|
[commentId, userId, allowAny]
|
|
);
|
|
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
await requestAiModerationReview({ type: 'discussion-comment', id: commentId }, { incrementRetries: true });
|
|
return getEntityDiscussionCommentById(commentId);
|
|
}
|
|
|
|
async function deleteEntityDiscussionCommentsForEntity(
|
|
client: DbClient,
|
|
entityType: DiscussionEntityType,
|
|
entityId: number
|
|
): Promise<void> {
|
|
await client.query(
|
|
`
|
|
DELETE FROM entity_discussion_comments
|
|
WHERE entity_type = $1
|
|
AND entity_id = $2
|
|
`,
|
|
[entityType, entityId]
|
|
);
|
|
}
|
|
|
|
export function isConfigType(type: string): type is ConfigType {
|
|
return Object.hasOwn(configDefinitions, type);
|
|
}
|
|
|
|
export async function listConfig(type: ConfigType, locale = defaultLocale) {
|
|
const definition = configDefinitions[type];
|
|
return query(
|
|
`
|
|
SELECT ${configSelect(definition, locale)}, ${auditSelect('c')}
|
|
FROM ${definition.table} c
|
|
${auditJoins('c')}
|
|
ORDER BY ${configOrder()}
|
|
`
|
|
);
|
|
}
|
|
|
|
async function getConfigById(type: ConfigType, id: number, locale = defaultLocale) {
|
|
const definition = configDefinitions[type];
|
|
return queryOne(
|
|
`
|
|
SELECT ${configSelect(definition, locale)}, ${auditSelect('c')}
|
|
FROM ${definition.table} c
|
|
${auditJoins('c')}
|
|
WHERE c.id = $1
|
|
`,
|
|
[id]
|
|
);
|
|
}
|
|
|
|
export async function createConfig(type: ConfigType, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const definition = configDefinitions[type];
|
|
const name = cleanName(payload.name);
|
|
const translations = cleanTranslations(payload.translations, ['name']);
|
|
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
|
|
const isDefault = definition.hasDefault ? Boolean(payload.isDefault) : false;
|
|
const isRateable = definition.hasRateable ? Boolean(payload.isRateable) : false;
|
|
const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
const sortOrder = await nextSortOrder(client, definition.table);
|
|
if (definition.hasDefault && isDefault) {
|
|
await client.query(
|
|
`UPDATE ${definition.table} SET is_default = false, updated_by_user_id = $1, updated_at = now() WHERE is_default = true`,
|
|
[userId]
|
|
);
|
|
}
|
|
const columns = ['name'];
|
|
const values: unknown[] = [name];
|
|
if (definition.hasItemDrop) {
|
|
columns.push('has_item_drop');
|
|
values.push(hasItemDrop);
|
|
}
|
|
if (definition.hasDefault) {
|
|
columns.push('is_default');
|
|
values.push(isDefault);
|
|
}
|
|
if (definition.hasRateable) {
|
|
columns.push('is_rateable');
|
|
values.push(isRateable);
|
|
}
|
|
if (definition.hasChangeLog) {
|
|
columns.push('change_log');
|
|
values.push(changeLog);
|
|
}
|
|
columns.push('sort_order', 'created_by_user_id', 'updated_by_user_id');
|
|
values.push(sortOrder, userId, userId);
|
|
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO ${definition.table} (${columns.join(', ')})
|
|
VALUES (${placeholders})
|
|
RETURNING id
|
|
`,
|
|
values
|
|
);
|
|
|
|
const createdId = result.rows[0].id;
|
|
await replaceEntityTranslations(client, definition.entityType, createdId, translations, ['name']);
|
|
await recordEditLog(client, type, createdId, 'create', userId);
|
|
return createdId;
|
|
});
|
|
|
|
return getConfigById(type, id, locale);
|
|
}
|
|
|
|
export async function reorderConfig(type: ConfigType, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const definition = configDefinitions[type];
|
|
const ids = cleanIds(payload.ids);
|
|
if (ids.length === 0) {
|
|
throw validationError('server.validation.selectRecord');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
await reorderTableRows(client, definition.table, type, ids, userId);
|
|
});
|
|
|
|
return listConfig(type, locale);
|
|
}
|
|
|
|
export async function updateConfig(
|
|
type: ConfigType,
|
|
id: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number,
|
|
locale = defaultLocale
|
|
) {
|
|
const definition = configDefinitions[type];
|
|
const name = cleanName(payload.name);
|
|
const translations = cleanTranslations(payload.translations, ['name']);
|
|
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
|
|
const isDefault = definition.hasDefault ? Boolean(payload.isDefault) : false;
|
|
const isRateable = definition.hasRateable ? Boolean(payload.isRateable) : false;
|
|
const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
|
|
const before = await getConfigById(type, id, defaultLocale);
|
|
|
|
const updated = await withTransaction(async (client) => {
|
|
if (definition.hasDefault && isDefault) {
|
|
await client.query(
|
|
`UPDATE ${definition.table} SET is_default = false, updated_by_user_id = $2, updated_at = now() WHERE id <> $1 AND is_default = true`,
|
|
[id, userId]
|
|
);
|
|
}
|
|
|
|
const assignments = ['name = $1'];
|
|
const values: unknown[] = [name];
|
|
if (definition.hasItemDrop) {
|
|
values.push(hasItemDrop);
|
|
assignments.push(`has_item_drop = $${values.length}`);
|
|
}
|
|
if (definition.hasDefault) {
|
|
values.push(isDefault);
|
|
assignments.push(`is_default = $${values.length}`);
|
|
}
|
|
if (definition.hasRateable) {
|
|
values.push(isRateable);
|
|
assignments.push(`is_rateable = $${values.length}`);
|
|
}
|
|
if (definition.hasChangeLog) {
|
|
values.push(changeLog);
|
|
assignments.push(`change_log = $${values.length}`);
|
|
}
|
|
values.push(userId);
|
|
assignments.push(`updated_by_user_id = $${values.length}`, 'updated_at = now()');
|
|
values.push(id);
|
|
const result = await client.query(
|
|
`
|
|
UPDATE ${definition.table}
|
|
SET ${assignments.join(', ')}
|
|
WHERE id = $${values.length}
|
|
`,
|
|
values
|
|
);
|
|
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
if (definition.hasItemDrop && !hasItemDrop) {
|
|
await client.query('DELETE FROM pokemon_skill_item_drops WHERE skill_id = $1', [id]);
|
|
}
|
|
|
|
await replaceEntityTranslations(client, definition.entityType, id, translations, ['name']);
|
|
const changes = before
|
|
? configEditChanges(definition, before as ConfigChangeSource, { name, translations, hasItemDrop, isDefault, isRateable, changeLog })
|
|
: [];
|
|
await recordEditLog(client, type, id, 'update', userId, changes);
|
|
return true;
|
|
});
|
|
|
|
return updated ? getConfigById(type, id, locale) : null;
|
|
}
|
|
|
|
export async function deleteConfig(type: ConfigType, id: number, userId: number) {
|
|
const definition = configDefinitions[type];
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ id: number }>(`DELETE FROM ${definition.table} WHERE id = $1 RETURNING id`, [id]);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await deleteEntityTranslations(client, definition.entityType, id);
|
|
await recordEditLog(client, type, id, 'delete', userId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
async function reorderContent(type: SortableContentType, payload: Record<string, unknown>, userId: number): Promise<void> {
|
|
const definition = sortableContentDefinitions[type];
|
|
const ids = cleanIds(payload.ids);
|
|
if (ids.length === 0) {
|
|
throw validationError('server.validation.selectRecord');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
await reorderTableRows(client, definition.table, definition.entityType, ids, userId);
|
|
});
|
|
}
|
|
|
|
export async function reorderPokemon(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
await reorderContent('pokemon', payload, userId);
|
|
return listPokemon({}, locale);
|
|
}
|
|
|
|
export async function reorderItems(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
await reorderContent('items', payload, userId);
|
|
return listItems({}, locale);
|
|
}
|
|
|
|
export async function reorderAncientArtifacts(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
await reorderContent('ancient-artifacts', payload, userId);
|
|
return listAncientArtifacts({}, 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 isEventItem = asString(paramsQuery.isEventItem);
|
|
const environmentId = Number(asString(paramsQuery.environmentId));
|
|
const skillIds = parseIdList(asString(paramsQuery.skillIds));
|
|
const favoriteThingIds = parseIdList(asString(paramsQuery.favoriteThingIds));
|
|
|
|
if (isEventItem === 'true' || isEventItem === 'false') {
|
|
params.push(isEventItem === 'true');
|
|
conditions.push(`p.is_event_item = $${params.length}`);
|
|
}
|
|
|
|
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 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,
|
|
${systemListJsonSql('i.category_key', itemCategoryOptions, locale)} 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
|
|
WHERE pft.pokemon_id = $1
|
|
GROUP BY i.id, i.name, i.image_path, i.category_key, i.sort_order
|
|
ORDER BY i.category_key, ${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 {
|
|
dataId: optionalPositiveInteger(payload.dataId, 'server.validation.pokemonIdRequired'),
|
|
dataIdentifier: cleanOptionalText(payload.dataIdentifier),
|
|
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 normalizePokemonDataIdentity(payload: PokemonPayload): Promise<void> {
|
|
if (payload.dataId === null) {
|
|
payload.dataIdentifier = '';
|
|
return;
|
|
}
|
|
|
|
const data = await loadPokemonCsvData();
|
|
const pokemonRow = data.pokemonByLookup.get(String(payload.dataId));
|
|
if (!pokemonRow) {
|
|
throw validationError('server.validation.pokemonDataNotFound');
|
|
}
|
|
|
|
payload.dataIdentifier = csvText(pokemonRow, 'identifier');
|
|
}
|
|
|
|
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);
|
|
await normalizePokemonDataIdentity(cleanPayload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
const pokemonId = await nextPokemonInternalId(client, cleanPayload.dataId);
|
|
const sortOrder = await nextSortOrder(client, 'pokemon');
|
|
await client.query(
|
|
`
|
|
INSERT INTO pokemon (
|
|
id,
|
|
data_id,
|
|
data_identifier,
|
|
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, $23, $24, $24)
|
|
`,
|
|
[
|
|
pokemonId,
|
|
cleanPayload.dataId,
|
|
cleanPayload.dataIdentifier,
|
|
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);
|
|
await normalizePokemonDataIdentity(cleanPayload);
|
|
if (cleanPayload.dataId !== null && cleanPayload.dataId !== id) {
|
|
throw validationError('server.validation.pokemonDataIdMismatch');
|
|
}
|
|
const before = await getPokemon(id, defaultLocale);
|
|
|
|
const updated = await withTransaction(async (client) => {
|
|
const result = await client.query(
|
|
`
|
|
UPDATE pokemon
|
|
SET
|
|
data_id = $1,
|
|
data_identifier = $2,
|
|
display_id = $3,
|
|
name = $4,
|
|
is_event_item = $5,
|
|
genus = $6,
|
|
details = $7,
|
|
height_inches = $8,
|
|
weight_pounds = $9,
|
|
environment_id = $10,
|
|
hp = $11,
|
|
attack = $12,
|
|
defense = $13,
|
|
special_attack = $14,
|
|
special_defense = $15,
|
|
speed = $16,
|
|
image_path = $17,
|
|
image_style = $18,
|
|
image_version = $19,
|
|
image_variant = $20,
|
|
image_description = $21,
|
|
updated_by_user_id = $22,
|
|
updated_at = now()
|
|
WHERE id = $23
|
|
`,
|
|
[
|
|
cleanPayload.dataId,
|
|
cleanPayload.dataIdentifier,
|
|
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(paramsQuery: QueryParams = {}, locale = defaultLocale) {
|
|
const habitatName = localizedName('habitats', 'h', locale);
|
|
const itemName = localizedName('items', 'i', locale);
|
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
|
const params: unknown[] = [];
|
|
const conditions: string[] = [];
|
|
const isEventItem = asString(paramsQuery.isEventItem);
|
|
|
|
if (isEventItem === 'true' || isEventItem === 'false') {
|
|
params.push(isEventItem === 'true');
|
|
conditions.push(`h.is_event_item = $${params.length}`);
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
|
|
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')}
|
|
${whereClause}
|
|
ORDER BY ${orderByEntity('h')}
|
|
`, params);
|
|
}
|
|
|
|
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 itemDetails = localizedField('items', 'i.id', 'i.details', 'details', locale);
|
|
const tagName = localizedName('favorite-things', 't', locale);
|
|
|
|
return `
|
|
SELECT
|
|
i.id,
|
|
i.display_id AS "displayId",
|
|
${itemName} AS name,
|
|
i.name AS "baseName",
|
|
${itemDetails} AS details,
|
|
i.details AS "baseDetails",
|
|
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,
|
|
${systemListJsonSql('i.category_key', itemCategoryOptions, locale)} AS category,
|
|
CASE
|
|
WHEN i.usage_key IS NULL THEN NULL
|
|
ELSE ${systemListJsonSql('i.usage_key', itemUsageOptions, locale)}
|
|
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
|
|
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 isEventItem = asString(paramsQuery.isEventItem);
|
|
const tagIds = parseIdList(asString(paramsQuery.tagIds));
|
|
const search = asString(paramsQuery.search)?.trim();
|
|
const recipeOrder = asString(paramsQuery.recipeOrder) === '1';
|
|
const categoryOption = Number.isInteger(categoryId) && categoryId > 0
|
|
? systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired')
|
|
: null;
|
|
const usageOption = Number.isInteger(usageId) && usageId > 0
|
|
? systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired')
|
|
: null;
|
|
|
|
if (search) {
|
|
params.push(`%${search}%`);
|
|
conditions.push(`${localizedName('items', 'i', locale)} ILIKE $${params.length}`);
|
|
}
|
|
|
|
if (isEventItem === 'true' || isEventItem === 'false') {
|
|
params.push(isEventItem === 'true');
|
|
conditions.push(`i.is_event_item = $${params.length}`);
|
|
}
|
|
|
|
if (categoryOption) {
|
|
params.push(categoryOption.key);
|
|
conditions.push(`i.category_key = $${params.length}`);
|
|
}
|
|
|
|
if (usageOption) {
|
|
params.push(usageOption.key);
|
|
conditions.push(`i.usage_key = $${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, i.display_id, ${orderByEntity('i')}`
|
|
: `ORDER BY i.display_id, ${orderByEntity('i')}`;
|
|
return query(`${itemProjection(locale)} ${whereClause} ${orderClause}`, params);
|
|
}
|
|
|
|
export async function getItem(id: number, locale = defaultLocale) {
|
|
const item = await queryOne(`${itemProjection(locale)} WHERE i.id = $1`, [id]);
|
|
if (!item) {
|
|
return null;
|
|
}
|
|
|
|
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
|
|
const resultItemName = localizedName('items', 'result_item', locale);
|
|
const materialItemName = localizedName('items', 'mi', locale);
|
|
const habitatName = localizedName('habitats', 'h', locale);
|
|
const recipeItemName = localizedName('items', 'recipe_item', locale);
|
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
|
const skillName = localizedName('skills', 's', locale);
|
|
|
|
const [acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory, 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,
|
|
'displayId', result_item.display_id,
|
|
'name', ${resultItemName},
|
|
'image', ${uploadedImageJson('result_item.image_path')},
|
|
'category', ${systemListJsonSql('result_item.category_key', itemCategoryOptions, locale)},
|
|
'usage', CASE
|
|
WHEN result_item.usage_key IS NULL THEN NULL
|
|
ELSE ${systemListJsonSql('result_item.usage_key', itemUsageOptions, locale)}
|
|
END
|
|
) AS item
|
|
FROM recipes r
|
|
JOIN items result_item ON result_item.id = r.item_id
|
|
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
|
WHERE r.item_id = $1
|
|
`,
|
|
[id]
|
|
),
|
|
query(
|
|
`
|
|
SELECT
|
|
r.id,
|
|
${resultItemName} AS name,
|
|
${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 categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired');
|
|
const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined
|
|
? null
|
|
: requirePositiveInteger(payload.usageId, 'server.validation.usageRequired');
|
|
const category = systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired');
|
|
const usage = usageId === null ? null : systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired');
|
|
|
|
return {
|
|
displayId: requirePositiveInteger(payload.displayId, 'server.validation.itemDisplayIdRequired'),
|
|
name: cleanName(payload.name, 'server.validation.itemNameRequired'),
|
|
details: cleanOptionalText(payload.details),
|
|
translations: cleanTranslations(payload.translations, ['name', 'details']),
|
|
categoryId,
|
|
categoryKey: category.key,
|
|
usageId,
|
|
usageKey: usage?.key ?? null,
|
|
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 (
|
|
display_id,
|
|
name,
|
|
details,
|
|
category_key,
|
|
usage_key,
|
|
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, $12, $13, $13)
|
|
RETURNING id
|
|
`,
|
|
[
|
|
cleanPayload.displayId,
|
|
cleanPayload.name,
|
|
cleanPayload.details,
|
|
cleanPayload.categoryKey,
|
|
cleanPayload.usageKey,
|
|
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', 'details']);
|
|
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 display_id = $1,
|
|
name = $2,
|
|
details = $3,
|
|
category_key = $4,
|
|
usage_key = $5,
|
|
dyeable = $6,
|
|
dual_dyeable = $7,
|
|
pattern_editable = $8,
|
|
no_recipe = $9,
|
|
is_event_item = $10,
|
|
image_path = $11,
|
|
updated_by_user_id = $12,
|
|
updated_at = now()
|
|
WHERE id = $13
|
|
`,
|
|
[
|
|
cleanPayload.displayId,
|
|
cleanPayload.name,
|
|
cleanPayload.details,
|
|
cleanPayload.categoryKey,
|
|
cleanPayload.usageKey,
|
|
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', 'details']);
|
|
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;
|
|
});
|
|
}
|
|
|
|
function ancientArtifactProjection(locale: string): string {
|
|
const artifactName = localizedName('ancient-artifacts', 'a', locale);
|
|
const artifactDetails = localizedField('ancient-artifacts', 'a.id', 'a.details', 'details', locale);
|
|
const tagName = localizedName('favorite-things', 't', locale);
|
|
|
|
return `
|
|
SELECT
|
|
a.id,
|
|
a.display_id AS "displayId",
|
|
${artifactName} AS name,
|
|
a.name AS "baseName",
|
|
${artifactDetails} AS details,
|
|
a.details AS "baseDetails",
|
|
${translationsSelect('ancient-artifacts', 'a.id')} AS translations,
|
|
${systemListJsonSql('a.category_key', ancientArtifactCategoryOptions, locale)} AS category,
|
|
${uploadedImageJson('a.image_path')} AS image,
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', t.id, 'name', ${tagName}) ORDER BY ${orderByEntity('t')})
|
|
FROM ancient_artifact_favorite_things aaft
|
|
JOIN favorite_things t ON t.id = aaft.favorite_thing_id
|
|
WHERE aaft.ancient_artifact_id = a.id
|
|
), '[]'::json) AS tags,
|
|
${auditSelect('a', 'artifact_created_user', 'artifact_updated_user')}
|
|
FROM ancient_artifacts a
|
|
${auditJoins('a', 'artifact_created_user', 'artifact_updated_user')}
|
|
`;
|
|
}
|
|
|
|
export async function listAncientArtifacts(paramsQuery: QueryParams = {}, locale = defaultLocale) {
|
|
const params: unknown[] = [];
|
|
const conditions: string[] = [];
|
|
const search = asString(paramsQuery.search)?.trim();
|
|
const categoryId = Number(asString(paramsQuery.categoryId));
|
|
const tagIds = parseIdList(asString(paramsQuery.tagIds));
|
|
const categoryOption = Number.isInteger(categoryId) && categoryId > 0
|
|
? systemListOptionById(ancientArtifactCategoryOptions, categoryId, 'server.validation.categoryRequired')
|
|
: null;
|
|
|
|
if (search) {
|
|
params.push(`%${search}%`);
|
|
conditions.push(`${localizedName('ancient-artifacts', 'a', locale)} ILIKE $${params.length}`);
|
|
}
|
|
|
|
if (categoryOption) {
|
|
params.push(categoryOption.key);
|
|
conditions.push(`a.category_key = $${params.length}`);
|
|
}
|
|
|
|
const tagFilter = sqlForRelationFilter(
|
|
tagIds,
|
|
'any',
|
|
'ancient_artifact_favorite_things',
|
|
'ancient_artifact_id',
|
|
'favorite_thing_id',
|
|
'a.id',
|
|
params
|
|
);
|
|
if (tagFilter) {
|
|
conditions.push(tagFilter);
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
return query(`${ancientArtifactProjection(locale)} ${whereClause} ORDER BY a.display_id, ${orderByEntity('a')}`, params);
|
|
}
|
|
|
|
export async function getAncientArtifact(id: number, locale = defaultLocale) {
|
|
const artifact = await queryOne(`${ancientArtifactProjection(locale)} WHERE a.id = $1`, [id]);
|
|
if (!artifact) {
|
|
return null;
|
|
}
|
|
|
|
const editHistory = await getEditHistory('ancient-artifacts', id);
|
|
const imageHistory = await listEntityImageUploads('ancient-artifacts', id);
|
|
return { ...artifact, editHistory, imageHistory };
|
|
}
|
|
|
|
function cleanAncientArtifactPayload(payload: Record<string, unknown>): AncientArtifactPayload {
|
|
const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired');
|
|
const category = systemListOptionById(ancientArtifactCategoryOptions, categoryId, 'server.validation.categoryRequired');
|
|
|
|
return {
|
|
displayId: requirePositiveInteger(payload.displayId, 'server.validation.artifactDisplayIdRequired'),
|
|
name: cleanName(payload.name, 'server.validation.artifactNameRequired'),
|
|
details: cleanOptionalText(payload.details),
|
|
translations: cleanTranslations(payload.translations, ['name', 'details']),
|
|
categoryId,
|
|
categoryKey: category.key,
|
|
tagIds: cleanIds(payload.tagIds),
|
|
imagePath: cleanUploadImagePath(payload.imagePath, 'ancient-artifacts')
|
|
};
|
|
}
|
|
|
|
async function replaceAncientArtifactRelations(client: DbClient, artifactId: number, payload: AncientArtifactPayload): Promise<void> {
|
|
await client.query('DELETE FROM ancient_artifact_favorite_things WHERE ancient_artifact_id = $1', [artifactId]);
|
|
|
|
for (const tagId of payload.tagIds) {
|
|
await client.query(
|
|
'INSERT INTO ancient_artifact_favorite_things (ancient_artifact_id, favorite_thing_id) VALUES ($1, $2)',
|
|
[artifactId, tagId]
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function createAncientArtifact(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanAncientArtifactPayload(payload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
const sortOrder = await nextSortOrder(client, 'ancient_artifacts');
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO ancient_artifacts (
|
|
display_id,
|
|
name,
|
|
details,
|
|
category_key,
|
|
image_path,
|
|
sort_order,
|
|
created_by_user_id,
|
|
updated_by_user_id
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
|
|
RETURNING id
|
|
`,
|
|
[
|
|
cleanPayload.displayId,
|
|
cleanPayload.name,
|
|
cleanPayload.details,
|
|
cleanPayload.categoryKey,
|
|
cleanPayload.imagePath,
|
|
sortOrder,
|
|
userId
|
|
]
|
|
);
|
|
const artifactId = result.rows[0].id;
|
|
await linkEntityImageUpload(client, 'ancient-artifacts', artifactId, cleanPayload.imagePath, cleanPayload.name);
|
|
await replaceAncientArtifactRelations(client, artifactId, cleanPayload);
|
|
await replaceEntityTranslations(client, 'ancient-artifacts', artifactId, cleanPayload.translations, ['name', 'details']);
|
|
await recordEditLog(client, 'ancient-artifacts', artifactId, 'create', userId);
|
|
return artifactId;
|
|
});
|
|
|
|
return getAncientArtifact(id, locale);
|
|
}
|
|
|
|
export async function updateAncientArtifact(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanAncientArtifactPayload(payload);
|
|
const before = await getAncientArtifact(id, defaultLocale);
|
|
|
|
const updated = await withTransaction(async (client) => {
|
|
const result = await client.query(
|
|
`
|
|
UPDATE ancient_artifacts
|
|
SET display_id = $1,
|
|
name = $2,
|
|
details = $3,
|
|
category_key = $4,
|
|
image_path = $5,
|
|
updated_by_user_id = $6,
|
|
updated_at = now()
|
|
WHERE id = $7
|
|
`,
|
|
[cleanPayload.displayId, cleanPayload.name, cleanPayload.details, cleanPayload.categoryKey, cleanPayload.imagePath, userId, id]
|
|
);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await linkEntityImageUpload(client, 'ancient-artifacts', id, cleanPayload.imagePath, cleanPayload.name);
|
|
await replaceAncientArtifactRelations(client, id, cleanPayload);
|
|
await replaceEntityTranslations(client, 'ancient-artifacts', id, cleanPayload.translations, ['name', 'details']);
|
|
const changes = before ? await ancientArtifactEditChanges(client, before as unknown as AncientArtifactChangeSource, cleanPayload) : [];
|
|
await recordEditLog(client, 'ancient-artifacts', id, 'update', userId, changes);
|
|
return true;
|
|
});
|
|
|
|
return updated ? getAncientArtifact(id, locale) : null;
|
|
}
|
|
|
|
export async function deleteAncientArtifact(id: number, userId: number) {
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ id: number }>('DELETE FROM ancient_artifacts WHERE id = $1 RETURNING id', [id]);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await deleteEntityDiscussionCommentsForEntity(client, 'ancient-artifacts', id);
|
|
await deleteEntityTranslations(client, 'ancient-artifacts', id);
|
|
await recordEditLog(client, 'ancient-artifacts', 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 categoryOption = Number.isInteger(categoryId) && categoryId > 0
|
|
? systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired')
|
|
: null;
|
|
const resultItemName = localizedName('items', 'result_item', locale);
|
|
const materialItemName = localizedName('items', 'i', locale);
|
|
|
|
if (categoryOption) {
|
|
params.push(categoryOption.key);
|
|
conditions.push(`result_item.category_key = $${params.length}`);
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
return query(`
|
|
SELECT
|
|
r.id,
|
|
${resultItemName} AS name,
|
|
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', i.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${orderByEntity('i')})
|
|
FROM recipe_materials rm
|
|
JOIN items i ON i.id = rm.item_id
|
|
WHERE rm.recipe_id = r.id
|
|
), '[]'::json) AS materials
|
|
FROM recipes r
|
|
JOIN items result_item ON result_item.id = r.item_id
|
|
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
|
${whereClause}
|
|
ORDER BY ${orderByEntity('r')}
|
|
`, params);
|
|
}
|
|
|
|
export async function getRecipe(id: number, locale = defaultLocale) {
|
|
const resultItemName = localizedName('items', 'result_item', locale);
|
|
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
|
|
const materialItemName = localizedName('items', 'i', locale);
|
|
|
|
const recipe = await queryOne(
|
|
`
|
|
SELECT
|
|
r.id,
|
|
${resultItemName} AS name,
|
|
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${orderByEntity('am')})
|
|
FROM recipe_acquisition_methods ram
|
|
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
|
|
WHERE ram.recipe_id = r.id
|
|
), '[]'::json) AS acquisition_methods,
|
|
COALESCE((
|
|
SELECT json_agg(
|
|
json_build_object(
|
|
'id', i.id,
|
|
'name', ${materialItemName},
|
|
'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,
|
|
'displayId', result_item.display_id,
|
|
'name', ${resultItemName},
|
|
'image', ${uploadedImageJson('result_item.image_path')},
|
|
'category', ${systemListJsonSql('result_item.category_key', itemCategoryOptions, locale)},
|
|
'usage', CASE
|
|
WHEN result_item.usage_key IS NULL THEN NULL
|
|
ELSE ${systemListJsonSql('result_item.usage_key', itemUsageOptions, locale)}
|
|
END
|
|
) AS item
|
|
FROM recipes r
|
|
JOIN items result_item ON result_item.id = r.item_id
|
|
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
|
WHERE r.id = $1
|
|
`,
|
|
[id]
|
|
);
|
|
|
|
if (!recipe) {
|
|
return null;
|
|
}
|
|
|
|
const editHistory = await getEditHistory('recipes', id);
|
|
return { ...recipe, editHistory };
|
|
}
|
|
|
|
function cleanRecipePayload(payload: Record<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;
|
|
});
|
|
}
|
|
|
|
const dataToolScopes = ['pokemon', 'habitats', 'items', 'artifacts', 'recipes', 'checklist'] as const satisfies readonly DataToolScope[];
|
|
const dataToolMainTables: Record<DataToolScope, string> = {
|
|
pokemon: 'pokemon',
|
|
habitats: 'habitats',
|
|
items: 'items',
|
|
artifacts: 'ancient_artifacts',
|
|
recipes: 'recipes',
|
|
checklist: 'daily_checklist_items'
|
|
};
|
|
|
|
const dataToolColumns = {
|
|
pokemon: [
|
|
'id',
|
|
'data_id',
|
|
'data_identifier',
|
|
'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',
|
|
'created_at',
|
|
'updated_at'
|
|
],
|
|
pokemonTypeLinks: ['pokemon_id', 'type_id', 'slot_order'],
|
|
pokemonSkills: ['pokemon_id', 'skill_id'],
|
|
pokemonFavoriteThings: ['pokemon_id', 'favorite_thing_id'],
|
|
pokemonSkillItemDrops: ['pokemon_id', 'skill_id', 'item_id'],
|
|
habitats: ['id', 'name', 'is_event_item', 'image_path', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
|
|
habitatRecipeItems: ['habitat_id', 'item_id', 'quantity'],
|
|
habitatPokemon: ['habitat_id', 'pokemon_id', 'map_id', 'time_of_day', 'weather', 'rarity'],
|
|
items: [
|
|
'id',
|
|
'display_id',
|
|
'name',
|
|
'details',
|
|
'category_key',
|
|
'usage_key',
|
|
'dyeable',
|
|
'dual_dyeable',
|
|
'pattern_editable',
|
|
'no_recipe',
|
|
'is_event_item',
|
|
'image_path',
|
|
'sort_order',
|
|
'created_by_user_id',
|
|
'updated_by_user_id',
|
|
'created_at',
|
|
'updated_at'
|
|
],
|
|
itemAcquisitionMethods: ['item_id', 'acquisition_method_id'],
|
|
itemFavoriteThings: ['item_id', 'favorite_thing_id'],
|
|
artifactFavoriteThings: ['ancient_artifact_id', 'favorite_thing_id'],
|
|
artifacts: [
|
|
'id',
|
|
'display_id',
|
|
'name',
|
|
'details',
|
|
'category_key',
|
|
'image_path',
|
|
'sort_order',
|
|
'created_by_user_id',
|
|
'updated_by_user_id',
|
|
'created_at',
|
|
'updated_at'
|
|
],
|
|
recipes: ['id', 'item_id', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
|
|
recipeAcquisitionMethods: ['recipe_id', 'acquisition_method_id'],
|
|
recipeMaterials: ['recipe_id', 'item_id', 'quantity'],
|
|
checklist: ['id', 'title', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
|
|
translations: ['entity_type', 'entity_id', 'locale', 'field_name', 'value'],
|
|
editLogs: ['id', 'entity_type', 'entity_id', 'action', 'user_id', 'changes', 'created_at'],
|
|
imageUploads: [
|
|
'id',
|
|
'entity_type',
|
|
'entity_id',
|
|
'entity_name',
|
|
'path',
|
|
'original_filename',
|
|
'mime_type',
|
|
'byte_size',
|
|
'created_by_user_id',
|
|
'created_at'
|
|
],
|
|
discussionComments: [
|
|
'id',
|
|
'entity_type',
|
|
'entity_id',
|
|
'parent_comment_id',
|
|
'body',
|
|
'ai_moderation_status',
|
|
'ai_moderation_language_code',
|
|
'ai_moderation_content_hash',
|
|
'ai_moderation_checked_at',
|
|
'ai_moderation_retry_count',
|
|
'ai_moderation_updated_at',
|
|
'created_by_user_id',
|
|
'deleted_by_user_id',
|
|
'deleted_at',
|
|
'created_at',
|
|
'updated_at'
|
|
]
|
|
} as const;
|
|
|
|
function isDataToolScope(value: unknown): value is DataToolScope {
|
|
return typeof value === 'string' && dataToolScopes.includes(value as DataToolScope);
|
|
}
|
|
|
|
function normalizeDataToolScopes(scopes: DataToolScope[]): DataToolScope[] {
|
|
const scopeSet = new Set(scopes);
|
|
if (scopeSet.has('items')) {
|
|
scopeSet.add('recipes');
|
|
}
|
|
return dataToolScopes.filter((scope) => scopeSet.has(scope));
|
|
}
|
|
|
|
function cleanDataToolScopes(value: unknown): DataToolScope[] {
|
|
if (!Array.isArray(value)) {
|
|
throw validationError('server.validation.dataToolScopeRequired');
|
|
}
|
|
|
|
const scopes: DataToolScope[] = [];
|
|
for (const scope of value) {
|
|
if (!isDataToolScope(scope)) {
|
|
throw validationError('server.validation.dataToolScopeInvalid');
|
|
}
|
|
if (!scopes.includes(scope)) {
|
|
scopes.push(scope);
|
|
}
|
|
}
|
|
|
|
if (scopes.length === 0) {
|
|
throw validationError('server.validation.dataToolScopeRequired');
|
|
}
|
|
return normalizeDataToolScopes(scopes);
|
|
}
|
|
|
|
function cleanDataToolsBundle(value: unknown): DataToolsBundle {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw validationError('server.validation.dataToolBundleInvalid');
|
|
}
|
|
|
|
const bundle = value as Record<string, unknown>;
|
|
if (bundle.version !== 1 || !bundle.data || typeof bundle.data !== 'object' || Array.isArray(bundle.data)) {
|
|
throw validationError('server.validation.dataToolBundleInvalid');
|
|
}
|
|
|
|
return {
|
|
version: 1,
|
|
exportedAt: typeof bundle.exportedAt === 'string' ? bundle.exportedAt : new Date().toISOString(),
|
|
scopes: cleanDataToolScopes(bundle.scopes),
|
|
data: bundle.data as DataToolsBundle['data']
|
|
};
|
|
}
|
|
|
|
function dataToolTableRows(data: DataToolScopeData | undefined, key: string): DataToolRows {
|
|
const rows = data?.[key];
|
|
if (rows === undefined) {
|
|
return [];
|
|
}
|
|
if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== 'object' || Array.isArray(row))) {
|
|
throw validationError('server.validation.dataToolBundleInvalid');
|
|
}
|
|
return rows as DataToolRows;
|
|
}
|
|
|
|
function dataToolDataWithRows(key: string, ...sources: Array<DataToolScopeData | undefined>): DataToolScopeData | undefined {
|
|
return sources.find((source) => source?.[key] !== undefined);
|
|
}
|
|
|
|
async function tableRows(client: DbClient, sql: string, params: unknown[] = []): Promise<DataToolRows> {
|
|
const result = await client.query<Record<string, unknown>>(sql, params);
|
|
return result.rows;
|
|
}
|
|
|
|
function normalizeImportValue(column: string, value: unknown, row: Record<string, unknown>): unknown {
|
|
if (value === undefined) {
|
|
if (column === 'display_id' && typeof row.id === 'number') {
|
|
return row.id;
|
|
}
|
|
if (column === 'details') {
|
|
return '';
|
|
}
|
|
if (column === 'image_path') {
|
|
return '';
|
|
}
|
|
if (column === 'category_key') {
|
|
return 'other';
|
|
}
|
|
return null;
|
|
}
|
|
if (column === 'changes' && typeof value !== 'string') {
|
|
return JSON.stringify(value ?? []);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function insertRows(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> {
|
|
for (const row of rows) {
|
|
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
|
|
const values = columns.map((column) => normalizeImportValue(column, row[column], row));
|
|
await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`, values);
|
|
}
|
|
}
|
|
|
|
async function resetIdentity(client: DbClient, tableName: string): Promise<void> {
|
|
const result = await client.query<{ maxId: number | null }>(`SELECT MAX(id)::integer AS "maxId" FROM ${tableName}`);
|
|
const maxId = result.rows[0]?.maxId ?? null;
|
|
if (maxId === null) {
|
|
await client.query(`ALTER TABLE ${tableName} ALTER COLUMN id RESTART WITH 1`);
|
|
return;
|
|
}
|
|
|
|
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3, true)', [tableName, 'id', maxId]);
|
|
}
|
|
|
|
async function resetDataToolIdentities(client: DbClient): Promise<void> {
|
|
for (const tableName of [
|
|
'daily_checklist_items',
|
|
'items',
|
|
'ancient_artifacts',
|
|
'recipes',
|
|
'habitats',
|
|
'wiki_edit_logs',
|
|
'entity_image_uploads',
|
|
'entity_discussion_comments'
|
|
]) {
|
|
await resetIdentity(client, tableName);
|
|
}
|
|
}
|
|
|
|
async function deleteGenericEntityRows(client: DbClient, entityTypes: string[]): Promise<void> {
|
|
await client.query('DELETE FROM entity_discussion_comments WHERE entity_type = ANY($1::text[])', [entityTypes]);
|
|
await client.query('DELETE FROM entity_image_uploads WHERE entity_type = ANY($1::text[])', [entityTypes]);
|
|
await client.query('DELETE FROM wiki_edit_logs WHERE entity_type = ANY($1::text[])', [entityTypes]);
|
|
await client.query('DELETE FROM entity_translations WHERE entity_type = ANY($1::text[])', [entityTypes]);
|
|
}
|
|
|
|
async function wipeRecipesData(client: DbClient): Promise<void> {
|
|
await deleteGenericEntityRows(client, ['recipes']);
|
|
await client.query('DELETE FROM recipe_acquisition_methods');
|
|
await client.query('DELETE FROM recipe_materials');
|
|
await client.query('DELETE FROM recipes');
|
|
}
|
|
|
|
async function wipeItemsData(client: DbClient): Promise<void> {
|
|
await wipeRecipesData(client);
|
|
await deleteGenericEntityRows(client, ['items']);
|
|
await client.query('DELETE FROM item_acquisition_methods');
|
|
await client.query('DELETE FROM item_favorite_things');
|
|
await client.query('DELETE FROM habitat_recipe_items');
|
|
await client.query('DELETE FROM pokemon_skill_item_drops');
|
|
await client.query('DELETE FROM items');
|
|
}
|
|
|
|
async function wipeAncientArtifactsData(client: DbClient): Promise<void> {
|
|
await deleteGenericEntityRows(client, ['ancient-artifacts']);
|
|
await client.query('DELETE FROM ancient_artifact_favorite_things');
|
|
await client.query('DELETE FROM ancient_artifacts');
|
|
}
|
|
|
|
async function wipePokemonData(client: DbClient): Promise<void> {
|
|
await deleteGenericEntityRows(client, ['pokemon']);
|
|
await client.query('DELETE FROM habitat_pokemon');
|
|
await client.query('DELETE FROM pokemon_skill_item_drops');
|
|
await client.query('DELETE FROM pokemon_pokemon_types');
|
|
await client.query('DELETE FROM pokemon_skills');
|
|
await client.query('DELETE FROM pokemon_favorite_things');
|
|
await client.query('DELETE FROM pokemon');
|
|
}
|
|
|
|
async function wipeHabitatsData(client: DbClient): Promise<void> {
|
|
await deleteGenericEntityRows(client, ['habitats']);
|
|
await client.query('DELETE FROM habitat_recipe_items');
|
|
await client.query('DELETE FROM habitat_pokemon');
|
|
await client.query('DELETE FROM habitats');
|
|
}
|
|
|
|
async function wipeChecklistData(client: DbClient): Promise<void> {
|
|
await deleteGenericEntityRows(client, ['daily-checklist-items']);
|
|
await client.query('DELETE FROM daily_checklist_items');
|
|
}
|
|
|
|
async function wipeDataToolScopes(client: DbClient, scopes: DataToolScope[], resetIdentities = true): Promise<void> {
|
|
const scopeSet = new Set(scopes);
|
|
if (scopeSet.has('items')) {
|
|
await wipeItemsData(client);
|
|
} else if (scopeSet.has('recipes')) {
|
|
await wipeRecipesData(client);
|
|
}
|
|
if (scopeSet.has('artifacts')) {
|
|
await wipeAncientArtifactsData(client);
|
|
}
|
|
if (scopeSet.has('pokemon')) {
|
|
await wipePokemonData(client);
|
|
}
|
|
if (scopeSet.has('habitats')) {
|
|
await wipeHabitatsData(client);
|
|
}
|
|
if (scopeSet.has('checklist')) {
|
|
await wipeChecklistData(client);
|
|
}
|
|
if (resetIdentities) {
|
|
await resetDataToolIdentities(client);
|
|
}
|
|
}
|
|
|
|
async function exportGenericScopeData(client: DbClient, entityType: string, includeImages: boolean): Promise<DataToolScopeData> {
|
|
const data: DataToolScopeData = {
|
|
translations: await tableRows(client, 'SELECT * FROM entity_translations WHERE entity_type = $1 ORDER BY entity_id, locale, field_name', [entityType]),
|
|
editLogs: await tableRows(client, 'SELECT * FROM wiki_edit_logs WHERE entity_type = $1 ORDER BY id', [entityType]),
|
|
discussionComments: await tableRows(
|
|
client,
|
|
`
|
|
SELECT *
|
|
FROM entity_discussion_comments
|
|
WHERE entity_type = $1
|
|
ORDER BY parent_comment_id NULLS FIRST, id
|
|
`,
|
|
[entityType]
|
|
)
|
|
};
|
|
|
|
if (includeImages) {
|
|
data.imageUploads = await tableRows(client, 'SELECT * FROM entity_image_uploads WHERE entity_type = $1 ORDER BY id', [entityType]);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<DataToolScopeData> {
|
|
if (scope === 'pokemon') {
|
|
return {
|
|
pokemon: await tableRows(client, 'SELECT * FROM pokemon ORDER BY sort_order, id'),
|
|
pokemonTypeLinks: await tableRows(client, 'SELECT * FROM pokemon_pokemon_types ORDER BY pokemon_id, slot_order'),
|
|
pokemonSkills: await tableRows(client, 'SELECT * FROM pokemon_skills ORDER BY pokemon_id, skill_id'),
|
|
pokemonFavoriteThings: await tableRows(client, 'SELECT * FROM pokemon_favorite_things ORDER BY pokemon_id, favorite_thing_id'),
|
|
pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'),
|
|
habitatPokemon: await tableRows(client, 'SELECT * FROM habitat_pokemon ORDER BY habitat_id, pokemon_id, map_id, time_of_day, weather'),
|
|
...(await exportGenericScopeData(client, 'pokemon', true))
|
|
};
|
|
}
|
|
|
|
if (scope === 'habitats') {
|
|
return {
|
|
habitats: await tableRows(client, 'SELECT * FROM habitats ORDER BY sort_order, id'),
|
|
habitatRecipeItems: await tableRows(client, 'SELECT * FROM habitat_recipe_items ORDER BY habitat_id, item_id'),
|
|
habitatPokemon: await tableRows(client, 'SELECT * FROM habitat_pokemon ORDER BY habitat_id, pokemon_id, map_id, time_of_day, weather'),
|
|
...(await exportGenericScopeData(client, 'habitats', true))
|
|
};
|
|
}
|
|
|
|
if (scope === 'items') {
|
|
return {
|
|
items: await tableRows(client, 'SELECT * FROM items ORDER BY display_id, sort_order, id'),
|
|
itemAcquisitionMethods: await tableRows(client, 'SELECT * FROM item_acquisition_methods ORDER BY item_id, acquisition_method_id'),
|
|
itemFavoriteThings: await tableRows(client, 'SELECT * FROM item_favorite_things ORDER BY item_id, favorite_thing_id'),
|
|
pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'),
|
|
habitatRecipeItems: await tableRows(client, 'SELECT * FROM habitat_recipe_items ORDER BY habitat_id, item_id'),
|
|
...(await exportGenericScopeData(client, 'items', true))
|
|
};
|
|
}
|
|
|
|
if (scope === 'artifacts') {
|
|
return {
|
|
artifacts: await tableRows(client, 'SELECT * FROM ancient_artifacts ORDER BY display_id, sort_order, id'),
|
|
artifactFavoriteThings: await tableRows(
|
|
client,
|
|
'SELECT * FROM ancient_artifact_favorite_things ORDER BY ancient_artifact_id, favorite_thing_id'
|
|
),
|
|
...(await exportGenericScopeData(client, 'ancient-artifacts', true))
|
|
};
|
|
}
|
|
|
|
if (scope === 'recipes') {
|
|
return {
|
|
recipes: await tableRows(client, 'SELECT * FROM recipes ORDER BY sort_order, id'),
|
|
recipeAcquisitionMethods: await tableRows(client, 'SELECT * FROM recipe_acquisition_methods ORDER BY recipe_id, acquisition_method_id'),
|
|
recipeMaterials: await tableRows(client, 'SELECT * FROM recipe_materials ORDER BY recipe_id, item_id'),
|
|
...(await exportGenericScopeData(client, 'recipes', false))
|
|
};
|
|
}
|
|
|
|
return {
|
|
checklist: await tableRows(client, 'SELECT * FROM daily_checklist_items ORDER BY sort_order, id'),
|
|
translations: await tableRows(
|
|
client,
|
|
'SELECT * FROM entity_translations WHERE entity_type = $1 ORDER BY entity_id, locale, field_name',
|
|
['daily-checklist-items']
|
|
),
|
|
editLogs: await tableRows(client, 'SELECT * FROM wiki_edit_logs WHERE entity_type = $1 ORDER BY id', ['daily-checklist-items'])
|
|
};
|
|
}
|
|
|
|
async function importScopeMainRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
|
|
const itemData = bundle.data.items;
|
|
const artifactData = bundle.data.artifacts;
|
|
const pokemonData = bundle.data.pokemon;
|
|
const habitatData = bundle.data.habitats;
|
|
const checklistData = bundle.data.checklist;
|
|
const recipeData = bundle.data.recipes;
|
|
|
|
await insertRows(client, 'items', dataToolColumns.items, dataToolTableRows(itemData, 'items'));
|
|
await insertRows(client, 'ancient_artifacts', dataToolColumns.artifacts, dataToolTableRows(artifactData, 'artifacts'));
|
|
await insertRows(client, 'pokemon', dataToolColumns.pokemon, dataToolTableRows(pokemonData, 'pokemon'));
|
|
await insertRows(client, 'habitats', dataToolColumns.habitats, dataToolTableRows(habitatData, 'habitats'));
|
|
await insertRows(client, 'daily_checklist_items', dataToolColumns.checklist, dataToolTableRows(checklistData, 'checklist'));
|
|
await insertRows(client, 'recipes', dataToolColumns.recipes, dataToolTableRows(recipeData, 'recipes'));
|
|
}
|
|
|
|
async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
|
|
const itemData = bundle.data.items;
|
|
const artifactData = bundle.data.artifacts;
|
|
const pokemonData = bundle.data.pokemon;
|
|
const habitatData = bundle.data.habitats;
|
|
const recipeData = bundle.data.recipes;
|
|
const pokemonDropData = dataToolDataWithRows('pokemonSkillItemDrops', pokemonData, itemData);
|
|
const habitatRecipeData = dataToolDataWithRows('habitatRecipeItems', habitatData, itemData);
|
|
const habitatPokemonData = dataToolDataWithRows('habitatPokemon', habitatData, pokemonData);
|
|
|
|
await insertRows(client, 'item_acquisition_methods', dataToolColumns.itemAcquisitionMethods, dataToolTableRows(itemData, 'itemAcquisitionMethods'));
|
|
await insertRows(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolTableRows(itemData, 'itemFavoriteThings'));
|
|
await insertRows(
|
|
client,
|
|
'ancient_artifact_favorite_things',
|
|
dataToolColumns.artifactFavoriteThings,
|
|
dataToolTableRows(artifactData, 'artifactFavoriteThings')
|
|
);
|
|
await insertRows(client, 'pokemon_pokemon_types', dataToolColumns.pokemonTypeLinks, dataToolTableRows(pokemonData, 'pokemonTypeLinks'));
|
|
await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills'));
|
|
await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings'));
|
|
await insertRows(client, 'pokemon_skill_item_drops', dataToolColumns.pokemonSkillItemDrops, dataToolTableRows(pokemonDropData, 'pokemonSkillItemDrops'));
|
|
await insertRows(client, 'recipe_acquisition_methods', dataToolColumns.recipeAcquisitionMethods, dataToolTableRows(recipeData, 'recipeAcquisitionMethods'));
|
|
await insertRows(client, 'recipe_materials', dataToolColumns.recipeMaterials, dataToolTableRows(recipeData, 'recipeMaterials'));
|
|
await insertRows(client, 'habitat_recipe_items', dataToolColumns.habitatRecipeItems, dataToolTableRows(habitatRecipeData, 'habitatRecipeItems'));
|
|
await insertRows(client, 'habitat_pokemon', dataToolColumns.habitatPokemon, dataToolTableRows(habitatPokemonData, 'habitatPokemon'));
|
|
}
|
|
|
|
async function importGenericScopeRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
|
|
for (const scope of bundle.scopes) {
|
|
const data = bundle.data[scope];
|
|
await insertRows(client, 'entity_translations', dataToolColumns.translations, dataToolTableRows(data, 'translations'));
|
|
await insertRows(client, 'wiki_edit_logs', dataToolColumns.editLogs, dataToolTableRows(data, 'editLogs'));
|
|
await insertRows(client, 'entity_image_uploads', dataToolColumns.imageUploads, dataToolTableRows(data, 'imageUploads'));
|
|
await insertRows(client, 'entity_discussion_comments', dataToolColumns.discussionComments, dataToolTableRows(data, 'discussionComments'));
|
|
}
|
|
}
|
|
|
|
async function importDataToolsBundle(client: DbClient, bundle: DataToolsBundle): Promise<void> {
|
|
await importScopeMainRows(client, bundle);
|
|
await importScopeRelationRows(client, bundle);
|
|
await importGenericScopeRows(client, bundle);
|
|
await resetDataToolIdentities(client);
|
|
}
|
|
|
|
export async function getAdminDataToolsSummary(): Promise<{ scopes: DataToolScopeSummary[] }> {
|
|
const scopes: DataToolScopeSummary[] = [];
|
|
for (const scope of dataToolScopes) {
|
|
const result = await queryOne<{ count: number }>(`SELECT COUNT(*)::integer AS count FROM ${dataToolMainTables[scope]}`);
|
|
scopes.push({ scope, count: result?.count ?? 0 });
|
|
}
|
|
return { scopes };
|
|
}
|
|
|
|
export async function exportAdminData(payload: Record<string, unknown>): Promise<DataToolsBundle> {
|
|
const scopes = cleanDataToolScopes(payload.scopes);
|
|
return withTransaction(async (client) => {
|
|
const data: DataToolsBundle['data'] = {};
|
|
for (const scope of scopes) {
|
|
data[scope] = await exportScopeData(client, scope);
|
|
}
|
|
return { version: 1, exportedAt: new Date().toISOString(), scopes, data };
|
|
});
|
|
}
|
|
|
|
export async function importAdminData(payload: Record<string, unknown>): Promise<{ scopes: DataToolScopeSummary[] }> {
|
|
const bundle = cleanDataToolsBundle(payload.bundle);
|
|
await withTransaction(async (client) => {
|
|
await wipeDataToolScopes(client, bundle.scopes, false);
|
|
await importDataToolsBundle(client, bundle);
|
|
});
|
|
return getAdminDataToolsSummary();
|
|
}
|
|
|
|
export async function wipeAdminData(payload: Record<string, unknown>): Promise<{ scopes: DataToolScopeSummary[] }> {
|
|
const scopes = cleanDataToolScopes(payload.scopes);
|
|
await withTransaction(async (client) => {
|
|
await wipeDataToolScopes(client, scopes);
|
|
});
|
|
return getAdminDataToolsSummary();
|
|
}
|