feat: add ancient artifacts and refactor item categories

Introduce Ancient Artifacts with full CRUD and image support
Migrate item categories and usages to system-defined lists
Add display_id to items and artifacts for custom sorting
This commit is contained in:
2026-05-04 08:28:56 +08:00
parent 5ccc25b248
commit 4238be7761
25 changed files with 1857 additions and 181 deletions

View File

@@ -30,10 +30,12 @@ CREATE TABLE IF NOT EXISTS entity_translations (
'item-usages',
'acquisition-methods',
'items',
'ancient-artifacts',
'maps',
'habitats',
'daily-checklist-items',
'life-tags'
'life-tags',
'game-versions'
)
),
entity_id integer NOT NULL,
@@ -46,6 +48,30 @@ CREATE TABLE IF NOT EXISTS entity_translations (
CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx
ON entity_translations (entity_type, entity_id, field_name, locale);
ALTER TABLE entity_translations
DROP CONSTRAINT IF EXISTS entity_translations_entity_type_check;
ALTER TABLE entity_translations
ADD CONSTRAINT entity_translations_entity_type_check CHECK (
entity_type IN (
'pokemon',
'pokemon-types',
'skills',
'environments',
'favorite-things',
'item-categories',
'item-usages',
'acquisition-methods',
'items',
'ancient-artifacts',
'maps',
'habitats',
'daily-checklist-items',
'life-tags',
'game-versions'
)
);
CREATE TABLE IF NOT EXISTS users (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
@@ -241,6 +267,11 @@ VALUES
('items.delete', 'Delete items', 'Delete item records.', 'Items', true),
('items.order', 'Order items', 'Reorder item records.', 'Items', true),
('items.upload', 'Upload item images', 'Upload item images.', 'Items', true),
('ancient-artifacts.create', 'Create Ancient Artifacts', 'Create Ancient Artifact records.', 'Ancient Artifacts', true),
('ancient-artifacts.update', 'Update Ancient Artifacts', 'Edit Ancient Artifact records.', 'Ancient Artifacts', true),
('ancient-artifacts.delete', 'Delete Ancient Artifacts', 'Delete Ancient Artifact records.', 'Ancient Artifacts', true),
('ancient-artifacts.order', 'Order Ancient Artifacts', 'Reorder Ancient Artifact records.', 'Ancient Artifacts', true),
('ancient-artifacts.upload', 'Upload Ancient Artifact images', 'Upload Ancient Artifact images.', 'Ancient Artifacts', true),
('recipes.create', 'Create recipes', 'Create recipe records.', 'Recipes', true),
('recipes.update', 'Update recipes', 'Edit recipe records.', 'Recipes', true),
('recipes.delete', 'Delete recipes', 'Delete recipe records.', 'Recipes', true),
@@ -327,6 +358,11 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'items.delete',
'items.order',
'items.upload',
'ancient-artifacts.create',
'ancient-artifacts.update',
'ancient-artifacts.delete',
'ancient-artifacts.order',
'ancient-artifacts.upload',
'recipes.create',
'recipes.update',
'recipes.delete',
@@ -395,6 +431,10 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'items.update',
'items.order',
'items.upload',
'ancient-artifacts.create',
'ancient-artifacts.update',
'ancient-artifacts.order',
'ancient-artifacts.upload',
'recipes.create',
'recipes.update',
'recipes.order',
@@ -416,6 +456,31 @@ WHERE r.key = 'editor'
)
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
'ancient-artifacts.create',
'ancient-artifacts.update',
'ancient-artifacts.delete',
'ancient-artifacts.order',
'ancient-artifacts.upload'
])
WHERE r.key = 'admin'
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
'ancient-artifacts.create',
'ancient-artifacts.update',
'ancient-artifacts.order',
'ancient-artifacts.upload'
])
WHERE r.key = 'editor'
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
@@ -798,8 +863,12 @@ CREATE TABLE IF NOT EXISTS acquisition_methods (
CREATE TABLE IF NOT EXISTS items (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
display_id integer NOT NULL CHECK (display_id > 0),
name text NOT NULL UNIQUE,
category_id integer NOT NULL REFERENCES item_categories(id),
details text NOT NULL DEFAULT '',
category_key text NOT NULL DEFAULT 'other',
usage_key text,
category_id integer REFERENCES item_categories(id),
usage_id integer REFERENCES item_usages(id),
dyeable boolean NOT NULL DEFAULT false,
dual_dyeable boolean NOT NULL DEFAULT false,
@@ -811,6 +880,35 @@ CREATE TABLE IF NOT EXISTS items (
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
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(),
CHECK (category_key IN (
'furniture',
'misc',
'outdoor',
'utilities',
'buildings',
'blocks',
'kits',
'nature',
'food',
'materials',
'key-items',
'other'
)),
CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road'))
);
CREATE TABLE IF NOT EXISTS ancient_artifacts (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
display_id integer NOT NULL UNIQUE CHECK (display_id > 0),
name text NOT NULL UNIQUE,
details text NOT NULL DEFAULT '',
category_key text NOT NULL CHECK (category_key IN ('lost-relics-l', 'lost-relics-s', 'fossils')),
image_path text NOT NULL DEFAULT '',
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
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()
);
@@ -842,6 +940,12 @@ CREATE TABLE IF NOT EXISTS item_favorite_things (
PRIMARY KEY (item_id, favorite_thing_id)
);
CREATE TABLE IF NOT EXISTS ancient_artifact_favorite_things (
ancient_artifact_id integer NOT NULL REFERENCES ancient_artifacts(id) ON DELETE CASCADE,
favorite_thing_id integer NOT NULL REFERENCES favorite_things(id),
PRIMARY KEY (ancient_artifact_id, favorite_thing_id)
);
CREATE TABLE IF NOT EXISTS pokemon_skill_item_drops (
pokemon_id integer NOT NULL,
skill_id integer NOT NULL,
@@ -899,6 +1003,116 @@ CREATE TABLE IF NOT EXISTS habitat_pokemon (
ALTER TABLE life_tags
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
ALTER TABLE items
ADD COLUMN IF NOT EXISTS display_id integer,
ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS category_key text,
ADD COLUMN IF NOT EXISTS usage_key text;
ALTER TABLE ancient_artifacts
ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT '';
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'items'
AND column_name = 'category_id'
AND table_schema = current_schema()
) THEN
ALTER TABLE items ALTER COLUMN category_id DROP NOT NULL;
END IF;
END $$;
UPDATE items
SET display_id = id
WHERE display_id IS NULL;
UPDATE items i
SET category_key = CASE lower(trim(c.name))
WHEN 'furniture' THEN 'furniture'
WHEN 'misc' THEN 'misc'
WHEN 'outdoor' THEN 'outdoor'
WHEN 'utilities' THEN 'utilities'
WHEN 'buildings' THEN 'buildings'
WHEN 'blocks' THEN 'blocks'
WHEN 'kits' THEN 'kits'
WHEN 'nature' THEN 'nature'
WHEN 'food' THEN 'food'
WHEN 'materials' THEN 'materials'
WHEN 'key items' THEN 'key-items'
WHEN 'key-items' THEN 'key-items'
WHEN 'other' THEN 'other'
ELSE 'other'
END
FROM item_categories c
WHERE i.category_id = c.id
AND (i.category_key IS NULL OR i.category_key = '');
UPDATE items i
SET usage_key = CASE lower(trim(u.name))
WHEN 'decoration' THEN 'decoration'
WHEN 'relaxation' THEN 'relaxation'
WHEN 'toy' THEN 'toy'
WHEN 'road' THEN 'road'
ELSE NULL
END
FROM item_usages u
WHERE i.usage_id = u.id
AND i.usage_key IS NULL;
UPDATE items
SET category_key = 'other'
WHERE category_key IS NULL
OR category_key NOT IN (
'furniture',
'misc',
'outdoor',
'utilities',
'buildings',
'blocks',
'kits',
'nature',
'food',
'materials',
'key-items',
'other'
);
UPDATE items
SET usage_key = NULL
WHERE usage_key IS NOT NULL
AND usage_key NOT IN ('decoration', 'relaxation', 'toy', 'road');
ALTER TABLE items
ALTER COLUMN display_id SET NOT NULL,
ALTER COLUMN category_key SET NOT NULL,
ALTER COLUMN category_key SET DEFAULT 'other';
ALTER TABLE items
DROP CONSTRAINT IF EXISTS items_display_id_positive,
DROP CONSTRAINT IF EXISTS items_category_key_check,
DROP CONSTRAINT IF EXISTS items_usage_key_check;
ALTER TABLE items
ADD CONSTRAINT items_display_id_positive CHECK (display_id > 0),
ADD CONSTRAINT items_category_key_check CHECK (category_key IN (
'furniture',
'misc',
'outdoor',
'utilities',
'buildings',
'blocks',
'kits',
'nature',
'food',
'materials',
'key-items',
'other'
)),
ADD CONSTRAINT items_usage_key_check CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road'));
CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id);
CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id);
@@ -911,6 +1125,10 @@ CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sor
CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id);
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
CREATE INDEX IF NOT EXISTS items_sort_order_idx ON items(sort_order, id);
CREATE UNIQUE INDEX IF NOT EXISTS items_display_event_item_key ON items(display_id, is_event_item);
CREATE INDEX IF NOT EXISTS items_display_order_idx ON items(is_event_item, display_id, sort_order, id);
CREATE INDEX IF NOT EXISTS ancient_artifacts_sort_order_idx ON ancient_artifacts(sort_order, id);
CREATE INDEX IF NOT EXISTS ancient_artifacts_display_order_idx ON ancient_artifacts(display_id, sort_order, id);
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
CREATE INDEX IF NOT EXISTS maps_sort_order_idx ON maps(sort_order, id);
CREATE INDEX IF NOT EXISTS habitats_sort_order_idx ON habitats(sort_order, id);
@@ -933,7 +1151,7 @@ CREATE INDEX IF NOT EXISTS wiki_edit_logs_user_id_idx
CREATE TABLE IF NOT EXISTS entity_image_uploads (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'habitats')),
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'habitats', 'ancient-artifacts')),
entity_id integer,
entity_name text NOT NULL,
path text NOT NULL UNIQUE,
@@ -946,6 +1164,14 @@ CREATE TABLE IF NOT EXISTS entity_image_uploads (
CHECK (path !~ '(^/|\\.\\.)')
);
ALTER TABLE entity_image_uploads
DROP CONSTRAINT IF EXISTS entity_image_uploads_entity_type_check;
ALTER TABLE entity_image_uploads
ADD CONSTRAINT entity_image_uploads_entity_type_check CHECK (
entity_type IN ('pokemon', 'items', 'habitats', 'ancient-artifacts')
);
CREATE INDEX IF NOT EXISTS entity_image_uploads_entity_idx
ON entity_image_uploads(entity_type, entity_id, created_at DESC, id DESC);
@@ -954,7 +1180,7 @@ CREATE INDEX IF NOT EXISTS entity_image_uploads_user_idx
CREATE TABLE IF NOT EXISTS entity_discussion_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'recipes', 'habitats')),
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'recipes', 'habitats', 'ancient-artifacts')),
entity_id integer NOT NULL,
parent_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 1000),
@@ -980,6 +1206,14 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comments_parent_idx
CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx
ON entity_discussion_comments(created_by_user_id);
ALTER TABLE entity_discussion_comments
DROP CONSTRAINT IF EXISTS entity_discussion_comments_entity_type_check;
ALTER TABLE entity_discussion_comments
ADD CONSTRAINT entity_discussion_comments_entity_type_check CHECK (
entity_type IN ('pokemon', 'items', 'recipes', 'habitats', 'ancient-artifacts')
);
ALTER TABLE life_tags
ADD COLUMN IF NOT EXISTS is_rateable boolean NOT NULL DEFAULT false;

