initial commit

This commit is contained in:
2026-04-29 17:46:58 +08:00
commit b428595769
38 changed files with 2229 additions and 0 deletions

8
backend/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}

View 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
View 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"]
}