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:
2026-05-10 16:59:07 +08:00
parent 26bef1b749
commit 42319695e9
11 changed files with 271 additions and 85 deletions

View File

@@ -219,6 +219,18 @@ INSERT INTO rate_limit_settings (id)
VALUES (true)
ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS module_settings (
id boolean PRIMARY KEY DEFAULT true CHECK (id = true),
trading_enabled boolean NOT NULL DEFAULT true,
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()
);
INSERT INTO module_settings (id)
VALUES (true)
ON CONFLICT (id) DO NOTHING;
INSERT INTO permissions (key, name, description, category, system_permission)
VALUES
('admin.access', 'Access admin', 'Open the management area.', 'Admin', true),

View File

@@ -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)
]);

View File

@@ -89,6 +89,7 @@ import {
getItem,
listDish,
getLifePost,
getModuleSettings,
getOptions,
getPokemon,
getPublicUserProfile,
@@ -149,6 +150,7 @@ import {
updateItem,
updateLanguage,
updateLifePost,
updateModuleSettings,
updatePokemon,
updateRecipe,
updateAdminThreadChannel,
@@ -1309,6 +1311,8 @@ app.get('/api/languages', async () => listLanguages());
app.get('/api/system-wordings', async (request) => getSystemWordings(requestLocale(request)));
app.get('/api/module-settings', async () => getModuleSettings());
app.get('/api/options', async (request) => getOptions(requestLocale(request)));
app.get('/api/project-updates', async (request) =>
@@ -2385,6 +2389,19 @@ app.put('/api/admin/ai-moderation', async (request, reply) => {
return updateAiModerationSettings(request.body as Record<string, unknown>, user.id);
});
app.get('/api/admin/module-settings', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.config.read');
return user ? getModuleSettings() : undefined;
});
app.put('/api/admin/module-settings', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.update', 'adminWrite');
if (!user) {
return;
}
return updateModuleSettings(request.body as Record<string, unknown>, user.id);
});
app.get('/api/admin/rate-limits', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.rate-limits.read');
return user ? getRateLimitSettings() : undefined;