initial commit
This commit is contained in:
8
backend/Dockerfile
Normal file
8
backend/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 3001
|
||||
CMD ["npm", "run", "start"]
|
||||
122
backend/db/schema.sql
Normal file
122
backend/db/schema.sql
Normal file
@@ -0,0 +1,122 @@
|
||||
CREATE TABLE IF NOT EXISTS environments (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS skills (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
subcategory text,
|
||||
UNIQUE (name, subcategory)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS favorite_things (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pokemon (
|
||||
id integer PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
environment_id integer NOT NULL REFERENCES environments(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pokemon_skills (
|
||||
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
||||
skill_id integer NOT NULL REFERENCES skills(id),
|
||||
PRIMARY KEY (pokemon_id, skill_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pokemon_favorite_things (
|
||||
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
||||
favorite_thing_id integer NOT NULL REFERENCES favorite_things(id),
|
||||
PRIMARY KEY (pokemon_id, favorite_thing_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_categories (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_usages (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS acquisition_methods (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_tags (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recipes (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recipe_acquisition_methods (
|
||||
recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id),
|
||||
PRIMARY KEY (recipe_id, acquisition_method_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
category_id integer NOT NULL REFERENCES item_categories(id),
|
||||
usage_id integer NOT NULL REFERENCES item_usages(id),
|
||||
recipe_id integer REFERENCES recipes(id),
|
||||
dyeable boolean NOT NULL DEFAULT false,
|
||||
dual_dyeable boolean NOT NULL DEFAULT false,
|
||||
pattern_editable boolean NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_acquisition_methods (
|
||||
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id),
|
||||
PRIMARY KEY (item_id, acquisition_method_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_item_tags (
|
||||
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
item_tag_id integer NOT NULL REFERENCES item_tags(id),
|
||||
PRIMARY KEY (item_id, item_tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recipe_materials (
|
||||
recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
item_id integer NOT NULL REFERENCES items(id),
|
||||
quantity integer NOT NULL CHECK (quantity > 0),
|
||||
PRIMARY KEY (recipe_id, item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS maps (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS habitats (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS habitat_recipe_items (
|
||||
habitat_id integer NOT NULL REFERENCES habitats(id) ON DELETE CASCADE,
|
||||
item_id integer NOT NULL REFERENCES items(id),
|
||||
quantity integer NOT NULL CHECK (quantity > 0),
|
||||
PRIMARY KEY (habitat_id, item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS habitat_pokemon (
|
||||
habitat_id integer NOT NULL REFERENCES habitats(id) ON DELETE CASCADE,
|
||||
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
||||
map_id integer NOT NULL REFERENCES maps(id),
|
||||
time_of_day text NOT NULL CHECK (time_of_day IN ('早晨', '中午', '傍晚', '晚上')),
|
||||
weather text NOT NULL CHECK (weather IN ('晴天', '阴天', '雨天')),
|
||||
rarity integer NOT NULL CHECK (rarity BETWEEN 1 AND 3),
|
||||
PRIMARY KEY (habitat_id, pokemon_id, map_id, time_of_day, weather)
|
||||
);
|
||||
147
backend/db/seed.sql
Normal file
147
backend/db/seed.sql
Normal file
@@ -0,0 +1,147 @@
|
||||
INSERT INTO environments (id, name) VALUES
|
||||
(1, '森林'),
|
||||
(2, '水边'),
|
||||
(3, '草原')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO skills (id, name, subcategory) VALUES
|
||||
(1, '采集', NULL),
|
||||
(2, '乱撒', '棉花'),
|
||||
(3, '浇水', NULL),
|
||||
(4, '搬运', NULL)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO favorite_things (id, name) VALUES
|
||||
(1, '莓果'),
|
||||
(2, '花朵'),
|
||||
(3, '木材'),
|
||||
(4, '清水'),
|
||||
(5, '棉花')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO pokemon (id, name, environment_id) VALUES
|
||||
(1, '妙蛙种子', 1),
|
||||
(4, '小火龙', 3),
|
||||
(7, '杰尼龟', 2),
|
||||
(25, '皮卡丘', 3)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO pokemon_skills (pokemon_id, skill_id) VALUES
|
||||
(1, 1),
|
||||
(1, 3),
|
||||
(4, 4),
|
||||
(7, 3),
|
||||
(25, 1),
|
||||
(25, 2)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO pokemon_favorite_things (pokemon_id, favorite_thing_id) VALUES
|
||||
(1, 1),
|
||||
(1, 2),
|
||||
(4, 3),
|
||||
(7, 4),
|
||||
(25, 1),
|
||||
(25, 5)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO item_categories (id, name) VALUES
|
||||
(1, '家具'),
|
||||
(2, '材料'),
|
||||
(3, '装饰')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO item_usages (id, name) VALUES
|
||||
(1, '栖息地配方'),
|
||||
(2, '建造'),
|
||||
(3, '装饰')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO acquisition_methods (id, name) VALUES
|
||||
(1, '采集'),
|
||||
(2, '制作'),
|
||||
(3, '探索')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO item_tags (id, name) VALUES
|
||||
(1, '自然'),
|
||||
(2, '木质'),
|
||||
(3, '柔软'),
|
||||
(4, '水域')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO recipes (id, name) VALUES
|
||||
(1, '木质长椅材料单'),
|
||||
(2, '棉花垫材料单')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO items (id, name, category_id, usage_id, recipe_id, dyeable, dual_dyeable, pattern_editable) VALUES
|
||||
(1, '原木', 2, 2, NULL, false, false, false),
|
||||
(2, '棉花', 2, 2, NULL, true, false, false),
|
||||
(3, '木质长椅', 1, 3, 1, true, true, false),
|
||||
(4, '清水瓶', 3, 1, NULL, false, false, true),
|
||||
(5, '棉花垫', 1, 3, 2, true, false, true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO item_acquisition_methods (item_id, acquisition_method_id) VALUES
|
||||
(1, 1),
|
||||
(2, 1),
|
||||
(3, 2),
|
||||
(4, 3),
|
||||
(5, 2)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO item_item_tags (item_id, item_tag_id) VALUES
|
||||
(1, 1),
|
||||
(1, 2),
|
||||
(2, 3),
|
||||
(3, 2),
|
||||
(4, 4),
|
||||
(5, 3)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO recipe_acquisition_methods (recipe_id, acquisition_method_id) VALUES
|
||||
(1, 2),
|
||||
(2, 2)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO recipe_materials (recipe_id, item_id, quantity) VALUES
|
||||
(1, 1, 6),
|
||||
(2, 2, 4)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO maps (id, name) VALUES
|
||||
(1, '起始平原'),
|
||||
(2, '微风森林'),
|
||||
(3, '湖畔小径')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO habitats (id, name) VALUES
|
||||
(1, '绿荫营地'),
|
||||
(2, '湖边小窝')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO habitat_recipe_items (habitat_id, item_id, quantity) VALUES
|
||||
(1, 1, 8),
|
||||
(1, 3, 1),
|
||||
(2, 2, 3),
|
||||
(2, 4, 2)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO habitat_pokemon (habitat_id, pokemon_id, map_id, time_of_day, weather, rarity) VALUES
|
||||
(1, 1, 2, '早晨', '晴天', 1),
|
||||
(1, 25, 1, '傍晚', '阴天', 2),
|
||||
(2, 7, 3, '中午', '雨天', 1),
|
||||
(2, 1, 3, '早晨', '雨天', 2)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
SELECT setval(pg_get_serial_sequence('environments', 'id'), (SELECT max(id) FROM environments));
|
||||
SELECT setval(pg_get_serial_sequence('skills', 'id'), (SELECT max(id) FROM skills));
|
||||
SELECT setval(pg_get_serial_sequence('favorite_things', 'id'), (SELECT max(id) FROM favorite_things));
|
||||
SELECT setval(pg_get_serial_sequence('item_categories', 'id'), (SELECT max(id) FROM item_categories));
|
||||
SELECT setval(pg_get_serial_sequence('item_usages', 'id'), (SELECT max(id) FROM item_usages));
|
||||
SELECT setval(pg_get_serial_sequence('acquisition_methods', 'id'), (SELECT max(id) FROM acquisition_methods));
|
||||
SELECT setval(pg_get_serial_sequence('item_tags', 'id'), (SELECT max(id) FROM item_tags));
|
||||
SELECT setval(pg_get_serial_sequence('recipes', 'id'), (SELECT max(id) FROM recipes));
|
||||
SELECT setval(pg_get_serial_sequence('items', 'id'), (SELECT max(id) FROM items));
|
||||
SELECT setval(pg_get_serial_sequence('maps', 'id'), (SELECT max(id) FROM maps));
|
||||
SELECT setval(pg_get_serial_sequence('habitats', 'id'), (SELECT max(id) FROM habitats));
|
||||
25
backend/package.json
Normal file
25
backend/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@pokopia/backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"start": "tsx src/server.ts",
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test --import tsx tests/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "latest",
|
||||
"fastify": "latest",
|
||||
"pg": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "latest",
|
||||
"@types/pg": "latest",
|
||||
"tsx": "latest",
|
||||
"typescript": "latest"
|
||||
}
|
||||
}
|
||||
31
backend/src/db.ts
Normal file
31
backend/src/db.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import pg from 'pg';
|
||||
|
||||
const { Pool } = pg;
|
||||
type QueryResultRow = pg.QueryResultRow;
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL ?? 'postgres://pokopia:pokopia@localhost:5432/pokopia';
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: databaseUrl
|
||||
});
|
||||
|
||||
export async function query<T extends QueryResultRow>(sql: string, params: unknown[] = []): Promise<T[]> {
|
||||
const result = await pool.query<T>(sql, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function queryOne<T extends QueryResultRow>(sql: string, params: unknown[] = []): Promise<T | null> {
|
||||
const rows = await query<T>(sql, params);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function initializeDatabase(): Promise<void> {
|
||||
const dbDir = path.join(process.cwd(), 'db');
|
||||
const schema = await readFile(path.join(dbDir, 'schema.sql'), 'utf8');
|
||||
const seed = await readFile(path.join(dbDir, 'seed.sql'), 'utf8');
|
||||
|
||||
await pool.query(schema);
|
||||
await pool.query(seed);
|
||||
}
|
||||
49
backend/src/filter.ts
Normal file
49
backend/src/filter.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export type MatchMode = 'any' | 'all';
|
||||
|
||||
export function parseIdList(value: unknown): number[] {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => Number(item.trim()))
|
||||
.filter((item) => Number.isInteger(item) && item > 0);
|
||||
}
|
||||
|
||||
export function parseMatchMode(value: unknown): MatchMode {
|
||||
return value === 'all' ? 'all' : 'any';
|
||||
}
|
||||
|
||||
export function sqlForRelationFilter(
|
||||
ids: number[],
|
||||
mode: MatchMode,
|
||||
relationTable: string,
|
||||
ownerColumn: string,
|
||||
targetColumn: string,
|
||||
ownerExpression: string,
|
||||
params: unknown[]
|
||||
): string {
|
||||
if (ids.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
params.push(ids);
|
||||
const paramIndex = params.length;
|
||||
|
||||
if (mode === 'all') {
|
||||
return `(
|
||||
SELECT count(DISTINCT ${targetColumn})
|
||||
FROM ${relationTable}
|
||||
WHERE ${ownerColumn} = ${ownerExpression}
|
||||
AND ${targetColumn} = ANY($${paramIndex}::int[])
|
||||
) = ${ids.length}`;
|
||||
}
|
||||
|
||||
return `EXISTS (
|
||||
SELECT 1
|
||||
FROM ${relationTable}
|
||||
WHERE ${ownerColumn} = ${ownerExpression}
|
||||
AND ${targetColumn} = ANY($${paramIndex}::int[])
|
||||
)`;
|
||||
}
|
||||
355
backend/src/queries.ts
Normal file
355
backend/src/queries.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
|
||||
import { query, queryOne } from './db.ts';
|
||||
|
||||
type QueryValue = string | string[] | undefined;
|
||||
|
||||
type QueryParams = Record<string, QueryValue>;
|
||||
|
||||
function asString(value: QueryValue): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function optionSelect(tableName: string): Promise<Array<{ id: number; name: string }>> {
|
||||
return query(`SELECT id, name FROM ${tableName} ORDER BY name`);
|
||||
}
|
||||
|
||||
const pokemonProjection = `
|
||||
SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
json_build_object('id', e.id, 'name', e.name) AS environment,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'subcategory', s.subcategory) ORDER BY s.name, s.subcategory)
|
||||
FROM pokemon_skills ps
|
||||
JOIN skills s ON s.id = ps.skill_id
|
||||
WHERE ps.pokemon_id = p.id
|
||||
), '[]'::json) AS skills,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', ft.id, 'name', ft.name) ORDER BY ft.name)
|
||||
FROM pokemon_favorite_things pft
|
||||
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
|
||||
WHERE pft.pokemon_id = p.id
|
||||
), '[]'::json) AS favorite_things
|
||||
FROM pokemon p
|
||||
JOIN environments e ON e.id = p.environment_id
|
||||
`;
|
||||
|
||||
export async function getOptions() {
|
||||
const [skills, environments, favoriteThings, itemCategories, itemUsages, itemTags] = await Promise.all([
|
||||
query<{ id: number; name: string; subcategory: string | null }>(
|
||||
'SELECT id, name, subcategory FROM skills ORDER BY name, subcategory'
|
||||
),
|
||||
optionSelect('environments'),
|
||||
optionSelect('favorite_things'),
|
||||
optionSelect('item_categories'),
|
||||
optionSelect('item_usages'),
|
||||
optionSelect('item_tags')
|
||||
]);
|
||||
|
||||
return {
|
||||
skills,
|
||||
environments,
|
||||
favoriteThings,
|
||||
itemCategories,
|
||||
itemUsages,
|
||||
itemTags
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPokemon(paramsQuery: QueryParams) {
|
||||
const params: unknown[] = [];
|
||||
const conditions: string[] = [];
|
||||
const search = asString(paramsQuery.search)?.trim();
|
||||
const environmentId = Number(asString(paramsQuery.environmentId));
|
||||
const skillIds = parseIdList(asString(paramsQuery.skillIds));
|
||||
const favoriteThingIds = parseIdList(asString(paramsQuery.favoriteThingIds));
|
||||
|
||||
if (search) {
|
||||
params.push(`%${search}%`);
|
||||
conditions.push(`p.name ILIKE $${params.length}`);
|
||||
}
|
||||
|
||||
if (Number.isInteger(environmentId) && environmentId > 0) {
|
||||
params.push(environmentId);
|
||||
conditions.push(`p.environment_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const skillFilter = sqlForRelationFilter(
|
||||
skillIds,
|
||||
parseMatchMode(asString(paramsQuery.skillMode)),
|
||||
'pokemon_skills',
|
||||
'pokemon_id',
|
||||
'skill_id',
|
||||
'p.id',
|
||||
params
|
||||
);
|
||||
if (skillFilter) {
|
||||
conditions.push(skillFilter);
|
||||
}
|
||||
|
||||
const favoriteThingFilter = sqlForRelationFilter(
|
||||
favoriteThingIds,
|
||||
parseMatchMode(asString(paramsQuery.favoriteThingMode)),
|
||||
'pokemon_favorite_things',
|
||||
'pokemon_id',
|
||||
'favorite_thing_id',
|
||||
'p.id',
|
||||
params
|
||||
);
|
||||
if (favoriteThingFilter) {
|
||||
conditions.push(favoriteThingFilter);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
return query(`${pokemonProjection} ${whereClause} ORDER BY p.id`, params);
|
||||
}
|
||||
|
||||
export async function getPokemon(id: number) {
|
||||
const pokemon = await queryOne(`${pokemonProjection} WHERE p.id = $1`, [id]);
|
||||
if (!pokemon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const habitats = await query(
|
||||
`
|
||||
SELECT
|
||||
h.id,
|
||||
h.name,
|
||||
hp.time_of_day,
|
||||
hp.weather,
|
||||
hp.rarity,
|
||||
json_build_object('id', m.id, 'name', m.name) AS map
|
||||
FROM habitat_pokemon hp
|
||||
JOIN habitats h ON h.id = hp.habitat_id
|
||||
JOIN maps m ON m.id = hp.map_id
|
||||
WHERE hp.pokemon_id = $1
|
||||
ORDER BY h.name, hp.rarity, m.name
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return { ...pokemon, habitats };
|
||||
}
|
||||
|
||||
export async function listHabitats() {
|
||||
return query(`
|
||||
SELECT
|
||||
h.id,
|
||||
h.name,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', hri.quantity) ORDER BY i.name)
|
||||
FROM habitat_recipe_items hri
|
||||
JOIN items i ON i.id = hri.item_id
|
||||
WHERE hri.habitat_id = h.id
|
||||
), '[]'::json) AS recipe,
|
||||
COALESCE((
|
||||
SELECT json_agg(DISTINCT jsonb_build_object('id', p.id, 'name', p.name))
|
||||
FROM habitat_pokemon hp
|
||||
JOIN pokemon p ON p.id = hp.pokemon_id
|
||||
WHERE hp.habitat_id = h.id
|
||||
), '[]'::json) AS pokemon
|
||||
FROM habitats h
|
||||
ORDER BY h.name
|
||||
`);
|
||||
}
|
||||
|
||||
export async function getHabitat(id: number) {
|
||||
const habitat = await queryOne(
|
||||
`
|
||||
SELECT
|
||||
h.id,
|
||||
h.name,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', hri.quantity) ORDER BY i.name)
|
||||
FROM habitat_recipe_items hri
|
||||
JOIN items i ON i.id = hri.item_id
|
||||
WHERE hri.habitat_id = h.id
|
||||
), '[]'::json) AS recipe
|
||||
FROM habitats h
|
||||
WHERE h.id = $1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!habitat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pokemon = await query(
|
||||
`
|
||||
SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
hp.time_of_day,
|
||||
hp.weather,
|
||||
hp.rarity,
|
||||
json_build_object('id', m.id, 'name', m.name) AS map
|
||||
FROM habitat_pokemon hp
|
||||
JOIN pokemon p ON p.id = hp.pokemon_id
|
||||
JOIN maps m ON m.id = hp.map_id
|
||||
WHERE hp.habitat_id = $1
|
||||
ORDER BY hp.rarity, p.id, m.name
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return { ...habitat, pokemon };
|
||||
}
|
||||
|
||||
const itemProjection = `
|
||||
SELECT
|
||||
i.id,
|
||||
i.name,
|
||||
json_build_object('id', c.id, 'name', c.name) AS category,
|
||||
json_build_object('id', u.id, 'name', u.name) AS usage,
|
||||
json_build_object(
|
||||
'dyeable', i.dyeable,
|
||||
'dualDyeable', i.dual_dyeable,
|
||||
'patternEditable', i.pattern_editable
|
||||
) AS customization,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY t.name)
|
||||
FROM item_item_tags iit
|
||||
JOIN item_tags t ON t.id = iit.item_tag_id
|
||||
WHERE iit.item_id = i.id
|
||||
), '[]'::json) AS tags
|
||||
FROM items i
|
||||
JOIN item_categories c ON c.id = i.category_id
|
||||
JOIN item_usages u ON u.id = i.usage_id
|
||||
`;
|
||||
|
||||
export async function listItems(paramsQuery: QueryParams) {
|
||||
const params: unknown[] = [];
|
||||
const conditions: string[] = [];
|
||||
const categoryId = Number(asString(paramsQuery.categoryId));
|
||||
const usageId = Number(asString(paramsQuery.usageId));
|
||||
const tagIds = parseIdList(asString(paramsQuery.tagIds));
|
||||
const search = asString(paramsQuery.search)?.trim();
|
||||
|
||||
if (search) {
|
||||
params.push(`%${search}%`);
|
||||
conditions.push(`i.name ILIKE $${params.length}`);
|
||||
}
|
||||
|
||||
if (Number.isInteger(categoryId) && categoryId > 0) {
|
||||
params.push(categoryId);
|
||||
conditions.push(`i.category_id = $${params.length}`);
|
||||
}
|
||||
|
||||
if (Number.isInteger(usageId) && usageId > 0) {
|
||||
params.push(usageId);
|
||||
conditions.push(`i.usage_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const tagFilter = sqlForRelationFilter(
|
||||
tagIds,
|
||||
'any',
|
||||
'item_item_tags',
|
||||
'item_id',
|
||||
'item_tag_id',
|
||||
'i.id',
|
||||
params
|
||||
);
|
||||
if (tagFilter) {
|
||||
conditions.push(tagFilter);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
return query(`${itemProjection} ${whereClause} ORDER BY c.name, i.name`, params);
|
||||
}
|
||||
|
||||
export async function getItem(id: number) {
|
||||
const item = await queryOne(`${itemProjection} WHERE i.id = $1`, [id]);
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [acquisitionMethods, recipe, relatedHabitats] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
SELECT am.id, am.name
|
||||
FROM item_acquisition_methods iam
|
||||
JOIN acquisition_methods am ON am.id = iam.acquisition_method_id
|
||||
WHERE iam.item_id = $1
|
||||
ORDER BY am.name
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
queryOne(
|
||||
`
|
||||
SELECT
|
||||
r.id,
|
||||
r.name,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name)
|
||||
FROM recipe_acquisition_methods ram
|
||||
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
|
||||
WHERE ram.recipe_id = r.id
|
||||
), '[]'::json) AS acquisition_methods,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', mi.id, 'name', mi.name, 'quantity', rm.quantity) ORDER BY mi.name)
|
||||
FROM recipe_materials rm
|
||||
JOIN items mi ON mi.id = rm.item_id
|
||||
WHERE rm.recipe_id = r.id
|
||||
), '[]'::json) AS materials
|
||||
FROM items i
|
||||
JOIN recipes r ON r.id = i.recipe_id
|
||||
WHERE i.id = $1
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT h.id, h.name, hri.quantity
|
||||
FROM habitat_recipe_items hri
|
||||
JOIN habitats h ON h.id = hri.habitat_id
|
||||
WHERE hri.item_id = $1
|
||||
ORDER BY h.name
|
||||
`,
|
||||
[id]
|
||||
)
|
||||
]);
|
||||
|
||||
return { ...item, acquisitionMethods, recipe, relatedHabitats };
|
||||
}
|
||||
|
||||
export async function listRecipes() {
|
||||
return query(`
|
||||
SELECT
|
||||
r.id,
|
||||
r.name,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', rm.quantity) ORDER BY i.name)
|
||||
FROM recipe_materials rm
|
||||
JOIN items i ON i.id = rm.item_id
|
||||
WHERE rm.recipe_id = r.id
|
||||
), '[]'::json) AS materials
|
||||
FROM recipes r
|
||||
ORDER BY r.name
|
||||
`);
|
||||
}
|
||||
|
||||
export async function getRecipe(id: number) {
|
||||
return queryOne(
|
||||
`
|
||||
SELECT
|
||||
r.id,
|
||||
r.name,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name)
|
||||
FROM recipe_acquisition_methods ram
|
||||
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
|
||||
WHERE ram.recipe_id = r.id
|
||||
), '[]'::json) AS acquisition_methods,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', rm.quantity) ORDER BY i.name)
|
||||
FROM recipe_materials rm
|
||||
JOIN items i ON i.id = rm.item_id
|
||||
WHERE rm.recipe_id = r.id
|
||||
), '[]'::json) AS materials
|
||||
FROM recipes r
|
||||
WHERE r.id = $1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
}
|
||||
89
backend/src/server.ts
Normal file
89
backend/src/server.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import cors from '@fastify/cors';
|
||||
import Fastify from 'fastify';
|
||||
import { initializeDatabase, pool } from './db.ts';
|
||||
import {
|
||||
getHabitat,
|
||||
getItem,
|
||||
getOptions,
|
||||
getPokemon,
|
||||
getRecipe,
|
||||
listHabitats,
|
||||
listItems,
|
||||
listPokemon,
|
||||
listRecipes
|
||||
} from './queries.ts';
|
||||
|
||||
const app = Fastify({
|
||||
logger: true
|
||||
});
|
||||
|
||||
await app.register(cors, {
|
||||
origin: process.env.FRONTEND_ORIGIN ?? true
|
||||
});
|
||||
|
||||
app.get('/health', async () => ({ ok: true }));
|
||||
|
||||
app.get('/api/options', async () => getOptions());
|
||||
|
||||
app.get('/api/pokemon', async (request) => listPokemon(request.query as Record<string, string | string[] | undefined>));
|
||||
|
||||
app.get('/api/pokemon/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const pokemon = await getPokemon(Number(id));
|
||||
|
||||
if (!pokemon) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
}
|
||||
|
||||
return pokemon;
|
||||
});
|
||||
|
||||
app.get('/api/habitats', async () => listHabitats());
|
||||
|
||||
app.get('/api/habitats/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const habitat = await getHabitat(Number(id));
|
||||
|
||||
if (!habitat) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
}
|
||||
|
||||
return habitat;
|
||||
});
|
||||
|
||||
app.get('/api/items', async (request) => listItems(request.query as Record<string, string | string[] | undefined>));
|
||||
|
||||
app.get('/api/items/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const item = await getItem(Number(id));
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
app.get('/api/recipes', async () => listRecipes());
|
||||
|
||||
app.get('/api/recipes/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const recipe = await getRecipe(Number(id));
|
||||
|
||||
if (!recipe) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
}
|
||||
|
||||
return recipe;
|
||||
});
|
||||
|
||||
const port = Number(process.env.BACKEND_PORT ?? 3001);
|
||||
|
||||
try {
|
||||
await initializeDatabase();
|
||||
await app.listen({ host: '0.0.0.0', port });
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
await pool.end();
|
||||
process.exit(1);
|
||||
}
|
||||
33
backend/tests/filter.test.ts
Normal file
33
backend/tests/filter.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { parseIdList, parseMatchMode, sqlForRelationFilter } from '../src/filter.ts';
|
||||
|
||||
describe('filter helpers', () => {
|
||||
it('parses comma separated ids and drops invalid values', () => {
|
||||
assert.deepEqual(parseIdList('1, 2, x, -1, 3'), [1, 2, 3]);
|
||||
});
|
||||
|
||||
it('defaults match mode to any', () => {
|
||||
assert.equal(parseMatchMode('all'), 'all');
|
||||
assert.equal(parseMatchMode('anything'), 'any');
|
||||
assert.equal(parseMatchMode(undefined), 'any');
|
||||
});
|
||||
|
||||
it('builds relation filters with bound array parameters', () => {
|
||||
const params: unknown[] = [];
|
||||
const sql = sqlForRelationFilter(
|
||||
[1, 2],
|
||||
'all',
|
||||
'pokemon_skills',
|
||||
'pokemon_id',
|
||||
'skill_id',
|
||||
'p.id',
|
||||
params
|
||||
);
|
||||
|
||||
assert.equal(params.length, 1);
|
||||
assert.deepEqual(params[0], [1, 2]);
|
||||
assert.match(sql, /count\(DISTINCT skill_id\)/);
|
||||
assert.match(sql, /= 2/);
|
||||
});
|
||||
});
|
||||
14
backend/tsconfig.json
Normal file
14
backend/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user