v2.1.2026 — PostgreSQL, Auth, Household, Shopping Smart-Add, Docker
Backend: - SQLite → PostgreSQL (pg_trgm search, async services) - All services rewritten to async with pg Pool - Data imported (50 recipes, 8 categories) - better-sqlite3 removed Frontend: - ProfilePage complete (edit profile, change password, no more stubs) - HouseholdCard (create, join via code, manage members, leave) - Shopping scope toggle (personal/household) - IngredientPickerModal (smart add with basics filter) - Auth token auto-attached to all API calls (token.ts) - Removed PlaceholderPage Infrastructure: - Docker Compose (backend + frontend + postgres) - Dockerfile for backend (node:22-alpine + tsx) - Dockerfile for frontend (vite build + nginx) - nginx.conf with API proxy + SPA fallback - .env.example for production secrets Spec: - AUTH-V2-SPEC updated: household join flow, manual shopping items
This commit is contained in:
@@ -15,7 +15,7 @@ export async function botRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get('/api/bot/recipes', async (request) => {
|
||||
const query = request.query as any;
|
||||
return recipeSvc.listRecipes({
|
||||
return await recipeSvc.listRecipes({
|
||||
page: query.page ? Number(query.page) : undefined,
|
||||
limit: query.limit ? Number(query.limit) : undefined,
|
||||
});
|
||||
@@ -24,7 +24,7 @@ export async function botRoutes(app: FastifyInstance) {
|
||||
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);
|
||||
const recipe = await recipeSvc.createRecipe(body);
|
||||
return reply.status(201).send(recipe);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ import { listCategories, createCategory } from '../services/recipe.service.js';
|
||||
|
||||
export async function categoryRoutes(app: FastifyInstance) {
|
||||
app.get('/api/categories', async () => {
|
||||
return listCategories();
|
||||
return await 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);
|
||||
const cat = await createCategory(name);
|
||||
return reply.status(201).send(cat);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,18 +6,15 @@ export async function noteRoutes(app: FastifyInstance) {
|
||||
app.get('/api/recipes/:id/notes', { preHandler: [optionalAuthMiddleware] }, async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const userId = request.user?.id;
|
||||
|
||||
return svc.listNotes(id, userId);
|
||||
return await svc.listNotes(id, userId);
|
||||
});
|
||||
|
||||
app.post('/api/recipes/:id/notes', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { content } = request.body as { content: string };
|
||||
const userId = request.user?.id;
|
||||
|
||||
if (!content) return reply.status(400).send({ error: 'content required' });
|
||||
|
||||
const note = svc.createNote(id, content, userId);
|
||||
const note = await svc.createNote(id, content, userId);
|
||||
if (!note) return reply.status(404).send({ error: 'Recipe not found' });
|
||||
return reply.status(201).send(note);
|
||||
});
|
||||
@@ -26,10 +23,8 @@ export async function noteRoutes(app: FastifyInstance) {
|
||||
const { id } = request.params as { id: string };
|
||||
const { content } = request.body as { content: string };
|
||||
const userId = request.user?.id;
|
||||
|
||||
if (!content) return reply.status(400).send({ error: 'content required' });
|
||||
|
||||
const note = svc.updateNote(id, content, userId);
|
||||
const note = await svc.updateNote(id, content, userId);
|
||||
if (!note) return reply.status(404).send({ error: 'Not found' });
|
||||
return note;
|
||||
});
|
||||
@@ -37,8 +32,7 @@ export async function noteRoutes(app: FastifyInstance) {
|
||||
app.delete('/api/notes/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const userId = request.user?.id;
|
||||
|
||||
const ok = svc.deleteNote(id, userId);
|
||||
const ok = await svc.deleteNote(id, userId);
|
||||
if (!ok) return reply.status(404).send({ error: 'Not found' });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { scrapeOgData } from '../services/og-scraper.service.js';
|
||||
import { getDb } from '../db/connection.js';
|
||||
import { query } from '../db/connection.js';
|
||||
import sharp from 'sharp';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -10,7 +10,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = path.resolve(__dirname, '../../data');
|
||||
|
||||
export async function ogScrapeRoutes(app: FastifyInstance) {
|
||||
// Preview: Just fetch OG data without downloading
|
||||
app.get('/api/og-preview', async (request, reply) => {
|
||||
const { url } = request.query as { url?: string };
|
||||
if (!url) return reply.status(400).send({ error: 'url parameter required' });
|
||||
@@ -23,23 +22,19 @@ export async function ogScrapeRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
// Download OG image and attach to recipe
|
||||
app.post('/api/recipes/:id/fetch-image', 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' });
|
||||
|
||||
const db = getDb();
|
||||
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(id) as any;
|
||||
if (!recipe) return reply.status(404).send({ error: 'Recipe not found' });
|
||||
const { rows } = await query('SELECT id FROM recipes WHERE id = $1', [id]);
|
||||
if (rows.length === 0) return reply.status(404).send({ error: 'Recipe not found' });
|
||||
|
||||
try {
|
||||
// Scrape OG data
|
||||
const ogData = await scrapeOgData(url);
|
||||
if (!ogData.image) return reply.status(404).send({ error: 'No image found at URL' });
|
||||
|
||||
// Download image
|
||||
const imgRes = await fetch(ogData.image, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
@@ -48,7 +43,6 @@ export async function ogScrapeRoutes(app: FastifyInstance) {
|
||||
|
||||
const buffer = Buffer.from(await imgRes.arrayBuffer());
|
||||
|
||||
// Process with sharp → WebP, max 1200px wide
|
||||
const imgDir = path.join(DATA_DIR, 'images', 'recipes', id);
|
||||
fs.mkdirSync(imgDir, { recursive: true });
|
||||
const imgPath = path.join(imgDir, 'hero.webp');
|
||||
@@ -58,10 +52,8 @@ export async function ogScrapeRoutes(app: FastifyInstance) {
|
||||
.webp({ quality: 85 })
|
||||
.toFile(imgPath);
|
||||
|
||||
// Update recipe
|
||||
const imageUrl = `/images/recipes/${id}/hero.webp`;
|
||||
db.prepare('UPDATE recipes SET image_url = ?, source_url = ?, updated_at = datetime(\'now\') WHERE id = ?')
|
||||
.run(imageUrl, url, id);
|
||||
await query('UPDATE recipes SET image_url = $1, source_url = $2, updated_at = NOW() WHERE id = $3', [imageUrl, url, id]);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as svc from '../services/recipe.service.js';
|
||||
|
||||
export async function recipeRoutes(app: FastifyInstance) {
|
||||
app.get('/api/recipes/random', async (request, reply) => {
|
||||
const recipe = svc.getRandomRecipe();
|
||||
const recipe = await svc.getRandomRecipe();
|
||||
if (!recipe) return reply.status(404).send({ error: 'No recipes found' });
|
||||
return recipe;
|
||||
});
|
||||
@@ -12,7 +12,7 @@ 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);
|
||||
const results = await svc.searchRecipes(q);
|
||||
return { data: results, total: results.length };
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function recipeRoutes(app: FastifyInstance) {
|
||||
const query = request.query as any;
|
||||
const userId = request.user?.id;
|
||||
|
||||
return svc.listRecipes({
|
||||
return await svc.listRecipes({
|
||||
page: query.page ? Number(query.page) : undefined,
|
||||
limit: query.limit ? Number(query.limit) : undefined,
|
||||
category_id: query.category_id,
|
||||
@@ -34,7 +34,7 @@ export async function recipeRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get('/api/recipes/:slug', async (request, reply) => {
|
||||
const { slug } = request.params as { slug: string };
|
||||
const recipe = svc.getRecipeBySlug(slug);
|
||||
const recipe = await svc.getRecipeBySlug(slug);
|
||||
if (!recipe) return reply.status(404).send({ error: 'Not found' });
|
||||
return recipe;
|
||||
});
|
||||
@@ -42,20 +42,20 @@ export async function recipeRoutes(app: FastifyInstance) {
|
||||
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);
|
||||
const recipe = await 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);
|
||||
const recipe = await 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);
|
||||
const ok = await svc.deleteRecipe(id);
|
||||
if (!ok) return reply.status(404).send({ error: 'Not found' });
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -64,7 +64,7 @@ export async function recipeRoutes(app: FastifyInstance) {
|
||||
const { id } = request.params as { id: string };
|
||||
const userId = request.user?.id;
|
||||
|
||||
const result = svc.toggleFavorite(id, userId);
|
||||
const result = await svc.toggleFavorite(id, userId);
|
||||
if (!result) return reply.status(404).send({ error: 'Not found' });
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -3,24 +3,18 @@ import { optionalAuthMiddleware } from '../middleware/auth.js';
|
||||
import * as svc from '../services/shopping.service.js';
|
||||
|
||||
export async function shoppingRoutes(app: FastifyInstance) {
|
||||
// List shopping items with optional authentication
|
||||
app.get('/api/shopping', { preHandler: [optionalAuthMiddleware] }, async (request) => {
|
||||
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
||||
const userId = request.user?.id;
|
||||
const shoppingScope = scope || 'personal';
|
||||
|
||||
return svc.listItems(userId, shoppingScope);
|
||||
return await svc.listItems(userId, scope || 'personal');
|
||||
});
|
||||
|
||||
// Add items from recipe with optional authentication
|
||||
app.post('/api/shopping/from-recipe/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
||||
const userId = request.user?.id;
|
||||
const shoppingScope = scope || 'personal';
|
||||
|
||||
try {
|
||||
const items = svc.addFromRecipe(id, userId, shoppingScope);
|
||||
const items = await svc.addFromRecipe(id, userId, scope || 'personal');
|
||||
if (!items) return reply.status(404).send({ error: 'Recipe not found' });
|
||||
return reply.status(201).send({ added: items.length });
|
||||
} catch (error: any) {
|
||||
@@ -31,17 +25,13 @@ export async function shoppingRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
// Add single item with optional authentication
|
||||
app.post('/api/shopping', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||
const { name, amount, unit } = request.body as { name: string; amount?: number; unit?: string };
|
||||
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
||||
const userId = request.user?.id;
|
||||
const shoppingScope = scope || 'personal';
|
||||
|
||||
if (!name) return reply.status(400).send({ error: 'name required' });
|
||||
|
||||
try {
|
||||
const item = svc.addItem(name, amount, unit, userId, shoppingScope);
|
||||
const item = await svc.addItem(name, amount, unit, userId, scope || 'personal');
|
||||
return reply.status(201).send(item);
|
||||
} catch (error: any) {
|
||||
if (error.message === 'USER_NOT_IN_HOUSEHOLD') {
|
||||
@@ -51,42 +41,32 @@ export async function shoppingRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle check status with optional authentication
|
||||
app.patch('/api/shopping/:id/check', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const userId = request.user?.id;
|
||||
|
||||
const item = svc.toggleCheck(id, userId);
|
||||
const item = await svc.toggleCheck(id, userId);
|
||||
if (!item) return reply.status(404).send({ error: 'Not found' });
|
||||
return item;
|
||||
});
|
||||
|
||||
// Delete all items with optional authentication
|
||||
app.delete('/api/shopping/all', { preHandler: [optionalAuthMiddleware] }, async (request) => {
|
||||
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
||||
const userId = request.user?.id;
|
||||
const shoppingScope = scope || 'personal';
|
||||
|
||||
const count = svc.deleteAll(userId, shoppingScope);
|
||||
const count = await svc.deleteAll(userId, scope || 'personal');
|
||||
return { ok: true, deleted: count };
|
||||
});
|
||||
|
||||
// Delete checked items with optional authentication
|
||||
app.delete('/api/shopping/checked', { preHandler: [optionalAuthMiddleware] }, async (request) => {
|
||||
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
||||
const userId = request.user?.id;
|
||||
const shoppingScope = scope || 'personal';
|
||||
|
||||
const count = svc.deleteChecked(userId, shoppingScope);
|
||||
const count = await svc.deleteChecked(userId, scope || 'personal');
|
||||
return { ok: true, deleted: count };
|
||||
});
|
||||
|
||||
// Delete single item with optional authentication
|
||||
app.delete('/api/shopping/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const userId = request.user?.id;
|
||||
|
||||
const ok = svc.deleteItem(id, userId);
|
||||
const ok = await svc.deleteItem(id, userId);
|
||||
if (!ok) return reply.status(404).send({ error: 'Not found' });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -3,12 +3,12 @@ import * as svc from '../services/tag.service.js';
|
||||
|
||||
export async function tagRoutes(app: FastifyInstance) {
|
||||
app.get('/api/tags', async () => {
|
||||
return svc.listTags();
|
||||
return await svc.listTags();
|
||||
});
|
||||
|
||||
app.get('/api/tags/:name/recipes', async (request, reply) => {
|
||||
const { name } = request.params as { name: string };
|
||||
const result = svc.getRecipesByTag(name);
|
||||
const result = await svc.getRecipesByTag(name);
|
||||
if (!result) return reply.status(404).send({ error: 'Tag not found' });
|
||||
return result;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user