feat(dish): add dish management and public view

Add database schema, permissions, and API endpoints for dishes
Implement frontend views and admin management for dish data
This commit is contained in:
2026-05-04 21:00:23 +08:00
parent 2ff2519647
commit 2220d5d595
12 changed files with 2147 additions and 25 deletions

View File

@@ -35,12 +35,15 @@ CREATE TABLE IF NOT EXISTS entity_translations (
'habitats',
'daily-checklist-items',
'life-tags',
'game-versions'
'game-versions',
'dish-categories',
'dish-flavors',
'dishes'
)
),
entity_id integer NOT NULL,
locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE,
field_name text NOT NULL CHECK (field_name IN ('name', 'title', 'details', 'genus')),
field_name text NOT NULL CHECK (field_name IN ('name', 'title', 'details', 'genus', 'effect', 'mosslaxEffect')),
value text NOT NULL,
PRIMARY KEY (entity_type, entity_id, locale, field_name)
);
@@ -68,10 +71,21 @@ ALTER TABLE entity_translations
'habitats',
'daily-checklist-items',
'life-tags',
'game-versions'
'game-versions',
'dish-categories',
'dish-flavors',
'dishes'
)
);
ALTER TABLE entity_translations
DROP CONSTRAINT IF EXISTS entity_translations_field_name_check;
ALTER TABLE entity_translations
ADD CONSTRAINT entity_translations_field_name_check CHECK (
field_name IN ('name', 'title', 'details', 'genus', 'effect', 'mosslaxEffect')
);
CREATE TABLE IF NOT EXISTS users (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
@@ -291,6 +305,10 @@ VALUES
('recipes.update', 'Update recipes', 'Edit recipe records.', 'Recipes', true),
('recipes.delete', 'Delete recipes', 'Delete recipe records.', 'Recipes', true),
('recipes.order', 'Order recipes', 'Reorder recipe records.', 'Recipes', true),
('dish.create', 'Create Dish records', 'Create Dish categories and dish records.', 'Dish', true),
('dish.update', 'Update Dish records', 'Edit Dish categories and dish records.', 'Dish', true),
('dish.delete', 'Delete Dish records', 'Delete Dish categories and dish records.', 'Dish', true),
('dish.order', 'Order Dish records', 'Reorder Dish categories and dish records.', 'Dish', true),
('life.posts.create', 'Create Life posts', 'Create Life posts.', 'Life', true),
('life.posts.update', 'Update own Life posts', 'Edit own Life posts.', 'Life', true),
('life.posts.delete', 'Delete own Life posts', 'Delete own Life posts.', 'Life', true),
@@ -385,6 +403,10 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'recipes.update',
'recipes.delete',
'recipes.order',
'dish.create',
'dish.update',
'dish.delete',
'dish.order',
'life.posts.create',
'life.posts.update',
'life.posts.delete',
@@ -459,6 +481,9 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'recipes.create',
'recipes.update',
'recipes.order',
'dish.create',
'dish.update',
'dish.order',
'life.posts.create',
'life.posts.update',
'life.posts.delete',
@@ -505,6 +530,29 @@ JOIN permissions p ON p.key = ANY (ARRAY[
WHERE r.key = 'editor'
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
'dish.create',
'dish.update',
'dish.delete',
'dish.order'
])
WHERE r.key = 'admin'
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
'dish.create',
'dish.update',
'dish.order'
])
WHERE r.key = 'editor'
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
@@ -1024,6 +1072,112 @@ CREATE TABLE IF NOT EXISTS recipe_materials (
PRIMARY KEY (recipe_id, item_id)
);
CREATE TABLE IF NOT EXISTS dish_categories (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
cookware_item_id integer NOT NULL REFERENCES items(id),
main_material_item_id integer NOT NULL REFERENCES items(id),
total_material_quantity integer NOT NULL DEFAULT 2 CHECK (total_material_quantity >= 2),
effect text NOT NULL DEFAULT '',
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE dish_categories
ADD COLUMN IF NOT EXISTS main_material_item_id integer REFERENCES items(id);
ALTER TABLE dish_categories
ADD COLUMN IF NOT EXISTS total_material_quantity integer NOT NULL DEFAULT 2;
DO $$
BEGIN
IF to_regclass('public.dishes') IS NOT NULL
AND EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'dishes'
AND column_name = 'main_material_item_id'
)
THEN
EXECUTE '
UPDATE dish_categories dc
SET main_material_item_id = source.main_material_item_id
FROM (
SELECT DISTINCT ON (category_id) category_id, main_material_item_id
FROM dishes
WHERE main_material_item_id IS NOT NULL
ORDER BY category_id, sort_order, id
) AS source
WHERE dc.id = source.category_id
AND dc.main_material_item_id IS NULL
';
END IF;
END $$;
UPDATE dish_categories
SET main_material_item_id = cookware_item_id
WHERE main_material_item_id IS NULL;
ALTER TABLE dish_categories
ALTER COLUMN main_material_item_id SET NOT NULL;
ALTER TABLE dish_categories
ALTER COLUMN total_material_quantity SET DEFAULT 2;
UPDATE dish_categories
SET total_material_quantity = 2
WHERE total_material_quantity < 2;
ALTER TABLE dish_categories
DROP CONSTRAINT IF EXISTS dish_categories_total_material_quantity_check;
ALTER TABLE dish_categories
ADD CONSTRAINT dish_categories_total_material_quantity_check CHECK (total_material_quantity >= 2);
CREATE TABLE IF NOT EXISTS dish_flavors (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dishes (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
category_id integer NOT NULL REFERENCES dish_categories(id) ON DELETE CASCADE,
item_id integer NOT NULL UNIQUE REFERENCES items(id),
flavor_id integer NOT NULL REFERENCES dish_flavors(id),
secondary_material_1_item_id integer REFERENCES items(id),
secondary_material_2_item_id integer REFERENCES items(id),
pokemon_skill_id integer REFERENCES skills(id),
mosslax_effect text NOT NULL DEFAULT '',
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (
secondary_material_1_item_id IS NULL
OR secondary_material_2_item_id IS NULL
OR secondary_material_1_item_id <> secondary_material_2_item_id
)
);
ALTER TABLE dishes
ADD COLUMN IF NOT EXISTS flavor_id integer REFERENCES dish_flavors(id);
ALTER TABLE dishes
ALTER COLUMN secondary_material_1_item_id DROP NOT NULL;
ALTER TABLE dishes
DROP COLUMN IF EXISTS main_material_item_id;
CREATE TABLE IF NOT EXISTS maps (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
@@ -1193,6 +1347,10 @@ CREATE INDEX IF NOT EXISTS items_display_order_idx ON items(is_event_item, displ
CREATE INDEX IF NOT EXISTS ancient_artifacts_sort_order_idx ON ancient_artifacts(sort_order, id);
CREATE INDEX IF NOT EXISTS ancient_artifacts_display_order_idx ON ancient_artifacts(display_id, sort_order, id);
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
CREATE INDEX IF NOT EXISTS dish_categories_sort_order_idx ON dish_categories(sort_order, id);
CREATE INDEX IF NOT EXISTS dish_flavors_sort_order_idx ON dish_flavors(sort_order, id);
CREATE INDEX IF NOT EXISTS dishes_category_sort_order_idx ON dishes(category_id, sort_order, id);
CREATE INDEX IF NOT EXISTS dishes_sort_order_idx ON dishes(sort_order, id);
CREATE INDEX IF NOT EXISTS maps_sort_order_idx ON maps(sort_order, id);
CREATE INDEX IF NOT EXISTS habitats_sort_order_idx ON habitats(sort_order, id);

View File

@@ -63,7 +63,7 @@ type GlobalSearchResults = {
groups: GlobalSearchGroup[];
};
type TranslationField = 'name' | 'title' | 'details' | 'genus';
type TranslationField = 'name' | 'title' | 'details' | 'genus' | 'effect' | 'mosslaxEffect';
type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>;
type EntityType =
| 'pokemon'
@@ -80,7 +80,10 @@ type EntityType =
| 'habitats'
| 'daily-checklist-items'
| 'life-tags'
| 'game-versions';
| 'game-versions'
| 'dish-categories'
| 'dish-flavors'
| 'dishes';
type ConfigType =
| 'pokemon-types'
@@ -90,7 +93,8 @@ type ConfigType =
| 'acquisition-methods'
| 'maps'
| 'life-tags'
| 'game-versions';
| 'game-versions'
| 'dish-flavors';
type ConfigDefinition = {
table: string;
@@ -232,6 +236,25 @@ type RecipePayload = {
materials: IdQuantity[];
};
type DishCategoryPayload = {
name: string;
effect: string;
translations: TranslationInput;
cookwareItemId: number;
mainMaterialItemId: number;
totalMaterialQuantity: number;
};
type DishPayload = {
categoryId: number;
itemId: number;
flavorId: number;
secondaryMaterialItemIds: number[];
pokemonSkillId: number | null;
mosslaxEffect: string;
translations: TranslationInput;
};
type DailyChecklistPayload = {
title: string;
translations: TranslationInput;
@@ -550,6 +573,25 @@ type RecipeChangeSource = {
acquisition_methods: Array<{ name: string }>;
materials: Array<{ name: string; quantity: number }>;
};
type DishCategoryChangeSource = {
name: string;
effect: string;
translations?: TranslationInput;
cookware: { name: string };
mainMaterial: { name: string };
totalMaterialQuantity: number;
};
type DishChangeSource = {
category: { name: string };
item: { name: string };
flavor: { name: string };
secondaryMaterials: Array<{ name: string }>;
pokemonSkill: { name: string } | null;
mosslaxEffect: string;
translations?: TranslationInput;
};
type DailyChecklistChangeSource = {
title: string;
} & TranslationChangeSource;
@@ -625,7 +667,8 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
maps: { table: 'maps', entityType: 'maps' },
'life-tags': { table: 'life_tags', entityType: 'life-tags', hasDefault: true, hasRateable: true },
'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true }
'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true },
'dish-flavors': { table: 'dish_flavors', entityType: 'dish-flavors' }
};
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
@@ -2008,7 +2051,9 @@ const translationChangeLabels: Record<TranslationField, string> = {
name: 'Name',
title: 'Title',
details: 'Details',
genus: 'Genus'
genus: 'Genus',
effect: 'Effect',
mosslaxEffect: 'Mosslax effect'
};
function translationFieldValue(
@@ -2414,7 +2459,8 @@ export async function getOptions(locale = defaultLocale) {
acquisitionMethods,
maps,
lifeCategories,
gameVersions
gameVersions,
dishFlavors
] = await Promise.all([
optionSelect('pokemon_types', 'pokemon-types', locale),
skillOptions(locale),
@@ -2423,7 +2469,8 @@ export async function getOptions(locale = defaultLocale) {
optionSelect('acquisition_methods', 'acquisition-methods', locale),
optionSelect('maps', 'maps', locale),
lifeCategoryOptions(locale),
gameVersionOptions(locale)
gameVersionOptions(locale),
optionSelect('dish_flavors', 'dish-flavors', locale)
]);
return {
@@ -2438,7 +2485,8 @@ export async function getOptions(locale = defaultLocale) {
itemTags: favoriteThings,
maps,
lifeCategories,
gameVersions
gameVersions,
dishFlavors
};
}
@@ -6989,6 +7037,488 @@ export async function deleteRecipe(id: number, userId: number) {
});
}
function dishCategoryProjection(locale: string): string {
const categoryName = localizedName('dish-categories', 'dc', locale);
const categoryEffect = localizedField('dish-categories', 'dc.id', 'dc.effect', 'effect', locale);
const cookwareName = localizedName('items', 'cookware_item', locale);
const mainMaterialName = localizedName('items', 'main_material_item', locale);
const dishItemName = localizedName('items', 'dish_item', locale);
const flavorName = localizedName('dish-flavors', 'dish_flavor', locale);
const secondaryMaterialName = localizedName('items', 'secondary_material_item', locale);
const skillName = localizedName('skills', 'dish_skill', locale);
const mosslaxEffect = localizedField('dishes', 'd.id', 'd.mosslax_effect', 'mosslaxEffect', locale);
return `
SELECT
dc.id,
${categoryName} AS name,
dc.name AS "baseName",
${categoryEffect} AS effect,
dc.effect AS "baseEffect",
dc.total_material_quantity AS "totalMaterialQuantity",
${translationsSelect('dish-categories', 'dc.id')} AS translations,
${auditSelect('dc', 'category_created_user', 'category_updated_user')},
json_build_object(
'id', cookware_item.id,
'displayId', cookware_item.display_id,
'name', ${cookwareName},
'image', ${uploadedImageJson('cookware_item.image_path')},
'category', ${systemListJsonSql('cookware_item.category_key', itemCategoryOptions, locale)}
) AS cookware,
json_build_object(
'id', main_material_item.id,
'displayId', main_material_item.display_id,
'name', ${mainMaterialName},
'image', ${uploadedImageJson('main_material_item.image_path')},
'category', ${systemListJsonSql('main_material_item.category_key', itemCategoryOptions, locale)}
) AS "mainMaterial",
COALESCE((
SELECT json_agg(
json_build_object(
'id', d.id,
'flavor', json_build_object('id', dish_flavor.id, 'name', ${flavorName}),
'mosslaxEffect', ${mosslaxEffect},
'baseMosslaxEffect', d.mosslax_effect,
'translations', ${translationsSelect('dishes', 'd.id')},
'createdAt', d.created_at,
'updatedAt', d.updated_at,
'createdBy', CASE
WHEN dish_created_user.id IS NULL THEN NULL
ELSE json_build_object('id', dish_created_user.id, 'displayName', dish_created_user.display_name)
END,
'updatedBy', CASE
WHEN dish_updated_user.id IS NULL THEN NULL
ELSE json_build_object('id', dish_updated_user.id, 'displayName', dish_updated_user.display_name)
END,
'category', json_build_object('id', dc.id, 'name', ${categoryName}),
'item', json_build_object(
'id', dish_item.id,
'displayId', dish_item.display_id,
'name', ${dishItemName},
'image', ${uploadedImageJson('dish_item.image_path')},
'category', ${systemListJsonSql('dish_item.category_key', itemCategoryOptions, locale)}
),
'secondaryMaterials', COALESCE((
SELECT json_agg(
json_build_object(
'id', secondary_material_item.id,
'displayId', secondary_material_item.display_id,
'name', ${secondaryMaterialName},
'image', ${uploadedImageJson('secondary_material_item.image_path')},
'category', ${systemListJsonSql('secondary_material_item.category_key', itemCategoryOptions, locale)}
)
ORDER BY secondary_slots.slot
)
FROM (VALUES (d.secondary_material_1_item_id, 1), (d.secondary_material_2_item_id, 2)) AS secondary_slots(item_id, slot)
JOIN items secondary_material_item ON secondary_material_item.id = secondary_slots.item_id
), '[]'::json),
'pokemonSkill', CASE
WHEN dish_skill.id IS NULL THEN NULL
ELSE json_build_object('id', dish_skill.id, 'name', ${skillName}, 'hasItemDrop', dish_skill.has_item_drop)
END
)
ORDER BY d.sort_order, d.id
)
FROM dishes d
JOIN items dish_item ON dish_item.id = d.item_id
JOIN dish_flavors dish_flavor ON dish_flavor.id = d.flavor_id
LEFT JOIN skills dish_skill ON dish_skill.id = d.pokemon_skill_id
LEFT JOIN users dish_created_user ON dish_created_user.id = d.created_by_user_id
LEFT JOIN users dish_updated_user ON dish_updated_user.id = d.updated_by_user_id
WHERE d.category_id = dc.id
), '[]'::json) AS dishes
FROM dish_categories dc
JOIN items cookware_item ON cookware_item.id = dc.cookware_item_id
JOIN items main_material_item ON main_material_item.id = dc.main_material_item_id
${auditJoins('dc', 'category_created_user', 'category_updated_user')}
`;
}
function dishProjection(locale: string): string {
const categoryName = localizedName('dish-categories', 'dc', locale);
const dishItemName = localizedName('items', 'dish_item', locale);
const flavorName = localizedName('dish-flavors', 'dish_flavor', locale);
const secondaryMaterialName = localizedName('items', 'secondary_material_item', locale);
const skillName = localizedName('skills', 'dish_skill', locale);
const mosslaxEffect = localizedField('dishes', 'd.id', 'd.mosslax_effect', 'mosslaxEffect', locale);
return `
SELECT
d.id,
json_build_object('id', dish_flavor.id, 'name', ${flavorName}) AS flavor,
${mosslaxEffect} AS "mosslaxEffect",
d.mosslax_effect AS "baseMosslaxEffect",
${translationsSelect('dishes', 'd.id')} AS translations,
${auditSelect('d', 'dish_created_user', 'dish_updated_user')},
json_build_object('id', dc.id, 'name', ${categoryName}) AS category,
json_build_object(
'id', dish_item.id,
'displayId', dish_item.display_id,
'name', ${dishItemName},
'image', ${uploadedImageJson('dish_item.image_path')},
'category', ${systemListJsonSql('dish_item.category_key', itemCategoryOptions, locale)}
) AS item,
COALESCE((
SELECT json_agg(
json_build_object(
'id', secondary_material_item.id,
'displayId', secondary_material_item.display_id,
'name', ${secondaryMaterialName},
'image', ${uploadedImageJson('secondary_material_item.image_path')},
'category', ${systemListJsonSql('secondary_material_item.category_key', itemCategoryOptions, locale)}
)
ORDER BY secondary_slots.slot
)
FROM (VALUES (d.secondary_material_1_item_id, 1), (d.secondary_material_2_item_id, 2)) AS secondary_slots(item_id, slot)
JOIN items secondary_material_item ON secondary_material_item.id = secondary_slots.item_id
), '[]'::json) AS "secondaryMaterials",
CASE
WHEN dish_skill.id IS NULL THEN NULL
ELSE json_build_object('id', dish_skill.id, 'name', ${skillName}, 'hasItemDrop', dish_skill.has_item_drop)
END AS "pokemonSkill"
FROM dishes d
JOIN dish_categories dc ON dc.id = d.category_id
JOIN items dish_item ON dish_item.id = d.item_id
JOIN dish_flavors dish_flavor ON dish_flavor.id = d.flavor_id
LEFT JOIN skills dish_skill ON dish_skill.id = d.pokemon_skill_id
${auditJoins('d', 'dish_created_user', 'dish_updated_user')}
`;
}
export async function listDish(locale = defaultLocale) {
return query(`${dishCategoryProjection(locale)} ORDER BY ${orderByEntity('dc')}`);
}
async function getDishCategory(id: number, locale = defaultLocale) {
return queryOne(`${dishCategoryProjection(locale)} WHERE dc.id = $1`, [id]);
}
async function getDish(id: number, locale = defaultLocale) {
return queryOne(`${dishProjection(locale)} WHERE d.id = $1`, [id]);
}
function cleanDishCategoryPayload(payload: Record<string, unknown>): DishCategoryPayload {
const totalMaterialQuantity = requirePositiveInteger(payload.totalMaterialQuantity, 'server.validation.invalidField');
if (totalMaterialQuantity < 2) {
throw validationError('server.validation.invalidField');
}
return {
name: cleanName(payload.name),
effect: cleanName(payload.effect, 'server.validation.invalidField'),
translations: cleanTranslations(payload.translations, ['name', 'effect']),
cookwareItemId: requirePositiveInteger(payload.cookwareItemId, 'server.validation.itemRequired'),
mainMaterialItemId: requirePositiveInteger(payload.mainMaterialItemId, 'server.validation.itemRequired'),
totalMaterialQuantity
};
}
function cleanDishPayload(payload: Record<string, unknown>): DishPayload {
const secondaryMaterialItemIds = cleanIds(payload.secondaryMaterialItemIds).slice(0, 2);
return {
categoryId: requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired'),
itemId: requirePositiveInteger(payload.itemId, 'server.validation.itemRequired'),
flavorId: requirePositiveInteger(payload.flavorId, 'server.validation.invalidField'),
secondaryMaterialItemIds,
pokemonSkillId: optionalPositiveInteger(payload.pokemonSkillId, 'server.validation.invalidField'),
mosslaxEffect: cleanName(payload.mosslaxEffect, 'server.validation.invalidField'),
translations: cleanTranslations(payload.translations, ['mosslaxEffect'])
};
}
async function ensureDishMaterialSlots(client: DbClient, payload: DishPayload): Promise<void> {
const result = await client.query<{ totalMaterialQuantity: number; mainMaterialItemId: number }>(
`
SELECT
total_material_quantity AS "totalMaterialQuantity",
main_material_item_id AS "mainMaterialItemId"
FROM dish_categories
WHERE id = $1
`,
[payload.categoryId]
);
if (result.rowCount === 0) {
throw validationError('server.validation.categoryRequired');
}
if (payload.secondaryMaterialItemIds.length > 1 && result.rows[0].totalMaterialQuantity <= 2) {
throw validationError('server.validation.invalidField');
}
if (payload.secondaryMaterialItemIds.includes(result.rows[0].mainMaterialItemId)) {
throw validationError('server.validation.invalidField');
}
}
async function ensureDishCategoryMaterialSlots(client: DbClient, id: number, payload: DishCategoryPayload): Promise<void> {
const result = await client.query<{ id: number }>(
`
SELECT id
FROM dishes
WHERE category_id = $1
AND (
($2::integer <= 2 AND secondary_material_2_item_id IS NOT NULL)
OR secondary_material_1_item_id = $3
OR secondary_material_2_item_id = $3
)
LIMIT 1
`,
[id, payload.totalMaterialQuantity, payload.mainMaterialItemId]
);
if ((result.rowCount ?? 0) > 0) {
throw validationError('server.validation.invalidField');
}
}
async function dishCategoryEditChanges(
client: DbClient,
before: DishCategoryChangeSource,
after: DishCategoryPayload
): Promise<EditChange[]> {
const changes: EditChange[] = [];
const itemNames = await entityNameMap(client, 'items', [after.cookwareItemId, after.mainMaterialItemId]);
pushChange(changes, 'Name', before.name, after.name);
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'effect']);
pushChange(changes, 'Cookware', before.cookware.name, itemNames.get(after.cookwareItemId));
pushChange(changes, 'Main material', before.mainMaterial.name, itemNames.get(after.mainMaterialItemId));
pushChange(changes, 'Total material quantity', String(before.totalMaterialQuantity), String(after.totalMaterialQuantity));
pushChange(changes, 'Effect', before.effect, after.effect);
return changes;
}
async function dishEditChanges(client: DbClient, before: DishChangeSource, after: DishPayload): Promise<EditChange[]> {
const changes: EditChange[] = [];
const categoryNames = await entityNameMap(client, 'dish_categories', [after.categoryId]);
const itemNames = await entityNameMap(client, 'items', [after.itemId, ...after.secondaryMaterialItemIds]);
const flavorNames = await entityNameMap(client, 'dish_flavors', [after.flavorId]);
const skillNames = await entityNameMap(client, 'skills', after.pokemonSkillId ? [after.pokemonSkillId] : []);
pushChange(changes, 'Category', before.category.name, categoryNames.get(after.categoryId));
pushChange(changes, 'Dish item', before.item.name, itemNames.get(after.itemId));
pushChange(changes, 'Flavor', before.flavor.name, flavorNames.get(after.flavorId));
pushTranslationChanges(changes, before.translations, after.translations, ['mosslaxEffect']);
pushChange(changes, 'Secondary materials', namedListValue(before.secondaryMaterials), namesFromIds(after.secondaryMaterialItemIds, itemNames));
pushChange(changes, 'Pokemon speciality', before.pokemonSkill?.name, after.pokemonSkillId ? skillNames.get(after.pokemonSkillId) : null);
pushChange(changes, 'Mosslax effect', before.mosslaxEffect, after.mosslaxEffect);
return changes;
}
export async function createDishCategory(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanDishCategoryPayload(payload);
const id = await withTransaction(async (client) => {
const sortOrder = await nextSortOrder(client, 'dish_categories');
const result = await client.query<{ id: number }>(
`
INSERT INTO dish_categories (
name,
cookware_item_id,
main_material_item_id,
total_material_quantity,
effect,
sort_order,
created_by_user_id,
updated_by_user_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
RETURNING id
`,
[
cleanPayload.name,
cleanPayload.cookwareItemId,
cleanPayload.mainMaterialItemId,
cleanPayload.totalMaterialQuantity,
cleanPayload.effect,
sortOrder,
userId
]
);
const categoryId = result.rows[0].id;
await replaceEntityTranslations(client, 'dish-categories', categoryId, cleanPayload.translations, ['name', 'effect']);
await recordEditLog(client, 'dish-categories', categoryId, 'create', userId);
return categoryId;
});
return getDishCategory(id, locale);
}
export async function updateDishCategory(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanDishCategoryPayload(payload);
const before = await getDishCategory(id, defaultLocale);
const updated = await withTransaction(async (client) => {
await ensureDishCategoryMaterialSlots(client, id, cleanPayload);
const result = await client.query(
`
UPDATE dish_categories
SET name = $1,
cookware_item_id = $2,
main_material_item_id = $3,
total_material_quantity = $4,
effect = $5,
updated_by_user_id = $6,
updated_at = now()
WHERE id = $7
`,
[
cleanPayload.name,
cleanPayload.cookwareItemId,
cleanPayload.mainMaterialItemId,
cleanPayload.totalMaterialQuantity,
cleanPayload.effect,
userId,
id
]
);
if (result.rowCount === 0) {
return false;
}
await replaceEntityTranslations(client, 'dish-categories', id, cleanPayload.translations, ['name', 'effect']);
const changes = before ? await dishCategoryEditChanges(client, before as unknown as DishCategoryChangeSource, cleanPayload) : [];
await recordEditLog(client, 'dish-categories', id, 'update', userId, changes);
return true;
});
return updated ? getDishCategory(id, locale) : null;
}
export async function deleteDishCategory(id: number, userId: number) {
return withTransaction(async (client) => {
const childRows = await client.query<{ id: number }>('SELECT id FROM dishes WHERE category_id = $1', [id]);
const result = await client.query<{ id: number }>('DELETE FROM dish_categories WHERE id = $1 RETURNING id', [id]);
if (result.rowCount === 0) {
return false;
}
await client.query('DELETE FROM entity_translations WHERE entity_type = $1 AND entity_id = $2', ['dish-categories', id]);
const childIds = childRows.rows.map((row) => row.id);
if (childIds.length) {
await client.query('DELETE FROM entity_translations WHERE entity_type = $1 AND entity_id = ANY($2::integer[])', [
'dishes',
childIds
]);
}
await recordEditLog(client, 'dish-categories', id, 'delete', userId);
return true;
});
}
export async function createDish(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanDishPayload(payload);
const id = await withTransaction(async (client) => {
await ensureDishMaterialSlots(client, cleanPayload);
const sortOrder = await nextSortOrder(client, 'dishes');
const result = await client.query<{ id: number }>(
`
INSERT INTO dishes (
category_id,
item_id,
flavor_id,
secondary_material_1_item_id,
secondary_material_2_item_id,
pokemon_skill_id,
mosslax_effect,
sort_order,
created_by_user_id,
updated_by_user_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
RETURNING id
`,
[
cleanPayload.categoryId,
cleanPayload.itemId,
cleanPayload.flavorId,
cleanPayload.secondaryMaterialItemIds[0] ?? null,
cleanPayload.secondaryMaterialItemIds[1] ?? null,
cleanPayload.pokemonSkillId,
cleanPayload.mosslaxEffect,
sortOrder,
userId
]
);
const dishId = result.rows[0].id;
await replaceEntityTranslations(client, 'dishes', dishId, cleanPayload.translations, ['mosslaxEffect']);
await recordEditLog(client, 'dishes', dishId, 'create', userId);
return dishId;
});
return getDish(id, locale);
}
export async function updateDish(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanDishPayload(payload);
const before = await getDish(id, defaultLocale);
const updated = await withTransaction(async (client) => {
await ensureDishMaterialSlots(client, cleanPayload);
const result = await client.query(
`
UPDATE dishes
SET category_id = $1,
item_id = $2,
flavor_id = $3,
secondary_material_1_item_id = $4,
secondary_material_2_item_id = $5,
pokemon_skill_id = $6,
mosslax_effect = $7,
updated_by_user_id = $8,
updated_at = now()
WHERE id = $9
`,
[
cleanPayload.categoryId,
cleanPayload.itemId,
cleanPayload.flavorId,
cleanPayload.secondaryMaterialItemIds[0] ?? null,
cleanPayload.secondaryMaterialItemIds[1] ?? null,
cleanPayload.pokemonSkillId,
cleanPayload.mosslaxEffect,
userId,
id
]
);
if (result.rowCount === 0) {
return false;
}
await replaceEntityTranslations(client, 'dishes', id, cleanPayload.translations, ['mosslaxEffect']);
const changes = before ? await dishEditChanges(client, before as unknown as DishChangeSource, cleanPayload) : [];
await recordEditLog(client, 'dishes', id, 'update', userId, changes);
return true;
});
return updated ? getDish(id, locale) : null;
}
export async function deleteDish(id: number, userId: number) {
return withTransaction(async (client) => {
const result = await client.query<{ id: number }>('DELETE FROM dishes WHERE id = $1 RETURNING id', [id]);
if (result.rowCount === 0) {
return false;
}
await deleteEntityTranslations(client, 'dishes', id);
await recordEditLog(client, 'dishes', id, 'delete', userId);
return true;
});
}
export async function reorderDishCategories(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const ids = cleanIds(payload.ids);
if (ids.length === 0) {
throw validationError('server.validation.selectRecord');
}
await withTransaction(async (client) => {
await reorderTableRows(client, 'dish_categories', 'dish-categories', ids, userId);
});
return listDish(locale);
}
export async function reorderDishes(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const ids = cleanIds(payload.ids);
if (ids.length === 0) {
throw validationError('server.validation.selectRecord');
}
await withTransaction(async (client) => {
await reorderTableRows(client, 'dishes', 'dishes', ids, userId);
});
return listDish(locale);
}
const dataToolScopes = ['pokemon', 'habitats', 'items', 'artifacts', 'recipes', 'checklist'] as const satisfies readonly DataToolScope[];
const dataToolMainTables: Record<DataToolScope, string> = {
pokemon: 'pokemon',

View File

@@ -38,6 +38,8 @@ import {
createAncientArtifact,
createConfig,
createDailyChecklistItem,
createDish,
createDishCategory,
createEntityDiscussionComment,
createEntityDiscussionReply,
createHabitat,
@@ -51,6 +53,8 @@ import {
deleteConfig,
deleteAncientArtifact,
deleteDailyChecklistItem,
deleteDish,
deleteDishCategory,
deleteEntityDiscussionComment,
deleteHabitat,
deleteItem,
@@ -71,6 +75,7 @@ import {
getAncientArtifact,
getHabitat,
getItem,
listDish,
getLifePost,
getOptions,
getPokemon,
@@ -99,6 +104,8 @@ import {
reorderConfig,
reorderAncientArtifacts,
reorderDailyChecklistItems,
reorderDishCategories,
reorderDishes,
reorderHabitats,
reorderItems,
reorderLanguages,
@@ -115,6 +122,8 @@ import {
updateConfig,
updateAncientArtifact,
updateDailyChecklistItem,
updateDish,
updateDishCategory,
updateHabitat,
updateItem,
updateLanguage,
@@ -1911,6 +1920,72 @@ app.delete('/api/recipes/:id', async (request, reply) => {
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.get('/api/dish', async (request) => listDish(requestLocale(request)));
app.post('/api/admin/dish/categories', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.create', 'wikiWrite');
return user
? reply.code(201).send(await createDishCategory(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
});
app.put('/api/admin/dish/categories/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.order', 'wikiWrite');
return user ? reorderDishCategories(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.put('/api/admin/dish/categories/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.update', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const category = await updateDishCategory(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return category ? category : notFound(reply, request);
});
app.delete('/api/admin/dish/categories/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.delete', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteDishCategory(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.post('/api/admin/dish/dishes', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.create', 'wikiWrite');
return user
? reply.code(201).send(await createDish(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
});
app.put('/api/admin/dish/dishes/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.order', 'wikiWrite');
return user ? reorderDishes(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.put('/api/admin/dish/dishes/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.update', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const dish = await updateDish(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return dish ? dish : notFound(reply, request);
});
app.delete('/api/admin/dish/dishes/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.delete', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteDish(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.post('/api/admin/daily-checklist', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'checklist.create', 'wikiWrite');
return user