feat(admin): implement management UI and CRUD APIs for all entities

Add full CRUD operations for Pokemon, Habitats, Items, Recipes, and Configs
Switch package manager from npm to pnpm across the project
Remove static seed data in favor of UI-driven data management
This commit is contained in:
2026-04-29 18:01:46 +08:00
parent b428595769
commit f6a40097c1
12 changed files with 1514 additions and 160 deletions

View File

@@ -1,10 +1,83 @@
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
import { query, queryOne } from './db.ts';
import { pool, query, queryOne } from './db.ts';
type QueryValue = string | string[] | undefined;
type QueryParams = Record<string, QueryValue>;
type DbClient = Awaited<ReturnType<typeof pool.connect>>;
type ConfigType =
| 'skills'
| 'environments'
| 'favorite-things'
| 'item-categories'
| 'item-usages'
| 'acquisition-methods'
| 'item-tags'
| 'maps';
type ConfigDefinition = {
table: string;
select: string;
order: string;
hasSubcategory?: boolean;
};
type IdQuantity = {
itemId: number;
quantity: number;
};
type PokemonPayload = {
id: number;
name: string;
environmentId: number;
skillIds: number[];
favoriteThingIds: number[];
};
type ItemPayload = {
name: string;
categoryId: number;
usageId: number;
recipeId: number | null;
dyeable: boolean;
dualDyeable: boolean;
patternEditable: boolean;
acquisitionMethodIds: number[];
tagIds: number[];
};
type RecipePayload = {
name: string;
acquisitionMethodIds: number[];
materials: IdQuantity[];
};
type HabitatPayload = {
name: string;
recipeItems: IdQuantity[];
pokemonAppearances: Array<{
pokemonId: number;
mapId: number;
timeOfDay: string;
weather: string;
rarity: number;
}>;
};
const configDefinitions: Record<ConfigType, ConfigDefinition> = {
skills: { table: 'skills', select: 'id, name, subcategory', order: 'name, subcategory', hasSubcategory: true },
environments: { table: 'environments', select: 'id, name', order: 'name' },
'favorite-things': { table: 'favorite_things', select: 'id, name', order: 'name' },
'item-categories': { table: 'item_categories', select: 'id, name', order: 'name' },
'item-usages': { table: 'item_usages', select: 'id, name', order: 'name' },
'acquisition-methods': { table: 'acquisition_methods', select: 'id, name', order: 'name' },
'item-tags': { table: 'item_tags', select: 'id, name', order: 'name' },
maps: { table: 'maps', select: 'id, name', order: 'name' }
};
function asString(value: QueryValue): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
@@ -13,6 +86,60 @@ function optionSelect(tableName: string): Promise<Array<{ id: number; name: stri
return query(`SELECT id, name FROM ${tableName} ORDER BY name`);
}
function requirePositiveInteger(value: unknown, fieldName: string): number {
const numberValue = Number(value);
if (!Number.isInteger(numberValue) || numberValue <= 0) {
throw new Error(`${fieldName} is required`);
}
return numberValue;
}
function cleanName(value: unknown): string {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error('Name is required');
}
return 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 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);
}
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();
}
}
const pokemonProjection = `
SELECT
p.id,
@@ -35,7 +162,16 @@ const pokemonProjection = `
`;
export async function getOptions() {
const [skills, environments, favoriteThings, itemCategories, itemUsages, itemTags] = await Promise.all([
const [
skills,
environments,
favoriteThings,
itemCategories,
itemUsages,
acquisitionMethods,
itemTags,
maps
] = await Promise.all([
query<{ id: number; name: string; subcategory: string | null }>(
'SELECT id, name, subcategory FROM skills ORDER BY name, subcategory'
),
@@ -43,7 +179,9 @@ export async function getOptions() {
optionSelect('favorite_things'),
optionSelect('item_categories'),
optionSelect('item_usages'),
optionSelect('item_tags')
optionSelect('acquisition_methods'),
optionSelect('item_tags'),
optionSelect('maps')
]);
return {
@@ -52,10 +190,57 @@ export async function getOptions() {
favoriteThings,
itemCategories,
itemUsages,
itemTags
acquisitionMethods,
itemTags,
maps
};
}
export function isConfigType(type: string): type is ConfigType {
return Object.hasOwn(configDefinitions, type);
}
export async function listConfig(type: ConfigType) {
const definition = configDefinitions[type];
return query(`SELECT ${definition.select} FROM ${definition.table} ORDER BY ${definition.order}`);
}
export async function createConfig(type: ConfigType, payload: Record<string, unknown>) {
const definition = configDefinitions[type];
const name = cleanName(payload.name);
const subcategory = typeof payload.subcategory === 'string' && payload.subcategory.trim() ? payload.subcategory.trim() : null;
if (definition.hasSubcategory) {
return queryOne(
`INSERT INTO ${definition.table} (name, subcategory) VALUES ($1, $2) RETURNING ${definition.select}`,
[name, subcategory]
);
}
return queryOne(`INSERT INTO ${definition.table} (name) VALUES ($1) RETURNING ${definition.select}`, [name]);
}
export async function updateConfig(type: ConfigType, id: number, payload: Record<string, unknown>) {
const definition = configDefinitions[type];
const name = cleanName(payload.name);
const subcategory = typeof payload.subcategory === 'string' && payload.subcategory.trim() ? payload.subcategory.trim() : null;
if (definition.hasSubcategory) {
return queryOne(
`UPDATE ${definition.table} SET name = $1, subcategory = $2 WHERE id = $3 RETURNING ${definition.select}`,
[name, subcategory, id]
);
}
return queryOne(`UPDATE ${definition.table} SET name = $1 WHERE id = $2 RETURNING ${definition.select}`, [name, id]);
}
export async function deleteConfig(type: ConfigType, id: number) {
const definition = configDefinitions[type];
const result = await pool.query(`DELETE FROM ${definition.table} WHERE id = $1`, [id]);
return (result.rowCount ?? 0) > 0;
}
export async function listPokemon(paramsQuery: QueryParams) {
const params: unknown[] = [];
const conditions: string[] = [];
@@ -131,6 +316,80 @@ export async function getPokemon(id: number) {
return { ...pokemon, habitats };
}
function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
const skillIds = cleanIds(payload.skillIds);
const favoriteThingIds = cleanIds(payload.favoriteThingIds);
if (skillIds.length > 2) {
throw new Error('Pokemon can have at most 2 skills');
}
if (favoriteThingIds.length > 6) {
throw new Error('Pokemon can have at most 6 favorite things');
}
return {
id: requirePositiveInteger(payload.id, 'Pokemon ID'),
name: cleanName(payload.name),
environmentId: requirePositiveInteger(payload.environmentId, 'Environment'),
skillIds,
favoriteThingIds
};
}
async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload): Promise<void> {
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 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
]);
}
}
export async function createPokemon(payload: Record<string, unknown>) {
const cleanPayload = cleanPokemonPayload(payload);
const id = await withTransaction(async (client) => {
await client.query('INSERT INTO pokemon (id, name, environment_id) VALUES ($1, $2, $3)', [
cleanPayload.id,
cleanPayload.name,
cleanPayload.environmentId
]);
await replacePokemonRelations(client, cleanPayload.id, cleanPayload);
return cleanPayload.id;
});
return getPokemon(id);
}
export async function updatePokemon(id: number, payload: Record<string, unknown>) {
const cleanPayload = cleanPokemonPayload({ ...payload, id });
const updated = await withTransaction(async (client) => {
const result = await client.query('UPDATE pokemon SET name = $1, environment_id = $2 WHERE id = $3', [
cleanPayload.name,
cleanPayload.environmentId,
id
]);
if (result.rowCount === 0) {
return false;
}
await replacePokemonRelations(client, id, cleanPayload);
return true;
});
return updated ? getPokemon(id) : null;
}
export async function deletePokemon(id: number) {
const result = await pool.query('DELETE FROM pokemon WHERE id = $1', [id]);
return (result.rowCount ?? 0) > 0;
}
export async function listHabitats() {
return query(`
SELECT
@@ -196,6 +455,94 @@ export async function getHabitat(id: number) {
return { ...habitat, pokemon };
}
function cleanHabitatPayload(payload: Record<string, unknown>): HabitatPayload {
const appearances = Array.isArray(payload.pokemonAppearances) ? payload.pokemonAppearances : [];
return {
name: cleanName(payload.name),
recipeItems: cleanQuantities(payload.recipeItems),
pokemonAppearances: appearances
.map((item) => {
const row = item as Record<string, unknown>;
return {
pokemonId: Number(row.pokemonId),
mapId: Number(row.mapId),
timeOfDay: String(row.timeOfDay ?? ''),
weather: String(row.weather ?? ''),
rarity: Number(row.rarity)
};
})
.filter(
(item) =>
Number.isInteger(item.pokemonId) &&
item.pokemonId > 0 &&
Number.isInteger(item.mapId) &&
item.mapId > 0 &&
['早晨', '中午', '傍晚', '晚上'].includes(item.timeOfDay) &&
['晴天', '阴天', '雨天'].includes(item.weather) &&
Number.isInteger(item.rarity) &&
item.rarity >= 1 &&
item.rarity <= 3
)
};
}
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>) {
const cleanPayload = cleanHabitatPayload(payload);
const id = await withTransaction(async (client) => {
const result = await client.query<{ id: number }>('INSERT INTO habitats (name) VALUES ($1) RETURNING id', [
cleanPayload.name
]);
const habitatId = result.rows[0].id;
await replaceHabitatRelations(client, habitatId, cleanPayload);
return habitatId;
});
return getHabitat(id);
}
export async function updateHabitat(id: number, payload: Record<string, unknown>) {
const cleanPayload = cleanHabitatPayload(payload);
const updated = await withTransaction(async (client) => {
const result = await client.query('UPDATE habitats SET name = $1 WHERE id = $2', [cleanPayload.name, id]);
if (result.rowCount === 0) {
return false;
}
await replaceHabitatRelations(client, id, cleanPayload);
return true;
});
return updated ? getHabitat(id) : null;
}
export async function deleteHabitat(id: number) {
const result = await pool.query('DELETE FROM habitats WHERE id = $1', [id]);
return (result.rowCount ?? 0) > 0;
}
const itemProjection = `
SELECT
i.id,
@@ -313,6 +660,108 @@ export async function getItem(id: number) {
return { ...item, acquisitionMethods, recipe, relatedHabitats };
}
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
const recipeId = payload.recipeId === null || payload.recipeId === '' || payload.recipeId === undefined
? null
: requirePositiveInteger(payload.recipeId, 'Recipe');
return {
name: cleanName(payload.name),
categoryId: requirePositiveInteger(payload.categoryId, 'Category'),
usageId: requirePositiveInteger(payload.usageId, 'Usage'),
recipeId,
dyeable: Boolean(payload.dyeable),
dualDyeable: Boolean(payload.dualDyeable),
patternEditable: Boolean(payload.patternEditable),
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
tagIds: cleanIds(payload.tagIds)
};
}
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_item_tags 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_item_tags (item_id, item_tag_id) VALUES ($1, $2)', [itemId, tagId]);
}
}
export async function createItem(payload: Record<string, unknown>) {
const cleanPayload = cleanItemPayload(payload);
const id = await withTransaction(async (client) => {
const result = await client.query<{ id: number }>(
`
INSERT INTO items (name, category_id, usage_id, recipe_id, dyeable, dual_dyeable, pattern_editable)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`,
[
cleanPayload.name,
cleanPayload.categoryId,
cleanPayload.usageId,
cleanPayload.recipeId,
cleanPayload.dyeable,
cleanPayload.dualDyeable,
cleanPayload.patternEditable
]
);
const itemId = result.rows[0].id;
await replaceItemRelations(client, itemId, cleanPayload);
return itemId;
});
return getItem(id);
}
export async function updateItem(id: number, payload: Record<string, unknown>) {
const cleanPayload = cleanItemPayload(payload);
const updated = await withTransaction(async (client) => {
const result = await client.query(
`
UPDATE items
SET name = $1,
category_id = $2,
usage_id = $3,
recipe_id = $4,
dyeable = $5,
dual_dyeable = $6,
pattern_editable = $7
WHERE id = $8
`,
[
cleanPayload.name,
cleanPayload.categoryId,
cleanPayload.usageId,
cleanPayload.recipeId,
cleanPayload.dyeable,
cleanPayload.dualDyeable,
cleanPayload.patternEditable,
id
]
);
if (result.rowCount === 0) {
return false;
}
await replaceItemRelations(client, id, cleanPayload);
return true;
});
return updated ? getItem(id) : null;
}
export async function deleteItem(id: number) {
const result = await pool.query('DELETE FROM items WHERE id = $1', [id]);
return (result.rowCount ?? 0) > 0;
}
export async function listRecipes() {
return query(`
SELECT
@@ -353,3 +802,64 @@ export async function getRecipe(id: number) {
[id]
);
}
function cleanRecipePayload(payload: Record<string, unknown>): RecipePayload {
return {
name: cleanName(payload.name),
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
]);
}
}
export async function createRecipe(payload: Record<string, unknown>) {
const cleanPayload = cleanRecipePayload(payload);
const id = await withTransaction(async (client) => {
const result = await client.query<{ id: number }>('INSERT INTO recipes (name) VALUES ($1) RETURNING id', [
cleanPayload.name
]);
const recipeId = result.rows[0].id;
await replaceRecipeRelations(client, recipeId, cleanPayload);
return recipeId;
});
return getRecipe(id);
}
export async function updateRecipe(id: number, payload: Record<string, unknown>) {
const cleanPayload = cleanRecipePayload(payload);
const updated = await withTransaction(async (client) => {
const result = await client.query('UPDATE recipes SET name = $1 WHERE id = $2', [cleanPayload.name, id]);
if (result.rowCount === 0) {
return false;
}
await replaceRecipeRelations(client, id, cleanPayload);
return true;
});
return updated ? getRecipe(id) : null;
}
export async function deleteRecipe(id: number) {
const result = await pool.query('DELETE FROM recipes WHERE id = $1', [id]);
return (result.rowCount ?? 0) > 0;
}