feat: stabilization + recipe edit/create UI
BIN
backend/data/images/recipes/01KHPTHD73XRVXTKRMFMAWAPA9/hero.webp
Normal file
|
After Width: | Height: | Size: 110 B |
|
After Width: | Height: | Size: 106 B |
BIN
backend/data/images/recipes/01KHPW02KFYAZN8Z8R0ZA6MQST/hero.webp
Normal file
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 24 KiB |
BIN
backend/data/images/recipes/01KHPW02KZQ9A19V49F192HNKB/hero.webp
Normal file
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 23 KiB |
BIN
backend/data/images/recipes/01KHPW02MFRCZ1EMDZX2YEMPWR/hero.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 18 KiB |
BIN
backend/data/recipes.db
Normal file
BIN
backend/data/recipes.db-shm
Normal file
BIN
backend/data/recipes.db-wal
Normal file
2561
backend/package-lock.json
generated
Normal file
32
backend/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"fastify": "^5.7.4",
|
||||
"sharp": "^0.34.5",
|
||||
"ulid": "^3.0.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^25.2.3",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
43
backend/src/app.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { healthRoutes } from './routes/health.js';
|
||||
import { categoryRoutes } from './routes/categories.js';
|
||||
import { recipeRoutes } from './routes/recipes.js';
|
||||
import { noteRoutes } from './routes/notes.js';
|
||||
import { shoppingRoutes } from './routes/shopping.js';
|
||||
import { tagRoutes } from './routes/tags.js';
|
||||
import { imageRoutes } from './routes/images.js';
|
||||
import { botRoutes } from './routes/bot.js';
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(cors, { origin: true });
|
||||
await app.after();
|
||||
|
||||
await app.register(healthRoutes);
|
||||
await app.after();
|
||||
|
||||
await app.register(categoryRoutes);
|
||||
await app.after();
|
||||
|
||||
await app.register(recipeRoutes);
|
||||
await app.after();
|
||||
|
||||
await app.register(noteRoutes);
|
||||
await app.after();
|
||||
|
||||
await app.register(shoppingRoutes);
|
||||
await app.after();
|
||||
|
||||
await app.register(tagRoutes);
|
||||
await app.after();
|
||||
|
||||
await app.register(imageRoutes);
|
||||
await app.after();
|
||||
|
||||
await app.register(botRoutes);
|
||||
await app.after();
|
||||
|
||||
return app;
|
||||
}
|
||||
24
backend/src/db/connection.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DB_PATH = path.resolve(__dirname, '../../data/recipes.db');
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
35
backend/src/db/migrate.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getDb } from './connection.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MIGRATIONS_DIR = path.resolve(__dirname, 'migrations');
|
||||
|
||||
export function runMigrations(): void {
|
||||
const db = getDb();
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
const applied = new Set(
|
||||
db.prepare('SELECT name FROM _migrations').all().map((r: any) => r.name)
|
||||
);
|
||||
|
||||
const files = fs.readdirSync(MIGRATIONS_DIR)
|
||||
.filter(f => f.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
for (const file of files) {
|
||||
if (applied.has(file)) continue;
|
||||
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf-8');
|
||||
console.log(`Running migration: ${file}`);
|
||||
db.exec(sql);
|
||||
db.prepare('INSERT INTO _migrations (name) VALUES (?)').run(file);
|
||||
}
|
||||
}
|
||||
136
backend/src/db/migrations/001_initial.sql
Normal file
@@ -0,0 +1,136 @@
|
||||
-- Categories
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
icon TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO categories (id, name, slug, sort_order) VALUES
|
||||
('01BACKEN00000000000000000', 'Backen', 'backen', 1),
|
||||
('01TORTEN00000000000000000', 'Torten', 'torten', 2),
|
||||
('01FRUEHSTUECK000000000000', 'Frühstück', 'fruehstueck', 3),
|
||||
('01MITTAG00000000000000000', 'Mittag', 'mittag', 4),
|
||||
('01ABEND000000000000000000', 'Abend', 'abend', 5),
|
||||
('01SNACKS0000000000000000A', 'Snacks', 'snacks', 6),
|
||||
('01DESSERTS000000000000000', 'Desserts', 'desserts', 7);
|
||||
|
||||
-- Tags
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Recipes
|
||||
CREATE TABLE IF NOT EXISTS recipes (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
category_id TEXT REFERENCES categories(id) ON DELETE SET NULL,
|
||||
difficulty TEXT CHECK(difficulty IN ('easy', 'medium', 'hard')) DEFAULT 'medium',
|
||||
prep_time INTEGER,
|
||||
cook_time INTEGER,
|
||||
total_time INTEGER,
|
||||
servings INTEGER DEFAULT 4,
|
||||
image_url TEXT,
|
||||
source_url TEXT,
|
||||
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Recipe Tags (many-to-many)
|
||||
CREATE TABLE IF NOT EXISTS recipe_tags (
|
||||
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (recipe_id, tag_id)
|
||||
);
|
||||
|
||||
-- Ingredients
|
||||
CREATE TABLE IF NOT EXISTS ingredients (
|
||||
id TEXT PRIMARY KEY,
|
||||
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
amount REAL,
|
||||
unit TEXT,
|
||||
name TEXT NOT NULL,
|
||||
group_name TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Steps
|
||||
CREATE TABLE IF NOT EXISTS steps (
|
||||
id TEXT PRIMARY KEY,
|
||||
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
step_number INTEGER NOT NULL,
|
||||
instruction TEXT NOT NULL,
|
||||
duration_minutes INTEGER,
|
||||
image_url TEXT
|
||||
);
|
||||
|
||||
-- Notes
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Shopping Items
|
||||
CREATE TABLE IF NOT EXISTS shopping_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
amount REAL,
|
||||
unit TEXT,
|
||||
checked INTEGER NOT NULL DEFAULT 0,
|
||||
recipe_id TEXT REFERENCES recipes(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- FTS5 for recipes
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS recipes_fts USING fts5(
|
||||
title, description, content=recipes, content_rowid=rowid
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS recipes_ai AFTER INSERT ON recipes BEGIN
|
||||
INSERT INTO recipes_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS recipes_ad AFTER DELETE ON recipes BEGIN
|
||||
INSERT INTO recipes_fts(recipes_fts, rowid, title, description) VALUES ('delete', old.rowid, old.title, old.description);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS recipes_au AFTER UPDATE ON recipes BEGIN
|
||||
INSERT INTO recipes_fts(recipes_fts, rowid, title, description) VALUES ('delete', old.rowid, old.title, old.description);
|
||||
INSERT INTO recipes_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description);
|
||||
END;
|
||||
|
||||
-- FTS5 for ingredients
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS ingredients_fts USING fts5(
|
||||
name, content=ingredients, content_rowid=rowid
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS ingredients_ai AFTER INSERT ON ingredients BEGIN
|
||||
INSERT INTO ingredients_fts(rowid, name) VALUES (new.rowid, new.name);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS ingredients_ad AFTER DELETE ON ingredients BEGIN
|
||||
INSERT INTO ingredients_fts(ingredients_fts, rowid, name) VALUES ('delete', old.rowid, old.name);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS ingredients_au AFTER UPDATE ON ingredients BEGIN
|
||||
INSERT INTO ingredients_fts(ingredients_fts, rowid, name) VALUES ('delete', old.rowid, old.name);
|
||||
INSERT INTO ingredients_fts(rowid, name) VALUES (new.rowid, new.name);
|
||||
END;
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_category ON recipes(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_slug ON recipes(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_favorite ON recipes(is_favorite);
|
||||
CREATE INDEX IF NOT EXISTS idx_ingredients_recipe ON ingredients(recipe_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_steps_recipe ON steps(recipe_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_recipe ON notes(recipe_id);
|
||||
18
backend/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { buildApp } from './app.js';
|
||||
import { runMigrations } from './db/migrate.js';
|
||||
|
||||
const PORT = Number(process.env.PORT || 6001);
|
||||
|
||||
async function main() {
|
||||
console.log('Running migrations...');
|
||||
runMigrations();
|
||||
|
||||
const app = await buildApp();
|
||||
await app.listen({ port: PORT, host: '0.0.0.0' });
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
43
backend/src/routes/bot.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as recipeSvc from '../services/recipe.service.js';
|
||||
import * as imageSvc from '../services/image.service.js';
|
||||
|
||||
export async function botRoutes(app: FastifyInstance) {
|
||||
const BOT_TOKEN = process.env.BOT_API_TOKEN;
|
||||
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
if (!BOT_TOKEN) return reply.status(503).send({ error: 'Bot API not configured' });
|
||||
const auth = request.headers.authorization;
|
||||
if (!auth || auth !== `Bearer ${BOT_TOKEN}`) {
|
||||
return reply.status(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/bot/recipes', async (request) => {
|
||||
const query = request.query as any;
|
||||
return recipeSvc.listRecipes({
|
||||
page: query.page ? Number(query.page) : undefined,
|
||||
limit: query.limit ? Number(query.limit) : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/bot/recipes', async (request, reply) => {
|
||||
const body = request.body as recipeSvc.CreateRecipeInput;
|
||||
if (!body.title) return reply.status(400).send({ error: 'title required' });
|
||||
const recipe = recipeSvc.createRecipe(body);
|
||||
return reply.status(201).send(recipe);
|
||||
});
|
||||
|
||||
app.post('/api/bot/recipes/:id/image-url', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { url } = request.body as { url: string };
|
||||
if (!url) return reply.status(400).send({ error: 'url required' });
|
||||
try {
|
||||
const result = await imageSvc.downloadAndSaveImage(id, url);
|
||||
if (!result) return reply.status(404).send({ error: 'Recipe not found' });
|
||||
return result;
|
||||
} catch (e: any) {
|
||||
return reply.status(400).send({ error: e.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
15
backend/src/routes/categories.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { listCategories, createCategory } from '../services/recipe.service.js';
|
||||
|
||||
export async function categoryRoutes(app: FastifyInstance) {
|
||||
app.get('/api/categories', async () => {
|
||||
return listCategories();
|
||||
});
|
||||
|
||||
app.post('/api/categories', async (request, reply) => {
|
||||
const { name } = request.body as { name: string };
|
||||
if (!name) return reply.status(400).send({ error: 'name required' });
|
||||
const cat = createCategory(name);
|
||||
return reply.status(201).send(cat);
|
||||
});
|
||||
}
|
||||
7
backend/src/routes/health.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
|
||||
export async function healthRoutes(app: FastifyInstance) {
|
||||
app.get('/api/health', async () => {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||
});
|
||||
}
|
||||
40
backend/src/routes/images.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import multipart from '@fastify/multipart';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as svc from '../services/image.service.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export async function imageRoutes(app: FastifyInstance) {
|
||||
await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
await app.after();
|
||||
|
||||
await app.register(fastifyStatic, {
|
||||
root: path.resolve(__dirname, '../../data/images'),
|
||||
prefix: '/images/',
|
||||
decorateReply: false,
|
||||
});
|
||||
await app.after();
|
||||
|
||||
app.post('/api/recipes/:id/image', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const file = await request.file();
|
||||
if (!file) return reply.status(400).send({ error: 'No file uploaded' });
|
||||
const buffer = await file.toBuffer();
|
||||
const result = await svc.saveRecipeImage(id, buffer);
|
||||
if (!result) return reply.status(404).send({ error: 'Recipe not found' });
|
||||
return result;
|
||||
});
|
||||
|
||||
app.post('/api/recipes/:id/steps/:stepNumber/image', async (request, reply) => {
|
||||
const { id, stepNumber } = request.params as { id: string; stepNumber: string };
|
||||
const file = await request.file();
|
||||
if (!file) return reply.status(400).send({ error: 'No file uploaded' });
|
||||
const buffer = await file.toBuffer();
|
||||
const result = await svc.saveStepImage(id, Number(stepNumber), buffer);
|
||||
if (!result) return reply.status(404).send({ error: 'Step not found' });
|
||||
return result;
|
||||
});
|
||||
}
|
||||
34
backend/src/routes/notes.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as svc from '../services/note.service.js';
|
||||
|
||||
export async function noteRoutes(app: FastifyInstance) {
|
||||
app.get('/api/recipes/:id/notes', async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
return svc.listNotes(id);
|
||||
});
|
||||
|
||||
app.post('/api/recipes/:id/notes', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { content } = request.body as { content: string };
|
||||
if (!content) return reply.status(400).send({ error: 'content required' });
|
||||
const note = svc.createNote(id, content);
|
||||
if (!note) return reply.status(404).send({ error: 'Recipe not found' });
|
||||
return reply.status(201).send(note);
|
||||
});
|
||||
|
||||
app.put('/api/notes/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { content } = request.body as { content: string };
|
||||
if (!content) return reply.status(400).send({ error: 'content required' });
|
||||
const note = svc.updateNote(id, content);
|
||||
if (!note) return reply.status(404).send({ error: 'Not found' });
|
||||
return note;
|
||||
});
|
||||
|
||||
app.delete('/api/notes/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = svc.deleteNote(id);
|
||||
if (!ok) return reply.status(404).send({ error: 'Not found' });
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
59
backend/src/routes/recipes.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as svc from '../services/recipe.service.js';
|
||||
|
||||
export async function recipeRoutes(app: FastifyInstance) {
|
||||
app.get('/api/recipes/search', async (request) => {
|
||||
const { q } = request.query as { q?: string };
|
||||
if (!q) return { data: [], total: 0 };
|
||||
const results = svc.searchRecipes(q);
|
||||
return { data: results, total: results.length };
|
||||
});
|
||||
|
||||
app.get('/api/recipes', async (request) => {
|
||||
const query = request.query as any;
|
||||
return svc.listRecipes({
|
||||
page: query.page ? Number(query.page) : undefined,
|
||||
limit: query.limit ? Number(query.limit) : undefined,
|
||||
category_id: query.category_id,
|
||||
category_slug: query.category,
|
||||
favorite: query.favorite !== undefined ? query.favorite === 'true' : undefined,
|
||||
difficulty: query.difficulty,
|
||||
maxTime: query.maxTime ? Number(query.maxTime) : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/recipes/:slug', async (request, reply) => {
|
||||
const { slug } = request.params as { slug: string };
|
||||
const recipe = svc.getRecipeBySlug(slug);
|
||||
if (!recipe) return reply.status(404).send({ error: 'Not found' });
|
||||
return recipe;
|
||||
});
|
||||
|
||||
app.post('/api/recipes', async (request, reply) => {
|
||||
const body = request.body as svc.CreateRecipeInput;
|
||||
if (!body.title) return reply.status(400).send({ error: 'title required' });
|
||||
const recipe = svc.createRecipe(body);
|
||||
return reply.status(201).send(recipe);
|
||||
});
|
||||
|
||||
app.put('/api/recipes/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const recipe = svc.updateRecipe(id, request.body as any);
|
||||
if (!recipe) return reply.status(404).send({ error: 'Not found' });
|
||||
return recipe;
|
||||
});
|
||||
|
||||
app.delete('/api/recipes/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = svc.deleteRecipe(id);
|
||||
if (!ok) return reply.status(404).send({ error: 'Not found' });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.patch('/api/recipes/:id/favorite', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const result = svc.toggleFavorite(id);
|
||||
if (!result) return reply.status(404).send({ error: 'Not found' });
|
||||
return result;
|
||||
});
|
||||
}
|
||||
41
backend/src/routes/shopping.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as svc from '../services/shopping.service.js';
|
||||
|
||||
export async function shoppingRoutes(app: FastifyInstance) {
|
||||
app.get('/api/shopping', async () => {
|
||||
return svc.listItems();
|
||||
});
|
||||
|
||||
app.post('/api/shopping/from-recipe/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const items = svc.addFromRecipe(id);
|
||||
if (!items) return reply.status(404).send({ error: 'Recipe not found' });
|
||||
return reply.status(201).send({ added: items.length });
|
||||
});
|
||||
|
||||
app.post('/api/shopping', async (request, reply) => {
|
||||
const { name, amount, unit } = request.body as { name: string; amount?: number; unit?: string };
|
||||
if (!name) return reply.status(400).send({ error: 'name required' });
|
||||
const item = svc.addItem(name, amount, unit);
|
||||
return reply.status(201).send(item);
|
||||
});
|
||||
|
||||
app.patch('/api/shopping/:id/check', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const item = svc.toggleCheck(id);
|
||||
if (!item) return reply.status(404).send({ error: 'Not found' });
|
||||
return item;
|
||||
});
|
||||
|
||||
app.delete('/api/shopping/checked', async () => {
|
||||
const count = svc.deleteChecked();
|
||||
return { ok: true, deleted: count };
|
||||
});
|
||||
|
||||
app.delete('/api/shopping/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = svc.deleteItem(id);
|
||||
if (!ok) return reply.status(404).send({ error: 'Not found' });
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
15
backend/src/routes/tags.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as svc from '../services/tag.service.js';
|
||||
|
||||
export async function tagRoutes(app: FastifyInstance) {
|
||||
app.get('/api/tags', async () => {
|
||||
return svc.listTags();
|
||||
});
|
||||
|
||||
app.get('/api/tags/:name/recipes', async (request, reply) => {
|
||||
const { name } = request.params as { name: string };
|
||||
const result = svc.getRecipesByTag(name);
|
||||
if (!result) return reply.status(404).send({ error: 'Tag not found' });
|
||||
return result;
|
||||
});
|
||||
}
|
||||
5
backend/src/schemas/note.schema.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createNoteSchema = z.object({
|
||||
content: z.string().min(1),
|
||||
});
|
||||
31
backend/src/schemas/recipe.schema.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ingredientSchema = z.object({
|
||||
amount: z.number().optional(),
|
||||
unit: z.string().optional(),
|
||||
name: z.string().min(1),
|
||||
group_name: z.string().optional(),
|
||||
sort_order: z.number().optional(),
|
||||
});
|
||||
|
||||
export const stepSchema = z.object({
|
||||
step_number: z.number().int().min(1),
|
||||
instruction: z.string().min(1),
|
||||
duration_minutes: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export const createRecipeSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
category_id: z.string().optional(),
|
||||
difficulty: z.enum(['easy', 'medium', 'hard']).optional(),
|
||||
prep_time: z.number().int().optional(),
|
||||
cook_time: z.number().int().optional(),
|
||||
servings: z.number().int().optional(),
|
||||
image_url: z.string().optional(),
|
||||
source_url: z.string().optional(),
|
||||
ingredients: z.array(ingredientSchema).optional(),
|
||||
steps: z.array(stepSchema).optional(),
|
||||
});
|
||||
|
||||
export const updateRecipeSchema = createRecipeSchema.partial();
|
||||
7
backend/src/schemas/shopping.schema.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createShoppingItemSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
amount: z.number().optional(),
|
||||
unit: z.string().optional(),
|
||||
});
|
||||
51
backend/src/services/image.service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { getDb } from '../db/connection.js';
|
||||
import sharp from 'sharp';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = path.resolve(__dirname, '../../data/images');
|
||||
|
||||
async function ensureDir(dir: string) {
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
export async function saveRecipeImage(recipeId: string, buffer: Buffer) {
|
||||
const db = getDb();
|
||||
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
|
||||
if (!recipe) return null;
|
||||
|
||||
const dir = path.join(DATA_DIR, 'recipes', recipeId);
|
||||
await ensureDir(dir);
|
||||
|
||||
await sharp(buffer).resize({ width: 1200, withoutEnlargement: true }).webp({ quality: 80 }).toFile(path.join(dir, 'hero.webp'));
|
||||
await sharp(buffer).resize({ width: 400, withoutEnlargement: true }).webp({ quality: 70 }).toFile(path.join(dir, 'hero_thumb.webp'));
|
||||
|
||||
const imagePath = `/images/recipes/${recipeId}/hero.webp`;
|
||||
db.prepare('UPDATE recipes SET image_url = ?, updated_at = datetime(\'now\') WHERE id = ?').run(imagePath, recipeId);
|
||||
return { image_url: imagePath, thumb_url: `/images/recipes/${recipeId}/hero_thumb.webp` };
|
||||
}
|
||||
|
||||
export async function saveStepImage(recipeId: string, stepNumber: number, buffer: Buffer) {
|
||||
const db = getDb();
|
||||
const step = db.prepare('SELECT id FROM steps WHERE recipe_id = ? AND step_number = ?').get(recipeId, stepNumber) as any;
|
||||
if (!step) return null;
|
||||
|
||||
const dir = path.join(DATA_DIR, 'recipes', recipeId, 'steps');
|
||||
await ensureDir(dir);
|
||||
|
||||
const filename = `step_${stepNumber}.webp`;
|
||||
await sharp(buffer).resize({ width: 1200, withoutEnlargement: true }).webp({ quality: 80 }).toFile(path.join(dir, filename));
|
||||
|
||||
const imageUrl = `/images/recipes/${recipeId}/steps/${filename}`;
|
||||
db.prepare('UPDATE steps SET image_url = ? WHERE id = ?').run(imageUrl, step.id);
|
||||
return { image_url: imageUrl };
|
||||
}
|
||||
|
||||
export async function downloadAndSaveImage(recipeId: string, url: string) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Failed to download image: ${response.status}`);
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
return saveRecipeImage(recipeId, buffer);
|
||||
}
|
||||
26
backend/src/services/note.service.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { getDb } from '../db/connection.js';
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
export function listNotes(recipeId: string) {
|
||||
return getDb().prepare('SELECT * FROM notes WHERE recipe_id = ? ORDER BY created_at DESC').all(recipeId);
|
||||
}
|
||||
|
||||
export function createNote(recipeId: string, content: string) {
|
||||
const db = getDb();
|
||||
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
|
||||
if (!recipe) return null;
|
||||
const id = ulid();
|
||||
db.prepare('INSERT INTO notes (id, recipe_id, content) VALUES (?, ?, ?)').run(id, recipeId, content);
|
||||
return db.prepare('SELECT * FROM notes WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
export function updateNote(id: string, content: string) {
|
||||
const db = getDb();
|
||||
const result = db.prepare('UPDATE notes SET content = ? WHERE id = ?').run(content, id);
|
||||
if (result.changes === 0) return null;
|
||||
return db.prepare('SELECT * FROM notes WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
export function deleteNote(id: string): boolean {
|
||||
return getDb().prepare('DELETE FROM notes WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
197
backend/src/services/recipe.service.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { getDb } from '../db/connection.js';
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function ensureUniqueSlug(baseSlug: string, excludeId?: string): string {
|
||||
const db = getDb();
|
||||
let slug = baseSlug;
|
||||
let i = 1;
|
||||
while (true) {
|
||||
const existing = excludeId
|
||||
? db.prepare('SELECT id FROM recipes WHERE slug = ? AND id != ?').get(slug, excludeId)
|
||||
: db.prepare('SELECT id FROM recipes WHERE slug = ?').get(slug);
|
||||
if (!existing) return slug;
|
||||
slug = `${baseSlug}-${i++}`;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateRecipeInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
category_id?: string;
|
||||
difficulty?: 'easy' | 'medium' | 'hard';
|
||||
prep_time?: number;
|
||||
cook_time?: number;
|
||||
servings?: number;
|
||||
image_url?: string;
|
||||
source_url?: string;
|
||||
ingredients?: { amount?: number; unit?: string; name: string; group_name?: string; sort_order?: number }[];
|
||||
steps?: { step_number: number; instruction: string; duration_minutes?: number }[];
|
||||
}
|
||||
|
||||
function mapTimeFields(row: any) {
|
||||
if (!row) return row;
|
||||
row.prep_time_min = row.prep_time ?? null;
|
||||
row.cook_time_min = row.cook_time ?? null;
|
||||
row.total_time_min = row.total_time ?? null;
|
||||
return row;
|
||||
}
|
||||
|
||||
export function listRecipes(opts: {
|
||||
page?: number; limit?: number; category_id?: string; category_slug?: string;
|
||||
favorite?: boolean; difficulty?: string; maxTime?: number;
|
||||
}) {
|
||||
const db = getDb();
|
||||
const page = opts.page || 1;
|
||||
const limit = opts.limit || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
if (opts.category_id) { conditions.push('r.category_id = ?'); params.push(opts.category_id); }
|
||||
if (opts.category_slug) { conditions.push('c.slug = ?'); params.push(opts.category_slug); }
|
||||
if (opts.favorite !== undefined) { conditions.push('r.is_favorite = ?'); params.push(opts.favorite ? 1 : 0); }
|
||||
if (opts.difficulty) { conditions.push('r.difficulty = ?'); params.push(opts.difficulty); }
|
||||
if (opts.maxTime) { conditions.push('r.total_time <= ?'); params.push(opts.maxTime); }
|
||||
|
||||
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
|
||||
const countRow = db.prepare(`SELECT COUNT(*) as total FROM recipes r LEFT JOIN categories c ON r.category_id = c.id ${where}`).get(...params) as any;
|
||||
const rows = db.prepare(
|
||||
`SELECT r.*, c.name as category_name, c.slug as category_slug FROM recipes r LEFT JOIN categories c ON r.category_id = c.id ${where} ORDER BY r.created_at DESC LIMIT ? OFFSET ?`
|
||||
).all(...params, limit, offset);
|
||||
|
||||
const data = rows.map(mapTimeFields);
|
||||
return { data, total: countRow.total, page, limit, totalPages: Math.ceil(countRow.total / limit) };
|
||||
}
|
||||
|
||||
export function getRecipeBySlug(slug: string) {
|
||||
const db = getDb();
|
||||
const recipe = db.prepare(
|
||||
'SELECT r.*, c.name as category_name FROM recipes r LEFT JOIN categories c ON r.category_id = c.id WHERE r.slug = ?'
|
||||
).get(slug) as any;
|
||||
if (!recipe) return null;
|
||||
|
||||
recipe.ingredients = db.prepare('SELECT * FROM ingredients WHERE recipe_id = ? ORDER BY sort_order').all(recipe.id);
|
||||
recipe.steps = db.prepare('SELECT * FROM steps WHERE recipe_id = ? ORDER BY step_number').all(recipe.id);
|
||||
recipe.notes = db.prepare('SELECT * FROM notes WHERE recipe_id = ? ORDER BY created_at DESC').all(recipe.id);
|
||||
const tagRows = db.prepare(
|
||||
'SELECT t.name FROM tags t JOIN recipe_tags rt ON t.id = rt.tag_id WHERE rt.recipe_id = ?'
|
||||
).all(recipe.id) as { name: string }[];
|
||||
recipe.tags = tagRows.map(t => t.name);
|
||||
mapTimeFields(recipe);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
export function createRecipe(input: CreateRecipeInput) {
|
||||
const db = getDb();
|
||||
const id = ulid();
|
||||
const slug = ensureUniqueSlug(slugify(input.title));
|
||||
const totalTime = (input.prep_time || 0) + (input.cook_time || 0) || null;
|
||||
|
||||
const insertRecipe = db.prepare(`
|
||||
INSERT INTO recipes (id, title, slug, description, category_id, difficulty, prep_time, cook_time, total_time, servings, image_url, source_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const insertIngredient = db.prepare(`
|
||||
INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const insertStep = db.prepare(`
|
||||
INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
insertRecipe.run(id, input.title, slug, input.description || null, input.category_id || null,
|
||||
input.difficulty || 'medium', input.prep_time || null, input.cook_time || null, totalTime,
|
||||
input.servings || 4, input.image_url || null, input.source_url || null);
|
||||
|
||||
if (input.ingredients) {
|
||||
for (let i = 0; i < input.ingredients.length; i++) {
|
||||
const ing = input.ingredients[i];
|
||||
insertIngredient.run(ulid(), id, ing.amount || null, ing.unit || null, ing.name, ing.group_name || null, ing.sort_order ?? i);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.steps) {
|
||||
for (const step of input.steps) {
|
||||
insertStep.run(ulid(), id, step.step_number, step.instruction, step.duration_minutes || null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
transaction();
|
||||
return getRecipeBySlug(slug);
|
||||
}
|
||||
|
||||
export function updateRecipe(id: string, input: Partial<CreateRecipeInput>) {
|
||||
const db = getDb();
|
||||
const existing = db.prepare('SELECT * FROM recipes WHERE id = ?').get(id) as any;
|
||||
if (!existing) return null;
|
||||
|
||||
const slug = input.title ? ensureUniqueSlug(slugify(input.title), id) : existing.slug;
|
||||
const totalTime = ((input.prep_time ?? existing.prep_time) || 0) + ((input.cook_time ?? existing.cook_time) || 0) || null;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE recipes SET title=?, slug=?, description=?, category_id=?, difficulty=?, prep_time=?, cook_time=?, total_time=?, servings=?, image_url=?, source_url=?, updated_at=datetime('now')
|
||||
WHERE id=?
|
||||
`).run(
|
||||
input.title ?? existing.title, slug, input.description ?? existing.description,
|
||||
input.category_id ?? existing.category_id, input.difficulty ?? existing.difficulty,
|
||||
input.prep_time ?? existing.prep_time, input.cook_time ?? existing.cook_time, totalTime,
|
||||
input.servings ?? existing.servings, input.image_url ?? existing.image_url,
|
||||
input.source_url ?? existing.source_url, id
|
||||
);
|
||||
|
||||
return getRecipeBySlug(slug);
|
||||
}
|
||||
|
||||
export function deleteRecipe(id: string): boolean {
|
||||
const result = getDb().prepare('DELETE FROM recipes WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function toggleFavorite(id: string) {
|
||||
const db = getDb();
|
||||
const recipe = db.prepare('SELECT is_favorite FROM recipes WHERE id = ?').get(id) as any;
|
||||
if (!recipe) return null;
|
||||
const newVal = recipe.is_favorite ? 0 : 1;
|
||||
db.prepare('UPDATE recipes SET is_favorite = ? WHERE id = ?').run(newVal, id);
|
||||
return { id, is_favorite: newVal };
|
||||
}
|
||||
|
||||
export function searchRecipes(query: string) {
|
||||
const db = getDb();
|
||||
// Add * for prefix matching
|
||||
const ftsQuery = query.trim().split(/\s+/).map(t => `"${t}"*`).join(' ');
|
||||
return db.prepare(`
|
||||
SELECT r.*, c.name as category_name
|
||||
FROM recipes_fts fts
|
||||
JOIN recipes r ON r.rowid = fts.rowid
|
||||
LEFT JOIN categories c ON r.category_id = c.id
|
||||
WHERE recipes_fts MATCH ?
|
||||
ORDER BY rank
|
||||
`).all(ftsQuery).map(mapTimeFields);
|
||||
}
|
||||
|
||||
export function listCategories() {
|
||||
return getDb().prepare('SELECT * FROM categories ORDER BY sort_order').all();
|
||||
}
|
||||
|
||||
export function createCategory(name: string) {
|
||||
const db = getDb();
|
||||
const id = ulid();
|
||||
const slug = name.toLowerCase().replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
|
||||
const maxOrder = (db.prepare('SELECT MAX(sort_order) as m FROM categories').get() as any).m || 0;
|
||||
db.prepare('INSERT INTO categories (id, name, slug, sort_order) VALUES (?, ?, ?, ?)').run(id, name, slug, maxOrder + 1);
|
||||
return { id, name, slug, sort_order: maxOrder + 1 };
|
||||
}
|
||||
64
backend/src/services/shopping.service.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { getDb } from '../db/connection.js';
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
export function listItems() {
|
||||
const db = getDb();
|
||||
const items = db.prepare(`
|
||||
SELECT si.*, r.title as recipe_title
|
||||
FROM shopping_items si
|
||||
LEFT JOIN recipes r ON si.recipe_id = r.id
|
||||
ORDER BY si.checked, si.created_at DESC
|
||||
`).all() as any[];
|
||||
|
||||
const grouped: Record<string, any> = {};
|
||||
for (const item of items) {
|
||||
const key = item.recipe_id || '_custom';
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = { recipe_id: item.recipe_id, recipe_title: item.recipe_title || 'Eigene Einträge', items: [] };
|
||||
}
|
||||
grouped[key].items.push(item);
|
||||
}
|
||||
return Object.values(grouped);
|
||||
}
|
||||
|
||||
export function addFromRecipe(recipeId: string) {
|
||||
const db = getDb();
|
||||
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
|
||||
if (!recipe) return null;
|
||||
const ingredients = db.prepare('SELECT * FROM ingredients WHERE recipe_id = ?').all(recipeId) as any[];
|
||||
const insert = db.prepare('INSERT INTO shopping_items (id, name, amount, unit, recipe_id) VALUES (?, ?, ?, ?, ?)');
|
||||
const added: any[] = [];
|
||||
const txn = db.transaction(() => {
|
||||
for (const ing of ingredients) {
|
||||
const id = ulid();
|
||||
insert.run(id, ing.name, ing.amount, ing.unit, recipeId);
|
||||
added.push({ id, name: ing.name, amount: ing.amount, unit: ing.unit, recipe_id: recipeId, checked: 0 });
|
||||
}
|
||||
});
|
||||
txn();
|
||||
return added;
|
||||
}
|
||||
|
||||
export function addItem(name: string, amount?: number, unit?: string) {
|
||||
const db = getDb();
|
||||
const id = ulid();
|
||||
db.prepare('INSERT INTO shopping_items (id, name, amount, unit) VALUES (?, ?, ?, ?)').run(id, name, amount ?? null, unit ?? null);
|
||||
return db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
export function toggleCheck(id: string) {
|
||||
const db = getDb();
|
||||
const item = db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(id) as any;
|
||||
if (!item) return null;
|
||||
const newVal = item.checked ? 0 : 1;
|
||||
db.prepare('UPDATE shopping_items SET checked = ? WHERE id = ?').run(newVal, id);
|
||||
return { ...item, checked: newVal };
|
||||
}
|
||||
|
||||
export function deleteItem(id: string): boolean {
|
||||
return getDb().prepare('DELETE FROM shopping_items WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
export function deleteChecked(): number {
|
||||
return getDb().prepare('DELETE FROM shopping_items WHERE checked = 1').run().changes;
|
||||
}
|
||||
26
backend/src/services/tag.service.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { getDb } from '../db/connection.js';
|
||||
|
||||
export function listTags() {
|
||||
return getDb().prepare(`
|
||||
SELECT t.*, COUNT(rt.recipe_id) as recipe_count
|
||||
FROM tags t
|
||||
LEFT JOIN recipe_tags rt ON t.id = rt.tag_id
|
||||
GROUP BY t.id
|
||||
ORDER BY t.name
|
||||
`).all();
|
||||
}
|
||||
|
||||
export function getRecipesByTag(tagName: string) {
|
||||
const db = getDb();
|
||||
const tag = db.prepare('SELECT * FROM tags WHERE name = ? OR slug = ?').get(tagName, tagName) as any;
|
||||
if (!tag) return null;
|
||||
const recipes = db.prepare(`
|
||||
SELECT r.*, c.name as category_name
|
||||
FROM recipes r
|
||||
JOIN recipe_tags rt ON r.id = rt.recipe_id
|
||||
LEFT JOIN categories c ON r.category_id = c.id
|
||||
WHERE rt.tag_id = ?
|
||||
ORDER BY r.created_at DESC
|
||||
`).all(tag.id);
|
||||
return { tag, recipes };
|
||||
}
|
||||
18
backend/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||