diff --git a/DESIGN.md b/DESIGN.md index 53a904d..a2135d2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -184,6 +184,42 @@ - 非 Owner 即使拥有 `admin.users.update` 或自定义高等级角色,也不能分配或移除 `owner` 角色。 - 管理 API 只返回权限管理所需字段,不返回密码、session token hash、verification/reset token hash、内部审计 payload 或调试字段。 +## Admin Data Tools + +- Admin Data Tools 用于在管理端导出、导入和清空指定 Wiki 内容域数据。 +- Data Tools 只支持固定业务范围,不提供任意 SQL、任意表名输入或网页数据库控制台能力。 +- 权限: + - `admin.data.export`:可导出内容数据 bundle。 + - `admin.data.import`:可导入内容数据 bundle,并可执行 Wipe。 +- 初始默认只有 `owner` 拥有 Data Tools 权限;如需开放给其他角色,必须通过权限管理显式授予。 +- Data Tools 支持范围: + - Pokemon + - Habitats + - Items + - Recipes + - Daily CheckList +- Items 与 Recipes 存在依赖关系;选择 Items 进行导出、导入或 Wipe 时,系统必须自动同时纳入 Recipes,前端确认内容也必须显示 Recipes。 +- Wipe 行为: + - 删除所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。 + - Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。 + - Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。 + - Wipe Items 会先删除 Recipes,再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项和 Pokemon 掉落关联。 + - Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。 + - Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。 + - 对被清空的 identity 主表重置自增 ID;Pokemon 内部 ID 不是 identity,自定义 / 活动 Pokemon 的系统分配区间仍按当前数据库最大值继续。 +- Export 行为: + - 导出为版本化 JSON bundle,包含 `version`、`exportedAt`、`scopes` 和对应范围数据。 + - JSON bundle 用于系统导入,不作为前台展示内容。 + - 导出包含所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。 + - 导出必须包含对应 Wipe 会移除的跨范围关联行,例如 Pokemon 出现配置、Pokemon 掉落和栖息地配方项;导入这些关联时,引用的另一侧实体必须已存在。 + - JSON 不包含上传文件本身;`backend_uploads` volume 需要单独备份。 +- Import 行为: + - 当前只支持 Replace selected scopes:导入前先 Wipe bundle 中包含的范围,再在同一事务中还原 bundle 数据。 + - Import 不自动覆盖系统配置、语言、用户、角色、权限、系统文案或 Life 内容。 + - 导入数据引用的 System config、Languages、Users 或上传文件路径必须已存在;缺失依赖会导致导入失败并回滚。 + - Import 完成后重置相关 identity sequence 到当前最大 ID 之后。 + - 前端导入和 Wipe 必须使用确认 Modal,并要求输入固定确认词后才能执行。 + ## Referral - Referral 是账号功能,用于让已注册用户邀请新用户加入 Pokopia Wiki。 @@ -773,7 +809,7 @@ API 暴露边界: - 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。 - 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块: - 配置:System config。 - - 内容:Daily CheckList、Pokemon、物品、材料单和栖息地的维护、排序或删除入口。 + - 内容:Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口,以及 Data Tools。 - 本地化:Languages、System wordings。 - 访问权限:Users、Roles、Permissions、Rate limits。 - 登录用户的侧边栏账号入口进入 `/profile`;User Profile 属于账号入口,不作为 Wiki 主内容导航项。 @@ -870,6 +906,10 @@ API 暴露边界: - `POST /api/admin/permissions`:需要 `admin.permissions.create` - `PUT /api/admin/permissions/:id`:需要 `admin.permissions.update` - `DELETE /api/admin/permissions/:id`:需要 `admin.permissions.delete` +- `GET /api/admin/data-tools/summary`:需要 `admin.data.export` 或 `admin.data.import` +- `POST /api/admin/data-tools/export`:需要 `admin.data.export` +- `POST /api/admin/data-tools/import`:需要 `admin.data.import` +- `POST /api/admin/data-tools/wipe`:需要 `admin.data.import` 受权限保护的编辑 API: diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 1a720b1..c9a38e9 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -219,6 +219,8 @@ VALUES ('admin.config.update', 'Update system config', 'Edit management configuration records.', 'System config', true), ('admin.config.delete', 'Delete system config', 'Delete management configuration records.', 'System config', true), ('admin.config.order', 'Order system config', 'Reorder management configuration records.', 'System config', true), + ('admin.data.export', 'Export data', 'Export content data bundles.', 'Data tools', true), + ('admin.data.import', 'Import and wipe data', 'Import content data bundles and wipe content data.', 'Data tools', true), ('checklist.create', 'Create checklist tasks', 'Create Daily CheckList tasks.', 'CheckList', true), ('checklist.update', 'Update checklist tasks', 'Edit Daily CheckList tasks.', 'CheckList', true), ('checklist.delete', 'Delete checklist tasks', 'Delete Daily CheckList tasks.', 'CheckList', true), diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 8278688..bc5f87c 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -22,6 +22,19 @@ type QueryValue = string | string[] | undefined; type QueryParams = Record; type DbClient = PoolClient; +type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'recipes' | 'checklist'; +type DataToolScopeSummary = { + scope: DataToolScope; + count: number; +}; +type DataToolRows = Record[]; +type DataToolScopeData = Record; +type DataToolsBundle = { + version: 1; + exportedAt: string; + scopes: DataToolScope[]; + data: Partial>; +}; type TranslationField = 'name' | 'title' | 'details' | 'genus'; type TranslationInput = Record>>; @@ -5761,3 +5774,444 @@ export async function deleteRecipe(id: number, userId: number) { return true; }); } + +const dataToolScopes = ['pokemon', 'habitats', 'items', 'recipes', 'checklist'] as const satisfies readonly DataToolScope[]; +const dataToolMainTables: Record = { + pokemon: 'pokemon', + habitats: 'habitats', + items: 'items', + recipes: 'recipes', + checklist: 'daily_checklist_items' +}; + +const dataToolColumns = { + pokemon: [ + 'id', + 'data_id', + 'data_identifier', + 'display_id', + 'name', + 'is_event_item', + 'genus', + 'details', + 'height_inches', + 'weight_pounds', + 'environment_id', + 'hp', + 'attack', + 'defense', + 'special_attack', + 'special_defense', + 'speed', + 'image_path', + 'image_style', + 'image_version', + 'image_variant', + 'image_description', + 'sort_order', + 'created_by_user_id', + 'updated_by_user_id', + 'created_at', + 'updated_at' + ], + pokemonTypeLinks: ['pokemon_id', 'type_id', 'slot_order'], + pokemonSkills: ['pokemon_id', 'skill_id'], + pokemonFavoriteThings: ['pokemon_id', 'favorite_thing_id'], + pokemonSkillItemDrops: ['pokemon_id', 'skill_id', 'item_id'], + habitats: ['id', 'name', 'is_event_item', 'image_path', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'], + habitatRecipeItems: ['habitat_id', 'item_id', 'quantity'], + habitatPokemon: ['habitat_id', 'pokemon_id', 'map_id', 'time_of_day', 'weather', 'rarity'], + items: [ + 'id', + 'name', + 'category_id', + 'usage_id', + 'dyeable', + 'dual_dyeable', + 'pattern_editable', + 'no_recipe', + 'is_event_item', + 'image_path', + 'sort_order', + 'created_by_user_id', + 'updated_by_user_id', + 'created_at', + 'updated_at' + ], + itemAcquisitionMethods: ['item_id', 'acquisition_method_id'], + itemFavoriteThings: ['item_id', 'favorite_thing_id'], + recipes: ['id', 'item_id', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'], + recipeAcquisitionMethods: ['recipe_id', 'acquisition_method_id'], + recipeMaterials: ['recipe_id', 'item_id', 'quantity'], + checklist: ['id', 'title', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'], + translations: ['entity_type', 'entity_id', 'locale', 'field_name', 'value'], + editLogs: ['id', 'entity_type', 'entity_id', 'action', 'user_id', 'changes', 'created_at'], + imageUploads: [ + 'id', + 'entity_type', + 'entity_id', + 'entity_name', + 'path', + 'original_filename', + 'mime_type', + 'byte_size', + 'created_by_user_id', + 'created_at' + ], + discussionComments: [ + 'id', + 'entity_type', + 'entity_id', + 'parent_comment_id', + 'body', + 'ai_moderation_status', + 'ai_moderation_language_code', + 'ai_moderation_content_hash', + 'ai_moderation_checked_at', + 'ai_moderation_retry_count', + 'ai_moderation_updated_at', + 'created_by_user_id', + 'deleted_by_user_id', + 'deleted_at', + 'created_at', + 'updated_at' + ] +} as const; + +function isDataToolScope(value: unknown): value is DataToolScope { + return typeof value === 'string' && dataToolScopes.includes(value as DataToolScope); +} + +function normalizeDataToolScopes(scopes: DataToolScope[]): DataToolScope[] { + const scopeSet = new Set(scopes); + if (scopeSet.has('items')) { + scopeSet.add('recipes'); + } + return dataToolScopes.filter((scope) => scopeSet.has(scope)); +} + +function cleanDataToolScopes(value: unknown): DataToolScope[] { + if (!Array.isArray(value)) { + throw validationError('server.validation.dataToolScopeRequired'); + } + + const scopes: DataToolScope[] = []; + for (const scope of value) { + if (!isDataToolScope(scope)) { + throw validationError('server.validation.dataToolScopeInvalid'); + } + if (!scopes.includes(scope)) { + scopes.push(scope); + } + } + + if (scopes.length === 0) { + throw validationError('server.validation.dataToolScopeRequired'); + } + return normalizeDataToolScopes(scopes); +} + +function cleanDataToolsBundle(value: unknown): DataToolsBundle { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw validationError('server.validation.dataToolBundleInvalid'); + } + + const bundle = value as Record; + if (bundle.version !== 1 || !bundle.data || typeof bundle.data !== 'object' || Array.isArray(bundle.data)) { + throw validationError('server.validation.dataToolBundleInvalid'); + } + + return { + version: 1, + exportedAt: typeof bundle.exportedAt === 'string' ? bundle.exportedAt : new Date().toISOString(), + scopes: cleanDataToolScopes(bundle.scopes), + data: bundle.data as DataToolsBundle['data'] + }; +} + +function dataToolTableRows(data: DataToolScopeData | undefined, key: string): DataToolRows { + const rows = data?.[key]; + if (rows === undefined) { + return []; + } + if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== 'object' || Array.isArray(row))) { + throw validationError('server.validation.dataToolBundleInvalid'); + } + return rows as DataToolRows; +} + +function dataToolDataWithRows(key: string, ...sources: Array): DataToolScopeData | undefined { + return sources.find((source) => source?.[key] !== undefined); +} + +async function tableRows(client: DbClient, sql: string, params: unknown[] = []): Promise { + const result = await client.query>(sql, params); + return result.rows; +} + +function normalizeImportValue(column: string, value: unknown): unknown { + if (value === undefined) { + return null; + } + if (column === 'changes' && typeof value !== 'string') { + return JSON.stringify(value ?? []); + } + return value; +} + +async function insertRows(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise { + for (const row of rows) { + const placeholders = columns.map((_, index) => `$${index + 1}`).join(', '); + const values = columns.map((column) => normalizeImportValue(column, row[column])); + await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`, values); + } +} + +async function resetIdentity(client: DbClient, tableName: string): Promise { + const result = await client.query<{ maxId: number | null }>(`SELECT MAX(id)::integer AS "maxId" FROM ${tableName}`); + const maxId = result.rows[0]?.maxId ?? null; + if (maxId === null) { + await client.query(`ALTER TABLE ${tableName} ALTER COLUMN id RESTART WITH 1`); + return; + } + + await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3, true)', [tableName, 'id', maxId]); +} + +async function resetDataToolIdentities(client: DbClient): Promise { + for (const tableName of ['daily_checklist_items', 'items', 'recipes', 'habitats', 'wiki_edit_logs', 'entity_image_uploads', 'entity_discussion_comments']) { + await resetIdentity(client, tableName); + } +} + +async function deleteGenericEntityRows(client: DbClient, entityTypes: string[]): Promise { + await client.query('DELETE FROM entity_discussion_comments WHERE entity_type = ANY($1::text[])', [entityTypes]); + await client.query('DELETE FROM entity_image_uploads WHERE entity_type = ANY($1::text[])', [entityTypes]); + await client.query('DELETE FROM wiki_edit_logs WHERE entity_type = ANY($1::text[])', [entityTypes]); + await client.query('DELETE FROM entity_translations WHERE entity_type = ANY($1::text[])', [entityTypes]); +} + +async function wipeRecipesData(client: DbClient): Promise { + await deleteGenericEntityRows(client, ['recipes']); + await client.query('DELETE FROM recipe_acquisition_methods'); + await client.query('DELETE FROM recipe_materials'); + await client.query('DELETE FROM recipes'); +} + +async function wipeItemsData(client: DbClient): Promise { + await wipeRecipesData(client); + await deleteGenericEntityRows(client, ['items']); + await client.query('DELETE FROM item_acquisition_methods'); + await client.query('DELETE FROM item_favorite_things'); + await client.query('DELETE FROM habitat_recipe_items'); + await client.query('DELETE FROM pokemon_skill_item_drops'); + await client.query('DELETE FROM items'); +} + +async function wipePokemonData(client: DbClient): Promise { + await deleteGenericEntityRows(client, ['pokemon']); + await client.query('DELETE FROM habitat_pokemon'); + await client.query('DELETE FROM pokemon_skill_item_drops'); + await client.query('DELETE FROM pokemon_pokemon_types'); + await client.query('DELETE FROM pokemon_skills'); + await client.query('DELETE FROM pokemon_favorite_things'); + await client.query('DELETE FROM pokemon'); +} + +async function wipeHabitatsData(client: DbClient): Promise { + await deleteGenericEntityRows(client, ['habitats']); + await client.query('DELETE FROM habitat_recipe_items'); + await client.query('DELETE FROM habitat_pokemon'); + await client.query('DELETE FROM habitats'); +} + +async function wipeChecklistData(client: DbClient): Promise { + await deleteGenericEntityRows(client, ['daily-checklist-items']); + await client.query('DELETE FROM daily_checklist_items'); +} + +async function wipeDataToolScopes(client: DbClient, scopes: DataToolScope[], resetIdentities = true): Promise { + const scopeSet = new Set(scopes); + if (scopeSet.has('items')) { + await wipeItemsData(client); + } else if (scopeSet.has('recipes')) { + await wipeRecipesData(client); + } + if (scopeSet.has('pokemon')) { + await wipePokemonData(client); + } + if (scopeSet.has('habitats')) { + await wipeHabitatsData(client); + } + if (scopeSet.has('checklist')) { + await wipeChecklistData(client); + } + if (resetIdentities) { + await resetDataToolIdentities(client); + } +} + +async function exportGenericScopeData(client: DbClient, entityType: string, includeImages: boolean): Promise { + const data: DataToolScopeData = { + translations: await tableRows(client, 'SELECT * FROM entity_translations WHERE entity_type = $1 ORDER BY entity_id, locale, field_name', [entityType]), + editLogs: await tableRows(client, 'SELECT * FROM wiki_edit_logs WHERE entity_type = $1 ORDER BY id', [entityType]), + discussionComments: await tableRows( + client, + ` + SELECT * + FROM entity_discussion_comments + WHERE entity_type = $1 + ORDER BY parent_comment_id NULLS FIRST, id + `, + [entityType] + ) + }; + + if (includeImages) { + data.imageUploads = await tableRows(client, 'SELECT * FROM entity_image_uploads WHERE entity_type = $1 ORDER BY id', [entityType]); + } + + return data; +} + +async function exportScopeData(client: DbClient, scope: DataToolScope): Promise { + if (scope === 'pokemon') { + return { + pokemon: await tableRows(client, 'SELECT * FROM pokemon ORDER BY sort_order, id'), + pokemonTypeLinks: await tableRows(client, 'SELECT * FROM pokemon_pokemon_types ORDER BY pokemon_id, slot_order'), + pokemonSkills: await tableRows(client, 'SELECT * FROM pokemon_skills ORDER BY pokemon_id, skill_id'), + pokemonFavoriteThings: await tableRows(client, 'SELECT * FROM pokemon_favorite_things ORDER BY pokemon_id, favorite_thing_id'), + pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'), + habitatPokemon: await tableRows(client, 'SELECT * FROM habitat_pokemon ORDER BY habitat_id, pokemon_id, map_id, time_of_day, weather'), + ...(await exportGenericScopeData(client, 'pokemon', true)) + }; + } + + if (scope === 'habitats') { + return { + habitats: await tableRows(client, 'SELECT * FROM habitats ORDER BY sort_order, id'), + habitatRecipeItems: await tableRows(client, 'SELECT * FROM habitat_recipe_items ORDER BY habitat_id, item_id'), + habitatPokemon: await tableRows(client, 'SELECT * FROM habitat_pokemon ORDER BY habitat_id, pokemon_id, map_id, time_of_day, weather'), + ...(await exportGenericScopeData(client, 'habitats', true)) + }; + } + + if (scope === 'items') { + return { + items: await tableRows(client, 'SELECT * FROM items ORDER BY sort_order, id'), + itemAcquisitionMethods: await tableRows(client, 'SELECT * FROM item_acquisition_methods ORDER BY item_id, acquisition_method_id'), + itemFavoriteThings: await tableRows(client, 'SELECT * FROM item_favorite_things ORDER BY item_id, favorite_thing_id'), + pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'), + habitatRecipeItems: await tableRows(client, 'SELECT * FROM habitat_recipe_items ORDER BY habitat_id, item_id'), + ...(await exportGenericScopeData(client, 'items', true)) + }; + } + + if (scope === 'recipes') { + return { + recipes: await tableRows(client, 'SELECT * FROM recipes ORDER BY sort_order, id'), + recipeAcquisitionMethods: await tableRows(client, 'SELECT * FROM recipe_acquisition_methods ORDER BY recipe_id, acquisition_method_id'), + recipeMaterials: await tableRows(client, 'SELECT * FROM recipe_materials ORDER BY recipe_id, item_id'), + ...(await exportGenericScopeData(client, 'recipes', false)) + }; + } + + return { + checklist: await tableRows(client, 'SELECT * FROM daily_checklist_items ORDER BY sort_order, id'), + translations: await tableRows( + client, + 'SELECT * FROM entity_translations WHERE entity_type = $1 ORDER BY entity_id, locale, field_name', + ['daily-checklist-items'] + ), + editLogs: await tableRows(client, 'SELECT * FROM wiki_edit_logs WHERE entity_type = $1 ORDER BY id', ['daily-checklist-items']) + }; +} + +async function importScopeMainRows(client: DbClient, bundle: DataToolsBundle): Promise { + const itemData = bundle.data.items; + const pokemonData = bundle.data.pokemon; + const habitatData = bundle.data.habitats; + const checklistData = bundle.data.checklist; + const recipeData = bundle.data.recipes; + + await insertRows(client, 'items', dataToolColumns.items, dataToolTableRows(itemData, 'items')); + await insertRows(client, 'pokemon', dataToolColumns.pokemon, dataToolTableRows(pokemonData, 'pokemon')); + await insertRows(client, 'habitats', dataToolColumns.habitats, dataToolTableRows(habitatData, 'habitats')); + await insertRows(client, 'daily_checklist_items', dataToolColumns.checklist, dataToolTableRows(checklistData, 'checklist')); + await insertRows(client, 'recipes', dataToolColumns.recipes, dataToolTableRows(recipeData, 'recipes')); +} + +async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle): Promise { + const itemData = bundle.data.items; + const pokemonData = bundle.data.pokemon; + const habitatData = bundle.data.habitats; + const recipeData = bundle.data.recipes; + const pokemonDropData = dataToolDataWithRows('pokemonSkillItemDrops', pokemonData, itemData); + const habitatRecipeData = dataToolDataWithRows('habitatRecipeItems', habitatData, itemData); + const habitatPokemonData = dataToolDataWithRows('habitatPokemon', habitatData, pokemonData); + + await insertRows(client, 'item_acquisition_methods', dataToolColumns.itemAcquisitionMethods, dataToolTableRows(itemData, 'itemAcquisitionMethods')); + await insertRows(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolTableRows(itemData, 'itemFavoriteThings')); + await insertRows(client, 'pokemon_pokemon_types', dataToolColumns.pokemonTypeLinks, dataToolTableRows(pokemonData, 'pokemonTypeLinks')); + await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills')); + await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings')); + await insertRows(client, 'pokemon_skill_item_drops', dataToolColumns.pokemonSkillItemDrops, dataToolTableRows(pokemonDropData, 'pokemonSkillItemDrops')); + await insertRows(client, 'recipe_acquisition_methods', dataToolColumns.recipeAcquisitionMethods, dataToolTableRows(recipeData, 'recipeAcquisitionMethods')); + await insertRows(client, 'recipe_materials', dataToolColumns.recipeMaterials, dataToolTableRows(recipeData, 'recipeMaterials')); + await insertRows(client, 'habitat_recipe_items', dataToolColumns.habitatRecipeItems, dataToolTableRows(habitatRecipeData, 'habitatRecipeItems')); + await insertRows(client, 'habitat_pokemon', dataToolColumns.habitatPokemon, dataToolTableRows(habitatPokemonData, 'habitatPokemon')); +} + +async function importGenericScopeRows(client: DbClient, bundle: DataToolsBundle): Promise { + for (const scope of bundle.scopes) { + const data = bundle.data[scope]; + await insertRows(client, 'entity_translations', dataToolColumns.translations, dataToolTableRows(data, 'translations')); + await insertRows(client, 'wiki_edit_logs', dataToolColumns.editLogs, dataToolTableRows(data, 'editLogs')); + await insertRows(client, 'entity_image_uploads', dataToolColumns.imageUploads, dataToolTableRows(data, 'imageUploads')); + await insertRows(client, 'entity_discussion_comments', dataToolColumns.discussionComments, dataToolTableRows(data, 'discussionComments')); + } +} + +async function importDataToolsBundle(client: DbClient, bundle: DataToolsBundle): Promise { + await importScopeMainRows(client, bundle); + await importScopeRelationRows(client, bundle); + await importGenericScopeRows(client, bundle); + await resetDataToolIdentities(client); +} + +export async function getAdminDataToolsSummary(): Promise<{ scopes: DataToolScopeSummary[] }> { + const scopes: DataToolScopeSummary[] = []; + for (const scope of dataToolScopes) { + const result = await queryOne<{ count: number }>(`SELECT COUNT(*)::integer AS count FROM ${dataToolMainTables[scope]}`); + scopes.push({ scope, count: result?.count ?? 0 }); + } + return { scopes }; +} + +export async function exportAdminData(payload: Record): Promise { + const scopes = cleanDataToolScopes(payload.scopes); + return withTransaction(async (client) => { + const data: DataToolsBundle['data'] = {}; + for (const scope of scopes) { + data[scope] = await exportScopeData(client, scope); + } + return { version: 1, exportedAt: new Date().toISOString(), scopes, data }; + }); +} + +export async function importAdminData(payload: Record): Promise<{ scopes: DataToolScopeSummary[] }> { + const bundle = cleanDataToolsBundle(payload.bundle); + await withTransaction(async (client) => { + await wipeDataToolScopes(client, bundle.scopes, false); + await importDataToolsBundle(client, bundle); + }); + return getAdminDataToolsSummary(); +} + +export async function wipeAdminData(payload: Record): Promise<{ scopes: DataToolScopeSummary[] }> { + const scopes = cleanDataToolScopes(payload.scopes); + await withTransaction(async (client) => { + await wipeDataToolScopes(client, scopes); + }); + return getAdminDataToolsSummary(); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 32c3fdc..6eb61bf 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -59,14 +59,17 @@ import { deleteLifePostReaction, deletePokemon, deleteRecipe, + exportAdminData, fetchPokemonData, fetchPokemonImageOptions, + getAdminDataToolsSummary, getHabitat, getItem, getOptions, getPokemon, getPublicUserProfile, getRecipe, + importAdminData, isConfigType, listEntityDiscussionComments, listConfig, @@ -101,7 +104,8 @@ import { updateLanguage, updateLifePost, updatePokemon, - updateRecipe + updateRecipe, + wipeAdminData } from './queries.ts'; import { getAiModerationSettings, @@ -178,7 +182,7 @@ app.setErrorHandler(async (error, _request, reply) => { return reply.code(409).send({ message: await serverMessage(locale, 'duplicate') }); } - if (pgError.code === '23514') { + if (pgError.code === '23502' || pgError.code === '23514') { return reply.code(400).send({ message: await serverMessage(locale, 'invalidField') }); } @@ -1817,6 +1821,26 @@ app.put('/api/admin/rate-limits', async (request, reply) => { return updateRateLimitSettings(request.body as Record, user.id); }); +app.get('/api/admin/data-tools/summary', async (request, reply) => { + const user = await requireAnyPermission(request, reply, ['admin.data.export', 'admin.data.import']); + return user ? getAdminDataToolsSummary() : undefined; +}); + +app.post('/api/admin/data-tools/export', async (request, reply) => { + const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.export', 'adminWrite'); + return user ? exportAdminData(request.body as Record) : undefined; +}); + +app.post('/api/admin/data-tools/import', async (request, reply) => { + const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite'); + return user ? importAdminData(request.body as Record) : undefined; +}); + +app.post('/api/admin/data-tools/wipe', async (request, reply) => { + const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite'); + return user ? wipeAdminData(request.body as Record) : undefined; +}); + app.get('/api/admin/config/:type', async (request, reply) => { const user = await requirePermission(request, reply, 'admin.config.read'); if (!user) { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f643767..b40c532 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -296,6 +296,24 @@ export interface DailyChecklistItem { translations?: TranslationMap; } +export type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'recipes' | 'checklist'; + +export interface DataToolScopeSummary { + scope: DataToolScope; + count: number; +} + +export interface DataToolsSummary { + scopes: DataToolScopeSummary[]; +} + +export interface DataToolsBundle { + version: 1; + exportedAt: string; + scopes: DataToolScope[]; + data: Partial>>; +} + export type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks'; export type LifeReactionCounts = Record; export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed'; @@ -906,6 +924,10 @@ export const api = { rateLimitSettings: () => getJson('/api/admin/rate-limits'), updateRateLimitSettings: (payload: RateLimitSettingsPayload) => sendJson('/api/admin/rate-limits', 'PUT', payload), + dataToolsSummary: () => getJson('/api/admin/data-tools/summary'), + exportDataTools: (scopes: DataToolScope[]) => sendJson('/api/admin/data-tools/export', 'POST', { scopes }), + importDataTools: (bundle: DataToolsBundle) => sendJson('/api/admin/data-tools/import', 'POST', { bundle }), + wipeDataTools: (scopes: DataToolScope[]) => sendJson('/api/admin/data-tools/wipe', 'POST', { scopes }), register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload), verifyEmail: (token: string) => sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }), diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index ea4dc9a..ba7cdc5 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -835,6 +835,59 @@ button:disabled, gap: 12px; } +.data-tool-grid { + display: grid; + gap: 0; +} + +.data-tool-panel { + display: grid; + gap: 14px; + padding: 18px 0; + border-bottom: 1px solid var(--line); +} + +.data-tool-panel:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.data-tool-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.data-tool-panel__header h3 { + margin: 0; + color: var(--ink); + font-size: 15px; + font-weight: 900; +} + +.data-tool-scope-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; +} + +.data-tool-scope { + min-height: 44px; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 0; + color: var(--ink-soft); + font-weight: 800; +} + +.data-tool-scope input { + width: 18px; + height: 18px; +} + .pokemon-edit-form { height: clamp(420px, calc(100dvh - 188px), 640px); min-height: 0; diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 5518bdc..30c5069 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -24,6 +24,7 @@ import { iconRecipe, iconSave, iconTranslate, + iconUpload, type AppIcon } from '../icons'; import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n'; @@ -36,6 +37,9 @@ import { type AuthUser, type AdminUser, type ConfigType, + type DataToolScope, + type DataToolsBundle, + type DataToolsSummary, type DailyChecklistItem, type GameVersion, type Habitat, @@ -65,6 +69,7 @@ type AdminTab = | 'permissions' | 'rateLimits' | 'aiModeration' + | 'dataTools' | 'config' | 'languages' | 'wordings' @@ -97,6 +102,7 @@ const rateLimitPolicyKeys: RateLimitPolicyKey[] = [ 'upload', 'fetch' ]; +const dataToolScopeKeys: DataToolScope[] = ['pokemon', 'habitats', 'items', 'recipes', 'checklist']; const defaultRateLimitPolicies: Record = { accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 }, adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 }, @@ -113,6 +119,7 @@ const adminTabIcons: Record = { permissions: iconKey, rateLimits: iconAdmin, aiModeration: iconAdmin, + dataTools: iconAdmin, config: iconAdmin, languages: iconTranslate, wordings: iconTranslate, @@ -140,7 +147,8 @@ const adminNavigationGroups = computed(() => { { key: 'pokemon', label: t('pages.admin.pokemonList'), permission: ['pokemon.order', 'pokemon.delete'] }, { key: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] }, { key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] }, - { key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] } + { key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] }, + { key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] } ] }, { @@ -200,6 +208,7 @@ const habitatRows = ref([]); const wordingRows = ref([]); const aiModerationSettings = ref(null); const rateLimitSettings = ref(null); +const dataToolsSummary = ref(null); const currentUser = ref(null); const busy = ref(false); const contentLoading = ref(false); @@ -251,10 +260,16 @@ const userRoleModalOpen = ref(false); const roleModalOpen = ref(false); const rolePermissionsModalOpen = ref(false); const permissionModalOpen = ref(false); +const dataToolImportModalOpen = ref(false); +const dataToolWipeModalOpen = ref(false); const wordingLocale = ref(getCurrentLocale()); const wordingModule = ref(''); const wordingSurface = ref(''); const wordingMissingOnly = ref(false); +const selectedExportScopes = ref(['pokemon']); +const selectedWipeScopes = ref([]); +const pendingImportBundle = ref(null); +const dataToolConfirmText = ref(''); const selectedConfig = computed(() => configTypes.value.find((item) => item.key === activeConfigType.value) ?? configTypes.value[0]); const configTabs = computed(() => configTypes.value.map((item) => ({ value: item.key, label: item.label }))); @@ -352,6 +367,23 @@ const rateLimitPolicyOptions = computed>(() => + dataToolScopeKeys.map((scope) => ({ + value: scope, + label: t(`pages.admin.dataToolScope${scope.charAt(0).toUpperCase()}${scope.slice(1)}`), + count: dataToolsSummary.value?.scopes.find((item) => item.scope === scope)?.count ?? 0 + })) +); +const importScopeLabels = computed(() => + (pendingImportBundle.value?.scopes ?? []) + .map((scope) => dataToolScopeOptions.value.find((option) => option.value === scope)?.label ?? scope) + .join(' / ') +); +const wipeScopeLabels = computed(() => + selectedWipeScopes.value + .map((scope) => dataToolScopeOptions.value.find((option) => option.value === scope)?.label ?? scope) + .join(' / ') +); const filteredWordingRows = computed(() => wordingRows.value.filter((item) => { if (wordingModule.value && item.module !== wordingModule.value) return false; @@ -383,6 +415,36 @@ function canAny(permissionKey: string | string[]) { return Array.isArray(permissionKey) ? permissionKey.some((key) => can(key)) : can(permissionKey); } +function toggleDataToolScope(values: DataToolScope[], scope: DataToolScope) { + const nextValues = new Set(values); + if (nextValues.has(scope)) { + nextValues.delete(scope); + } else { + nextValues.add(scope); + } + return normalizeDataToolScopes([...nextValues]); +} + +function normalizeDataToolScopes(scopes: DataToolScope[]) { + const nextValues = new Set(scopes); + if (nextValues.has('items')) { + nextValues.add('recipes'); + } + return dataToolScopeKeys.filter((item) => nextValues.has(item)); +} + +function dataToolScopeLocked(values: DataToolScope[], scope: DataToolScope) { + return scope === 'recipes' && values.includes('items'); +} + +function toggleExportScope(scope: DataToolScope) { + selectedExportScopes.value = toggleDataToolScope(selectedExportScopes.value, scope); +} + +function toggleWipeScope(scope: DataToolScope) { + selectedWipeScopes.value = toggleDataToolScope(selectedWipeScopes.value, scope); +} + function dragSortLabel(name: string) { return t('pages.admin.dragSort', { name }); } @@ -928,6 +990,10 @@ async function loadRateLimitSettings() { resetRateLimitForm(rateLimitSettings.value); } +async function loadDataToolsSummary() { + dataToolsSummary.value = await api.dataToolsSummary(); +} + async function reloadWordings() { await run(loadWordings); } @@ -1051,6 +1117,7 @@ async function loadCurrentTab(showSkeleton = false) { if (activeTab.value === 'languages') await loadLanguages(); if (activeTab.value === 'wordings') await loadWordings(); if (activeTab.value === 'aiModeration') await loadAiModerationSettings(); + if (activeTab.value === 'dataTools') await loadDataToolsSummary(); if (activeTab.value === 'checklist') await loadChecklist(); if (activeTab.value === 'pokemon') await loadPokemon(); if (activeTab.value === 'items') await loadItems(); @@ -1155,6 +1222,117 @@ async function removeHabitat(id: number) { }); } +function downloadDataToolsBundle(bundle: DataToolsBundle) { + const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `pokopia-data-${bundle.exportedAt.slice(0, 10)}.json`; + document.body.append(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + +async function exportDataTools() { + const scopes = normalizeDataToolScopes(selectedExportScopes.value); + if (!scopes.length) { + message.value = t('pages.admin.dataToolSelectScope'); + return; + } + selectedExportScopes.value = scopes; + + await run(async () => { + const bundle = await api.exportDataTools(scopes); + downloadDataToolsBundle(bundle); + }); +} + +function openWipeDataTools() { + const scopes = normalizeDataToolScopes(selectedWipeScopes.value); + if (!scopes.length) { + message.value = t('pages.admin.dataToolSelectScope'); + return; + } + selectedWipeScopes.value = scopes; + dataToolConfirmText.value = ''; + dataToolWipeModalOpen.value = true; +} + +function closeWipeDataToolsModal() { + dataToolWipeModalOpen.value = false; + dataToolConfirmText.value = ''; +} + +async function confirmWipeDataTools() { + if (dataToolConfirmText.value !== 'WIPE') { + return; + } + + await run(async () => { + dataToolsSummary.value = await api.wipeDataTools(normalizeDataToolScopes(selectedWipeScopes.value)); + selectedWipeScopes.value = []; + closeWipeDataToolsModal(); + }); +} + +function validDataToolsBundle(value: unknown): value is DataToolsBundle { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const bundle = value as Partial; + return ( + bundle.version === 1 && + Array.isArray(bundle.scopes) && + bundle.scopes.length > 0 && + bundle.scopes.every((scope) => dataToolScopeKeys.includes(scope)) && + Boolean(bundle.data) && + typeof bundle.data === 'object' && + !Array.isArray(bundle.data) + ); +} + +async function selectImportDataToolsFile(event: Event) { + const input = event.target instanceof HTMLInputElement ? event.target : null; + const file = input?.files?.[0]; + if (input) { + input.value = ''; + } + if (!file) { + return; + } + + try { + const bundle = JSON.parse(await file.text()) as unknown; + if (!validDataToolsBundle(bundle)) { + message.value = t('pages.admin.dataToolInvalidBundle'); + return; + } + pendingImportBundle.value = { ...bundle, scopes: normalizeDataToolScopes(bundle.scopes) }; + dataToolConfirmText.value = ''; + dataToolImportModalOpen.value = true; + } catch { + message.value = t('pages.admin.dataToolInvalidBundle'); + } +} + +function closeImportDataToolsModal() { + dataToolImportModalOpen.value = false; + pendingImportBundle.value = null; + dataToolConfirmText.value = ''; +} + +async function confirmImportDataTools() { + if (!pendingImportBundle.value || dataToolConfirmText.value !== 'IMPORT') { + return; + } + + await run(async () => { + dataToolsSummary.value = await api.importDataTools(pendingImportBundle.value as DataToolsBundle); + closeImportDataToolsModal(); + }); +} + async function removeRole(id: number) { await run(async () => { await api.deleteRole(id); @@ -1425,6 +1603,78 @@ onMounted(() => {

{{ t('common.noRecords') }}

+
+
+

{{ t('pages.admin.dataTools') }}

+ +
+ +
+
+
+

{{ t('pages.admin.dataToolExport') }}

+ +
+
+ +
+

{{ t('pages.admin.dataToolDependencyNote') }}

+

{{ t('pages.admin.dataToolUploadsNote') }}

+
+ +
+
+

{{ t('pages.admin.dataToolImport') }}

+
+
+ + +
+

{{ t('pages.admin.dataToolDependencyNote') }}

+

{{ t('pages.admin.dataToolImportMode') }}

+
+ +
+
+

{{ t('pages.admin.dataToolWipe') }}

+ +
+
+ +
+

{{ t('pages.admin.dataToolDependencyNote') }}

+

{{ t('pages.admin.dataToolReplaceNote') }}

+
+
+
+

{{ t('pages.admin.config') }}

@@ -1802,6 +2052,44 @@ onMounted(() => {
+ + + + + + + + + +