Implement cursor-based pagination in backend API Add IntersectionObserver to frontend for automatic loading on scroll
2915 lines
96 KiB
TypeScript
2915 lines
96 KiB
TypeScript
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
|
|
import { pool, query, queryOne } from './db.ts';
|
|
import { Buffer } from 'node:buffer';
|
|
import type { PoolClient } from 'pg';
|
|
|
|
type QueryValue = string | string[] | undefined;
|
|
|
|
type QueryParams = Record<string, QueryValue>;
|
|
|
|
type DbClient = PoolClient;
|
|
|
|
type TranslationField = 'name' | 'title' | 'details' | 'genus';
|
|
type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>;
|
|
type EntityType =
|
|
| 'pokemon'
|
|
| 'pokemon-types'
|
|
| 'skills'
|
|
| 'environments'
|
|
| 'favorite-things'
|
|
| 'item-categories'
|
|
| 'item-usages'
|
|
| 'acquisition-methods'
|
|
| 'items'
|
|
| 'maps'
|
|
| 'habitats'
|
|
| 'daily-checklist-items';
|
|
|
|
type ConfigType =
|
|
| 'pokemon-types'
|
|
| 'skills'
|
|
| 'environments'
|
|
| 'favorite-things'
|
|
| 'item-categories'
|
|
| 'item-usages'
|
|
| 'acquisition-methods'
|
|
| 'maps';
|
|
|
|
type ConfigDefinition = {
|
|
table: string;
|
|
entityType: EntityType;
|
|
hasItemDrop?: boolean;
|
|
};
|
|
type SortableContentType = 'pokemon' | 'items' | 'recipes' | 'habitats';
|
|
type SortableContentDefinition = {
|
|
table: string;
|
|
entityType: SortableContentType;
|
|
};
|
|
|
|
type IdQuantity = {
|
|
itemId: number;
|
|
quantity: number;
|
|
};
|
|
|
|
type SkillItemDrop = {
|
|
skillId: number;
|
|
itemId: number;
|
|
};
|
|
|
|
type PokemonStats = {
|
|
hp: number;
|
|
attack: number;
|
|
defense: number;
|
|
specialAttack: number;
|
|
specialDefense: number;
|
|
speed: number;
|
|
};
|
|
|
|
type PokemonPayload = {
|
|
id: number;
|
|
name: string;
|
|
genus: string;
|
|
details: string;
|
|
heightInches: number;
|
|
weightPounds: number;
|
|
translations: TranslationInput;
|
|
typeIds: number[];
|
|
stats: PokemonStats;
|
|
environmentId: number;
|
|
skillIds: number[];
|
|
favoriteThingIds: number[];
|
|
skillItemDrops: SkillItemDrop[];
|
|
};
|
|
|
|
type ItemPayload = {
|
|
name: string;
|
|
translations: TranslationInput;
|
|
categoryId: number;
|
|
usageId: number | null;
|
|
dyeable: boolean;
|
|
dualDyeable: boolean;
|
|
patternEditable: boolean;
|
|
noRecipe: boolean;
|
|
acquisitionMethodIds: number[];
|
|
tagIds: number[];
|
|
};
|
|
|
|
type RecipePayload = {
|
|
itemId: number;
|
|
acquisitionMethodIds: number[];
|
|
materials: IdQuantity[];
|
|
};
|
|
|
|
type DailyChecklistPayload = {
|
|
title: string;
|
|
translations: TranslationInput;
|
|
};
|
|
|
|
type LifePostPayload = {
|
|
body: string;
|
|
};
|
|
|
|
type LifeCommentPayload = {
|
|
body: string;
|
|
};
|
|
|
|
type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
|
|
type LifeReactionCounts = Record<LifeReactionType, number>;
|
|
|
|
type LifeCommentRow = {
|
|
id: number;
|
|
postId: number;
|
|
parentCommentId: number | null;
|
|
body: string;
|
|
deleted: boolean;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
author: { id: number; displayName: string } | null;
|
|
};
|
|
|
|
type LifeComment = LifeCommentRow & {
|
|
replies: LifeComment[];
|
|
};
|
|
|
|
type LifePostRow = {
|
|
id: number;
|
|
body: string;
|
|
createdAt: Date;
|
|
createdAtCursor: string;
|
|
updatedAt: Date;
|
|
author: { id: number; displayName: string } | null;
|
|
updatedBy: { id: number; displayName: string } | null;
|
|
};
|
|
|
|
type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
|
|
comments: LifeComment[];
|
|
reactionCounts: LifeReactionCounts;
|
|
myReaction: LifeReactionType | null;
|
|
};
|
|
|
|
type LifePostCursor = {
|
|
createdAt: string;
|
|
id: number;
|
|
};
|
|
|
|
type LifePostsPage = {
|
|
items: LifePost[];
|
|
nextCursor: string | null;
|
|
hasMore: boolean;
|
|
};
|
|
|
|
type HabitatPayload = {
|
|
name: string;
|
|
translations: TranslationInput;
|
|
recipeItems: IdQuantity[];
|
|
pokemonAppearances: Array<{
|
|
pokemonId: number;
|
|
mapId: number;
|
|
timeOfDay: string;
|
|
weather: string;
|
|
rarity: number;
|
|
}>;
|
|
};
|
|
|
|
type LanguagePayload = {
|
|
code: string;
|
|
name: string;
|
|
enabled: boolean;
|
|
isDefault: boolean;
|
|
sortOrder: number;
|
|
};
|
|
|
|
type ValidationError = Error & { statusCode: number };
|
|
type EditAction = 'create' | 'update' | 'delete';
|
|
type EditChange = {
|
|
label: string;
|
|
before: string;
|
|
after: string;
|
|
};
|
|
type EditHistoryEntry = {
|
|
action: EditAction;
|
|
changes: EditChange[];
|
|
createdAt: Date;
|
|
user: { id: number; displayName: string } | null;
|
|
};
|
|
type PokemonChangeSource = {
|
|
name: string;
|
|
genus: string;
|
|
details: string;
|
|
heightInches: number;
|
|
weightPounds: number;
|
|
types: Array<{ name: string }>;
|
|
stats: PokemonStats;
|
|
environment: { name: string };
|
|
skills: Array<{ name: string; itemDrop?: { name: string } | null }>;
|
|
favorite_things: Array<{ name: string }>;
|
|
};
|
|
type ItemChangeSource = {
|
|
name: string;
|
|
category: { name: string };
|
|
usage: { name: string } | null;
|
|
customization: { dyeable: boolean; dualDyeable: boolean; patternEditable: boolean };
|
|
noRecipe: boolean;
|
|
acquisitionMethods: Array<{ name: string }>;
|
|
tags: Array<{ name: string }>;
|
|
};
|
|
type HabitatChangeSource = {
|
|
name: string;
|
|
recipe: Array<{ name: string; quantity: number }>;
|
|
pokemon: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }>;
|
|
};
|
|
type RecipeChangeSource = {
|
|
item: { name: string };
|
|
acquisition_methods: Array<{ name: string }>;
|
|
materials: Array<{ name: string; quantity: number }>;
|
|
};
|
|
|
|
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
|
const weathers = ['晴天', '阴天', '雨天'];
|
|
const defaultLocale = 'en';
|
|
const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
|
|
const defaultLifePostLimit = 20;
|
|
const maxLifePostLimit = 50;
|
|
const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const;
|
|
const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
|
|
{ key: 'hp', label: 'HP' },
|
|
{ key: 'attack', label: 'Attack' },
|
|
{ key: 'defense', label: 'Defense' },
|
|
{ key: 'specialAttack', label: 'Special Attack' },
|
|
{ key: 'specialDefense', label: 'Special Defense' },
|
|
{ key: 'speed', label: 'Speed' }
|
|
];
|
|
|
|
const configDefinitions: Record<ConfigType, ConfigDefinition> = {
|
|
'pokemon-types': { table: 'pokemon_types', entityType: 'pokemon-types' },
|
|
skills: { table: 'skills', entityType: 'skills', hasItemDrop: true },
|
|
environments: { table: 'environments', entityType: 'environments' },
|
|
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things' },
|
|
'item-categories': { table: 'item_categories', entityType: 'item-categories' },
|
|
'item-usages': { table: 'item_usages', entityType: 'item-usages' },
|
|
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
|
|
maps: { table: 'maps', entityType: 'maps' }
|
|
};
|
|
|
|
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
|
|
pokemon: { table: 'pokemon', entityType: 'pokemon' },
|
|
items: { table: 'items', entityType: 'items' },
|
|
recipes: { table: 'recipes', entityType: 'recipes' },
|
|
habitats: { table: 'habitats', entityType: 'habitats' }
|
|
};
|
|
|
|
function asString(value: QueryValue): string | undefined {
|
|
return Array.isArray(value) ? value[0] : value;
|
|
}
|
|
|
|
export function cleanLocale(value: unknown): string {
|
|
const locale = typeof value === 'string' ? value.trim() : '';
|
|
return localePattern.test(locale) ? locale : defaultLocale;
|
|
}
|
|
|
|
function sqlLiteral(value: string): string {
|
|
return `'${value.replaceAll("'", "''")}'`;
|
|
}
|
|
|
|
function localizedField(
|
|
entityType: EntityType,
|
|
entityIdExpression: string,
|
|
baseExpression: string,
|
|
fieldName: TranslationField,
|
|
locale: string
|
|
): string {
|
|
const entity = sqlLiteral(entityType);
|
|
const field = sqlLiteral(fieldName);
|
|
const requestedLocale = sqlLiteral(cleanLocale(locale));
|
|
const defaultLocaleSql = sqlLiteral(defaultLocale);
|
|
|
|
return `
|
|
COALESCE(
|
|
(
|
|
SELECT et.value
|
|
FROM entity_translations et
|
|
WHERE et.entity_type = ${entity}
|
|
AND et.entity_id = ${entityIdExpression}
|
|
AND et.locale = ${requestedLocale}
|
|
AND et.field_name = ${field}
|
|
),
|
|
(
|
|
SELECT et.value
|
|
FROM entity_translations et
|
|
WHERE et.entity_type = ${entity}
|
|
AND et.entity_id = ${entityIdExpression}
|
|
AND et.locale = ${defaultLocaleSql}
|
|
AND et.field_name = ${field}
|
|
),
|
|
${baseExpression}
|
|
)
|
|
`;
|
|
}
|
|
|
|
function localizedName(entityType: EntityType, entityAlias: string, locale: string): string {
|
|
return localizedField(entityType, `${entityAlias}.id`, `${entityAlias}.name`, 'name', locale);
|
|
}
|
|
|
|
function translationsSelect(entityType: EntityType, entityIdExpression: string): string {
|
|
return `
|
|
COALESCE((
|
|
SELECT jsonb_object_agg(locale, fields)
|
|
FROM (
|
|
SELECT locale, jsonb_object_agg(field_name, value) AS fields
|
|
FROM entity_translations
|
|
WHERE entity_type = ${sqlLiteral(entityType)}
|
|
AND entity_id = ${entityIdExpression}
|
|
GROUP BY locale
|
|
) translation_rows
|
|
), '{}'::jsonb)
|
|
`;
|
|
}
|
|
|
|
function cleanTranslations(value: unknown, allowedFields: TranslationField[]): TranslationInput {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
|
|
const translations: TranslationInput = {};
|
|
const allowedFieldSet = new Set(allowedFields);
|
|
|
|
for (const [locale, fields] of Object.entries(value as Record<string, unknown>)) {
|
|
if (!localePattern.test(locale) || locale === defaultLocale || !fields || typeof fields !== 'object' || Array.isArray(fields)) {
|
|
continue;
|
|
}
|
|
|
|
const cleanFields: Partial<Record<TranslationField, string>> = {};
|
|
for (const [fieldName, fieldValue] of Object.entries(fields as Record<string, unknown>)) {
|
|
if (!allowedFieldSet.has(fieldName as TranslationField) || typeof fieldValue !== 'string') {
|
|
continue;
|
|
}
|
|
|
|
const cleanValue = fieldValue.trim();
|
|
if (cleanValue !== '') {
|
|
cleanFields[fieldName as TranslationField] = cleanValue;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(cleanFields).length > 0) {
|
|
translations[locale] = cleanFields;
|
|
}
|
|
}
|
|
|
|
return translations;
|
|
}
|
|
|
|
async function replaceEntityTranslations(
|
|
client: DbClient,
|
|
entityType: EntityType,
|
|
entityId: number,
|
|
translations: TranslationInput,
|
|
fields: TranslationField[]
|
|
): Promise<void> {
|
|
await client.query(
|
|
`
|
|
DELETE FROM entity_translations
|
|
WHERE entity_type = $1
|
|
AND entity_id = $2
|
|
AND field_name = ANY($3::text[])
|
|
`,
|
|
[entityType, entityId, fields]
|
|
);
|
|
|
|
for (const [locale, translatedFields] of Object.entries(translations)) {
|
|
for (const fieldName of fields) {
|
|
const value = translatedFields[fieldName];
|
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
continue;
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
INSERT INTO entity_translations (entity_type, entity_id, locale, field_name, value)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
`,
|
|
[entityType, entityId, locale, fieldName, value.trim()]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function deleteEntityTranslations(client: DbClient, entityType: EntityType, entityId: number): Promise<void> {
|
|
await client.query('DELETE FROM entity_translations WHERE entity_type = $1 AND entity_id = $2', [entityType, entityId]);
|
|
}
|
|
|
|
function optionSelect(
|
|
tableName: string,
|
|
entityType: EntityType,
|
|
locale: string
|
|
): Promise<Array<{ id: number; name: string }>> {
|
|
const name = localizedName(entityType, 'o', locale);
|
|
return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`);
|
|
}
|
|
|
|
function skillOptions(locale: string): Promise<Array<{ id: number; name: string; hasItemDrop: boolean }>> {
|
|
const name = localizedName('skills', 's', locale);
|
|
return query(`SELECT s.id, ${name} AS name, s.has_item_drop AS "hasItemDrop" FROM skills s ORDER BY ${orderByEntity('s')}`);
|
|
}
|
|
|
|
function auditSelect(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string {
|
|
return `
|
|
${entityAlias}.created_at AS "createdAt",
|
|
${entityAlias}.updated_at AS "updatedAt",
|
|
CASE
|
|
WHEN ${createdAlias}.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', ${createdAlias}.id, 'displayName', ${createdAlias}.display_name)
|
|
END AS "createdBy",
|
|
CASE
|
|
WHEN ${updatedAlias}.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', ${updatedAlias}.id, 'displayName', ${updatedAlias}.display_name)
|
|
END AS "updatedBy"
|
|
`;
|
|
}
|
|
|
|
function auditJoins(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string {
|
|
return `
|
|
LEFT JOIN users ${createdAlias} ON ${createdAlias}.id = ${entityAlias}.created_by_user_id
|
|
LEFT JOIN users ${updatedAlias} ON ${updatedAlias}.id = ${entityAlias}.updated_by_user_id
|
|
`;
|
|
}
|
|
|
|
function configOrder(): string {
|
|
return orderByEntity('c');
|
|
}
|
|
|
|
function configSelect(definition: ConfigDefinition, locale: string): string {
|
|
const name = localizedName(definition.entityType, 'c', locale);
|
|
const translations = translationsSelect(definition.entityType, 'c.id');
|
|
return definition.hasItemDrop
|
|
? `c.id, ${name} AS name, c.name AS "baseName", ${translations} AS translations, c.has_item_drop AS "hasItemDrop"`
|
|
: `c.id, ${name} AS name, c.name AS "baseName", ${translations} AS translations`;
|
|
}
|
|
|
|
function validationError(message: string): ValidationError {
|
|
const error = new Error(message) as ValidationError;
|
|
error.statusCode = 400;
|
|
return error;
|
|
}
|
|
|
|
function requirePositiveInteger(value: unknown, message: string): number {
|
|
const numberValue = Number(value);
|
|
if (!Number.isInteger(numberValue) || numberValue <= 0) {
|
|
throw validationError(message);
|
|
}
|
|
return numberValue;
|
|
}
|
|
|
|
function cleanName(value: unknown, message = 'Name is required'): string {
|
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
throw validationError(message);
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function cleanOptionalText(value: unknown): string {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function cleanIds(value: unknown): number[] {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
return [...new Set(value.map((item) => Number(item)).filter((item) => Number.isInteger(item) && item > 0))];
|
|
}
|
|
|
|
function cleanIdValues(value: unknown): number[] {
|
|
return cleanIds(Array.isArray(value) ? value : [value]);
|
|
}
|
|
|
|
function cleanPokemonStats(value: unknown): PokemonStats {
|
|
const row = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
|
|
return pokemonStatLabels.reduce((stats, stat) => {
|
|
const numberValue = Number(row[stat.key] ?? 0);
|
|
if (!Number.isInteger(numberValue) || numberValue < 0) {
|
|
throw validationError(`${stat.label} must be a non-negative integer`);
|
|
}
|
|
|
|
return { ...stats, [stat.key]: numberValue };
|
|
}, {} as PokemonStats);
|
|
}
|
|
|
|
function cleanNonNegativeNumber(value: unknown, message: string): number {
|
|
const numberValue = Number(value ?? 0);
|
|
if (!Number.isFinite(numberValue) || numberValue < 0) {
|
|
throw validationError(message);
|
|
}
|
|
return numberValue;
|
|
}
|
|
|
|
function cleanQuantities(value: unknown): IdQuantity[] {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
|
|
return value
|
|
.map((item) => {
|
|
const row = item as Partial<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 reorderTableRows(
|
|
client: DbClient,
|
|
tableName: string,
|
|
entityType: string,
|
|
ids: number[],
|
|
userId: number
|
|
): Promise<void> {
|
|
const existing = await client.query<{ id: number }>(
|
|
`SELECT id FROM ${tableName} WHERE id = ANY($1::integer[])`,
|
|
[ids]
|
|
);
|
|
|
|
if (existing.rowCount !== ids.length) {
|
|
throw validationError('Record does not exist');
|
|
}
|
|
|
|
for (const [index, id] of ids.entries()) {
|
|
await client.query(
|
|
`
|
|
UPDATE ${tableName}
|
|
SET sort_order = $1, updated_by_user_id = $2, updated_at = now()
|
|
WHERE id = $3
|
|
`,
|
|
[(index + 1) * 10, userId, id]
|
|
);
|
|
await recordEditLog(client, entityType, id, 'update', userId);
|
|
}
|
|
}
|
|
|
|
async function recordEditLog(
|
|
client: DbClient,
|
|
entityType: string,
|
|
entityId: number,
|
|
action: EditAction,
|
|
userId: number,
|
|
changes: EditChange[] = []
|
|
): Promise<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('Language code is invalid');
|
|
}
|
|
|
|
const sortOrder = Number(payload.sortOrder ?? 0);
|
|
|
|
return {
|
|
code,
|
|
name: cleanName(payload.name, 'Language name is required'),
|
|
enabled: payload.enabled !== false,
|
|
isDefault: Boolean(payload.isDefault),
|
|
sortOrder: Number.isInteger(sortOrder) && sortOrder >= 0 ? sortOrder : 0
|
|
};
|
|
}
|
|
|
|
function requireLanguageCode(value: unknown): string {
|
|
const code = typeof value === 'string' ? value.trim() : '';
|
|
if (!localePattern.test(code)) {
|
|
throw validationError('Language code is invalid');
|
|
}
|
|
return code;
|
|
}
|
|
|
|
export async function listLanguages(includeDisabled = false) {
|
|
return query(
|
|
`
|
|
SELECT code, name, enabled, is_default AS "isDefault", sort_order AS "sortOrder"
|
|
FROM languages
|
|
${includeDisabled ? '' : 'WHERE enabled = true'}
|
|
ORDER BY sort_order, name
|
|
`
|
|
);
|
|
}
|
|
|
|
export async function createLanguage(payload: Record<string, unknown>) {
|
|
const cleanPayload = cleanLanguagePayload(payload, true);
|
|
if (cleanPayload.isDefault && cleanPayload.code !== defaultLocale) {
|
|
throw validationError('Default language must be English');
|
|
}
|
|
if (!cleanPayload.enabled && cleanPayload.isDefault) {
|
|
throw validationError('Default language must be enabled');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
if (cleanPayload.isDefault) {
|
|
await client.query('UPDATE languages SET is_default = false');
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
INSERT INTO languages (code, name, enabled, is_default, sort_order)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
`,
|
|
[cleanPayload.code, cleanPayload.name, cleanPayload.enabled, cleanPayload.isDefault, cleanPayload.sortOrder]
|
|
);
|
|
});
|
|
|
|
return listLanguages(true);
|
|
}
|
|
|
|
export async function updateLanguage(code: string, payload: Record<string, unknown>) {
|
|
const locale = requireLanguageCode(code);
|
|
const cleanPayload = cleanLanguagePayload({ ...payload, code: locale }, false);
|
|
if (cleanPayload.isDefault && locale !== defaultLocale) {
|
|
throw validationError('Default language must be English');
|
|
}
|
|
if (!cleanPayload.enabled && cleanPayload.isDefault) {
|
|
throw validationError('Default language must be enabled');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
const current = await client.query<{ isDefault: boolean }>(
|
|
'SELECT is_default AS "isDefault" FROM languages WHERE code = $1',
|
|
[locale]
|
|
);
|
|
|
|
if (current.rowCount === 0) {
|
|
throw validationError('Language not found');
|
|
}
|
|
|
|
if (!cleanPayload.enabled && current.rows[0].isDefault) {
|
|
throw validationError('Default language must be enabled');
|
|
}
|
|
|
|
if (current.rows[0].isDefault && !cleanPayload.isDefault) {
|
|
throw validationError('A default language is required');
|
|
}
|
|
|
|
if (cleanPayload.isDefault) {
|
|
await client.query('UPDATE languages SET is_default = false WHERE code <> $1', [locale]);
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
UPDATE languages
|
|
SET name = $1,
|
|
enabled = $2,
|
|
is_default = $3,
|
|
sort_order = $4
|
|
WHERE code = $5
|
|
`,
|
|
[cleanPayload.name, cleanPayload.enabled, cleanPayload.isDefault, cleanPayload.sortOrder, locale]
|
|
);
|
|
});
|
|
|
|
return listLanguages(true);
|
|
}
|
|
|
|
export async function deleteLanguage(code: string) {
|
|
const locale = requireLanguageCode(code);
|
|
if (locale === defaultLocale) {
|
|
throw validationError('Default language cannot be deleted');
|
|
}
|
|
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ isDefault: boolean }>(
|
|
'DELETE FROM languages WHERE code = $1 AND is_default = false RETURNING is_default AS "isDefault"',
|
|
[locale]
|
|
);
|
|
return (result.rowCount ?? 0) > 0;
|
|
});
|
|
}
|
|
|
|
export async function reorderLanguages(payload: Record<string, unknown>) {
|
|
const codes = Array.isArray(payload.codes) ? payload.codes.map(requireLanguageCode) : [];
|
|
if (codes.length === 0) {
|
|
throw validationError('Please select a language');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
const existing = await client.query<{ code: string }>(
|
|
'SELECT code FROM languages WHERE code = ANY($1::text[])',
|
|
[codes]
|
|
);
|
|
|
|
if (existing.rowCount !== codes.length) {
|
|
throw validationError('Language does not exist');
|
|
}
|
|
|
|
for (const [index, code] of codes.entries()) {
|
|
await client.query(
|
|
`
|
|
UPDATE languages
|
|
SET sort_order = $1
|
|
WHERE code = $2
|
|
`,
|
|
[(index + 1) * 10, code]
|
|
);
|
|
}
|
|
});
|
|
|
|
return listLanguages(true);
|
|
}
|
|
|
|
function displayValue(value: string | null | undefined): string {
|
|
const cleanValue = value?.trim() ?? '';
|
|
return cleanValue === '' ? 'None' : cleanValue;
|
|
}
|
|
|
|
function pushChange(changes: EditChange[], label: string, before: string | null | undefined, after: string | null | undefined): void {
|
|
const beforeValue = displayValue(before);
|
|
const afterValue = displayValue(after);
|
|
|
|
if (beforeValue !== afterValue) {
|
|
changes.push({ label, before: beforeValue, after: afterValue });
|
|
}
|
|
}
|
|
|
|
function boolValue(value: boolean): string {
|
|
return value ? 'Yes' : 'No';
|
|
}
|
|
|
|
function namedListValue(items: Array<{ name: string }> | null | undefined): string {
|
|
if (!items?.length) {
|
|
return 'None';
|
|
}
|
|
|
|
return [...new Set(items.map((item) => item.name))]
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.join(' / ');
|
|
}
|
|
|
|
function quantityListValue(items: Array<{ name: string; quantity: number }> | null | undefined): string {
|
|
if (!items?.length) {
|
|
return 'None';
|
|
}
|
|
|
|
return items
|
|
.map((item) => ({ name: item.name, value: `${item.name} x${item.quantity}` }))
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
.map((item) => item.value)
|
|
.join(' / ');
|
|
}
|
|
|
|
function skillDropListValue(skills: Array<{ name: string; itemDrop?: { name: string } | null }> | null | undefined): string {
|
|
const rows = skills
|
|
?.filter((skill) => skill.itemDrop)
|
|
.map((skill) => `${skill.name}: ${skill.itemDrop?.name}`)
|
|
.sort((a, b) => a.localeCompare(b)) ?? [];
|
|
|
|
return rows.length ? rows.join(' / ') : 'None';
|
|
}
|
|
|
|
function pokemonStatsValue(stats: PokemonStats | null | undefined): string {
|
|
return pokemonStatLabels.map((stat) => `${stat.label}: ${stats?.[stat.key] ?? 0}`).join(' / ');
|
|
}
|
|
|
|
function roundMeasure(value: number, precision: number): number {
|
|
const scale = 10 ** precision;
|
|
return Math.round(value * scale) / scale;
|
|
}
|
|
|
|
function formatFixedMeasure(value: number, precision: number): string {
|
|
return value.toFixed(precision);
|
|
}
|
|
|
|
function feetInchesValue(inches: number): string {
|
|
const totalInches = Math.round(inches);
|
|
const feet = Math.floor(totalInches / 12);
|
|
const remainingInches = totalInches - feet * 12;
|
|
return `${feet}'${remainingInches}"`;
|
|
}
|
|
|
|
function pokemonHeightValue(inches: number | null | undefined): string {
|
|
const value = inches ?? 0;
|
|
return `${feetInchesValue(value)} / ${formatFixedMeasure(roundMeasure(value * 0.0254, 2), 2)} m`;
|
|
}
|
|
|
|
function pokemonWeightValue(pounds: number | null | undefined): string {
|
|
const value = pounds ?? 0;
|
|
return `${formatFixedMeasure(roundMeasure(value, 1), 1)} lb / ${formatFixedMeasure(roundMeasure(value * 0.45359237, 2), 2)} kg`;
|
|
}
|
|
|
|
function appearanceListValue(
|
|
rows: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }> | null | undefined
|
|
): string {
|
|
if (!rows?.length) {
|
|
return 'None';
|
|
}
|
|
|
|
return rows
|
|
.map((row) => `${row.name}: ${row.time_of_day} / ${row.weather} / ${row.rarity} stars / ${row.map.name}`)
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.join(' / ');
|
|
}
|
|
|
|
async function entityNameMap(client: DbClient, tableName: string, ids: number[]): Promise<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, 'Genus', before.genus, after.genus);
|
|
pushChange(changes, 'Details', before.details, after.details);
|
|
pushChange(changes, 'Height', pokemonHeightValue(before.heightInches), pokemonHeightValue(after.heightInches));
|
|
pushChange(changes, 'Weight', pokemonWeightValue(before.weightPounds), pokemonWeightValue(after.weightPounds));
|
|
pushChange(changes, 'Types', namedListValue(before.types), namesFromIds(after.typeIds, typeNames));
|
|
pushChange(changes, 'Stats', pokemonStatsValue(before.stats), pokemonStatsValue(after.stats));
|
|
pushChange(changes, 'Ideal Habitat', before.environment.name, environmentNames.get(after.environmentId));
|
|
pushChange(changes, 'Specialities', namedListValue(before.skills), namesFromIds(after.skillIds, skillNames));
|
|
pushChange(changes, 'Favourites', namedListValue(before.favorite_things), namesFromIds(after.favoriteThingIds, favoriteThingNames));
|
|
pushChange(changes, 'Speciality drops', skillDropListValue(before.skills), afterDrops);
|
|
|
|
return changes;
|
|
}
|
|
|
|
async function itemEditChanges(
|
|
client: DbClient,
|
|
before: ItemChangeSource,
|
|
after: ItemPayload
|
|
): Promise<EditChange[]> {
|
|
const changes: EditChange[] = [];
|
|
const categoryNames = await entityNameMap(client, 'item_categories', [after.categoryId]);
|
|
const usageNames = await entityNameMap(client, 'item_usages', after.usageId ? [after.usageId] : []);
|
|
const methodNames = await entityNameMap(client, 'acquisition_methods', after.acquisitionMethodIds);
|
|
const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
|
|
|
|
pushChange(changes, 'Name', before.name, after.name);
|
|
pushChange(changes, 'Category', before.category.name, categoryNames.get(after.categoryId));
|
|
pushChange(changes, 'Usage', before.usage?.name, after.usageId ? usageNames.get(after.usageId) : null);
|
|
pushChange(changes, 'Dyeable', boolValue(before.customization.dyeable), boolValue(after.dyeable));
|
|
pushChange(changes, 'Dual dyeable', boolValue(before.customization.dualDyeable), boolValue(after.dualDyeable));
|
|
pushChange(changes, 'Pattern editable', boolValue(before.customization.patternEditable), boolValue(after.patternEditable));
|
|
pushChange(changes, 'No recipe', boolValue(before.noRecipe), boolValue(after.noRecipe));
|
|
pushChange(changes, 'Acquisition methods', namedListValue(before.acquisitionMethods), namesFromIds(after.acquisitionMethodIds, methodNames));
|
|
pushChange(changes, 'Tags', namedListValue(before.tags), namesFromIds(after.tagIds, tagNames));
|
|
|
|
return changes;
|
|
}
|
|
|
|
async function habitatEditChanges(
|
|
client: DbClient,
|
|
before: HabitatChangeSource,
|
|
after: HabitatPayload
|
|
): Promise<EditChange[]> {
|
|
const changes: EditChange[] = [];
|
|
const pokemonNames = await entityNameMap(client, 'pokemon', after.pokemonAppearances.map((row) => row.pokemonId));
|
|
const mapNames = await entityNameMap(client, 'maps', after.pokemonAppearances.map((row) => row.mapId));
|
|
const afterAppearances = after.pokemonAppearances
|
|
.map((row) => {
|
|
const pokemonName = pokemonNames.get(row.pokemonId);
|
|
const mapName = mapNames.get(row.mapId);
|
|
return pokemonName && mapName ? `${pokemonName}: ${row.timeOfDay} / ${row.weather} / ${row.rarity} stars / ${mapName}` : null;
|
|
})
|
|
.filter((row): row is string => row !== null)
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.join(' / ');
|
|
|
|
pushChange(changes, 'Name', before.name, after.name);
|
|
pushChange(changes, 'Recipe', quantityListValue(before.recipe), await quantityPayloadValue(client, after.recipeItems));
|
|
pushChange(changes, 'Possible Pokemon', appearanceListValue(before.pokemon), afterAppearances);
|
|
|
|
return changes;
|
|
}
|
|
|
|
async function recipeEditChanges(
|
|
client: DbClient,
|
|
before: RecipeChangeSource,
|
|
after: RecipePayload
|
|
): Promise<EditChange[]> {
|
|
const changes: EditChange[] = [];
|
|
const itemNames = await entityNameMap(client, 'items', [after.itemId]);
|
|
const methodNames = await entityNameMap(client, 'acquisition_methods', after.acquisitionMethodIds);
|
|
|
|
pushChange(changes, 'Item', before.item.name, itemNames.get(after.itemId));
|
|
pushChange(changes, 'Acquisition methods', namedListValue(before.acquisition_methods), namesFromIds(after.acquisitionMethodIds, methodNames));
|
|
pushChange(changes, 'Materials', quantityListValue(before.materials), await quantityPayloadValue(client, after.materials));
|
|
|
|
return changes;
|
|
}
|
|
|
|
function getEditHistory(entityType: string, entityId: number): Promise<EditHistoryEntry[]> {
|
|
return query(
|
|
`
|
|
SELECT
|
|
l.action,
|
|
COALESCE(l.changes, '[]'::jsonb) AS changes,
|
|
l.created_at AS "createdAt",
|
|
CASE
|
|
WHEN u.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', u.id, 'displayName', u.display_name)
|
|
END AS user
|
|
FROM wiki_edit_logs l
|
|
LEFT JOIN users u ON u.id = l.user_id
|
|
WHERE l.entity_type = $1
|
|
AND l.entity_id = $2
|
|
ORDER BY l.created_at DESC, l.id DESC
|
|
`,
|
|
[entityType, entityId]
|
|
);
|
|
}
|
|
|
|
function pokemonProjection(locale: string): string {
|
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
|
const pokemonGenus = localizedField('pokemon', 'p.id', 'p.genus', 'genus', locale);
|
|
const pokemonDetails = localizedField('pokemon', 'p.id', 'p.details', 'details', locale);
|
|
const typeName = localizedName('pokemon-types', 'pt', locale);
|
|
const environmentName = localizedName('environments', 'e', locale);
|
|
const skillName = localizedName('skills', 's', locale);
|
|
const favoriteThingName = localizedName('favorite-things', 'ft', locale);
|
|
|
|
return `
|
|
SELECT
|
|
p.id,
|
|
${pokemonName} AS name,
|
|
p.name AS "baseName",
|
|
${pokemonGenus} AS genus,
|
|
p.genus AS "baseGenus",
|
|
${pokemonDetails} AS details,
|
|
p.details AS "baseDetails",
|
|
p.height_inches AS "heightInches",
|
|
round((p.height_inches * 0.0254)::numeric, 2)::double precision AS "heightMeters",
|
|
p.weight_pounds AS "weightPounds",
|
|
round((p.weight_pounds * 0.45359237)::numeric, 2)::double precision AS "weightKg",
|
|
json_build_object(
|
|
'hp', p.hp,
|
|
'attack', p.attack,
|
|
'defense', p.defense,
|
|
'specialAttack', p.special_attack,
|
|
'specialDefense', p.special_defense,
|
|
'speed', p.speed
|
|
) AS stats,
|
|
${translationsSelect('pokemon', 'p.id')} AS translations,
|
|
${auditSelect('p', 'pokemon_created_user', 'pokemon_updated_user')},
|
|
json_build_object('id', e.id, 'name', ${environmentName}) AS environment,
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', pt.id, 'name', ${typeName}) ORDER BY ppt.slot_order)
|
|
FROM pokemon_pokemon_types ppt
|
|
JOIN pokemon_types pt ON pt.id = ppt.type_id
|
|
WHERE ppt.pokemon_id = p.id
|
|
), '[]'::json) AS types,
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', s.id, 'name', ${skillName}, 'hasItemDrop', s.has_item_drop) ORDER BY ${orderByEntity('s')})
|
|
FROM pokemon_skills ps
|
|
JOIN skills s ON s.id = ps.skill_id
|
|
WHERE ps.pokemon_id = p.id
|
|
), '[]'::json) AS skills,
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', ft.id, 'name', ${favoriteThingName}) ORDER BY ${orderByEntity('ft')})
|
|
FROM pokemon_favorite_things pft
|
|
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
|
|
WHERE pft.pokemon_id = p.id
|
|
), '[]'::json) AS favorite_things
|
|
FROM pokemon p
|
|
JOIN environments e ON e.id = p.environment_id
|
|
${auditJoins('p', 'pokemon_created_user', 'pokemon_updated_user')}
|
|
`;
|
|
}
|
|
|
|
export async function getOptions(locale = defaultLocale) {
|
|
const [
|
|
pokemonTypes,
|
|
skills,
|
|
environments,
|
|
favoriteThings,
|
|
itemCategories,
|
|
itemUsages,
|
|
acquisitionMethods,
|
|
maps
|
|
] = await Promise.all([
|
|
optionSelect('pokemon_types', 'pokemon-types', locale),
|
|
skillOptions(locale),
|
|
optionSelect('environments', 'environments', locale),
|
|
optionSelect('favorite_things', 'favorite-things', locale),
|
|
optionSelect('item_categories', 'item-categories', locale),
|
|
optionSelect('item_usages', 'item-usages', locale),
|
|
optionSelect('acquisition_methods', 'acquisition-methods', locale),
|
|
optionSelect('maps', 'maps', locale)
|
|
]);
|
|
|
|
return {
|
|
pokemonTypes,
|
|
skills,
|
|
environments,
|
|
favoriteThings,
|
|
itemCategories,
|
|
itemUsages,
|
|
acquisitionMethods,
|
|
itemTags: favoriteThings,
|
|
maps
|
|
};
|
|
}
|
|
|
|
function cleanDailyChecklistPayload(payload: Record<string, unknown>): DailyChecklistPayload {
|
|
return {
|
|
title: cleanName(payload.title, 'Please enter a task'),
|
|
translations: cleanTranslations(payload.translations, ['title'])
|
|
};
|
|
}
|
|
|
|
export async function listDailyChecklistItems(locale = defaultLocale) {
|
|
const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
|
|
return query(
|
|
`
|
|
SELECT c.id, ${title} AS title, c.title AS "baseTitle", ${translationsSelect('daily-checklist-items', 'c.id')} AS translations
|
|
FROM daily_checklist_items c
|
|
ORDER BY c.sort_order, c.id
|
|
`
|
|
);
|
|
}
|
|
|
|
async function getDailyChecklistItemById(id: number, locale = defaultLocale) {
|
|
const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
|
|
return queryOne(
|
|
`
|
|
SELECT c.id, ${title} AS title, c.title AS "baseTitle", ${translationsSelect('daily-checklist-items', 'c.id')} AS translations
|
|
FROM daily_checklist_items c
|
|
WHERE c.id = $1
|
|
`,
|
|
[id]
|
|
);
|
|
}
|
|
|
|
export async function createDailyChecklistItem(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanDailyChecklistPayload(payload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
const orderResult = await client.query<{ sortOrder: number }>(
|
|
'SELECT COALESCE(MAX(sort_order), 0) + 10 AS "sortOrder" FROM daily_checklist_items'
|
|
);
|
|
const sortOrder = orderResult.rows[0]?.sortOrder ?? 10;
|
|
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO daily_checklist_items (title, sort_order, created_by_user_id, updated_by_user_id)
|
|
VALUES ($1, $2, $3, $3)
|
|
RETURNING id
|
|
`,
|
|
[cleanPayload.title, sortOrder, userId]
|
|
);
|
|
|
|
const createdId = result.rows[0].id;
|
|
await replaceEntityTranslations(client, 'daily-checklist-items', createdId, cleanPayload.translations, ['title']);
|
|
await recordEditLog(client, 'daily-checklist-items', createdId, 'create', userId);
|
|
return createdId;
|
|
});
|
|
|
|
return getDailyChecklistItemById(id, locale);
|
|
}
|
|
|
|
export async function updateDailyChecklistItem(
|
|
id: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number,
|
|
locale = defaultLocale
|
|
) {
|
|
const cleanPayload = cleanDailyChecklistPayload(payload);
|
|
|
|
const updated = await withTransaction(async (client) => {
|
|
const result = await client.query(
|
|
`
|
|
UPDATE daily_checklist_items
|
|
SET title = $1, updated_by_user_id = $2, updated_at = now()
|
|
WHERE id = $3
|
|
`,
|
|
[cleanPayload.title, userId, id]
|
|
);
|
|
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await replaceEntityTranslations(client, 'daily-checklist-items', id, cleanPayload.translations, ['title']);
|
|
await recordEditLog(client, 'daily-checklist-items', id, 'update', userId);
|
|
return true;
|
|
});
|
|
|
|
return updated ? getDailyChecklistItemById(id, locale) : null;
|
|
}
|
|
|
|
export async function reorderDailyChecklistItems(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const ids = cleanIds(payload.ids);
|
|
if (ids.length === 0) {
|
|
throw validationError('Please select a task');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
const existing = await client.query<{ id: number }>(
|
|
'SELECT id FROM daily_checklist_items WHERE id = ANY($1::integer[])',
|
|
[ids]
|
|
);
|
|
|
|
if (existing.rowCount !== ids.length) {
|
|
throw validationError('Task does not exist');
|
|
}
|
|
|
|
for (const [index, id] of ids.entries()) {
|
|
await client.query(
|
|
`
|
|
UPDATE daily_checklist_items
|
|
SET sort_order = $1, updated_by_user_id = $2, updated_at = now()
|
|
WHERE id = $3
|
|
`,
|
|
[(index + 1) * 10, userId, id]
|
|
);
|
|
await recordEditLog(client, 'daily-checklist-items', id, 'update', userId);
|
|
}
|
|
});
|
|
|
|
return listDailyChecklistItems(locale);
|
|
}
|
|
|
|
export async function deleteDailyChecklistItem(id: number, userId: number) {
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ id: number }>('DELETE FROM daily_checklist_items WHERE id = $1 RETURNING id', [id]);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await deleteEntityTranslations(client, 'daily-checklist-items', id);
|
|
await recordEditLog(client, 'daily-checklist-items', id, 'delete', userId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function cleanLifePostPayload(payload: Record<string, unknown>): LifePostPayload {
|
|
const body = cleanName(payload.body, 'Please enter a post');
|
|
if (body.length > 2000) {
|
|
throw validationError('Post is too long');
|
|
}
|
|
|
|
return { body };
|
|
}
|
|
|
|
function cleanLifeCommentPayload(payload: Record<string, unknown>): LifeCommentPayload {
|
|
const body = cleanName(payload.body, 'Please enter a comment');
|
|
if (body.length > 1000) {
|
|
throw validationError('Comment is too long');
|
|
}
|
|
|
|
return { body };
|
|
}
|
|
|
|
function emptyLifeReactionCounts(): LifeReactionCounts {
|
|
return {
|
|
like: 0,
|
|
helpful: 0,
|
|
fun: 0,
|
|
thanks: 0
|
|
};
|
|
}
|
|
|
|
function isLifeReactionType(value: unknown): value is LifeReactionType {
|
|
return typeof value === 'string' && lifeReactionTypes.includes(value as LifeReactionType);
|
|
}
|
|
|
|
function cleanLifeReactionType(value: unknown): LifeReactionType {
|
|
if (!isLifeReactionType(value)) {
|
|
throw validationError('Reaction is invalid');
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function lifePostProjection(): string {
|
|
return `
|
|
SELECT
|
|
lp.id,
|
|
lp.body,
|
|
lp.created_at AS "createdAt",
|
|
lp.created_at::text AS "createdAtCursor",
|
|
lp.updated_at AS "updatedAt",
|
|
CASE
|
|
WHEN created_user.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', created_user.id, 'displayName', created_user.display_name)
|
|
END AS author,
|
|
CASE
|
|
WHEN updated_user.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name)
|
|
END AS "updatedBy"
|
|
FROM life_posts lp
|
|
LEFT JOIN users created_user ON created_user.id = lp.created_by_user_id
|
|
LEFT JOIN users updated_user ON updated_user.id = lp.updated_by_user_id
|
|
`;
|
|
}
|
|
|
|
function cleanLifePostLimit(value: QueryValue): number {
|
|
const rawLimit = asString(value);
|
|
if (rawLimit === undefined || rawLimit === '') {
|
|
return defaultLifePostLimit;
|
|
}
|
|
|
|
const limit = Number(rawLimit);
|
|
return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxLifePostLimit) : defaultLifePostLimit;
|
|
}
|
|
|
|
function decodeLifePostCursor(value: QueryValue): LifePostCursor | null {
|
|
const rawCursor = asString(value);
|
|
if (!rawCursor) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<LifePostCursor>;
|
|
const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
|
|
const id = Number(cursor.id);
|
|
|
|
if (!createdAt || Number.isNaN(new Date(createdAt).getTime()) || !Number.isInteger(id) || id <= 0) {
|
|
throw validationError('Cursor is invalid');
|
|
}
|
|
|
|
return { createdAt, id };
|
|
} catch (error) {
|
|
if (error instanceof Error && 'statusCode' in error) {
|
|
throw error;
|
|
}
|
|
throw validationError('Cursor is invalid');
|
|
}
|
|
}
|
|
|
|
function encodeLifePostCursor(post: LifePostRow): string {
|
|
return Buffer.from(JSON.stringify({ createdAt: post.createdAtCursor, id: post.id }), 'utf8').toString('base64url');
|
|
}
|
|
|
|
function hydrateLifePost(
|
|
post: LifePostRow,
|
|
commentsByPost: Map<number, LifeComment[]>,
|
|
countsByPost: Map<number, LifeReactionCounts>,
|
|
myReactionsByPost: Map<number, LifeReactionType>
|
|
): LifePost {
|
|
return {
|
|
id: post.id,
|
|
body: post.body,
|
|
createdAt: post.createdAt,
|
|
updatedAt: post.updatedAt,
|
|
author: post.author,
|
|
updatedBy: post.updatedBy,
|
|
comments: commentsByPost.get(post.id) ?? [],
|
|
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
|
|
myReaction: myReactionsByPost.get(post.id) ?? null
|
|
};
|
|
}
|
|
|
|
function lifeCommentProjection(whereClause: string): string {
|
|
return `
|
|
SELECT
|
|
lc.id,
|
|
lc.post_id AS "postId",
|
|
lc.parent_comment_id AS "parentCommentId",
|
|
CASE WHEN lc.deleted_at IS NULL THEN lc.body ELSE '' END AS body,
|
|
lc.deleted_at IS NOT NULL AS deleted,
|
|
lc.created_at AS "createdAt",
|
|
lc.updated_at AS "updatedAt",
|
|
CASE
|
|
WHEN lc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name)
|
|
END AS author
|
|
FROM life_post_comments lc
|
|
LEFT JOIN users comment_user ON comment_user.id = lc.created_by_user_id
|
|
${whereClause}
|
|
`;
|
|
}
|
|
|
|
function buildLifeCommentTree(rows: LifeCommentRow[]): LifeComment[] {
|
|
const comments = new Map<number, LifeComment>();
|
|
const topLevelComments: LifeComment[] = [];
|
|
|
|
for (const row of rows) {
|
|
comments.set(row.id, { ...row, replies: [] });
|
|
}
|
|
|
|
for (const comment of comments.values()) {
|
|
if (comment.parentCommentId === null) {
|
|
topLevelComments.push(comment);
|
|
continue;
|
|
}
|
|
|
|
const parent = comments.get(comment.parentCommentId);
|
|
if (parent?.parentCommentId === null) {
|
|
parent.replies.push(comment);
|
|
} else {
|
|
topLevelComments.push(comment);
|
|
}
|
|
}
|
|
|
|
return topLevelComments;
|
|
}
|
|
|
|
async function lifeCommentsForPosts(postIds: number[]): Promise<Map<number, LifeComment[]>> {
|
|
const commentsByPost = new Map<number, LifeComment[]>();
|
|
if (postIds.length === 0) {
|
|
return commentsByPost;
|
|
}
|
|
|
|
const rows = await query<LifeCommentRow>(
|
|
`
|
|
${lifeCommentProjection('WHERE lc.post_id = ANY($1::integer[])')}
|
|
ORDER BY lc.created_at, lc.id
|
|
`,
|
|
[postIds]
|
|
);
|
|
|
|
for (const postId of postIds) {
|
|
commentsByPost.set(postId, buildLifeCommentTree(rows.filter((comment) => comment.postId === postId)));
|
|
}
|
|
|
|
return commentsByPost;
|
|
}
|
|
|
|
async function lifeReactionsForPosts(
|
|
postIds: number[],
|
|
userId: number | null
|
|
): Promise<{
|
|
countsByPost: Map<number, LifeReactionCounts>;
|
|
myReactionsByPost: Map<number, LifeReactionType>;
|
|
}> {
|
|
const countsByPost = new Map<number, LifeReactionCounts>();
|
|
const myReactionsByPost = new Map<number, LifeReactionType>();
|
|
|
|
for (const postId of postIds) {
|
|
countsByPost.set(postId, emptyLifeReactionCounts());
|
|
}
|
|
|
|
if (postIds.length === 0) {
|
|
return { countsByPost, myReactionsByPost };
|
|
}
|
|
|
|
const countRows = await query<{ postId: number; reactionType: LifeReactionType; count: number }>(
|
|
`
|
|
SELECT
|
|
post_id AS "postId",
|
|
reaction_type AS "reactionType",
|
|
COUNT(*)::integer AS count
|
|
FROM life_post_reactions
|
|
WHERE post_id = ANY($1::integer[])
|
|
GROUP BY post_id, reaction_type
|
|
`,
|
|
[postIds]
|
|
);
|
|
|
|
for (const row of countRows) {
|
|
const counts = countsByPost.get(row.postId);
|
|
if (counts && isLifeReactionType(row.reactionType)) {
|
|
counts[row.reactionType] = row.count;
|
|
}
|
|
}
|
|
|
|
if (userId !== null) {
|
|
const myRows = await query<{ postId: number; reactionType: LifeReactionType }>(
|
|
`
|
|
SELECT post_id AS "postId", reaction_type AS "reactionType"
|
|
FROM life_post_reactions
|
|
WHERE post_id = ANY($1::integer[])
|
|
AND user_id = $2
|
|
`,
|
|
[postIds, userId]
|
|
);
|
|
|
|
for (const row of myRows) {
|
|
if (isLifeReactionType(row.reactionType)) {
|
|
myReactionsByPost.set(row.postId, row.reactionType);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { countsByPost, myReactionsByPost };
|
|
}
|
|
|
|
async function getLifeCommentById(id: number): Promise<LifeComment | null> {
|
|
const row = await queryOne<LifeCommentRow>(
|
|
`
|
|
${lifeCommentProjection('WHERE lc.id = $1')}
|
|
`,
|
|
[id]
|
|
);
|
|
|
|
return row ? { ...row, replies: [] } : null;
|
|
}
|
|
|
|
export async function listLifePosts(paramsQuery: QueryParams = {}, userId: number | null = null): Promise<LifePostsPage> {
|
|
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
|
const limit = cleanLifePostLimit(paramsQuery.limit);
|
|
const params: unknown[] = [];
|
|
let cursorClause = '';
|
|
|
|
if (cursor) {
|
|
params.push(cursor.createdAt, cursor.id);
|
|
cursorClause = `
|
|
WHERE (lp.created_at, lp.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)
|
|
`;
|
|
}
|
|
|
|
params.push(limit + 1);
|
|
const rows = await query<LifePostRow>(
|
|
`
|
|
${lifePostProjection()}
|
|
${cursorClause}
|
|
ORDER BY lp.created_at DESC, lp.id DESC
|
|
LIMIT $${params.length}
|
|
`,
|
|
params
|
|
);
|
|
const hasMore = rows.length > limit;
|
|
const posts = hasMore ? rows.slice(0, limit) : rows;
|
|
|
|
const postIds = posts.map((post) => post.id);
|
|
const commentsByPost = await lifeCommentsForPosts(postIds);
|
|
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId);
|
|
|
|
return {
|
|
items: posts.map((post) => hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost)),
|
|
nextCursor: hasMore && posts.length > 0 ? encodeLifePostCursor(posts[posts.length - 1]) : null,
|
|
hasMore
|
|
};
|
|
}
|
|
|
|
async function getLifePostById(id: number, userId: number | null = null): Promise<LifePost | null> {
|
|
const post = await queryOne<LifePostRow>(
|
|
`
|
|
${lifePostProjection()}
|
|
WHERE lp.id = $1
|
|
`,
|
|
[id]
|
|
);
|
|
|
|
if (!post) {
|
|
return null;
|
|
}
|
|
|
|
const commentsByPost = await lifeCommentsForPosts([post.id]);
|
|
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId);
|
|
return hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost);
|
|
}
|
|
|
|
export async function createLifePost(payload: Record<string, unknown>, userId: number) {
|
|
const cleanPayload = cleanLifePostPayload(payload);
|
|
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
INSERT INTO life_posts (body, created_by_user_id, updated_by_user_id)
|
|
VALUES ($1, $2, $2)
|
|
RETURNING id
|
|
`,
|
|
[cleanPayload.body, userId]
|
|
);
|
|
|
|
return getLifePostById(result?.id ?? 0, userId);
|
|
}
|
|
|
|
export async function updateLifePost(id: number, payload: Record<string, unknown>, userId: number) {
|
|
const cleanPayload = cleanLifePostPayload(payload);
|
|
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
UPDATE life_posts
|
|
SET body = $1, updated_by_user_id = $2, updated_at = now()
|
|
WHERE id = $3
|
|
AND created_by_user_id = $2
|
|
RETURNING id
|
|
`,
|
|
[cleanPayload.body, userId, id]
|
|
);
|
|
|
|
return result ? getLifePostById(result.id, userId) : null;
|
|
}
|
|
|
|
export async function deleteLifePost(id: number, userId: number) {
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
DELETE FROM life_posts
|
|
WHERE id = $1
|
|
AND created_by_user_id = $2
|
|
RETURNING id
|
|
`,
|
|
[id, userId]
|
|
);
|
|
|
|
return Boolean(result);
|
|
}
|
|
|
|
export async function setLifePostReaction(postId: number, payload: Record<string, unknown>, userId: number) {
|
|
const reactionType = cleanLifeReactionType(payload.reactionType);
|
|
|
|
const result = await queryOne<{ postId: number }>(
|
|
`
|
|
INSERT INTO life_post_reactions (post_id, user_id, reaction_type)
|
|
SELECT $1, $2, $3
|
|
WHERE EXISTS (SELECT 1 FROM life_posts WHERE id = $1)
|
|
ON CONFLICT (post_id, user_id)
|
|
DO UPDATE SET reaction_type = EXCLUDED.reaction_type, updated_at = now()
|
|
RETURNING post_id AS "postId"
|
|
`,
|
|
[postId, userId, reactionType]
|
|
);
|
|
|
|
return result ? getLifePostById(result.postId, userId) : null;
|
|
}
|
|
|
|
export async function deleteLifePostReaction(postId: number, userId: number) {
|
|
await queryOne<{ postId: number }>(
|
|
`
|
|
DELETE FROM life_post_reactions
|
|
WHERE post_id = $1
|
|
AND user_id = $2
|
|
RETURNING post_id AS "postId"
|
|
`,
|
|
[postId, userId]
|
|
);
|
|
|
|
return getLifePostById(postId, userId);
|
|
}
|
|
|
|
export async function createLifeComment(postId: number, payload: Record<string, unknown>, userId: number) {
|
|
const cleanPayload = cleanLifeCommentPayload(payload);
|
|
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
INSERT INTO life_post_comments (post_id, body, created_by_user_id)
|
|
SELECT $1, $2, $3
|
|
WHERE EXISTS (SELECT 1 FROM life_posts WHERE id = $1)
|
|
RETURNING id
|
|
`,
|
|
[postId, cleanPayload.body, userId]
|
|
);
|
|
|
|
return result ? getLifeCommentById(result.id) : null;
|
|
}
|
|
|
|
export async function createLifeCommentReply(
|
|
postId: number,
|
|
commentId: number,
|
|
payload: Record<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, created_by_user_id)
|
|
SELECT lc.post_id, lc.id, $3, $4
|
|
FROM life_post_comments lc
|
|
WHERE lc.post_id = $1
|
|
AND lc.id = $2
|
|
AND lc.parent_comment_id IS NULL
|
|
AND lc.deleted_at IS NULL
|
|
RETURNING id
|
|
`,
|
|
[postId, commentId, cleanPayload.body, userId]
|
|
);
|
|
|
|
return result ? getLifeCommentById(result.id) : null;
|
|
}
|
|
|
|
export async function deleteLifeComment(id: number, userId: number) {
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
UPDATE life_post_comments
|
|
SET deleted_at = now(), deleted_by_user_id = $2, updated_at = now()
|
|
WHERE id = $1
|
|
AND created_by_user_id = $2
|
|
AND deleted_at IS NULL
|
|
RETURNING id
|
|
`,
|
|
[id, userId]
|
|
);
|
|
|
|
return Boolean(result);
|
|
}
|
|
|
|
export function isConfigType(type: string): type is ConfigType {
|
|
return Object.hasOwn(configDefinitions, type);
|
|
}
|
|
|
|
export async function listConfig(type: ConfigType, locale = defaultLocale) {
|
|
const definition = configDefinitions[type];
|
|
return query(
|
|
`
|
|
SELECT ${configSelect(definition, locale)}, ${auditSelect('c')}
|
|
FROM ${definition.table} c
|
|
${auditJoins('c')}
|
|
ORDER BY ${configOrder()}
|
|
`
|
|
);
|
|
}
|
|
|
|
async function getConfigById(type: ConfigType, id: number, locale = defaultLocale) {
|
|
const definition = configDefinitions[type];
|
|
return queryOne(
|
|
`
|
|
SELECT ${configSelect(definition, locale)}, ${auditSelect('c')}
|
|
FROM ${definition.table} c
|
|
${auditJoins('c')}
|
|
WHERE c.id = $1
|
|
`,
|
|
[id]
|
|
);
|
|
}
|
|
|
|
export async function createConfig(type: ConfigType, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const definition = configDefinitions[type];
|
|
const name = cleanName(payload.name);
|
|
const translations = cleanTranslations(payload.translations, ['name']);
|
|
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
const sortOrder = await nextSortOrder(client, definition.table);
|
|
const result = definition.hasItemDrop
|
|
? await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO ${definition.table} (name, has_item_drop, sort_order, created_by_user_id, updated_by_user_id)
|
|
VALUES ($1, $2, $3, $4, $4)
|
|
RETURNING id
|
|
`,
|
|
[name, hasItemDrop, sortOrder, userId]
|
|
)
|
|
: await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO ${definition.table} (name, sort_order, created_by_user_id, updated_by_user_id)
|
|
VALUES ($1, $2, $3, $3)
|
|
RETURNING id
|
|
`,
|
|
[name, sortOrder, userId]
|
|
);
|
|
|
|
const createdId = result.rows[0].id;
|
|
await replaceEntityTranslations(client, definition.entityType, createdId, translations, ['name']);
|
|
await recordEditLog(client, type, createdId, 'create', userId);
|
|
return createdId;
|
|
});
|
|
|
|
return getConfigById(type, id, locale);
|
|
}
|
|
|
|
export async function reorderConfig(type: ConfigType, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const definition = configDefinitions[type];
|
|
const ids = cleanIds(payload.ids);
|
|
if (ids.length === 0) {
|
|
throw validationError('Please select a record');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
await reorderTableRows(client, definition.table, type, ids, userId);
|
|
});
|
|
|
|
return listConfig(type, locale);
|
|
}
|
|
|
|
export async function updateConfig(
|
|
type: ConfigType,
|
|
id: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number,
|
|
locale = defaultLocale
|
|
) {
|
|
const definition = configDefinitions[type];
|
|
const name = cleanName(payload.name);
|
|
const translations = cleanTranslations(payload.translations, ['name']);
|
|
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
|
|
|
|
const updated = await withTransaction(async (client) => {
|
|
const result = definition.hasItemDrop
|
|
? await client.query(
|
|
`
|
|
UPDATE ${definition.table}
|
|
SET name = $1, has_item_drop = $2, updated_by_user_id = $3, updated_at = now()
|
|
WHERE id = $4
|
|
`,
|
|
[name, hasItemDrop, userId, id]
|
|
)
|
|
: await client.query(
|
|
`
|
|
UPDATE ${definition.table}
|
|
SET name = $1, updated_by_user_id = $2, updated_at = now()
|
|
WHERE id = $3
|
|
`,
|
|
[name, userId, id]
|
|
);
|
|
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
if (definition.hasItemDrop && !hasItemDrop) {
|
|
await client.query('DELETE FROM pokemon_skill_item_drops WHERE skill_id = $1', [id]);
|
|
}
|
|
|
|
await replaceEntityTranslations(client, definition.entityType, id, translations, ['name']);
|
|
await recordEditLog(client, type, id, 'update', userId);
|
|
return true;
|
|
});
|
|
|
|
return updated ? getConfigById(type, id, locale) : null;
|
|
}
|
|
|
|
export async function deleteConfig(type: ConfigType, id: number, userId: number) {
|
|
const definition = configDefinitions[type];
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ id: number }>(`DELETE FROM ${definition.table} WHERE id = $1 RETURNING id`, [id]);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await deleteEntityTranslations(client, definition.entityType, id);
|
|
await recordEditLog(client, type, id, 'delete', userId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
async function reorderContent(type: SortableContentType, payload: Record<string, unknown>, userId: number): Promise<void> {
|
|
const definition = sortableContentDefinitions[type];
|
|
const ids = cleanIds(payload.ids);
|
|
if (ids.length === 0) {
|
|
throw validationError('Please select a record');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
await reorderTableRows(client, definition.table, definition.entityType, ids, userId);
|
|
});
|
|
}
|
|
|
|
export async function reorderPokemon(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
await reorderContent('pokemon', payload, userId);
|
|
return listPokemon({}, locale);
|
|
}
|
|
|
|
export async function reorderItems(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
await reorderContent('items', payload, userId);
|
|
return listItems({}, locale);
|
|
}
|
|
|
|
export async function reorderRecipes(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
await reorderContent('recipes', payload, userId);
|
|
return listRecipes({}, locale);
|
|
}
|
|
|
|
export async function reorderHabitats(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
await reorderContent('habitats', payload, userId);
|
|
return listHabitats(locale);
|
|
}
|
|
|
|
export async function listPokemon(paramsQuery: QueryParams, locale = defaultLocale) {
|
|
const params: unknown[] = [];
|
|
const conditions: string[] = [];
|
|
const search = asString(paramsQuery.search)?.trim();
|
|
const environmentId = Number(asString(paramsQuery.environmentId));
|
|
const skillIds = parseIdList(asString(paramsQuery.skillIds));
|
|
const favoriteThingIds = parseIdList(asString(paramsQuery.favoriteThingIds));
|
|
|
|
if (search) {
|
|
params.push(`%${search}%`);
|
|
conditions.push(`${localizedName('pokemon', 'p', locale)} ILIKE $${params.length}`);
|
|
}
|
|
|
|
if (Number.isInteger(environmentId) && environmentId > 0) {
|
|
params.push(environmentId);
|
|
conditions.push(`p.environment_id = $${params.length}`);
|
|
}
|
|
|
|
const skillFilter = sqlForRelationFilter(
|
|
skillIds,
|
|
parseMatchMode(asString(paramsQuery.skillMode)),
|
|
'pokemon_skills',
|
|
'pokemon_id',
|
|
'skill_id',
|
|
'p.id',
|
|
params
|
|
);
|
|
if (skillFilter) {
|
|
conditions.push(skillFilter);
|
|
}
|
|
|
|
const favoriteThingFilter = sqlForRelationFilter(
|
|
favoriteThingIds,
|
|
parseMatchMode(asString(paramsQuery.favoriteThingMode)),
|
|
'pokemon_favorite_things',
|
|
'pokemon_id',
|
|
'favorite_thing_id',
|
|
'p.id',
|
|
params
|
|
);
|
|
if (favoriteThingFilter) {
|
|
conditions.push(favoriteThingFilter);
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
return query(`${pokemonProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('p')}`, params);
|
|
}
|
|
|
|
export async function getPokemon(id: number, locale = defaultLocale) {
|
|
const pokemon = await queryOne(`${pokemonProjection(locale)} WHERE p.id = $1`, [id]);
|
|
if (!pokemon) {
|
|
return null;
|
|
}
|
|
|
|
const habitatName = localizedName('habitats', 'h', locale);
|
|
const mapName = localizedName('maps', 'm', locale);
|
|
const itemName = localizedName('items', 'i', locale);
|
|
const categoryName = localizedName('item-categories', 'c', locale);
|
|
const tagName = localizedName('favorite-things', 'ft', locale);
|
|
|
|
const [habitats, itemDrops, favoriteThingItems, editHistory] = await Promise.all([
|
|
query(
|
|
`
|
|
SELECT
|
|
h.id,
|
|
${habitatName} AS name,
|
|
hp.time_of_day,
|
|
hp.weather,
|
|
hp.rarity,
|
|
json_build_object('id', m.id, 'name', ${mapName}) AS map
|
|
FROM habitat_pokemon hp
|
|
JOIN habitats h ON h.id = hp.habitat_id
|
|
JOIN maps m ON m.id = hp.map_id
|
|
WHERE hp.pokemon_id = $1
|
|
ORDER BY ${orderByEntity('h')}, hp.rarity, ${orderByEntity('m')}
|
|
`,
|
|
[id]
|
|
),
|
|
query<{ skillId: number; id: number; name: string }>(
|
|
`
|
|
SELECT psid.skill_id AS "skillId", i.id, ${itemName} AS name
|
|
FROM pokemon_skill_item_drops psid
|
|
JOIN skills s ON s.id = psid.skill_id
|
|
JOIN items i ON i.id = psid.item_id
|
|
WHERE psid.pokemon_id = $1
|
|
AND s.has_item_drop = true
|
|
ORDER BY ${orderByEntity('s')}, ${orderByEntity('i')}
|
|
`,
|
|
[id]
|
|
),
|
|
query(
|
|
`
|
|
SELECT
|
|
i.id,
|
|
${itemName} AS name,
|
|
json_build_object('id', c.id, 'name', ${categoryName}) AS category,
|
|
json_agg(json_build_object('id', ft.id, 'name', ${tagName}) ORDER BY ${orderByEntity('ft')}) AS tags
|
|
FROM pokemon_favorite_things pft
|
|
JOIN item_favorite_things ift ON ift.favorite_thing_id = pft.favorite_thing_id
|
|
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
|
|
JOIN items i ON i.id = ift.item_id
|
|
JOIN item_categories c ON c.id = i.category_id
|
|
WHERE pft.pokemon_id = $1
|
|
GROUP BY i.id, i.name, i.sort_order, c.id, c.name, c.sort_order
|
|
ORDER BY ${orderByEntity('c')}, ${orderByEntity('i')}
|
|
`,
|
|
[id]
|
|
),
|
|
getEditHistory('pokemon', id)
|
|
]);
|
|
|
|
const dropsBySkill = itemDrops.reduce((itemsBySkill, item) => {
|
|
itemsBySkill.set(item.skillId, { id: item.id, name: item.name });
|
|
return itemsBySkill;
|
|
}, new Map<number, { id: number; name: string }>());
|
|
|
|
const skills = Array.isArray(pokemon.skills)
|
|
? pokemon.skills.map((skill: { id: number; name: string }) => ({
|
|
...skill,
|
|
itemDrop: dropsBySkill.get(skill.id) ?? null
|
|
}))
|
|
: [];
|
|
|
|
return { ...pokemon, skills, habitats, favoriteThingItems, editHistory };
|
|
}
|
|
|
|
function cleanPokemonPayload(payload: Record<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('Choose at least 1 type');
|
|
}
|
|
if (cleanTypeIds.length > 2) {
|
|
throw validationError('Choose at most 2 types');
|
|
}
|
|
if (skillIds.length > 2) {
|
|
throw validationError('Choose at most 2 specialities');
|
|
}
|
|
if (favoriteThingIds.length > 6) {
|
|
throw validationError('Choose at most 6 favourites');
|
|
}
|
|
|
|
if (Array.isArray(payload.skillItemDrops)) {
|
|
for (const item of payload.skillItemDrops) {
|
|
const row = item as Record<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('Drop items must be linked to selected specialities');
|
|
}
|
|
|
|
skillItemDrops.set(String(skillId), { skillId, itemId });
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: requirePositiveInteger(payload.id, 'Pokemon ID is required'),
|
|
name: cleanName(payload.name, 'Pokemon name is required'),
|
|
genus: cleanOptionalText(payload.genus),
|
|
details: cleanOptionalText(payload.details),
|
|
heightInches: cleanNonNegativeNumber(payload.heightInches, 'Height must be a non-negative number'),
|
|
weightPounds: cleanNonNegativeNumber(payload.weightPounds, 'Weight must be a non-negative number'),
|
|
translations: cleanTranslations(payload.translations, ['name', 'details', 'genus']),
|
|
typeIds,
|
|
stats: cleanPokemonStats(payload.stats),
|
|
environmentId: requirePositiveInteger(payload.environmentId, 'Ideal Habitat is required'),
|
|
skillIds,
|
|
favoriteThingIds,
|
|
skillItemDrops: [...skillItemDrops.values()]
|
|
};
|
|
}
|
|
|
|
async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload): Promise<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('This speciality cannot have a drop item');
|
|
}
|
|
}
|
|
|
|
for (const drop of payload.skillItemDrops) {
|
|
await client.query(
|
|
'INSERT INTO pokemon_skill_item_drops (pokemon_id, skill_id, item_id) VALUES ($1, $2, $3)',
|
|
[pokemonId, drop.skillId, drop.itemId]
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function createPokemon(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanPokemonPayload(payload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
const sortOrder = await nextSortOrder(client, 'pokemon');
|
|
await client.query(
|
|
`
|
|
INSERT INTO pokemon (
|
|
id,
|
|
name,
|
|
genus,
|
|
details,
|
|
height_inches,
|
|
weight_pounds,
|
|
environment_id,
|
|
hp,
|
|
attack,
|
|
defense,
|
|
special_attack,
|
|
special_defense,
|
|
speed,
|
|
sort_order,
|
|
created_by_user_id,
|
|
updated_by_user_id
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15)
|
|
`,
|
|
[
|
|
cleanPayload.id,
|
|
cleanPayload.name,
|
|
cleanPayload.genus,
|
|
cleanPayload.details,
|
|
cleanPayload.heightInches,
|
|
cleanPayload.weightPounds,
|
|
cleanPayload.environmentId,
|
|
cleanPayload.stats.hp,
|
|
cleanPayload.stats.attack,
|
|
cleanPayload.stats.defense,
|
|
cleanPayload.stats.specialAttack,
|
|
cleanPayload.stats.specialDefense,
|
|
cleanPayload.stats.speed,
|
|
sortOrder,
|
|
userId
|
|
]
|
|
);
|
|
await replacePokemonRelations(client, cleanPayload.id, cleanPayload);
|
|
await replaceEntityTranslations(client, 'pokemon', cleanPayload.id, cleanPayload.translations, ['name', 'details', 'genus']);
|
|
await recordEditLog(client, 'pokemon', cleanPayload.id, 'create', userId);
|
|
return cleanPayload.id;
|
|
});
|
|
return getPokemon(id, locale);
|
|
}
|
|
|
|
export async function updatePokemon(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanPokemonPayload({ ...payload, id });
|
|
const before = await getPokemon(id, defaultLocale);
|
|
|
|
const updated = await withTransaction(async (client) => {
|
|
const result = await client.query(
|
|
`
|
|
UPDATE pokemon
|
|
SET
|
|
name = $1,
|
|
genus = $2,
|
|
details = $3,
|
|
height_inches = $4,
|
|
weight_pounds = $5,
|
|
environment_id = $6,
|
|
hp = $7,
|
|
attack = $8,
|
|
defense = $9,
|
|
special_attack = $10,
|
|
special_defense = $11,
|
|
speed = $12,
|
|
updated_by_user_id = $13,
|
|
updated_at = now()
|
|
WHERE id = $14
|
|
`,
|
|
[
|
|
cleanPayload.name,
|
|
cleanPayload.genus,
|
|
cleanPayload.details,
|
|
cleanPayload.heightInches,
|
|
cleanPayload.weightPounds,
|
|
cleanPayload.environmentId,
|
|
cleanPayload.stats.hp,
|
|
cleanPayload.stats.attack,
|
|
cleanPayload.stats.defense,
|
|
cleanPayload.stats.specialAttack,
|
|
cleanPayload.stats.specialDefense,
|
|
cleanPayload.stats.speed,
|
|
userId,
|
|
id
|
|
]
|
|
);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
await replacePokemonRelations(client, id, cleanPayload);
|
|
await replaceEntityTranslations(client, 'pokemon', id, cleanPayload.translations, ['name', 'details', 'genus']);
|
|
const changes = before ? await pokemonEditChanges(client, before as unknown as PokemonChangeSource, cleanPayload) : [];
|
|
await recordEditLog(client, 'pokemon', id, 'update', userId, changes);
|
|
return true;
|
|
});
|
|
return updated ? getPokemon(id, locale) : null;
|
|
}
|
|
|
|
export async function deletePokemon(id: number, userId: number) {
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ id: number }>('DELETE FROM pokemon WHERE id = $1 RETURNING id', [id]);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await deleteEntityTranslations(client, 'pokemon', id);
|
|
await recordEditLog(client, 'pokemon', id, 'delete', userId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export async function listHabitats(locale = defaultLocale) {
|
|
const habitatName = localizedName('habitats', 'h', locale);
|
|
const itemName = localizedName('items', 'i', locale);
|
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
|
|
|
return query(`
|
|
SELECT
|
|
h.id,
|
|
${habitatName} AS name,
|
|
h.name AS "baseName",
|
|
${translationsSelect('habitats', 'h.id')} AS translations,
|
|
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${orderByEntity('i')})
|
|
FROM habitat_recipe_items hri
|
|
JOIN items i ON i.id = hri.item_id
|
|
WHERE hri.habitat_id = h.id
|
|
), '[]'::json) AS recipe,
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', pokemon_rows.id, 'name', pokemon_rows.name) ORDER BY pokemon_rows.sort_order, pokemon_rows.id)
|
|
FROM (
|
|
SELECT DISTINCT p.id, ${pokemonName} AS name, p.sort_order
|
|
FROM habitat_pokemon hp
|
|
JOIN pokemon p ON p.id = hp.pokemon_id
|
|
WHERE hp.habitat_id = h.id
|
|
) pokemon_rows
|
|
), '[]'::json) AS pokemon
|
|
FROM habitats h
|
|
${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')}
|
|
ORDER BY ${orderByEntity('h')}
|
|
`);
|
|
}
|
|
|
|
export async function getHabitat(id: number, locale = defaultLocale) {
|
|
const habitatName = localizedName('habitats', 'h', locale);
|
|
const itemName = localizedName('items', 'i', locale);
|
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
|
const mapName = localizedName('maps', 'm', locale);
|
|
|
|
const habitat = await queryOne(
|
|
`
|
|
SELECT
|
|
h.id,
|
|
${habitatName} AS name,
|
|
h.name AS "baseName",
|
|
${translationsSelect('habitats', 'h.id')} AS translations,
|
|
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${orderByEntity('i')})
|
|
FROM habitat_recipe_items hri
|
|
JOIN items i ON i.id = hri.item_id
|
|
WHERE hri.habitat_id = h.id
|
|
), '[]'::json) AS recipe
|
|
FROM habitats h
|
|
${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')}
|
|
WHERE h.id = $1
|
|
`,
|
|
[id]
|
|
);
|
|
|
|
if (!habitat) {
|
|
return null;
|
|
}
|
|
|
|
const [pokemon, editHistory] = await Promise.all([
|
|
query(
|
|
`
|
|
SELECT
|
|
p.id,
|
|
${pokemonName} AS name,
|
|
hp.time_of_day,
|
|
hp.weather,
|
|
hp.rarity,
|
|
json_build_object('id', m.id, 'name', ${mapName}) AS map
|
|
FROM habitat_pokemon hp
|
|
JOIN pokemon p ON p.id = hp.pokemon_id
|
|
JOIN maps m ON m.id = hp.map_id
|
|
WHERE hp.habitat_id = $1
|
|
ORDER BY hp.rarity, ${orderByEntity('p')}, ${orderByEntity('m')}
|
|
`,
|
|
[id]
|
|
),
|
|
getEditHistory('habitats', id)
|
|
]);
|
|
|
|
return { ...habitat, pokemon, editHistory };
|
|
}
|
|
|
|
function cleanHabitatPayload(payload: Record<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 ?? row.mapId);
|
|
const selectedTimeOfDays = cleanOptions(row.timeOfDays ?? row.timeOfDay, timeOfDays);
|
|
const selectedWeathers = cleanOptions(row.weathers ?? row.weather, weathers);
|
|
const rarity = Number(row.rarity);
|
|
|
|
if (!Number.isInteger(pokemonId) || pokemonId <= 0 || !Number.isInteger(rarity) || rarity < 1 || rarity > 3) {
|
|
continue;
|
|
}
|
|
|
|
for (const mapId of mapIds) {
|
|
for (const timeOfDay of selectedTimeOfDays) {
|
|
for (const weather of selectedWeathers) {
|
|
pokemonAppearances.set(`${pokemonId}:${mapId}:${timeOfDay}:${weather}`, {
|
|
pokemonId,
|
|
mapId,
|
|
timeOfDay,
|
|
weather,
|
|
rarity
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
name: cleanName(payload.name, 'Habitat name is required'),
|
|
translations: cleanTranslations(payload.translations, ['name']),
|
|
recipeItems: cleanQuantities(payload.recipeItems),
|
|
pokemonAppearances: [...pokemonAppearances.values()]
|
|
};
|
|
}
|
|
|
|
async function replaceHabitatRelations(client: DbClient, habitatId: number, payload: HabitatPayload): Promise<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, sort_order, created_by_user_id, updated_by_user_id)
|
|
VALUES ($1, $2, $3, $3)
|
|
RETURNING id
|
|
`,
|
|
[cleanPayload.name, sortOrder, userId]
|
|
);
|
|
const habitatId = result.rows[0].id;
|
|
await replaceHabitatRelations(client, habitatId, cleanPayload);
|
|
await replaceEntityTranslations(client, 'habitats', habitatId, cleanPayload.translations, ['name']);
|
|
await recordEditLog(client, 'habitats', habitatId, 'create', userId);
|
|
return habitatId;
|
|
});
|
|
return getHabitat(id, locale);
|
|
}
|
|
|
|
export async function updateHabitat(id: number, payload: Record<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, updated_by_user_id = $2, updated_at = now() WHERE id = $3',
|
|
[cleanPayload.name, userId, id]
|
|
);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
await replaceHabitatRelations(client, id, cleanPayload);
|
|
await replaceEntityTranslations(client, 'habitats', id, cleanPayload.translations, ['name']);
|
|
const changes = before ? await habitatEditChanges(client, before as unknown as HabitatChangeSource, cleanPayload) : [];
|
|
await recordEditLog(client, 'habitats', id, 'update', userId, changes);
|
|
return true;
|
|
});
|
|
return updated ? getHabitat(id, locale) : null;
|
|
}
|
|
|
|
export async function deleteHabitat(id: number, userId: number) {
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ id: number }>('DELETE FROM habitats WHERE id = $1 RETURNING id', [id]);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await deleteEntityTranslations(client, 'habitats', id);
|
|
await recordEditLog(client, 'habitats', id, 'delete', userId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function itemProjection(locale: string): string {
|
|
const itemName = localizedName('items', 'i', locale);
|
|
const categoryName = localizedName('item-categories', 'c', locale);
|
|
const usageName = localizedName('item-usages', 'u', locale);
|
|
const tagName = localizedName('favorite-things', 't', locale);
|
|
|
|
return `
|
|
SELECT
|
|
i.id,
|
|
${itemName} AS name,
|
|
i.name AS "baseName",
|
|
${translationsSelect('items', 'i.id')} AS translations,
|
|
${auditSelect('i', 'item_created_user', 'item_updated_user')},
|
|
json_build_object('id', c.id, 'name', ${categoryName}) AS category,
|
|
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'name', ${usageName}) END AS usage,
|
|
json_build_object(
|
|
'dyeable', i.dyeable,
|
|
'dualDyeable', i.dual_dyeable,
|
|
'patternEditable', i.pattern_editable
|
|
) AS customization,
|
|
i.no_recipe AS "noRecipe",
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', t.id, 'name', ${tagName}) ORDER BY ${orderByEntity('t')})
|
|
FROM item_favorite_things ift
|
|
JOIN favorite_things t ON t.id = ift.favorite_thing_id
|
|
WHERE ift.item_id = i.id
|
|
), '[]'::json) AS tags,
|
|
CASE
|
|
WHEN item_recipe.id IS NULL THEN NULL
|
|
ELSE json_build_object(
|
|
'id', item_recipe.id,
|
|
'createdAt', item_recipe.created_at,
|
|
'updatedAt', item_recipe.updated_at,
|
|
'createdBy', CASE
|
|
WHEN recipe_created_user.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', recipe_created_user.id, 'displayName', recipe_created_user.display_name)
|
|
END,
|
|
'updatedBy', CASE
|
|
WHEN recipe_updated_user.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', recipe_updated_user.id, 'displayName', recipe_updated_user.display_name)
|
|
END
|
|
)
|
|
END AS recipe
|
|
FROM items i
|
|
JOIN item_categories c ON c.id = i.category_id
|
|
LEFT JOIN item_usages u ON u.id = i.usage_id
|
|
LEFT JOIN recipes item_recipe ON item_recipe.item_id = i.id
|
|
LEFT JOIN users recipe_created_user ON recipe_created_user.id = item_recipe.created_by_user_id
|
|
LEFT JOIN users recipe_updated_user ON recipe_updated_user.id = item_recipe.updated_by_user_id
|
|
${auditJoins('i', 'item_created_user', 'item_updated_user')}
|
|
`;
|
|
}
|
|
|
|
export async function listItems(paramsQuery: QueryParams, locale = defaultLocale) {
|
|
const params: unknown[] = [];
|
|
const conditions: string[] = [];
|
|
const categoryId = Number(asString(paramsQuery.categoryId));
|
|
const usageId = Number(asString(paramsQuery.usageId));
|
|
const tagIds = parseIdList(asString(paramsQuery.tagIds));
|
|
const search = asString(paramsQuery.search)?.trim();
|
|
const recipeOrder = asString(paramsQuery.recipeOrder) === '1';
|
|
|
|
if (search) {
|
|
params.push(`%${search}%`);
|
|
conditions.push(`${localizedName('items', 'i', locale)} ILIKE $${params.length}`);
|
|
}
|
|
|
|
if (Number.isInteger(categoryId) && categoryId > 0) {
|
|
params.push(categoryId);
|
|
conditions.push(`i.category_id = $${params.length}`);
|
|
}
|
|
|
|
if (Number.isInteger(usageId) && usageId > 0) {
|
|
params.push(usageId);
|
|
conditions.push(`i.usage_id = $${params.length}`);
|
|
}
|
|
|
|
const tagFilter = sqlForRelationFilter(
|
|
tagIds,
|
|
'any',
|
|
'item_favorite_things',
|
|
'item_id',
|
|
'favorite_thing_id',
|
|
'i.id',
|
|
params
|
|
);
|
|
if (tagFilter) {
|
|
conditions.push(tagFilter);
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
const orderClause = recipeOrder
|
|
? `ORDER BY CASE WHEN item_recipe.id IS NULL THEN 1 ELSE 0 END, item_recipe.sort_order, item_recipe.id, ${orderByEntity('i')}`
|
|
: `ORDER BY ${orderByEntity('i')}`;
|
|
return query(`${itemProjection(locale)} ${whereClause} ${orderClause}`, params);
|
|
}
|
|
|
|
export async function getItem(id: number, locale = defaultLocale) {
|
|
const item = await queryOne(`${itemProjection(locale)} WHERE i.id = $1`, [id]);
|
|
if (!item) {
|
|
return null;
|
|
}
|
|
|
|
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
|
|
const resultItemName = localizedName('items', 'result_item', locale);
|
|
const materialItemName = localizedName('items', 'mi', locale);
|
|
const habitatName = localizedName('habitats', 'h', locale);
|
|
const recipeItemName = localizedName('items', 'recipe_item', locale);
|
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
|
const skillName = localizedName('skills', 's', locale);
|
|
|
|
const [acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory] = await Promise.all([
|
|
query(
|
|
`
|
|
SELECT am.id, ${acquisitionMethodName} AS name
|
|
FROM item_acquisition_methods iam
|
|
JOIN acquisition_methods am ON am.id = iam.acquisition_method_id
|
|
WHERE iam.item_id = $1
|
|
ORDER BY ${orderByEntity('am')}
|
|
`,
|
|
[id]
|
|
),
|
|
queryOne(
|
|
`
|
|
SELECT
|
|
r.id,
|
|
${resultItemName} AS name,
|
|
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${orderByEntity('am')})
|
|
FROM recipe_acquisition_methods ram
|
|
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
|
|
WHERE ram.recipe_id = r.id
|
|
), '[]'::json) AS acquisition_methods,
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', mi.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${orderByEntity('mi')})
|
|
FROM recipe_materials rm
|
|
JOIN items mi ON mi.id = rm.item_id
|
|
WHERE rm.recipe_id = r.id
|
|
), '[]'::json) AS materials,
|
|
json_build_object('id', result_item.id, 'name', ${resultItemName}) AS item
|
|
FROM recipes r
|
|
JOIN items result_item ON result_item.id = r.item_id
|
|
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
|
WHERE r.item_id = $1
|
|
`,
|
|
[id]
|
|
),
|
|
query(
|
|
`
|
|
SELECT
|
|
r.id,
|
|
${resultItemName} AS name,
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', mi.id, 'name', ${materialItemName}, 'quantity', recipe_material.quantity) ORDER BY ${orderByEntity('mi')})
|
|
FROM recipe_materials recipe_material
|
|
JOIN items mi ON mi.id = recipe_material.item_id
|
|
WHERE recipe_material.recipe_id = r.id
|
|
), '[]'::json) AS materials
|
|
FROM recipe_materials used_material
|
|
JOIN recipes r ON r.id = used_material.recipe_id
|
|
JOIN items result_item ON result_item.id = r.item_id
|
|
WHERE used_material.item_id = $1
|
|
ORDER BY ${orderByEntity('r')}
|
|
`,
|
|
[id]
|
|
),
|
|
query(
|
|
`
|
|
SELECT
|
|
h.id,
|
|
${habitatName} AS name,
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', recipe_item.id, 'name', ${recipeItemName}, 'quantity', recipe_item_row.quantity) ORDER BY ${orderByEntity('recipe_item')})
|
|
FROM habitat_recipe_items recipe_item_row
|
|
JOIN items recipe_item ON recipe_item.id = recipe_item_row.item_id
|
|
WHERE recipe_item_row.habitat_id = h.id
|
|
), '[]'::json) AS recipe
|
|
FROM habitat_recipe_items used_item
|
|
JOIN habitats h ON h.id = used_item.habitat_id
|
|
WHERE used_item.item_id = $1
|
|
ORDER BY ${orderByEntity('h')}
|
|
`,
|
|
[id]
|
|
),
|
|
query(
|
|
`
|
|
SELECT
|
|
json_build_object('id', p.id, 'name', ${pokemonName}) AS pokemon,
|
|
json_build_object('id', s.id, 'name', ${skillName}) AS skill
|
|
FROM pokemon_skill_item_drops psid
|
|
JOIN pokemon p ON p.id = psid.pokemon_id
|
|
JOIN skills s ON s.id = psid.skill_id
|
|
WHERE psid.item_id = $1
|
|
AND s.has_item_drop = true
|
|
ORDER BY ${orderByEntity('p')}, ${orderByEntity('s')}
|
|
`,
|
|
[id]
|
|
),
|
|
getEditHistory('items', id)
|
|
]);
|
|
|
|
return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory };
|
|
}
|
|
|
|
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
|
const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined
|
|
? null
|
|
: requirePositiveInteger(payload.usageId, 'Usage is required');
|
|
|
|
return {
|
|
name: cleanName(payload.name, 'Item name is required'),
|
|
translations: cleanTranslations(payload.translations, ['name']),
|
|
categoryId: requirePositiveInteger(payload.categoryId, 'Category is required'),
|
|
usageId,
|
|
dyeable: Boolean(payload.dyeable),
|
|
dualDyeable: Boolean(payload.dualDyeable),
|
|
patternEditable: Boolean(payload.patternEditable),
|
|
noRecipe: Boolean(payload.noRecipe),
|
|
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
|
|
tagIds: cleanIds(payload.tagIds)
|
|
};
|
|
}
|
|
|
|
async function ensureItemCanDisableRecipe(client: DbClient, itemId: number, noRecipe: boolean): Promise<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('An item with a recipe cannot be marked as recipe-free');
|
|
}
|
|
}
|
|
|
|
async function replaceItemRelations(client: DbClient, itemId: number, payload: ItemPayload): Promise<void> {
|
|
await client.query('DELETE FROM item_acquisition_methods WHERE item_id = $1', [itemId]);
|
|
await client.query('DELETE FROM item_favorite_things WHERE item_id = $1', [itemId]);
|
|
|
|
for (const methodId of payload.acquisitionMethodIds) {
|
|
await client.query('INSERT INTO item_acquisition_methods (item_id, acquisition_method_id) VALUES ($1, $2)', [
|
|
itemId,
|
|
methodId
|
|
]);
|
|
}
|
|
|
|
for (const tagId of payload.tagIds) {
|
|
await client.query('INSERT INTO item_favorite_things (item_id, favorite_thing_id) VALUES ($1, $2)', [
|
|
itemId,
|
|
tagId
|
|
]);
|
|
}
|
|
}
|
|
|
|
export async function createItem(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanItemPayload(payload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
const sortOrder = await nextSortOrder(client, 'items');
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO items (
|
|
name,
|
|
category_id,
|
|
usage_id,
|
|
dyeable,
|
|
dual_dyeable,
|
|
pattern_editable,
|
|
no_recipe,
|
|
sort_order,
|
|
created_by_user_id,
|
|
updated_by_user_id
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
|
|
RETURNING id
|
|
`,
|
|
[
|
|
cleanPayload.name,
|
|
cleanPayload.categoryId,
|
|
cleanPayload.usageId,
|
|
cleanPayload.dyeable,
|
|
cleanPayload.dualDyeable,
|
|
cleanPayload.patternEditable,
|
|
cleanPayload.noRecipe,
|
|
sortOrder,
|
|
userId
|
|
]
|
|
);
|
|
const itemId = result.rows[0].id;
|
|
await replaceItemRelations(client, itemId, cleanPayload);
|
|
await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name']);
|
|
await recordEditLog(client, 'items', itemId, 'create', userId);
|
|
return itemId;
|
|
});
|
|
return getItem(id, locale);
|
|
}
|
|
|
|
export async function updateItem(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanItemPayload(payload);
|
|
const before = await getItem(id, defaultLocale);
|
|
|
|
const updated = await withTransaction(async (client) => {
|
|
await ensureItemCanDisableRecipe(client, id, cleanPayload.noRecipe);
|
|
const result = await client.query(
|
|
`
|
|
UPDATE items
|
|
SET name = $1,
|
|
category_id = $2,
|
|
usage_id = $3,
|
|
dyeable = $4,
|
|
dual_dyeable = $5,
|
|
pattern_editable = $6,
|
|
no_recipe = $7,
|
|
updated_by_user_id = $8,
|
|
updated_at = now()
|
|
WHERE id = $9
|
|
`,
|
|
[
|
|
cleanPayload.name,
|
|
cleanPayload.categoryId,
|
|
cleanPayload.usageId,
|
|
cleanPayload.dyeable,
|
|
cleanPayload.dualDyeable,
|
|
cleanPayload.patternEditable,
|
|
cleanPayload.noRecipe,
|
|
userId,
|
|
id
|
|
]
|
|
);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
await replaceItemRelations(client, id, cleanPayload);
|
|
await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name']);
|
|
const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : [];
|
|
await recordEditLog(client, 'items', id, 'update', userId, changes);
|
|
return true;
|
|
});
|
|
return updated ? getItem(id, locale) : null;
|
|
}
|
|
|
|
export async function deleteItem(id: number, userId: number) {
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ id: number }>('DELETE FROM items WHERE id = $1 RETURNING id', [id]);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await deleteEntityTranslations(client, 'items', id);
|
|
await recordEditLog(client, 'items', id, 'delete', userId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaultLocale) {
|
|
const params: unknown[] = [];
|
|
const conditions: string[] = [];
|
|
const categoryId = Number(asString(paramsQuery.categoryId));
|
|
const resultItemName = localizedName('items', 'result_item', locale);
|
|
const materialItemName = localizedName('items', 'i', locale);
|
|
|
|
if (Number.isInteger(categoryId) && categoryId > 0) {
|
|
params.push(categoryId);
|
|
conditions.push(`result_item.category_id = $${params.length}`);
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
return query(`
|
|
SELECT
|
|
r.id,
|
|
${resultItemName} AS name,
|
|
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', i.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${orderByEntity('i')})
|
|
FROM recipe_materials rm
|
|
JOIN items i ON i.id = rm.item_id
|
|
WHERE rm.recipe_id = r.id
|
|
), '[]'::json) AS materials
|
|
FROM recipes r
|
|
JOIN items result_item ON result_item.id = r.item_id
|
|
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
|
${whereClause}
|
|
ORDER BY ${orderByEntity('r')}
|
|
`, params);
|
|
}
|
|
|
|
export async function getRecipe(id: number, locale = defaultLocale) {
|
|
const resultItemName = localizedName('items', 'result_item', locale);
|
|
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
|
|
const materialItemName = localizedName('items', 'i', locale);
|
|
|
|
const recipe = await queryOne(
|
|
`
|
|
SELECT
|
|
r.id,
|
|
${resultItemName} AS name,
|
|
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${orderByEntity('am')})
|
|
FROM recipe_acquisition_methods ram
|
|
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
|
|
WHERE ram.recipe_id = r.id
|
|
), '[]'::json) AS acquisition_methods,
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', i.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${orderByEntity('i')})
|
|
FROM recipe_materials rm
|
|
JOIN items i ON i.id = rm.item_id
|
|
WHERE rm.recipe_id = r.id
|
|
), '[]'::json) AS materials,
|
|
json_build_object('id', result_item.id, 'name', ${resultItemName}) AS item
|
|
FROM recipes r
|
|
JOIN items result_item ON result_item.id = r.item_id
|
|
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
|
WHERE r.id = $1
|
|
`,
|
|
[id]
|
|
);
|
|
|
|
if (!recipe) {
|
|
return null;
|
|
}
|
|
|
|
const editHistory = await getEditHistory('recipes', id);
|
|
return { ...recipe, editHistory };
|
|
}
|
|
|
|
function cleanRecipePayload(payload: Record<string, unknown>): RecipePayload {
|
|
return {
|
|
itemId: requirePositiveInteger(payload.itemId, 'Item is required'),
|
|
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
|
|
materials: cleanQuantities(payload.materials)
|
|
};
|
|
}
|
|
|
|
async function replaceRecipeRelations(client: DbClient, recipeId: number, payload: RecipePayload): Promise<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('Item is required');
|
|
}
|
|
|
|
if (result.rows[0].no_recipe) {
|
|
throw validationError('This item is marked as recipe-free');
|
|
}
|
|
}
|
|
|
|
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 recordEditLog(client, 'recipes', id, 'delete', userId);
|
|
return true;
|
|
});
|
|
}
|