feat(admin): add module settings to toggle trading feature
Introduce module_settings table to store global feature flags Add admin UI to enable or disable the Trading module Hide trading-related UI and skip data fetching when disabled
This commit is contained in:
@@ -569,6 +569,10 @@ type LanguagePayload = {
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type ModuleSettings = {
|
||||
tradingEnabled: boolean;
|
||||
};
|
||||
|
||||
type ValidationError = Error & { statusCode: number };
|
||||
type EditAction = 'create' | 'update' | 'delete';
|
||||
type EditChange = {
|
||||
@@ -1072,6 +1076,44 @@ function systemListJsonSql(expression: string, options: readonly SystemListOptio
|
||||
return `json_build_object('id', ${systemListIdSql(expression, options)}, 'key', ${expression}, 'name', ${systemListNameSql(expression, options, locale)})`;
|
||||
}
|
||||
|
||||
function publicModuleSettings(row: { tradingEnabled: boolean } | null): ModuleSettings {
|
||||
return {
|
||||
tradingEnabled: row?.tradingEnabled ?? true
|
||||
};
|
||||
}
|
||||
|
||||
async function moduleSettingsForClient(client: DbClient): Promise<ModuleSettings> {
|
||||
const result = await client.query<{ tradingEnabled: boolean }>(
|
||||
'SELECT trading_enabled AS "tradingEnabled" FROM module_settings WHERE id = true'
|
||||
);
|
||||
return publicModuleSettings(result.rows[0] ?? null);
|
||||
}
|
||||
|
||||
export async function getModuleSettings(): Promise<ModuleSettings> {
|
||||
const row = await queryOne<{ tradingEnabled: boolean }>(
|
||||
'SELECT trading_enabled AS "tradingEnabled" FROM module_settings WHERE id = true'
|
||||
);
|
||||
return publicModuleSettings(row);
|
||||
}
|
||||
|
||||
export async function updateModuleSettings(payload: Record<string, unknown>, userId: number): Promise<ModuleSettings> {
|
||||
const current = await getModuleSettings();
|
||||
const tradingEnabled = typeof payload.tradingEnabled === 'boolean' ? payload.tradingEnabled : current.tradingEnabled;
|
||||
const row = await queryOne<{ tradingEnabled: boolean }>(
|
||||
`
|
||||
INSERT INTO module_settings (id, trading_enabled, updated_by_user_id, updated_at)
|
||||
VALUES (true, $1, $2, now())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
trading_enabled = EXCLUDED.trading_enabled,
|
||||
updated_by_user_id = EXCLUDED.updated_by_user_id,
|
||||
updated_at = now()
|
||||
RETURNING trading_enabled AS "tradingEnabled"
|
||||
`,
|
||||
[tradingEnabled, userId]
|
||||
);
|
||||
return publicModuleSettings(row);
|
||||
}
|
||||
|
||||
function gameVersionOptions(locale: string): Promise<Array<{ id: number; name: string; changeLog: string }>> {
|
||||
const name = localizedName('game-versions', 'gv', locale);
|
||||
return query(`SELECT gv.id, ${name} AS name, gv.change_log AS "changeLog" FROM game_versions gv ORDER BY ${orderByEntity('gv')}`);
|
||||
@@ -2793,7 +2835,8 @@ export async function getOptions(locale = defaultLocale) {
|
||||
acquisitionMethods,
|
||||
maps,
|
||||
gameVersions,
|
||||
dishFlavors
|
||||
dishFlavors,
|
||||
moduleSettings
|
||||
] = await Promise.all([
|
||||
optionSelect('pokemon_types', 'pokemon-types', locale),
|
||||
skillOptions(locale),
|
||||
@@ -2802,7 +2845,8 @@ export async function getOptions(locale = defaultLocale) {
|
||||
optionSelect('acquisition_methods', 'acquisition-methods', locale),
|
||||
optionSelect('maps', 'maps', locale),
|
||||
gameVersionOptions(locale),
|
||||
optionSelect('dish_flavors', 'dish-flavors', locale)
|
||||
optionSelect('dish_flavors', 'dish-flavors', locale),
|
||||
getModuleSettings()
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -2817,7 +2861,8 @@ export async function getOptions(locale = defaultLocale) {
|
||||
itemTags: favoriteThings,
|
||||
maps,
|
||||
gameVersions,
|
||||
dishFlavors
|
||||
dishFlavors,
|
||||
moduleSettings
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5569,10 +5614,14 @@ export async function updateConfig(
|
||||
const translations = cleanTranslations(payload.translations, ['name']);
|
||||
const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
|
||||
const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
|
||||
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
|
||||
const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : false;
|
||||
const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
|
||||
const before = await getConfigById(type, id, defaultLocale);
|
||||
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
|
||||
const hasTrading = definition.hasTrading
|
||||
? Object.hasOwn(payload, 'hasTrading')
|
||||
? Boolean(payload.hasTrading)
|
||||
: Boolean((before as { hasTrading?: boolean } | null)?.hasTrading)
|
||||
: false;
|
||||
const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
|
||||
|
||||
if (oppositeId === id) {
|
||||
throw validationError('server.validation.invalidField');
|
||||
@@ -5775,6 +5824,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
||||
const relatedSkillName = localizedName('skills', 'related_skill', locale);
|
||||
const relatedFavoriteThingName = localizedName('favorite-things', 'related_favorite_thing', locale);
|
||||
const tradingItemName = localizedName('items', 'trading_item', locale);
|
||||
const moduleSettings = await getModuleSettings();
|
||||
|
||||
const [habitats, itemDrops, favoriteThingItems, tradingItems, relatedPokemon, editHistory, imageHistory] = await Promise.all([
|
||||
query(
|
||||
@@ -5825,28 +5875,30 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT
|
||||
ti.item_id AS "itemId",
|
||||
ti.preference,
|
||||
trading_item.id,
|
||||
${tradingItemName} AS name,
|
||||
${uploadedImageJson('trading_item.image_path')} AS image
|
||||
FROM pokemon_trading_items ti
|
||||
JOIN items trading_item ON trading_item.id = ti.item_id
|
||||
WHERE ti.pokemon_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM pokemon_skills ps
|
||||
JOIN skills trading_skill ON trading_skill.id = ps.skill_id
|
||||
WHERE ps.pokemon_id = ti.pokemon_id
|
||||
AND trading_skill.has_trading = true
|
||||
)
|
||||
ORDER BY ti.preference DESC, ${orderByEntity('trading_item')}
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
moduleSettings.tradingEnabled
|
||||
? query(
|
||||
`
|
||||
SELECT
|
||||
ti.item_id AS "itemId",
|
||||
ti.preference,
|
||||
trading_item.id,
|
||||
${tradingItemName} AS name,
|
||||
${uploadedImageJson('trading_item.image_path')} AS image
|
||||
FROM pokemon_trading_items ti
|
||||
JOIN items trading_item ON trading_item.id = ti.item_id
|
||||
WHERE ti.pokemon_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM pokemon_skills ps
|
||||
JOIN skills trading_skill ON trading_skill.id = ps.skill_id
|
||||
WHERE ps.pokemon_id = ti.pokemon_id
|
||||
AND trading_skill.has_trading = true
|
||||
)
|
||||
ORDER BY ti.preference DESC, ${orderByEntity('trading_item')}
|
||||
`,
|
||||
[id]
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
query(
|
||||
`
|
||||
WITH current_pokemon AS (
|
||||
@@ -6088,12 +6140,14 @@ async function normalizePokemonDataIdentity(payload: PokemonPayload): Promise<vo
|
||||
payload.dataIdentifier = csvText(pokemonRow, 'identifier');
|
||||
}
|
||||
|
||||
async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload): Promise<void> {
|
||||
async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload, replaceTrading: boolean): Promise<void> {
|
||||
await client.query('DELETE FROM pokemon_skill_item_drops WHERE pokemon_id = $1', [pokemonId]);
|
||||
await client.query('DELETE FROM pokemon_pokemon_types WHERE pokemon_id = $1', [pokemonId]);
|
||||
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]);
|
||||
await client.query('DELETE FROM pokemon_trading_items WHERE pokemon_id = $1', [pokemonId]);
|
||||
if (replaceTrading) {
|
||||
await client.query('DELETE FROM pokemon_trading_items WHERE pokemon_id = $1', [pokemonId]);
|
||||
}
|
||||
|
||||
for (const [index, typeId] of payload.typeIds.entries()) {
|
||||
await client.query('INSERT INTO pokemon_pokemon_types (pokemon_id, type_id, slot_order) VALUES ($1, $2, $3)', [
|
||||
@@ -6114,10 +6168,10 @@ async function replacePokemonRelations(client: DbClient, pokemonId: number, payl
|
||||
]);
|
||||
}
|
||||
|
||||
const tradingSkillResult = payload.skillIds.length
|
||||
const tradingSkillResult = replaceTrading && payload.skillIds.length
|
||||
? await client.query<{ id: number }>('SELECT id FROM skills WHERE id = ANY($1::integer[]) AND has_trading = true', [payload.skillIds])
|
||||
: { rows: [] };
|
||||
const hasTradingSkill = tradingSkillResult.rows.length > 0;
|
||||
const hasTradingSkill = replaceTrading && tradingSkillResult.rows.length > 0;
|
||||
|
||||
if (hasTradingSkill) {
|
||||
for (const tradingItem of payload.tradingItems) {
|
||||
@@ -6215,7 +6269,8 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
|
||||
]
|
||||
);
|
||||
await linkEntityImageUpload(client, 'pokemon', pokemonId, cleanPayload.image?.path, cleanPayload.name);
|
||||
await replacePokemonRelations(client, pokemonId, cleanPayload);
|
||||
const moduleSettings = await moduleSettingsForClient(client);
|
||||
await replacePokemonRelations(client, pokemonId, cleanPayload, moduleSettings.tradingEnabled);
|
||||
await replaceEntityTranslations(client, 'pokemon', pokemonId, cleanPayload.translations, ['name', 'details', 'genus']);
|
||||
await recordEditLog(client, 'pokemon', pokemonId, 'create', userId);
|
||||
return pokemonId;
|
||||
@@ -6291,7 +6346,8 @@ export async function updatePokemon(id: number, payload: Record<string, unknown>
|
||||
return false;
|
||||
}
|
||||
await linkEntityImageUpload(client, 'pokemon', id, cleanPayload.image?.path, cleanPayload.name);
|
||||
await replacePokemonRelations(client, id, cleanPayload);
|
||||
const moduleSettings = await moduleSettingsForClient(client);
|
||||
await replacePokemonRelations(client, id, cleanPayload, moduleSettings.tradingEnabled);
|
||||
await replaceEntityTranslations(client, 'pokemon', id, cleanPayload.translations, ['name', 'details', 'genus']);
|
||||
const changes = before ? await pokemonEditChanges(client, before as unknown as PokemonChangeSource, cleanPayload) : [];
|
||||
await recordEditLog(client, 'pokemon', id, 'update', userId, changes);
|
||||
@@ -6701,6 +6757,7 @@ export async function getItem(id: number, locale = defaultLocale) {
|
||||
const skillName = localizedName('skills', 's', locale);
|
||||
const possibleTagName = localizedName('favorite-things', 'possible_tag', locale);
|
||||
const evidenceTagName = localizedName('favorite-things', 'evidence_tag', locale);
|
||||
const moduleSettings = await getModuleSettings();
|
||||
|
||||
const [
|
||||
acquisitionMethods,
|
||||
@@ -6841,44 +6898,48 @@ export async function getItem(id: number, locale = defaultLocale) {
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
query<ItemPossibleTagEntity>(
|
||||
`
|
||||
SELECT possible_tag.id, ${possibleTagName} AS name
|
||||
FROM favorite_things possible_tag
|
||||
ORDER BY ${orderByEntity('possible_tag')}
|
||||
`
|
||||
),
|
||||
query<ItemPossibleTagObservation>(
|
||||
`
|
||||
SELECT
|
||||
json_build_object(
|
||||
'id', p.id,
|
||||
'displayId', p.display_id,
|
||||
'name', ${pokemonName},
|
||||
'isEventItem', p.is_event_item,
|
||||
'image', ${pokemonImageJson('p')}
|
||||
) AS pokemon,
|
||||
pti.preference,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', evidence_tag.id, 'name', ${evidenceTagName}) ORDER BY ${orderByEntity('evidence_tag')})
|
||||
FROM pokemon_favorite_things pft
|
||||
JOIN favorite_things evidence_tag ON evidence_tag.id = pft.favorite_thing_id
|
||||
WHERE pft.pokemon_id = p.id
|
||||
), '[]'::json) AS tags
|
||||
FROM pokemon_trading_items pti
|
||||
JOIN pokemon p ON p.id = pti.pokemon_id
|
||||
WHERE pti.item_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM pokemon_skills ps
|
||||
JOIN skills trading_skill ON trading_skill.id = ps.skill_id
|
||||
WHERE ps.pokemon_id = p.id
|
||||
AND trading_skill.has_trading = true
|
||||
)
|
||||
ORDER BY pti.preference DESC, p.display_id, p.id
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
moduleSettings.tradingEnabled
|
||||
? query<ItemPossibleTagEntity>(
|
||||
`
|
||||
SELECT possible_tag.id, ${possibleTagName} AS name
|
||||
FROM favorite_things possible_tag
|
||||
ORDER BY ${orderByEntity('possible_tag')}
|
||||
`
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
moduleSettings.tradingEnabled
|
||||
? query<ItemPossibleTagObservation>(
|
||||
`
|
||||
SELECT
|
||||
json_build_object(
|
||||
'id', p.id,
|
||||
'displayId', p.display_id,
|
||||
'name', ${pokemonName},
|
||||
'isEventItem', p.is_event_item,
|
||||
'image', ${pokemonImageJson('p')}
|
||||
) AS pokemon,
|
||||
pti.preference,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', evidence_tag.id, 'name', ${evidenceTagName}) ORDER BY ${orderByEntity('evidence_tag')})
|
||||
FROM pokemon_favorite_things pft
|
||||
JOIN favorite_things evidence_tag ON evidence_tag.id = pft.favorite_thing_id
|
||||
WHERE pft.pokemon_id = p.id
|
||||
), '[]'::json) AS tags
|
||||
FROM pokemon_trading_items pti
|
||||
JOIN pokemon p ON p.id = pti.pokemon_id
|
||||
WHERE pti.item_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM pokemon_skills ps
|
||||
JOIN skills trading_skill ON trading_skill.id = ps.skill_id
|
||||
WHERE ps.pokemon_id = p.id
|
||||
AND trading_skill.has_trading = true
|
||||
)
|
||||
ORDER BY pti.preference DESC, p.display_id, p.id
|
||||
`,
|
||||
[id]
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
getEditHistory('items', id),
|
||||
listEntityImageUploads('items', id)
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user