View File

@@ -22,7 +22,7 @@ type QueryValue = string | string[] | undefined;
type QueryParams = Record<string, QueryValue>;
type DbClient = PoolClient;
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'recipes' | 'checklist';
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
type DataToolScopeSummary = {
scope: DataToolScope;
count: number;
@@ -48,6 +48,7 @@ type EntityType =
| 'item-usages'
| 'acquisition-methods'
| 'items'
| 'ancient-artifacts'
| 'maps'
| 'habitats'
| 'daily-checklist-items'
@@ -59,8 +60,6 @@ type ConfigType =
| 'skills'
| 'environments'
| 'favorite-things'
| 'item-categories'
| 'item-usages'
| 'acquisition-methods'
| 'maps'
| 'life-tags'
@@ -74,7 +73,7 @@ type ConfigDefinition = {
hasRateable?: boolean;
hasChangeLog?: boolean;
};
type SortableContentType = 'pokemon' | 'items' | 'recipes' | 'habitats';
type SortableContentType = 'pokemon' | 'items' | 'ancient-artifacts' | 'recipes' | 'habitats';
type SortableContentDefinition = {
table: string;
entityType: SortableContentType;
@@ -171,10 +170,14 @@ type PokemonCsvData = {
};
type ItemPayload = {
displayId: number;
name: string;
details: string;
translations: TranslationInput;
categoryId: number;
categoryKey: string;
usageId: number | null;
usageKey: string | null;
dyeable: boolean;
dualDyeable: boolean;
patternEditable: boolean;
@@ -185,6 +188,17 @@ type ItemPayload = {
imagePath: string;
};
type AncientArtifactPayload = {
displayId: number;
name: string;
details: string;
translations: TranslationInput;
categoryId: number;
categoryKey: string;
tagIds: number[];
imagePath: string;
};
type RecipePayload = {
itemId: number;
acquisitionMethodIds: number[];
@@ -208,7 +222,7 @@ type LifeCommentPayload = {
languageCode: string | null;
};
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'ancient-artifacts';
type DiscussionEntityDefinition = {
table: string;
};
@@ -436,7 +450,9 @@ type PokemonChangeSource = {
favorite_things: Array<{ name: string }>;
} & TranslationChangeSource;
type ItemChangeSource = {
displayId: number;
name: string;
details: string;
isEventItem: boolean;
image: EntityImageValue | null;
category: { name: string };
@@ -446,6 +462,14 @@ type ItemChangeSource = {
acquisitionMethods: Array<{ name: string }>;
tags: Array<{ name: string }>;
} & TranslationChangeSource;
type AncientArtifactChangeSource = {
displayId: number;
name: string;
details: string;
image: EntityImageValue | null;
category: { name: string };
tags: Array<{ name: string }>;
} & TranslationChangeSource;
type HabitatChangeSource = {
name: string;
isEventItem: boolean;
@@ -491,13 +515,45 @@ const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
{ key: 'speed', label: 'Speed' }
];
type SystemListOption = {
id: number;
key: string;
labels: Record<typeof defaultLocale | 'zh-CN', string>;
};
const itemCategoryOptions = [
{ id: 1, key: 'furniture', labels: { en: 'Furniture', 'zh-CN': '家具' } },
{ id: 2, key: 'misc', labels: { en: 'Misc', 'zh-CN': '杂项' } },
{ id: 3, key: 'outdoor', labels: { en: 'Outdoor', 'zh-CN': '户外' } },
{ id: 4, key: 'utilities', labels: { en: 'Utilities', 'zh-CN': '实用工具' } },
{ id: 5, key: 'buildings', labels: { en: 'Buildings', 'zh-CN': '建筑' } },
{ id: 6, key: 'blocks', labels: { en: 'Blocks', 'zh-CN': '方块' } },
{ id: 7, key: 'kits', labels: { en: 'Kits', 'zh-CN': '套件' } },
{ id: 8, key: 'nature', labels: { en: 'Nature', 'zh-CN': '自然' } },
{ id: 9, key: 'food', labels: { en: 'Food', 'zh-CN': '食物' } },
{ id: 10, key: 'materials', labels: { en: 'Materials', 'zh-CN': '材料' } },
{ id: 11, key: 'key-items', labels: { en: 'Key Items', 'zh-CN': '关键物品' } },
{ id: 12, key: 'other', labels: { en: 'Other', 'zh-CN': '其他' } }
] as const satisfies readonly SystemListOption[];
const itemUsageOptions = [
{ id: 1, key: 'decoration', labels: { en: 'Decoration', 'zh-CN': '装饰' } },
{ id: 2, key: 'relaxation', labels: { en: 'Relaxation', 'zh-CN': '休闲' } },
{ id: 3, key: 'toy', labels: { en: 'Toy', 'zh-CN': '玩具' } },
{ id: 4, key: 'road', labels: { en: 'Road', 'zh-CN': '道路' } }
] as const satisfies readonly SystemListOption[];
const ancientArtifactCategoryOptions = [
{ id: 1, key: 'lost-relics-l', labels: { en: 'Lost Relics (L)', 'zh-CN': 'Lost Relics (L)' } },
{ id: 2, key: 'lost-relics-s', labels: { en: 'Lost Relics (S)', 'zh-CN': 'Lost Relics (S)' } },
{ id: 3, key: 'fossils', labels: { en: 'Fossils', 'zh-CN': '化石' } }
] as const satisfies readonly SystemListOption[];
const configDefinitions: Record<ConfigType, ConfigDefinition> = {
'pokemon-types': { table: 'pokemon_types', entityType: 'pokemon-types' },
skills: { table: 'skills', entityType: 'skills', hasItemDrop: true },
environments: { table: 'environments', entityType: 'environments' },
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things' },
'item-categories': { table: 'item_categories', entityType: 'item-categories' },
'item-usages': { table: 'item_usages', entityType: 'item-usages' },
'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 },
@@ -507,6 +563,7 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
pokemon: { table: 'pokemon', entityType: 'pokemon' },
items: { table: 'items', entityType: 'items' },
'ancient-artifacts': { table: 'ancient_artifacts', entityType: 'ancient-artifacts' },
recipes: { table: 'recipes', entityType: 'recipes' },
habitats: { table: 'habitats', entityType: 'habitats' }
};
@@ -515,7 +572,8 @@ const discussionEntityDefinitions: Record<DiscussionEntityType, DiscussionEntity
pokemon: { table: 'pokemon' },
items: { table: 'items' },
recipes: { table: 'recipes' },
habitats: { table: 'habitats' }
habitats: { table: 'habitats' },
'ancient-artifacts': { table: 'ancient_artifacts' }
};
let pokemonCsvDataCache: Promise<PokemonCsvData> | null = null;
@@ -726,6 +784,52 @@ function optionSelect(
return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`);
}
function systemListLabel(option: SystemListOption, locale: string): string {
const clean = cleanLocale(locale) as keyof SystemListOption['labels'];
return option.labels[clean] ?? option.labels[defaultLocale];
}
function systemListOptions(options: readonly SystemListOption[], locale: string): Array<{ id: number; key: string; name: string }> {
return options.map((option) => ({ id: option.id, key: option.key, name: systemListLabel(option, locale) }));
}
function systemListOptionById(
options: readonly SystemListOption[],
id: number,
message: string
): SystemListOption {
const option = options.find((item) => item.id === id);
if (!option) {
throw validationError(message);
}
return option;
}
function systemListOptionByKey(options: readonly SystemListOption[], key: string | null | undefined): SystemListOption | null {
return options.find((item) => item.key === key) ?? null;
}
function systemListNameByKey(options: readonly SystemListOption[], key: string | null | undefined, locale = defaultLocale): string | null {
const option = systemListOptionByKey(options, key);
return option ? systemListLabel(option, locale) : null;
}
function systemListIdSql(expression: string, options: readonly SystemListOption[]): string {
const cases = options.map((option) => `WHEN ${sqlLiteral(option.key)} THEN ${option.id}`).join(' ');
return `CASE ${expression} ${cases} ELSE NULL END`;
}
function systemListNameSql(expression: string, options: readonly SystemListOption[], locale: string): string {
const cases = options
.map((option) => `WHEN ${sqlLiteral(option.key)} THEN ${sqlLiteral(systemListLabel(option, locale))}`)
.join(' ');
return `CASE ${expression} ${cases} ELSE '' END`;
}
function systemListJsonSql(expression: string, options: readonly SystemListOption[], locale: string): string {
return `json_build_object('id', ${systemListIdSql(expression, options)}, 'key', ${expression}, 'name', ${systemListNameSql(expression, options, locale)})`;
}
function lifeCategoryOptions(locale: string): Promise<Array<{ id: number; name: string; isDefault: boolean; isRateable: boolean }>> {
const name = localizedName('life-tags', 'lc', locale);
return query(
@@ -821,7 +925,7 @@ function cleanOptionalText(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats'): string {
function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats' | 'ancient-artifacts'): string {
const imagePath = cleanOptionalText(value);
if (imagePath === '') {
return '';
@@ -2030,17 +2134,17 @@ async function itemEditChanges(
after: ItemPayload
): Promise<EditChange[]> {
const changes: EditChange[] = [];
const categoryNames = await entityNameMap(client, 'item_categories', [after.categoryId]);
const usageNames = await entityNameMap(client, 'item_usages', after.usageId ? [after.usageId] : []);
const methodNames = await entityNameMap(client, 'acquisition_methods', after.acquisitionMethodIds);
const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
pushChange(changes, 'Name', before.name, after.name);
pushTranslationChanges(changes, before.translations, after.translations, ['name']);
pushChange(changes, 'Display ID', String(before.displayId), String(after.displayId));
pushChange(changes, 'Description', before.details, after.details);
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'details']);
pushChange(changes, 'Event item', boolValue(before.isEventItem), boolValue(after.isEventItem));
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
pushChange(changes, 'Category', before.category.name, categoryNames.get(after.categoryId));
pushChange(changes, 'Usage', before.usage?.name, after.usageId ? usageNames.get(after.usageId) : null);
pushChange(changes, 'Category', before.category.name, systemListNameByKey(itemCategoryOptions, after.categoryKey));
pushChange(changes, 'Usage', before.usage?.name, systemListNameByKey(itemUsageOptions, after.usageKey));
pushChange(changes, 'Dyeable', boolValue(before.customization.dyeable), boolValue(after.dyeable));
pushChange(changes, 'Dual dyeable', boolValue(before.customization.dualDyeable), boolValue(after.dualDyeable));
pushChange(changes, 'Pattern editable', boolValue(before.customization.patternEditable), boolValue(after.patternEditable));
@@ -2051,6 +2155,24 @@ async function itemEditChanges(
return changes;
}
async function ancientArtifactEditChanges(
client: DbClient,
before: AncientArtifactChangeSource,
after: AncientArtifactPayload
): Promise<EditChange[]> {
const changes: EditChange[] = [];
const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
pushChange(changes, 'Name', before.name, after.name);
pushChange(changes, 'Display ID', String(before.displayId), String(after.displayId));
pushChange(changes, 'Description', before.details, after.details);
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'details']);
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
pushChange(changes, 'Category', before.category.name, systemListNameByKey(ancientArtifactCategoryOptions, after.categoryKey));
pushChange(changes, 'Tags', namedListValue(before.tags), namesFromIds(after.tagIds, tagNames));
return changes;
}
async function habitatEditChanges(
client: DbClient,
before: HabitatChangeSource,
@@ -2221,8 +2343,6 @@ export async function getOptions(locale = defaultLocale) {
skills,
environments,
favoriteThings,
itemCategories,
itemUsages,
acquisitionMethods,
maps,
lifeCategories,
@@ -2232,8 +2352,6 @@ export async function getOptions(locale = defaultLocale) {
skillOptions(locale),
optionSelect('environments', 'environments', locale),
optionSelect('favorite_things', 'favorite-things', locale),
optionSelect('item_categories', 'item-categories', locale),
optionSelect('item_usages', 'item-usages', locale),
optionSelect('acquisition_methods', 'acquisition-methods', locale),
optionSelect('maps', 'maps', locale),
lifeCategoryOptions(locale),
@@ -2245,8 +2363,9 @@ export async function getOptions(locale = defaultLocale) {
skills,
environments,
favoriteThings,
itemCategories,
itemUsages,
itemCategories: systemListOptions(itemCategoryOptions, locale),
itemUsages: systemListOptions(itemUsageOptions, locale),
ancientArtifactCategories: systemListOptions(ancientArtifactCategoryOptions, locale),
acquisitionMethods,
itemTags: favoriteThings,
maps,
@@ -3379,6 +3498,7 @@ export async function listUserCommentActivities(
const itemName = localizedName('items', 'i', locale);
const recipeItemName = localizedName('items', 'recipe_item', locale);
const habitatName = localizedName('habitats', 'h', locale);
const artifactName = localizedName('ancient-artifacts', 'a', locale);
const params: unknown[] = [user.id];
const outerConditions: string[] = [];
@@ -3444,6 +3564,7 @@ export async function listUserCommentActivities(
WHEN 'items' THEN ${itemName}
WHEN 'recipes' THEN ${recipeItemName}
WHEN 'habitats' THEN ${habitatName}
WHEN 'ancient-artifacts' THEN ${artifactName}
ELSE ''
END,
''
@@ -3455,6 +3576,7 @@ export async function listUserCommentActivities(
LEFT JOIN recipes r ON edc.entity_type = 'recipes' AND r.id = edc.entity_id
LEFT JOIN items recipe_item ON recipe_item.id = r.item_id
LEFT JOIN habitats h ON edc.entity_type = 'habitats' AND h.id = edc.entity_id
LEFT JOIN ancient_artifacts a ON edc.entity_type = 'ancient-artifacts' AND a.id = edc.entity_id
WHERE edc.created_by_user_id = $1
AND edc.deleted_at IS NULL
AND edc.ai_moderation_status = 'approved'
@@ -4434,6 +4556,11 @@ export async function reorderItems(payload: Record<string, unknown>, userId: num
return listItems({}, locale);
}
export async function reorderAncientArtifacts(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
await reorderContent('ancient-artifacts', payload, userId);
return listAncientArtifacts({}, locale);
}
export async function reorderRecipes(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
await reorderContent('recipes', payload, userId);
return listRecipes({}, locale);
@@ -4507,7 +4634,6 @@ export async function getPokemon(id: number, locale = defaultLocale) {
const habitatName = localizedName('habitats', 'h', locale);
const mapName = localizedName('maps', 'm', locale);
const itemName = localizedName('items', 'i', locale);
const categoryName = localizedName('item-categories', 'c', locale);
const tagName = localizedName('favorite-things', 'ft', locale);
const relatedPokemonName = localizedName('pokemon', 'related_pokemon', locale);
const relatedEnvironmentName = localizedName('environments', 'related_environment', locale);
@@ -4551,16 +4677,15 @@ export async function getPokemon(id: number, locale = defaultLocale) {
i.id,
${itemName} AS name,
${uploadedImageJson('i.image_path')} AS image,
json_build_object('id', c.id, 'name', ${categoryName}) AS category,
${systemListJsonSql('i.category_key', itemCategoryOptions, locale)} AS category,
json_agg(json_build_object('id', ft.id, 'name', ${tagName}) ORDER BY ${orderByEntity('ft')}) AS tags
FROM pokemon_favorite_things pft
JOIN item_favorite_things ift ON ift.favorite_thing_id = pft.favorite_thing_id
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
JOIN items i ON i.id = ift.item_id
JOIN item_categories c ON c.id = i.category_id
WHERE pft.pokemon_id = $1
GROUP BY i.id, i.name, i.image_path, i.sort_order, c.id, c.name, c.sort_order
ORDER BY ${orderByEntity('c')}, ${orderByEntity('i')}
GROUP BY i.id, i.name, i.image_path, i.category_key, i.sort_order
ORDER BY i.category_key, ${orderByEntity('i')}
`,
[id]
),
@@ -5190,21 +5315,26 @@ export async function deleteHabitat(id: number, userId: number) {
function itemProjection(locale: string): string {
const itemName = localizedName('items', 'i', locale);
const categoryName = localizedName('item-categories', 'c', locale);
const usageName = localizedName('item-usages', 'u', locale);
const itemDetails = localizedField('items', 'i.id', 'i.details', 'details', locale);
const tagName = localizedName('favorite-things', 't', locale);
return `
SELECT
i.id,
i.display_id AS "displayId",
${itemName} AS name,
i.name AS "baseName",
${itemDetails} AS details,
i.details AS "baseDetails",
i.is_event_item AS "isEventItem",
${translationsSelect('items', 'i.id')} AS translations,
${auditSelect('i', 'item_created_user', 'item_updated_user')},
${uploadedImageJson('i.image_path')} AS image,
json_build_object('id', c.id, 'name', ${categoryName}) AS category,
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'name', ${usageName}) END AS usage,
${systemListJsonSql('i.category_key', itemCategoryOptions, locale)} AS category,
CASE
WHEN i.usage_key IS NULL THEN NULL
ELSE ${systemListJsonSql('i.usage_key', itemUsageOptions, locale)}
END AS usage,
json_build_object(
'dyeable', i.dyeable,
'dualDyeable', i.dual_dyeable,
@@ -5234,8 +5364,6 @@ function itemProjection(locale: string): string {
)
END AS recipe
FROM items i
JOIN item_categories c ON c.id = i.category_id
LEFT JOIN item_usages u ON u.id = i.usage_id
LEFT JOIN recipes item_recipe ON item_recipe.item_id = i.id
LEFT JOIN users recipe_created_user ON recipe_created_user.id = item_recipe.created_by_user_id
LEFT JOIN users recipe_updated_user ON recipe_updated_user.id = item_recipe.updated_by_user_id
@@ -5248,23 +5376,35 @@ export async function listItems(paramsQuery: QueryParams, locale = defaultLocale
const conditions: string[] = [];
const categoryId = Number(asString(paramsQuery.categoryId));
const usageId = Number(asString(paramsQuery.usageId));
const isEventItem = asString(paramsQuery.isEventItem);
const tagIds = parseIdList(asString(paramsQuery.tagIds));
const search = asString(paramsQuery.search)?.trim();
const recipeOrder = asString(paramsQuery.recipeOrder) === '1';
const categoryOption = Number.isInteger(categoryId) && categoryId > 0
? systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired')
: null;
const usageOption = Number.isInteger(usageId) && usageId > 0
? systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired')
: null;
if (search) {
params.push(`%${search}%`);
conditions.push(`${localizedName('items', 'i', locale)} ILIKE $${params.length}`);
}
if (Number.isInteger(categoryId) && categoryId > 0) {
params.push(categoryId);
conditions.push(`i.category_id = $${params.length}`);
if (isEventItem === 'true' || isEventItem === 'false') {
params.push(isEventItem === 'true');
conditions.push(`i.is_event_item = $${params.length}`);
}
if (Number.isInteger(usageId) && usageId > 0) {
params.push(usageId);
conditions.push(`i.usage_id = $${params.length}`);
if (categoryOption) {
params.push(categoryOption.key);
conditions.push(`i.category_key = $${params.length}`);
}
if (usageOption) {
params.push(usageOption.key);
conditions.push(`i.usage_key = $${params.length}`);
}
const tagFilter = sqlForRelationFilter(
@@ -5282,8 +5422,8 @@ export async function listItems(paramsQuery: QueryParams, locale = defaultLocale
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const orderClause = recipeOrder
? `ORDER BY CASE WHEN item_recipe.id IS NULL THEN 1 ELSE 0 END, item_recipe.sort_order, item_recipe.id, ${orderByEntity('i')}`
: `ORDER BY ${orderByEntity('i')}`;
? `ORDER BY CASE WHEN item_recipe.id IS NULL THEN 1 ELSE 0 END, item_recipe.sort_order, item_recipe.id, i.display_id, ${orderByEntity('i')}`
: `ORDER BY i.display_id, ${orderByEntity('i')}`;
return query(`${itemProjection(locale)} ${whereClause} ${orderClause}`, params);
}
@@ -5295,8 +5435,6 @@ export async function getItem(id: number, locale = defaultLocale) {
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
const resultItemName = localizedName('items', 'result_item', locale);
const resultItemCategoryName = localizedName('item-categories', 'result_category', locale);
const resultItemUsageName = localizedName('item-usages', 'result_usage', locale);
const materialItemName = localizedName('items', 'mi', locale);
const habitatName = localizedName('habitats', 'h', locale);
const recipeItemName = localizedName('items', 'recipe_item', locale);
@@ -5342,18 +5480,17 @@ export async function getItem(id: number, locale = defaultLocale) {
), '[]'::json) AS materials,
json_build_object(
'id', result_item.id,
'displayId', result_item.display_id,
'name', ${resultItemName},
'image', ${uploadedImageJson('result_item.image_path')},
'category', json_build_object('id', result_category.id, 'name', ${resultItemCategoryName}),
'category', ${systemListJsonSql('result_item.category_key', itemCategoryOptions, locale)},
'usage', CASE
WHEN result_usage.id IS NULL THEN NULL
ELSE json_build_object('id', result_usage.id, 'name', ${resultItemUsageName})
WHEN result_item.usage_key IS NULL THEN NULL
ELSE ${systemListJsonSql('result_item.usage_key', itemUsageOptions, locale)}
END
) AS item
FROM recipes r
JOIN items result_item ON result_item.id = r.item_id
JOIN item_categories result_category ON result_category.id = result_item.category_id
LEFT JOIN item_usages result_usage ON result_usage.id = result_item.usage_id
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
WHERE r.item_id = $1
`,
@@ -5442,15 +5579,22 @@ export async function getItem(id: number, locale = defaultLocale) {
}
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired');
const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined
? null
: requirePositiveInteger(payload.usageId, 'server.validation.usageRequired');
const category = systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired');
const usage = usageId === null ? null : systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired');
return {
displayId: requirePositiveInteger(payload.displayId, 'server.validation.itemDisplayIdRequired'),
name: cleanName(payload.name, 'server.validation.itemNameRequired'),
translations: cleanTranslations(payload.translations, ['name']),
categoryId: requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired'),
details: cleanOptionalText(payload.details),
translations: cleanTranslations(payload.translations, ['name', 'details']),
categoryId,
categoryKey: category.key,
usageId,
usageKey: usage?.key ?? null,
dyeable: Boolean(payload.dyeable),
dualDyeable: Boolean(payload.dualDyeable),
patternEditable: Boolean(payload.patternEditable),
@@ -5500,9 +5644,11 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
const result = await client.query<{ id: number }>(
`
INSERT INTO items (
display_id,
name,
category_id,
usage_id,
details,
category_key,
usage_key,
dyeable,
dual_dyeable,
pattern_editable,
@@ -5513,13 +5659,15 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
created_by_user_id,
updated_by_user_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $13)
RETURNING id
`,
[
cleanPayload.displayId,
cleanPayload.name,
cleanPayload.categoryId,
cleanPayload.usageId,
cleanPayload.details,
cleanPayload.categoryKey,
cleanPayload.usageKey,
cleanPayload.dyeable,
cleanPayload.dualDyeable,
cleanPayload.patternEditable,
@@ -5533,7 +5681,7 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
const itemId = result.rows[0].id;
await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name);
await replaceItemRelations(client, itemId, cleanPayload);
await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name']);
await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name', 'details']);
await recordEditLog(client, 'items', itemId, 'create', userId);
return itemId;
});
@@ -5549,23 +5697,27 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
const result = await client.query(
`
UPDATE items
SET name = $1,
category_id = $2,
usage_id = $3,
dyeable = $4,
dual_dyeable = $5,
pattern_editable = $6,
no_recipe = $7,
is_event_item = $8,
image_path = $9,
updated_by_user_id = $10,
SET display_id = $1,
name = $2,
details = $3,
category_key = $4,
usage_key = $5,
dyeable = $6,
dual_dyeable = $7,
pattern_editable = $8,
no_recipe = $9,
is_event_item = $10,
image_path = $11,
updated_by_user_id = $12,
updated_at = now()
WHERE id = $11
WHERE id = $13
`,
[
cleanPayload.displayId,
cleanPayload.name,
cleanPayload.categoryId,
cleanPayload.usageId,
cleanPayload.details,
cleanPayload.categoryKey,
cleanPayload.usageKey,
cleanPayload.dyeable,
cleanPayload.dualDyeable,
cleanPayload.patternEditable,
@@ -5581,7 +5733,7 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
}
await linkEntityImageUpload(client, 'items', id, cleanPayload.imagePath, cleanPayload.name);
await replaceItemRelations(client, id, cleanPayload);
await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name']);
await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name', 'details']);
const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : [];
await recordEditLog(client, 'items', id, 'update', userId, changes);
return true;
@@ -5603,16 +5755,211 @@ export async function deleteItem(id: number, userId: number) {
});
}
function ancientArtifactProjection(locale: string): string {
const artifactName = localizedName('ancient-artifacts', 'a', locale);
const artifactDetails = localizedField('ancient-artifacts', 'a.id', 'a.details', 'details', locale);
const tagName = localizedName('favorite-things', 't', locale);
return `
SELECT
a.id,
a.display_id AS "displayId",
${artifactName} AS name,
a.name AS "baseName",
${artifactDetails} AS details,
a.details AS "baseDetails",
${translationsSelect('ancient-artifacts', 'a.id')} AS translations,
${systemListJsonSql('a.category_key', ancientArtifactCategoryOptions, locale)} AS category,
${uploadedImageJson('a.image_path')} AS image,
COALESCE((
SELECT json_agg(json_build_object('id', t.id, 'name', ${tagName}) ORDER BY ${orderByEntity('t')})
FROM ancient_artifact_favorite_things aaft
JOIN favorite_things t ON t.id = aaft.favorite_thing_id
WHERE aaft.ancient_artifact_id = a.id
), '[]'::json) AS tags,
${auditSelect('a', 'artifact_created_user', 'artifact_updated_user')}
FROM ancient_artifacts a
${auditJoins('a', 'artifact_created_user', 'artifact_updated_user')}
`;
}
export async function listAncientArtifacts(paramsQuery: QueryParams = {}, locale = defaultLocale) {
const params: unknown[] = [];
const conditions: string[] = [];
const search = asString(paramsQuery.search)?.trim();
const categoryId = Number(asString(paramsQuery.categoryId));
const tagIds = parseIdList(asString(paramsQuery.tagIds));
const categoryOption = Number.isInteger(categoryId) && categoryId > 0
? systemListOptionById(ancientArtifactCategoryOptions, categoryId, 'server.validation.categoryRequired')
: null;
if (search) {
params.push(`%${search}%`);
conditions.push(`${localizedName('ancient-artifacts', 'a', locale)} ILIKE $${params.length}`);
}
if (categoryOption) {
params.push(categoryOption.key);
conditions.push(`a.category_key = $${params.length}`);
}
const tagFilter = sqlForRelationFilter(
tagIds,
'any',
'ancient_artifact_favorite_things',
'ancient_artifact_id',
'favorite_thing_id',
'a.id',
params
);
if (tagFilter) {
conditions.push(tagFilter);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
return query(`${ancientArtifactProjection(locale)} ${whereClause} ORDER BY a.display_id, ${orderByEntity('a')}`, params);
}
export async function getAncientArtifact(id: number, locale = defaultLocale) {
const artifact = await queryOne(`${ancientArtifactProjection(locale)} WHERE a.id = $1`, [id]);
if (!artifact) {
return null;
}
const editHistory = await getEditHistory('ancient-artifacts', id);
const imageHistory = await listEntityImageUploads('ancient-artifacts', id);
return { ...artifact, editHistory, imageHistory };
}
function cleanAncientArtifactPayload(payload: Record<string, unknown>): AncientArtifactPayload {
const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired');
const category = systemListOptionById(ancientArtifactCategoryOptions, categoryId, 'server.validation.categoryRequired');
return {
displayId: requirePositiveInteger(payload.displayId, 'server.validation.artifactDisplayIdRequired'),
name: cleanName(payload.name, 'server.validation.artifactNameRequired'),
details: cleanOptionalText(payload.details),
translations: cleanTranslations(payload.translations, ['name', 'details']),
categoryId,
categoryKey: category.key,
tagIds: cleanIds(payload.tagIds),
imagePath: cleanUploadImagePath(payload.imagePath, 'ancient-artifacts')
};
}
async function replaceAncientArtifactRelations(client: DbClient, artifactId: number, payload: AncientArtifactPayload): Promise<void> {
await client.query('DELETE FROM ancient_artifact_favorite_things WHERE ancient_artifact_id = $1', [artifactId]);
for (const tagId of payload.tagIds) {
await client.query(
'INSERT INTO ancient_artifact_favorite_things (ancient_artifact_id, favorite_thing_id) VALUES ($1, $2)',
[artifactId, tagId]
);
}
}
export async function createAncientArtifact(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanAncientArtifactPayload(payload);
const id = await withTransaction(async (client) => {
const sortOrder = await nextSortOrder(client, 'ancient_artifacts');
const result = await client.query<{ id: number }>(
`
INSERT INTO ancient_artifacts (
display_id,
name,
details,
category_key,
image_path,
sort_order,
created_by_user_id,
updated_by_user_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
RETURNING id
`,
[
cleanPayload.displayId,
cleanPayload.name,
cleanPayload.details,
cleanPayload.categoryKey,
cleanPayload.imagePath,
sortOrder,
userId
]
);
const artifactId = result.rows[0].id;
await linkEntityImageUpload(client, 'ancient-artifacts', artifactId, cleanPayload.imagePath, cleanPayload.name);
await replaceAncientArtifactRelations(client, artifactId, cleanPayload);
await replaceEntityTranslations(client, 'ancient-artifacts', artifactId, cleanPayload.translations, ['name', 'details']);
await recordEditLog(client, 'ancient-artifacts', artifactId, 'create', userId);
return artifactId;
});
return getAncientArtifact(id, locale);
}
export async function updateAncientArtifact(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanAncientArtifactPayload(payload);
const before = await getAncientArtifact(id, defaultLocale);
const updated = await withTransaction(async (client) => {
const result = await client.query(
`
UPDATE ancient_artifacts
SET display_id = $1,
name = $2,
details = $3,
category_key = $4,
image_path = $5,
updated_by_user_id = $6,
updated_at = now()
WHERE id = $7
`,
[cleanPayload.displayId, cleanPayload.name, cleanPayload.details, cleanPayload.categoryKey, cleanPayload.imagePath, userId, id]
);
if (result.rowCount === 0) {
return false;
}
await linkEntityImageUpload(client, 'ancient-artifacts', id, cleanPayload.imagePath, cleanPayload.name);
await replaceAncientArtifactRelations(client, id, cleanPayload);
await replaceEntityTranslations(client, 'ancient-artifacts', id, cleanPayload.translations, ['name', 'details']);
const changes = before ? await ancientArtifactEditChanges(client, before as unknown as AncientArtifactChangeSource, cleanPayload) : [];
await recordEditLog(client, 'ancient-artifacts', id, 'update', userId, changes);
return true;
});
return updated ? getAncientArtifact(id, locale) : null;
}
export async function deleteAncientArtifact(id: number, userId: number) {
return withTransaction(async (client) => {
const result = await client.query<{ id: number }>('DELETE FROM ancient_artifacts WHERE id = $1 RETURNING id', [id]);
if (result.rowCount === 0) {
return false;
}
await deleteEntityDiscussionCommentsForEntity(client, 'ancient-artifacts', id);
await deleteEntityTranslations(client, 'ancient-artifacts', id);
await recordEditLog(client, 'ancient-artifacts', id, 'delete', userId);
return true;
});
}
export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaultLocale) {
const params: unknown[] = [];
const conditions: string[] = [];
const categoryId = Number(asString(paramsQuery.categoryId));
const categoryOption = Number.isInteger(categoryId) && categoryId > 0
? systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired')
: null;
const resultItemName = localizedName('items', 'result_item', locale);
const materialItemName = localizedName('items', 'i', locale);
if (Number.isInteger(categoryId) && categoryId > 0) {
params.push(categoryId);
conditions.push(`result_item.category_id = $${params.length}`);
if (categoryOption) {
params.push(categoryOption.key);
conditions.push(`result_item.category_key = $${params.length}`);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
@@ -5637,8 +5984,6 @@ export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaul
export async function getRecipe(id: number, locale = defaultLocale) {
const resultItemName = localizedName('items', 'result_item', locale);
const resultItemCategoryName = localizedName('item-categories', 'result_category', locale);
const resultItemUsageName = localizedName('item-usages', 'result_usage', locale);
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
const materialItemName = localizedName('items', 'i', locale);
@@ -5670,18 +6015,17 @@ export async function getRecipe(id: number, locale = defaultLocale) {
), '[]'::json) AS materials,
json_build_object(
'id', result_item.id,
'displayId', result_item.display_id,
'name', ${resultItemName},
'image', ${uploadedImageJson('result_item.image_path')},
'category', json_build_object('id', result_category.id, 'name', ${resultItemCategoryName}),
'category', ${systemListJsonSql('result_item.category_key', itemCategoryOptions, locale)},
'usage', CASE
WHEN result_usage.id IS NULL THEN NULL
ELSE json_build_object('id', result_usage.id, 'name', ${resultItemUsageName})
WHEN result_item.usage_key IS NULL THEN NULL
ELSE ${systemListJsonSql('result_item.usage_key', itemUsageOptions, locale)}
END
) AS item
FROM recipes r
JOIN items result_item ON result_item.id = r.item_id
JOIN item_categories result_category ON result_category.id = result_item.category_id
LEFT JOIN item_usages result_usage ON result_usage.id = result_item.usage_id
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
WHERE r.id = $1
`,
@@ -5791,11 +6135,12 @@ export async function deleteRecipe(id: number, userId: number) {
});
}
const dataToolScopes = ['pokemon', 'habitats', 'items', 'recipes', 'checklist'] as const satisfies readonly DataToolScope[];
const dataToolScopes = ['pokemon', 'habitats', 'items', 'artifacts', 'recipes', 'checklist'] as const satisfies readonly DataToolScope[];
const dataToolMainTables: Record<DataToolScope, string> = {
pokemon: 'pokemon',
habitats: 'habitats',
items: 'items',
artifacts: 'ancient_artifacts',
recipes: 'recipes',
checklist: 'daily_checklist_items'
};
@@ -5839,9 +6184,11 @@ const dataToolColumns = {
habitatPokemon: ['habitat_id', 'pokemon_id', 'map_id', 'time_of_day', 'weather', 'rarity'],
items: [
'id',
'display_id',
'name',
'category_id',
'usage_id',
'details',
'category_key',
'usage_key',
'dyeable',
'dual_dyeable',
'pattern_editable',
@@ -5856,6 +6203,20 @@ const dataToolColumns = {
],
itemAcquisitionMethods: ['item_id', 'acquisition_method_id'],
itemFavoriteThings: ['item_id', 'favorite_thing_id'],
artifactFavoriteThings: ['ancient_artifact_id', 'favorite_thing_id'],
artifacts: [
'id',
'display_id',
'name',
'details',
'category_key',
'image_path',
'sort_order',
'created_by_user_id',
'updated_by_user_id',
'created_at',
'updated_at'
],
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'],
@@ -5965,8 +6326,20 @@ async function tableRows(client: DbClient, sql: string, params: unknown[] = []):
return result.rows;
}
function normalizeImportValue(column: string, value: unknown): unknown {
function normalizeImportValue(column: string, value: unknown, row: Record<string, unknown>): unknown {
if (value === undefined) {
if (column === 'display_id' && typeof row.id === 'number') {
return row.id;
}
if (column === 'details') {
return '';
}
if (column === 'image_path') {
return '';
}
if (column === 'category_key') {
return 'other';
}
return null;
}
if (column === 'changes' && typeof value !== 'string') {
@@ -5978,7 +6351,7 @@ function normalizeImportValue(column: string, value: unknown): unknown {
async function insertRows(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> {
for (const row of rows) {
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
const values = columns.map((column) => normalizeImportValue(column, row[column]));
const values = columns.map((column) => normalizeImportValue(column, row[column], row));
await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`, values);
}
}
@@ -5995,7 +6368,16 @@ async function resetIdentity(client: DbClient, tableName: string): Promise<void>
}
async function resetDataToolIdentities(client: DbClient): Promise<void> {
for (const tableName of ['daily_checklist_items', 'items', 'recipes', 'habitats', 'wiki_edit_logs', 'entity_image_uploads', 'entity_discussion_comments']) {
for (const tableName of [
'daily_checklist_items',
'items',
'ancient_artifacts',
'recipes',
'habitats',
'wiki_edit_logs',
'entity_image_uploads',
'entity_discussion_comments'
]) {
await resetIdentity(client, tableName);
}
}
@@ -6024,6 +6406,12 @@ async function wipeItemsData(client: DbClient): Promise<void> {
await client.query('DELETE FROM items');
}
async function wipeAncientArtifactsData(client: DbClient): Promise<void> {
await deleteGenericEntityRows(client, ['ancient-artifacts']);
await client.query('DELETE FROM ancient_artifact_favorite_things');
await client.query('DELETE FROM ancient_artifacts');
}
async function wipePokemonData(client: DbClient): Promise<void> {
await deleteGenericEntityRows(client, ['pokemon']);
await client.query('DELETE FROM habitat_pokemon');
@@ -6053,6 +6441,9 @@ async function wipeDataToolScopes(client: DbClient, scopes: DataToolScope[], res
} else if (scopeSet.has('recipes')) {
await wipeRecipesData(client);
}
if (scopeSet.has('artifacts')) {
await wipeAncientArtifactsData(client);
}
if (scopeSet.has('pokemon')) {
await wipePokemonData(client);
}
@@ -6114,7 +6505,7 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<
if (scope === 'items') {
return {
items: await tableRows(client, 'SELECT * FROM items ORDER BY sort_order, id'),
items: await tableRows(client, 'SELECT * FROM items ORDER BY display_id, 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'),
@@ -6123,6 +6514,17 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<
};
}
if (scope === 'artifacts') {
return {
artifacts: await tableRows(client, 'SELECT * FROM ancient_artifacts ORDER BY display_id, sort_order, id'),
artifactFavoriteThings: await tableRows(
client,
'SELECT * FROM ancient_artifact_favorite_things ORDER BY ancient_artifact_id, favorite_thing_id'
),
...(await exportGenericScopeData(client, 'ancient-artifacts', true))
};
}
if (scope === 'recipes') {
return {
recipes: await tableRows(client, 'SELECT * FROM recipes ORDER BY sort_order, id'),
@@ -6145,12 +6547,14 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<
async function importScopeMainRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
const itemData = bundle.data.items;
const artifactData = bundle.data.artifacts;
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, 'ancient_artifacts', dataToolColumns.artifacts, dataToolTableRows(artifactData, 'artifacts'));
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'));
@@ -6159,6 +6563,7 @@ async function importScopeMainRows(client: DbClient, bundle: DataToolsBundle): P
async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
const itemData = bundle.data.items;
const artifactData = bundle.data.artifacts;
const pokemonData = bundle.data.pokemon;
const habitatData = bundle.data.habitats;
const recipeData = bundle.data.recipes;
@@ -6168,6 +6573,12 @@ async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle
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,
'ancient_artifact_favorite_things',
dataToolColumns.artifactFavoriteThings,
dataToolTableRows(artifactData, 'artifactFavoriteThings')
);
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'));

View File

@@ -35,6 +35,7 @@ import {
import { initializeDatabase, pool } from './db.ts';
import {
cleanLocale,
createAncientArtifact,
createConfig,
createDailyChecklistItem,
createEntityDiscussionComment,
@@ -48,6 +49,7 @@ import {
createPokemon,
createRecipe,
deleteConfig,
deleteAncientArtifact,
deleteDailyChecklistItem,
deleteEntityDiscussionComment,
deleteHabitat,
@@ -63,6 +65,7 @@ import {
fetchPokemonData,
fetchPokemonImageOptions,
getAdminDataToolsSummary,
getAncientArtifact,
getHabitat,
getItem,
getOptions,
@@ -71,6 +74,7 @@ import {
getRecipe,
importAdminData,
isConfigType,
listAncientArtifacts,
listEntityDiscussionComments,
listConfig,
listDailyChecklistItems,
@@ -86,6 +90,7 @@ import {
listUserLifePosts,
listUserReactionActivities,
reorderConfig,
reorderAncientArtifacts,
reorderDailyChecklistItems,
reorderHabitats,
reorderItems,
@@ -98,6 +103,7 @@ import {
setLifePostRating,
setLifePostReaction,
updateConfig,
updateAncientArtifact,
updateDailyChecklistItem,
updateHabitat,
updateItem,
@@ -1504,7 +1510,13 @@ app.post('/api/uploads/:entityType', async (request, reply) => {
}
const permissionKey =
entityType === 'pokemon' ? 'pokemon.upload' : entityType === 'items' ? 'items.upload' : 'habitats.upload';
entityType === 'pokemon'
? 'pokemon.upload'
: entityType === 'items'
? 'items.upload'
: entityType === 'habitats'
? 'habitats.upload'
: 'ancient-artifacts.upload';
const user = await requirePermissionWithRateLimits(request, reply, permissionKey, 'upload');
if (!user) {
return;
@@ -1643,6 +1655,53 @@ app.delete('/api/items/:id', async (request, reply) => {
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.get('/api/ancient-artifacts', async (request) =>
listAncientArtifacts(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
app.get('/api/ancient-artifacts/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const artifact = await getAncientArtifact(Number(id), requestLocale(request));
if (!artifact) {
return notFound(reply, request);
}
return artifact;
});
app.post('/api/ancient-artifacts', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'ancient-artifacts.create', 'wikiWrite');
return user
? reply.code(201).send(await createAncientArtifact(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
});
app.put('/api/ancient-artifacts/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'ancient-artifacts.update', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const artifact = await updateAncientArtifact(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
if (!artifact) {
return notFound(reply, request);
}
return artifact;
});
app.delete('/api/ancient-artifacts/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'ancient-artifacts.delete', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteAncientArtifact(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.get('/api/recipes', async (request) =>
listRecipes(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
@@ -1739,6 +1798,11 @@ app.put('/api/admin/items/order', async (request, reply) => {
return user ? reorderItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.put('/api/admin/ancient-artifacts/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'ancient-artifacts.order', 'wikiWrite');
return user ? reorderAncientArtifacts(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.put('/api/admin/recipes/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'recipes.order', 'wikiWrite');
return user ? reorderRecipes(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;

View File

@@ -5,7 +5,7 @@ import type { PoolClient } from 'pg';
import type { AuthUser } from './auth.ts';
import { query, queryOne } from './db.ts';
export type UploadEntityType = 'pokemon' | 'items' | 'habitats';
export type UploadEntityType = 'pokemon' | 'items' | 'habitats' | 'ancient-artifacts';
export type EntityImageUpload = {
id: number;
@@ -26,7 +26,7 @@ type MultipartField = {
value?: unknown;
};
const uploadEntityTypes = new Set<UploadEntityType>(['pokemon', 'items', 'habitats']);
const uploadEntityTypes = new Set<UploadEntityType>(['pokemon', 'items', 'habitats', 'ancient-artifacts']);
const imageMimeTypes = new Map([
['image/png', '.png'],
['image/jpeg', '.jpg'],