feat(pokemon): enforce bidirectional opposite sync and hide opposite text

Add unique indexes and transactional sync for opposite configurations
Remove explicit opposite names and labels from Pokemon detail view
This commit is contained in:
2026-05-07 16:21:11 +08:00
parent 953b90eba1
commit 575597b146
6 changed files with 83 additions and 57 deletions

View File

@@ -1575,6 +1575,14 @@ BEGIN
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS environments_opposite_environment_unique_idx
ON environments(opposite_environment_id)
WHERE opposite_environment_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS favorite_things_opposite_favorite_thing_unique_idx
ON favorite_things(opposite_favorite_thing_id)
WHERE opposite_favorite_thing_id IS NOT NULL;
UPDATE items
SET dyeability = CASE
WHEN dual_dyeable THEN 2

View File

@@ -2718,10 +2718,8 @@ function pokemonProjection(locale: string): string {
const pokemonDetails = localizedField('pokemon', 'p.id', 'p.details', 'details', locale);
const typeName = localizedName('pokemon-types', 'pt', locale);
const environmentName = localizedName('environments', 'e', locale);
const environmentOppositeName = localizedName('environments', 'environment_opposite', locale);
const skillName = localizedName('skills', 's', locale);
const favoriteThingName = localizedName('favorite-things', 'ft', locale);
const favoriteThingOppositeName = localizedName('favorite-things', 'opposite_ft', locale);
return `
SELECT
@@ -2754,12 +2752,7 @@ function pokemonProjection(locale: string): string {
json_build_object(
'id', e.id,
'name', ${environmentName},
'description', e.description,
'opposite',
CASE
WHEN environment_opposite.id IS NULL THEN NULL
ELSE json_build_object('id', environment_opposite.id, 'name', ${environmentOppositeName})
END
'description', e.description
) AS environment,
COALESCE((
SELECT json_agg(json_build_object('id', pt.id, 'name', ${typeName}) ORDER BY ppt.slot_order)
@@ -2777,23 +2770,16 @@ function pokemonProjection(locale: string): string {
SELECT json_agg(
json_build_object(
'id', ft.id,
'name', ${favoriteThingName},
'opposite',
CASE
WHEN opposite_ft.id IS NULL THEN NULL
ELSE json_build_object('id', opposite_ft.id, 'name', ${favoriteThingOppositeName})
END
'name', ${favoriteThingName}
)
ORDER BY ${orderByEntity('ft')}
)
FROM pokemon_favorite_things pft
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
LEFT JOIN favorite_things opposite_ft ON opposite_ft.id = ft.opposite_favorite_thing_id
WHERE pft.pokemon_id = p.id
), '[]'::json) AS favorite_things
FROM pokemon p
JOIN environments e ON e.id = p.environment_id
LEFT JOIN environments environment_opposite ON environment_opposite.id = e.opposite_environment_id
${auditJoins('p', 'pokemon_created_user', 'pokemon_updated_user')}
`;
}
@@ -5415,6 +5401,69 @@ export function isConfigType(type: string): type is ConfigType {
return Object.hasOwn(configDefinitions, type);
}
async function syncConfigOpposite(
client: DbClient,
definition: ConfigDefinition,
id: number,
oppositeId: number | null,
userId: number
): Promise<void> {
const oppositeColumn = definition.oppositeColumn;
if (!oppositeColumn) {
return;
}
if (oppositeId === id) {
throw validationError('server.validation.invalidField');
}
if (oppositeId !== null) {
const opposite = await client.query<{ id: number }>(
`SELECT id FROM ${definition.table} WHERE id = $1 FOR UPDATE`,
[oppositeId]
);
if (opposite.rowCount === 0) {
throw validationError('server.validation.selectRecord');
}
}
await client.query(
`
UPDATE ${definition.table}
SET ${oppositeColumn} = NULL,
updated_by_user_id = $2,
updated_at = now()
WHERE id <> $1
AND (${oppositeColumn} = $1 OR (${oppositeId === null ? 'FALSE' : `${oppositeColumn} = $3`}))
`,
oppositeId === null ? [id, userId] : [id, userId, oppositeId]
);
await client.query(
`
UPDATE ${definition.table}
SET ${oppositeColumn} = $1,
updated_by_user_id = $2,
updated_at = now()
WHERE id = $3
`,
[oppositeId, userId, id]
);
if (oppositeId !== null) {
await client.query(
`
UPDATE ${definition.table}
SET ${oppositeColumn} = $1,
updated_by_user_id = $2,
updated_at = now()
WHERE id = $3
`,
[id, userId, oppositeId]
);
}
}
export async function listConfig(type: ConfigType, locale = defaultLocale) {
const definition = configDefinitions[type];
return query(
@@ -5460,10 +5509,6 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
columns.push('description');
values.push(description);
}
if (definition.oppositeColumn) {
columns.push(definition.oppositeColumn);
values.push(oppositeId);
}
if (definition.hasItemDrop) {
columns.push('has_item_drop');
values.push(hasItemDrop);
@@ -5489,6 +5534,7 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
);
const createdId = result.rows[0].id;
await syncConfigOpposite(client, definition, createdId, oppositeId, userId);
await replaceEntityTranslations(client, definition.entityType, createdId, translations, ['name']);
await recordEditLog(client, type, createdId, 'create', userId);
return createdId;
@@ -5539,10 +5585,6 @@ export async function updateConfig(
values.push(description);
assignments.push(`description = $${values.length}`);
}
if (definition.oppositeColumn) {
values.push(oppositeId);
assignments.push(`${definition.oppositeColumn} = $${values.length}`);
}
if (definition.hasItemDrop) {
values.push(hasItemDrop);
assignments.push(`has_item_drop = $${values.length}`);
@@ -5571,6 +5613,8 @@ export async function updateConfig(
return false;
}
await syncConfigOpposite(client, definition, id, oppositeId, userId);
if (definition.hasItemDrop && !hasItemDrop) {
await client.query('DELETE FROM pokemon_skill_item_drops WHERE skill_id = $1', [id]);
}
@@ -5730,7 +5774,6 @@ export async function getPokemon(id: number, locale = defaultLocale) {
const relatedEnvironmentName = localizedName('environments', 'related_environment', locale);
const relatedSkillName = localizedName('skills', 'related_skill', locale);
const relatedFavoriteThingName = localizedName('favorite-things', 'related_favorite_thing', locale);
const relatedFavoriteThingOppositeName = localizedName('favorite-things', 'related_favorite_thing_opposite', locale);
const tradingItemName = localizedName('items', 'trading_item', locale);
const [habitats, itemDrops, favoriteThingItems, tradingItems, relatedPokemon, editHistory, imageHistory] = await Promise.all([
@@ -5894,11 +5937,6 @@ export async function getPokemon(id: number, locale = defaultLocale) {
json_build_object(
'id', related_favorite_thing.id,
'name', ${relatedFavoriteThingName},
'opposite',
CASE
WHEN related_favorite_thing_opposite.id IS NULL THEN NULL
ELSE json_build_object('id', related_favorite_thing_opposite.id, 'name', ${relatedFavoriteThingOppositeName})
END,
'matches', EXISTS (
SELECT 1
FROM current_favourites
@@ -5915,8 +5953,6 @@ export async function getPokemon(id: number, locale = defaultLocale) {
)
FROM pokemon_favorite_things related_pokemon_favourite
JOIN favorite_things related_favorite_thing ON related_favorite_thing.id = related_pokemon_favourite.favorite_thing_id
LEFT JOIN favorite_things related_favorite_thing_opposite
ON related_favorite_thing_opposite.id = related_favorite_thing.opposite_favorite_thing_id
WHERE related_pokemon_favourite.pokemon_id = related_pokemon.id
), '[]'::json) AS favorite_things
FROM scored_pokemon