Files
pokopiawiki.tootaio.com/backend/src/systemWordingQueries.ts
xiaomai cf0ae566c0 feat(pokemon): add image selection and display from pokesprite
Add image metadata fields to Pokemon schema and API
Implement image candidate fetching from pokesprite static tree
Add Pokédex-style image picker to edit form and display in details
2026-05-02 20:59:33 +08:00

350 lines
13 KiB
TypeScript

import { defaultLocale, systemWordingCatalogEntries, systemWordingFallback, type SystemWordingTree } from '../../system-wordings.ts';
import { pool, query } from './db.ts';
type SystemWordingSurface = 'frontend' | 'backend' | 'email';
type SystemWordingValueRow = {
key: string;
module: string;
surface: SystemWordingSurface;
description: string;
placeholders: unknown;
value: string;
defaultValue: string;
missing: boolean;
updatedAt: Date | null;
updatedBy: { id: number; displayName: string } | null;
};
type ValidationError = Error & { statusCode: number };
const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
const wordingKeyPattern = /^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+$/;
const placeholderPattern = /\{([A-Za-z0-9_]+)\}/g;
const surfaces = new Set<SystemWordingSurface>(['frontend', 'backend', 'email']);
const legacyMessageKeys = new Map<string, string>([
['Record does not exist', 'server.validation.recordMissing'],
['Language code is invalid', 'server.validation.languageCodeInvalid'],
['Language name is required', 'server.validation.languageNameRequired'],
['Default language must be English', 'server.validation.defaultLanguageMustBeEnglish'],
['Default language must be enabled', 'server.validation.defaultLanguageMustBeEnabled'],
['Language not found', 'server.validation.languageNotFound'],
['A default language is required', 'server.validation.defaultLanguageRequired'],
['Default language cannot be deleted', 'server.validation.defaultLanguageCannotBeDeleted'],
['Please select a language', 'server.validation.selectLanguage'],
['Language does not exist', 'server.validation.languageDoesNotExist'],
['Pokemon identifier is required', 'server.validation.pokemonIdentifierRequired'],
['Pokemon type data is unavailable', 'server.validation.pokemonTypeDataUnavailable'],
['Pokemon data was not found', 'server.validation.pokemonDataNotFound'],
['Pokemon image path is invalid', 'server.validation.pokemonImagePathInvalid'],
['Please enter a task', 'server.validation.taskRequired'],
['Please select a task', 'server.validation.selectTask'],
['Task does not exist', 'server.validation.taskDoesNotExist'],
['Please enter a post', 'server.validation.postRequired'],
['Post is too long', 'server.validation.postTooLong'],
['Please enter a comment', 'server.validation.commentRequired'],
['Comment is too long', 'server.validation.commentTooLong'],
['Reaction is invalid', 'server.validation.reactionInvalid'],
['Cursor is invalid', 'server.validation.cursorInvalid'],
['Tag is invalid', 'server.validation.tagInvalid'],
['Entity type is invalid', 'server.validation.entityTypeInvalid'],
['Record is invalid', 'server.validation.recordInvalid'],
['Comment is invalid', 'server.validation.commentInvalid'],
['Please select a record', 'server.validation.selectRecord'],
['Choose at least 1 type', 'server.validation.typeMin'],
['Choose at most 2 types', 'server.validation.typeMax'],
['Choose at most 2 specialities', 'server.validation.skillMax'],
['Choose at most 6 favourites', 'server.validation.favoriteMax'],
['Drop items must be linked to selected specialities', 'server.validation.dropItemSelectedSkill'],
['Pokemon ID is required', 'server.validation.pokemonIdRequired'],
['Pokemon name is required', 'server.validation.pokemonNameRequired'],
['Height must be a non-negative number', 'server.validation.heightNonNegative'],
['Weight must be a non-negative number', 'server.validation.weightNonNegative'],
['Ideal Habitat is required', 'server.validation.environmentRequired'],
['This speciality cannot have a drop item', 'server.validation.skillNoDrop'],
['Habitat name is required', 'server.validation.habitatNameRequired'],
['Usage is required', 'server.validation.usageRequired'],
['Item name is required', 'server.validation.itemNameRequired'],
['Category is required', 'server.validation.categoryRequired'],
['An item with a recipe cannot be marked as recipe-free', 'server.validation.recipeFreeWithRecipe'],
['Item is required', 'server.validation.itemRequired'],
['This item is marked as recipe-free', 'server.validation.recipeFreeItem'],
['Name is required', 'server.validation.nameRequired']
]);
function validationError(message: string): ValidationError {
const error = new Error(message) as ValidationError;
error.statusCode = 400;
return error;
}
function cleanLocale(value: unknown): string {
const locale = typeof value === 'string' ? value.trim() : '';
return localePattern.test(locale) ? locale : defaultLocale;
}
function requireLocale(value: unknown): string {
const locale = typeof value === 'string' ? value.trim() : '';
if (!localePattern.test(locale)) {
throw validationError('server.wordings.localeRequired');
}
return locale;
}
function requireWordingKey(value: unknown): string {
const key = typeof value === 'string' ? value.trim() : '';
if (!wordingKeyPattern.test(key)) {
throw validationError('server.wordings.keyNotFound');
}
return key;
}
function cleanSurface(value: unknown): SystemWordingSurface | '' {
const surface = typeof value === 'string' ? value.trim() : '';
return surfaces.has(surface as SystemWordingSurface) ? (surface as SystemWordingSurface) : '';
}
function collectPlaceholders(value: string): string[] {
return [...new Set([...value.matchAll(placeholderPattern)].map((match) => match[1]))].sort();
}
function placeholdersMatch(first: string[], second: string[]): boolean {
return first.length === second.length && first.every((placeholder, index) => placeholder === second[index]);
}
function interpolate(message: string, params: Record<string, string | number>): string {
return Object.entries(params).reduce(
(nextMessage, [key, value]) => nextMessage.replaceAll(`{${key}}`, String(value)),
message
);
}
function setNestedMessage(target: SystemWordingTree, key: string, value: string): void {
const parts = key.split('.');
let node = target;
for (const part of parts.slice(0, -1)) {
const current = node[part];
if (typeof current !== 'object' || current === null) {
node[part] = {};
}
node = node[part] as SystemWordingTree;
}
node[parts[parts.length - 1]] = value;
}
function nestedMessages(rows: Array<{ key: string; value: string }>): SystemWordingTree {
const messages: SystemWordingTree = {};
for (const row of rows) {
setNestedMessage(messages, row.key, row.value);
}
return messages;
}
function normalizePlaceholders(value: unknown): string[] {
return Array.isArray(value) ? value.map((item) => String(item)).sort() : [];
}
function legacyMessageKey(message: string): string | null {
if (message.startsWith('server.') || message.startsWith('email.')) {
return message;
}
if (message.endsWith(' must be a non-negative integer')) {
return 'server.validation.statNonNegative';
}
if (message.endsWith(' is empty')) {
return 'server.validation.pokemonDataFileEmpty';
}
if (message.startsWith('Pokemon data file ') && message.endsWith(' is unavailable')) {
return 'server.validation.pokemonDataFileUnavailable';
}
return legacyMessageKeys.get(message) ?? null;
}
export async function syncSystemWordingCatalog(): Promise<void> {
const entries = systemWordingCatalogEntries();
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const entry of entries) {
await client.query(
`
INSERT INTO system_wording_keys (key, module, surface, description, placeholders)
VALUES ($1, $2, $3, $4, $5::jsonb)
ON CONFLICT (key) DO UPDATE
SET module = EXCLUDED.module,
surface = EXCLUDED.surface,
description = EXCLUDED.description,
placeholders = EXCLUDED.placeholders,
updated_at = now()
`,
[entry.key, entry.module, entry.surface, entry.description, JSON.stringify(entry.placeholders)]
);
for (const [locale, value] of Object.entries(entry.values)) {
await client.query(
`
INSERT INTO system_wording_values (key, locale, value)
VALUES ($1, $2, $3)
ON CONFLICT (key, locale) DO NOTHING
`,
[entry.key, locale, value]
);
}
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
export async function systemMessage(
locale: string,
key: string,
params: Record<string, string | number> = {}
): Promise<string> {
const requestedLocale = cleanLocale(locale);
try {
const result = await pool.query<{ value: string }>(
`
SELECT COALESCE(requested.value, fallback.value) AS value
FROM system_wording_keys k
LEFT JOIN system_wording_values requested
ON requested.key = k.key
AND requested.locale = $2
LEFT JOIN system_wording_values fallback
ON fallback.key = k.key
AND fallback.locale = $3
WHERE k.key = $1
`,
[key, requestedLocale, defaultLocale]
);
const message = result.rows[0]?.value ?? systemWordingFallback(key, requestedLocale) ?? key;
return interpolate(message, params);
} catch {
return interpolate(systemWordingFallback(key, requestedLocale) ?? key, params);
}
}
export async function localizedStatusMessage(locale: string, message: string): Promise<string> {
const key = legacyMessageKey(message);
return key ? systemMessage(locale, key) : message;
}
export async function getSystemWordings(locale: string) {
const requestedLocale = cleanLocale(locale);
const rows = await query<{ key: string; value: string; missing: boolean }>(
`
SELECT
k.key,
COALESCE(requested.value, fallback.value, '') AS value,
($1 <> $2 AND requested.value IS NULL) AS missing
FROM system_wording_keys k
LEFT JOIN system_wording_values requested
ON requested.key = k.key
AND requested.locale = $1
LEFT JOIN system_wording_values fallback
ON fallback.key = k.key
AND fallback.locale = $2
WHERE k.enabled = true
ORDER BY k.key
`,
[requestedLocale, defaultLocale]
);
return {
locale: requestedLocale,
fallbackLocale: defaultLocale,
messages: nestedMessages(rows),
missingKeys: rows.filter((row) => row.missing).map((row) => row.key)
};
}
export async function listSystemWordingRows(filters: Record<string, unknown>) {
const locale = cleanLocale(filters.locale);
const module = typeof filters.module === 'string' ? filters.module.trim() : '';
const surface = cleanSurface(filters.surface);
const missingOnly = filters.missing === 'true' || filters.missing === true;
return query<SystemWordingValueRow>(
`
SELECT
k.key,
k.module,
k.surface,
k.description,
k.placeholders,
COALESCE(requested.value, '') AS value,
COALESCE(fallback.value, '') AS "defaultValue",
($1 <> $2 AND requested.value IS NULL) AS missing,
requested.updated_at AS "updatedAt",
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 system_wording_keys k
LEFT JOIN system_wording_values requested
ON requested.key = k.key
AND requested.locale = $1
LEFT JOIN system_wording_values fallback
ON fallback.key = k.key
AND fallback.locale = $2
LEFT JOIN users updated_user ON updated_user.id = requested.updated_by_user_id
WHERE k.enabled = true
AND ($3 = '' OR k.module = $3)
AND ($4 = '' OR k.surface = $4)
AND ($5 = false OR ($1 <> $2 AND requested.value IS NULL))
ORDER BY k.module, k.key
`,
[locale, defaultLocale, module, surface, missingOnly]
);
}
export async function updateSystemWordingValue(keyValue: string, payload: Record<string, unknown>, userId: number) {
const key = requireWordingKey(keyValue);
const locale = requireLocale(payload.locale);
const value = typeof payload.value === 'string' ? payload.value.trim() : '';
const keyRow = await pool.query<{ placeholders: unknown }>('SELECT placeholders FROM system_wording_keys WHERE key = $1', [key]);
const placeholders = normalizePlaceholders(keyRow.rows[0]?.placeholders);
if (keyRow.rowCount === 0) {
throw validationError('server.wordings.keyNotFound');
}
if (locale === defaultLocale && value === '') {
throw validationError('server.wordings.valueRequired');
}
if (value !== '' && !placeholdersMatch(placeholders, collectPlaceholders(value))) {
throw validationError('server.wordings.placeholderMismatch');
}
const result = await pool.query<{ code: string }>('SELECT code FROM languages WHERE code = $1', [locale]);
if (result.rowCount === 0) {
throw validationError('server.wordings.localeRequired');
}
if (value === '') {
await pool.query('DELETE FROM system_wording_values WHERE key = $1 AND locale = $2', [key, locale]);
} else {
await pool.query(
`
INSERT INTO system_wording_values (key, locale, value, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $3, $4, $4)
ON CONFLICT (key, locale) DO UPDATE
SET value = EXCLUDED.value,
updated_by_user_id = EXCLUDED.updated_by_user_id,
updated_at = now()
`,
[key, locale, value, userId]
);
}
return listSystemWordingRows({ locale });
}