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:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user