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:
clawd
2026-02-18 17:26:24 +00:00
parent 30e44370a1
commit 301e42b1dc
49 changed files with 2167 additions and 1474 deletions

View File

@@ -1,24 +1,17 @@
import Database from 'better-sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';
import pg from 'pg';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DB_PATH = path.resolve(__dirname, '../../data/recipes.db');
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://werk:werk_dev_secret@localhost:5432/luna_recipes';
let db: Database.Database | null = null;
export const pool = new pg.Pool({
connectionString: DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
});
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 async function query(text: string, params?: any[]) {
return pool.query(text, params);
}
export function closeDb(): void {
if (db) {
db.close();
db = null;
}
export async function closeDb(): Promise<void> {
await pool.end();
}

View File

@@ -0,0 +1,116 @@
import 'dotenv/config';
import fs from 'fs';
import { pool } from './connection.js';
import { runMigrations } from './migrate.js';
function loadJson(path: string): any[] {
try {
return JSON.parse(fs.readFileSync(path, 'utf-8'));
} catch {
console.log(`Skipping ${path} (not found or invalid)`);
return [];
}
}
async function main() {
console.log('Running migrations first...');
await runMigrations();
const client = await pool.connect();
try {
await client.query('BEGIN');
// Categories
const categories = loadJson('/tmp/luna_categories.json');
for (const c of categories) {
await client.query(
'INSERT INTO categories (id, name, slug, icon, sort_order, created_at) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (id) DO NOTHING',
[c.id, c.name, c.slug, c.icon, c.sort_order, c.created_at]
);
}
console.log(`Imported ${categories.length} categories`);
// Tags
const tags = loadJson('/tmp/luna_tags.json');
for (const t of tags) {
await client.query(
'INSERT INTO tags (id, name, slug, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING',
[t.id, t.name, t.slug, t.created_at]
);
}
console.log(`Imported ${tags.length} tags`);
// Recipes
const recipes = loadJson('/tmp/luna_recipes.json');
for (const r of recipes) {
await client.query(
`INSERT INTO recipes (id, title, slug, description, category_id, difficulty, prep_time, cook_time, total_time, servings, image_url, source_url, image_source, is_favorite, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) ON CONFLICT (id) DO NOTHING`,
[r.id, r.title, r.slug, r.description, r.category_id, r.difficulty, r.prep_time, r.cook_time, r.total_time, r.servings, r.image_url, r.source_url, r.image_source, r.is_favorite || 0, r.created_at, r.updated_at]
);
}
console.log(`Imported ${recipes.length} recipes`);
// Recipe Tags
const recipeTags = loadJson('/tmp/luna_recipe_tags.json');
for (const rt of recipeTags) {
await client.query(
'INSERT INTO recipe_tags (recipe_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[rt.recipe_id, rt.tag_id]
);
}
console.log(`Imported ${recipeTags.length} recipe_tags`);
// Ingredients
const ingredients = loadJson('/tmp/luna_ingredients.json');
for (const i of ingredients) {
await client.query(
'INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO NOTHING',
[i.id, i.recipe_id, i.amount, i.unit, i.name, i.group_name, i.sort_order]
);
}
console.log(`Imported ${ingredients.length} ingredients`);
// Steps
const steps = loadJson('/tmp/luna_steps.json');
for (const s of steps) {
await client.query(
'INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes, image_url) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (id) DO NOTHING',
[s.id, s.recipe_id, s.step_number, s.instruction, s.duration_minutes, s.image_url]
);
}
console.log(`Imported ${steps.length} steps`);
// Notes
const notes = loadJson('/tmp/luna_notes.json');
for (const n of notes) {
await client.query(
'INSERT INTO notes (id, recipe_id, content, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING',
[n.id, n.recipe_id, n.content, n.created_at]
);
}
console.log(`Imported ${notes.length} notes`);
// Shopping items
const shopping = loadJson('/tmp/luna_shopping.json');
for (const s of shopping) {
await client.query(
'INSERT INTO shopping_items (id, name, amount, unit, checked, recipe_id, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO NOTHING',
[s.id, s.name, s.amount, s.unit, Boolean(s.checked), s.recipe_id, s.created_at]
);
}
console.log(`Imported ${shopping.length} shopping items`);
await client.query('COMMIT');
console.log('Import complete!');
} catch (e) {
await client.query('ROLLBACK');
console.error('Import failed:', e);
throw e;
} finally {
client.release();
await pool.end();
}
}
main().catch(() => process.exit(1));

View File

@@ -1,25 +1,22 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { getDb } from './connection.js';
import { pool } 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(`
export async function runMigrations(): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS _migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
const applied = new Set(
db.prepare('SELECT name FROM _migrations').all().map((r: any) => r.name)
);
const { rows } = await pool.query('SELECT name FROM _migrations');
const applied = new Set(rows.map((r: any) => r.name));
const files = fs.readdirSync(MIGRATIONS_DIR)
.filter(f => f.endsWith('.sql'))
@@ -29,7 +26,18 @@ export function runMigrations(): void {
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);
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(sql);
await client.query('INSERT INTO _migrations (name) VALUES ($1)', [file]);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
}

View File

@@ -1,136 +0,0 @@
-- 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);

View File

@@ -0,0 +1,181 @@
-- Extensions
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- 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 TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Tags
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Recipes (mit allen v2 Feldern)
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,
image_source TEXT,
is_favorite INTEGER NOT NULL DEFAULT 0,
created_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Recipe Tags
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 DOUBLE PRECISION,
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 (with user_id)
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
user_id TEXT,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Users
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Add FK for recipes.created_by and notes.user_id
ALTER TABLE recipes ADD CONSTRAINT fk_recipes_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE notes ADD CONSTRAINT fk_notes_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
-- User Favorites
CREATE TABLE IF NOT EXISTS user_favorites (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, recipe_id)
);
-- Households
CREATE TABLE IF NOT EXISTS households (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
invite_code TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Household Members
CREATE TABLE IF NOT EXISTS household_members (
household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
joined_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (household_id, user_id)
);
-- Shopping Items (with user_id + household_id + source)
CREATE TABLE IF NOT EXISTS shopping_items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
amount DOUBLE PRECISION,
unit TEXT,
checked BOOLEAN NOT NULL DEFAULT FALSE,
recipe_id TEXT REFERENCES recipes(id) ON DELETE SET NULL,
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
household_id TEXT REFERENCES households(id) ON DELETE CASCADE,
source TEXT DEFAULT 'recipe' CHECK (source IN ('recipe', 'manual')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Full-text Search using pg_trgm
CREATE INDEX IF NOT EXISTS idx_recipes_title_trgm ON recipes USING gin (title gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_recipes_description_trgm ON recipes USING gin (description gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_ingredients_name_trgm ON ingredients USING gin (name gin_trgm_ops);
-- Standard 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);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_user_favorites_user_id ON user_favorites(user_id);
CREATE INDEX IF NOT EXISTS idx_user_favorites_recipe_id ON user_favorites(recipe_id);
CREATE INDEX IF NOT EXISTS idx_shopping_items_user_id ON shopping_items(user_id);
CREATE INDEX IF NOT EXISTS idx_shopping_items_household_id ON shopping_items(household_id);
CREATE INDEX IF NOT EXISTS idx_households_invite_code ON households(invite_code);
CREATE INDEX IF NOT EXISTS idx_household_members_user_id ON household_members(user_id);
CREATE INDEX IF NOT EXISTS idx_notes_user_id ON notes(user_id);
CREATE INDEX IF NOT EXISTS idx_recipes_created_by ON recipes(created_by);
-- Seed Categories
INSERT INTO categories (id, name, slug, icon, 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)
ON CONFLICT (id) DO NOTHING;
-- Updated_at trigger for recipes
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER recipes_updated_at BEFORE UPDATE ON recipes
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at();

View File

@@ -1,11 +0,0 @@
-- Add category icons and Vegan category
UPDATE categories SET icon = '🧁' WHERE slug = 'backen';
UPDATE categories SET icon = '🎂' WHERE slug = 'torten';
UPDATE categories SET icon = '🥐' WHERE slug = 'fruehstueck';
UPDATE categories SET icon = '🍝' WHERE slug = 'mittag';
UPDATE categories SET icon = '🥘' WHERE slug = 'abend';
UPDATE categories SET icon = '🥨' WHERE slug = 'snacks';
UPDATE categories SET icon = '🍮' WHERE slug = 'desserts';
INSERT OR IGNORE INTO categories (id, name, slug, icon, sort_order) VALUES
('01VEGAN000000000000000000', 'Vegan', 'vegan', '🌱', 8);

View File

@@ -1,43 +0,0 @@
-- Auth v2 Migration: Users and Authentication
-- Users table
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
avatar_url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- User favorites table (replaces is_favorite column approach)
CREATE TABLE IF NOT EXISTS user_favorites (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, recipe_id)
);
-- Add user_id to shopping_items (nullable for migration)
ALTER TABLE shopping_items ADD COLUMN user_id TEXT REFERENCES users(id) ON DELETE SET NULL;
-- Add user_id to notes (nullable for migration)
ALTER TABLE notes ADD COLUMN user_id TEXT REFERENCES users(id) ON DELETE SET NULL;
-- Add created_by to recipes (nullable for migration)
ALTER TABLE recipes ADD COLUMN created_by TEXT REFERENCES users(id) ON DELETE SET NULL;
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_user_favorites_user_id ON user_favorites(user_id);
CREATE INDEX IF NOT EXISTS idx_user_favorites_recipe_id ON user_favorites(recipe_id);
CREATE INDEX IF NOT EXISTS idx_shopping_items_user_id ON shopping_items(user_id);
CREATE INDEX IF NOT EXISTS idx_notes_user_id ON notes(user_id);
CREATE INDEX IF NOT EXISTS idx_recipes_created_by ON recipes(created_by);
-- Trigger for updating users.updated_at
CREATE TRIGGER IF NOT EXISTS users_update_timestamp AFTER UPDATE ON users BEGIN
UPDATE users SET updated_at = datetime('now') WHERE id = NEW.id;
END;

View File

@@ -1,27 +0,0 @@
-- Auth v2 Phase 2: Households
-- Households table
CREATE TABLE IF NOT EXISTS households (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
invite_code TEXT UNIQUE NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
-- Household members table (many-to-many relationship between users and households)
CREATE TABLE IF NOT EXISTS household_members (
household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
joined_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (household_id, user_id)
);
-- Add household_id to shopping_items for household shopping lists
ALTER TABLE shopping_items ADD COLUMN household_id TEXT REFERENCES households(id) ON DELETE CASCADE;
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_households_invite_code ON households(invite_code);
CREATE INDEX IF NOT EXISTS idx_household_members_household_id ON household_members(household_id);
CREATE INDEX IF NOT EXISTS idx_household_members_user_id ON household_members(user_id);
CREATE INDEX IF NOT EXISTS idx_shopping_items_household_id ON shopping_items(household_id);

View File

@@ -6,7 +6,7 @@ const PORT = Number(process.env.PORT || 6001);
async function main() {
console.log('Running migrations...');
runMigrations();
await runMigrations();
const app = await buildApp();
await app.listen({ port: PORT, host: '0.0.0.0' });

View File

@@ -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);
});

View File

@@ -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);
});
}

View File

@@ -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 };
});

View File

@@ -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,

View File

@@ -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;
});

View File

@@ -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 };
});

View File

@@ -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;
});

View File

@@ -1,7 +1,7 @@
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { ulid } from 'ulid';
import { getDb } from '../db/connection.js';
import { query } from '../db/connection.js';
const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'your-super-secret-refresh-key-change-in-production';
@@ -30,132 +30,77 @@ export interface AuthResult {
}
class AuthService {
private db = getDb();
async register(email: string, password: string, displayName: string): Promise<AuthResult> {
// Check if user already exists
const existingUser = this.db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existingUser) {
throw new Error('EMAIL_EXISTS');
}
const { rows: existing } = await query('SELECT id FROM users WHERE email = $1', [email]);
if (existing.length > 0) throw new Error('EMAIL_EXISTS');
// Hash password
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
// Create user
const userId = ulid();
const now = new Date().toISOString();
this.db.prepare(`
INSERT INTO users (id, email, password_hash, display_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(userId, email, passwordHash, displayName, now, now);
// Get created user
const user = this.db.prepare(`
SELECT id, email, display_name, avatar_url, created_at, updated_at
FROM users WHERE id = ?
`).get(userId) as User;
await query(
'INSERT INTO users (id, email, password_hash, display_name) VALUES ($1, $2, $3, $4)',
[userId, email, passwordHash, displayName]
);
// Generate tokens
const { rows } = await query(
'SELECT id, email, display_name, avatar_url, created_at, updated_at FROM users WHERE id = $1',
[userId]
);
const user = rows[0] as User;
const tokens = this.generateTokens(user);
return { user, tokens };
}
async login(email: string, password: string): Promise<AuthResult> {
// Get user by email
const userWithPassword = this.db.prepare(`
SELECT id, email, password_hash, display_name, avatar_url, created_at, updated_at
FROM users WHERE email = ?
`).get(email) as any;
const { rows } = await query(
'SELECT id, email, password_hash, display_name, avatar_url, created_at, updated_at FROM users WHERE email = $1',
[email]
);
if (rows.length === 0) throw new Error('INVALID_CREDENTIALS');
if (!userWithPassword) {
throw new Error('INVALID_CREDENTIALS');
}
// Check password
const userWithPassword = rows[0];
const passwordValid = await bcrypt.compare(password, userWithPassword.password_hash);
if (!passwordValid) {
throw new Error('INVALID_CREDENTIALS');
}
if (!passwordValid) throw new Error('INVALID_CREDENTIALS');
// Remove password from user object
const { password_hash, ...user } = userWithPassword;
// Generate tokens
const tokens = this.generateTokens(user);
return { user, tokens };
}
async getProfile(userId: string): Promise<User | null> {
const user = this.db.prepare(`
SELECT id, email, display_name, avatar_url, created_at, updated_at
FROM users WHERE id = ?
`).get(userId) as User | undefined;
return user || null;
const { rows } = await query(
'SELECT id, email, display_name, avatar_url, created_at, updated_at FROM users WHERE id = $1',
[userId]
);
return rows[0] || null;
}
async updateProfile(userId: string, data: { display_name?: string; avatar_url?: string }): Promise<User> {
const updates: string[] = [];
const values: any[] = [];
let idx = 1;
if (data.display_name !== undefined) {
updates.push('display_name = ?');
values.push(data.display_name);
}
if (data.display_name !== undefined) { updates.push(`display_name = $${idx}`); values.push(data.display_name); idx++; }
if (data.avatar_url !== undefined) { updates.push(`avatar_url = $${idx}`); values.push(data.avatar_url); idx++; }
if (updates.length === 0) throw new Error('NO_UPDATES_PROVIDED');
if (data.avatar_url !== undefined) {
updates.push('avatar_url = ?');
values.push(data.avatar_url);
}
if (updates.length === 0) {
throw new Error('NO_UPDATES_PROVIDED');
}
updates.push('updated_at = ?');
values.push(new Date().toISOString());
values.push(userId);
this.db.prepare(`
UPDATE users SET ${updates.join(', ')} WHERE id = ?
`).run(...values);
await query(`UPDATE users SET ${updates.join(', ')} WHERE id = $${idx}`, values);
const user = await this.getProfile(userId);
if (!user) {
throw new Error('USER_NOT_FOUND');
}
if (!user) throw new Error('USER_NOT_FOUND');
return user;
}
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<boolean> {
// Get current password hash
const userWithPassword = this.db.prepare(`
SELECT password_hash FROM users WHERE id = ?
`).get(userId) as any;
const { rows } = await query('SELECT password_hash FROM users WHERE id = $1', [userId]);
if (rows.length === 0) throw new Error('USER_NOT_FOUND');
if (!userWithPassword) {
throw new Error('USER_NOT_FOUND');
}
// Verify current password
const currentPasswordValid = await bcrypt.compare(currentPassword, userWithPassword.password_hash);
if (!currentPasswordValid) {
throw new Error('INVALID_CURRENT_PASSWORD');
}
// Hash new password
const newPasswordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
// Update password
this.db.prepare(`
UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?
`).run(newPasswordHash, new Date().toISOString(), userId);
const valid = await bcrypt.compare(currentPassword, rows[0].password_hash);
if (!valid) throw new Error('INVALID_CURRENT_PASSWORD');
const newHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
await query('UPDATE users SET password_hash = $1 WHERE id = $2', [newHash, userId]);
return true;
}
@@ -163,11 +108,7 @@ class AuthService {
try {
const decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET) as any;
const user = await this.getProfile(decoded.sub);
if (!user) {
throw new Error('USER_NOT_FOUND');
}
if (!user) throw new Error('USER_NOT_FOUND');
const tokens = this.generateTokens(user);
return { user, tokens };
} catch (error) {
@@ -184,17 +125,11 @@ class AuthService {
}
private generateTokens(user: User): AuthTokens {
const payload = {
sub: user.id,
email: user.email,
display_name: user.display_name,
};
const payload = { sub: user.id, email: user.email, display_name: user.display_name };
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRES_IN });
const refreshToken = jwt.sign({ sub: user.id }, JWT_REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRES_IN });
return { accessToken, refreshToken };
}
}
export const authService = new AuthService();
export const authService = new AuthService();

View File

@@ -1,5 +1,5 @@
import { ulid } from 'ulid';
import { getDb } from '../db/connection.js';
import { query } from '../db/connection.js';
export interface Household {
id: string;
@@ -22,9 +22,6 @@ export interface HouseholdWithMembers extends Household {
}
class HouseholdService {
private db = getDb();
// Generate a random 8-character invite code
private generateInviteCode(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
@@ -34,185 +31,102 @@ class HouseholdService {
return result;
}
// Ensure invite code is unique
private generateUniqueInviteCode(): string {
private async generateUniqueInviteCode(): Promise<string> {
let code: string;
let attempts = 0;
do {
code = this.generateInviteCode();
attempts++;
if (attempts > 100) {
throw new Error('Could not generate unique invite code');
}
} while (this.db.prepare('SELECT id FROM households WHERE invite_code = ?').get(code));
return code;
if (attempts > 100) throw new Error('Could not generate unique invite code');
const { rows } = await query('SELECT id FROM households WHERE invite_code = $1', [code]);
if (rows.length === 0) return code;
} while (true);
}
async createHousehold(userId: string, name: string): Promise<HouseholdWithMembers> {
// Check if user is already in a household
const existingMembership = this.db.prepare(`
SELECT household_id FROM household_members WHERE user_id = ?
`).get(userId);
if (existingMembership) {
throw new Error('USER_ALREADY_IN_HOUSEHOLD');
}
const { rows: existing } = await query('SELECT household_id FROM household_members WHERE user_id = $1', [userId]);
if (existing.length > 0) throw new Error('USER_ALREADY_IN_HOUSEHOLD');
const householdId = ulid();
const inviteCode = this.generateUniqueInviteCode();
const inviteCode = await this.generateUniqueInviteCode();
// Create household
this.db.prepare(`
INSERT INTO households (id, name, invite_code)
VALUES (?, ?, ?)
`).run(householdId, name, inviteCode);
await query('INSERT INTO households (id, name, invite_code) VALUES ($1, $2, $3)', [householdId, name, inviteCode]);
await query("INSERT INTO household_members (household_id, user_id, role) VALUES ($1, $2, 'owner')", [householdId, userId]);
// Add user as owner
this.db.prepare(`
INSERT INTO household_members (household_id, user_id, role)
VALUES (?, ?, 'owner')
`).run(householdId, userId);
// Return household with members
const household = await this.getMyHousehold(userId);
if (!household) {
throw new Error('Failed to create household');
}
if (!household) throw new Error('Failed to create household');
return household;
}
async joinHousehold(userId: string, inviteCode: string): Promise<HouseholdWithMembers> {
// Check if user is already in a household
const existingMembership = this.db.prepare(`
SELECT household_id FROM household_members WHERE user_id = ?
`).get(userId);
const { rows: existing } = await query('SELECT household_id FROM household_members WHERE user_id = $1', [userId]);
if (existing.length > 0) throw new Error('USER_ALREADY_IN_HOUSEHOLD');
if (existingMembership) {
throw new Error('USER_ALREADY_IN_HOUSEHOLD');
}
const { rows: households } = await query('SELECT id FROM households WHERE invite_code = $1', [inviteCode]);
if (households.length === 0) throw new Error('INVALID_INVITE_CODE');
// Find household by invite code
const household = this.db.prepare(`
SELECT id FROM households WHERE invite_code = ?
`).get(inviteCode) as { id: string } | undefined;
await query("INSERT INTO household_members (household_id, user_id, role) VALUES ($1, $2, 'member')", [households[0].id, userId]);
if (!household) {
throw new Error('INVALID_INVITE_CODE');
}
// Add user as member
this.db.prepare(`
INSERT INTO household_members (household_id, user_id, role)
VALUES (?, ?, 'member')
`).run(household.id, userId);
// Return household with members
const result = await this.getMyHousehold(userId);
if (!result) {
throw new Error('Failed to join household');
}
if (!result) throw new Error('Failed to join household');
return result;
}
async getMyHousehold(userId: string): Promise<HouseholdWithMembers | null> {
// Get user's household
const householdMembership = this.db.prepare(`
const { rows } = await query(`
SELECT h.*, hm.role
FROM households h
JOIN household_members hm ON h.id = hm.household_id
WHERE hm.user_id = ?
`).get(userId) as (Household & { role: 'owner' | 'member' }) | undefined;
WHERE hm.user_id = $1
`, [userId]);
if (!householdMembership) {
return null;
}
if (rows.length === 0) return null;
const householdRow = rows[0];
// Get all members of the household
const members = this.db.prepare(`
SELECT
u.id as user_id,
u.email,
u.display_name,
u.avatar_url,
hm.role,
hm.joined_at
const { rows: members } = await query(`
SELECT u.id as user_id, u.email, u.display_name, u.avatar_url, hm.role, hm.joined_at
FROM household_members hm
JOIN users u ON hm.user_id = u.id
WHERE hm.household_id = ?
WHERE hm.household_id = $1
ORDER BY hm.role DESC, hm.joined_at ASC
`).all(householdMembership.id) as HouseholdMember[];
`, [householdRow.id]);
const { role, ...household } = householdMembership;
return {
...household,
members
};
const { role, ...household } = householdRow;
return { ...household, members };
}
async leaveHousehold(userId: string, householdId: string): Promise<void> {
// Check if user is member of this household
const membership = this.db.prepare(`
SELECT role FROM household_members
WHERE user_id = ? AND household_id = ?
`).get(userId, householdId) as { role: string } | undefined;
const { rows } = await query(
'SELECT role FROM household_members WHERE user_id = $1 AND household_id = $2',
[userId, householdId]
);
if (rows.length === 0) throw new Error('NOT_HOUSEHOLD_MEMBER');
if (!membership) {
throw new Error('NOT_HOUSEHOLD_MEMBER');
}
// If user is owner, check if there are other members
if (membership.role === 'owner') {
const memberCount = this.db.prepare(`
SELECT COUNT(*) as count FROM household_members WHERE household_id = ?
`).get(householdId) as { count: number };
if (memberCount.count > 1) {
throw new Error('OWNER_CANNOT_LEAVE_WITH_MEMBERS');
}
// If owner is the only member, delete the entire household
this.db.prepare('DELETE FROM households WHERE id = ?').run(householdId);
if (rows[0].role === 'owner') {
const { rows: countRows } = await query('SELECT COUNT(*) as count FROM household_members WHERE household_id = $1', [householdId]);
if (parseInt(countRows[0].count) > 1) throw new Error('OWNER_CANNOT_LEAVE_WITH_MEMBERS');
await query('DELETE FROM households WHERE id = $1', [householdId]);
} else {
// Remove member from household
this.db.prepare(`
DELETE FROM household_members
WHERE user_id = ? AND household_id = ?
`).run(userId, householdId);
await query('DELETE FROM household_members WHERE user_id = $1 AND household_id = $2', [userId, householdId]);
}
}
async regenerateInviteCode(userId: string, householdId: string): Promise<string> {
// Check if user is owner of this household
const membership = this.db.prepare(`
SELECT role FROM household_members
WHERE user_id = ? AND household_id = ?
`).get(userId, householdId) as { role: string } | undefined;
const { rows } = await query(
'SELECT role FROM household_members WHERE user_id = $1 AND household_id = $2',
[userId, householdId]
);
if (rows.length === 0 || rows[0].role !== 'owner') throw new Error('ONLY_OWNER_CAN_REGENERATE_INVITE');
if (!membership || membership.role !== 'owner') {
throw new Error('ONLY_OWNER_CAN_REGENERATE_INVITE');
}
const newInviteCode = this.generateUniqueInviteCode();
this.db.prepare(`
UPDATE households SET invite_code = ? WHERE id = ?
`).run(newInviteCode, householdId);
return newInviteCode;
const newCode = await this.generateUniqueInviteCode();
await query('UPDATE households SET invite_code = $1 WHERE id = $2', [newCode, householdId]);
return newCode;
}
async getHouseholdByInviteCode(inviteCode: string): Promise<Household | null> {
const household = this.db.prepare(`
SELECT id, name, invite_code, created_at
FROM households WHERE invite_code = ?
`).get(inviteCode) as Household | undefined;
return household || null;
const { rows } = await query('SELECT id, name, invite_code, created_at FROM households WHERE invite_code = $1', [inviteCode]);
return rows[0] || null;
}
}
export const householdService = new HouseholdService();
export const householdService = new HouseholdService();

View File

@@ -1,4 +1,4 @@
import { getDb } from '../db/connection.js';
import { query } from '../db/connection.js';
import sharp from 'sharp';
import path from 'path';
import fs from 'fs';
@@ -12,9 +12,8 @@ async function ensureDir(dir: string) {
}
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 { rows } = await query('SELECT id FROM recipes WHERE id = $1', [recipeId]);
if (rows.length === 0) return null;
const dir = path.join(DATA_DIR, 'recipes', recipeId);
await ensureDir(dir);
@@ -23,14 +22,14 @@ export async function saveRecipeImage(recipeId: string, buffer: Buffer) {
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);
await query('UPDATE recipes SET image_url = $1, updated_at = NOW() WHERE id = $2', [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 { rows } = await query('SELECT id FROM steps WHERE recipe_id = $1 AND step_number = $2', [recipeId, stepNumber]);
if (rows.length === 0) return null;
const step = rows[0];
const dir = path.join(DATA_DIR, 'recipes', recipeId, 'steps');
await ensureDir(dir);
@@ -39,7 +38,7 @@ export async function saveStepImage(recipeId: string, stepNumber: number, buffer
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);
await query('UPDATE steps SET image_url = $1 WHERE id = $2', [imageUrl, step.id]);
return { image_url: imageUrl };
}

View File

@@ -1,64 +1,55 @@
import { getDb } from '../db/connection.js';
import { query } from '../db/connection.js';
import { ulid } from 'ulid';
export function listNotes(recipeId: string, userId?: string) {
const db = getDb();
export async function listNotes(recipeId: string, userId?: string) {
if (!userId) {
// Legacy: return all notes without user filtering
return db.prepare('SELECT * FROM notes WHERE recipe_id = ? AND user_id IS NULL ORDER BY created_at DESC').all(recipeId);
const { rows } = await query('SELECT * FROM notes WHERE recipe_id = $1 AND user_id IS NULL ORDER BY created_at DESC', [recipeId]);
return rows;
}
// Return only user's notes
return db.prepare('SELECT * FROM notes WHERE recipe_id = ? AND user_id = ? ORDER BY created_at DESC').all(recipeId, userId);
const { rows } = await query('SELECT * FROM notes WHERE recipe_id = $1 AND user_id = $2 ORDER BY created_at DESC', [recipeId, userId]);
return rows;
}
export function createNote(recipeId: string, content: string, userId?: string) {
const db = getDb();
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
if (!recipe) return null;
export async function createNote(recipeId: string, content: string, userId?: string) {
const { rows: recipe } = await query('SELECT id FROM recipes WHERE id = $1', [recipeId]);
if (recipe.length === 0) return null;
const id = ulid();
db.prepare('INSERT INTO notes (id, recipe_id, content, user_id) VALUES (?, ?, ?, ?)').run(id, recipeId, content, userId || null);
return db.prepare('SELECT * FROM notes WHERE id = ?').get(id);
await query('INSERT INTO notes (id, recipe_id, content, user_id) VALUES ($1, $2, $3, $4)', [id, recipeId, content, userId || null]);
const { rows } = await query('SELECT * FROM notes WHERE id = $1', [id]);
return rows[0];
}
export function updateNote(id: string, content: string, userId?: string) {
const db = getDb();
let query: string;
export async function updateNote(id: string, content: string, userId?: string) {
let sql: string;
let params: any[];
if (!userId) {
// Legacy: update notes without user filtering
query = 'UPDATE notes SET content = ? WHERE id = ? AND user_id IS NULL';
sql = 'UPDATE notes SET content = $1 WHERE id = $2 AND user_id IS NULL';
params = [content, id];
} else {
// Update only if note belongs to user
query = 'UPDATE notes SET content = ? WHERE id = ? AND user_id = ?';
sql = 'UPDATE notes SET content = $1 WHERE id = $2 AND user_id = $3';
params = [content, id, userId];
}
const result = db.prepare(query).run(...params);
if (result.changes === 0) return null;
return db.prepare('SELECT * FROM notes WHERE id = ?').get(id);
const result = await query(sql, params);
if ((result.rowCount ?? 0) === 0) return null;
const { rows } = await query('SELECT * FROM notes WHERE id = $1', [id]);
return rows[0];
}
export function deleteNote(id: string, userId?: string): boolean {
const db = getDb();
let query: string;
export async function deleteNote(id: string, userId?: string): Promise<boolean> {
let sql: string;
let params: any[];
if (!userId) {
// Legacy: delete notes without user filtering
query = 'DELETE FROM notes WHERE id = ? AND user_id IS NULL';
sql = 'DELETE FROM notes WHERE id = $1 AND user_id IS NULL';
params = [id];
} else {
// Delete only if note belongs to user
query = 'DELETE FROM notes WHERE id = ? AND user_id = ?';
sql = 'DELETE FROM notes WHERE id = $1 AND user_id = $2';
params = [id, userId];
}
return db.prepare(query).run(...params).changes > 0;
const result = await query(sql, params);
return (result.rowCount ?? 0) > 0;
}

View File

@@ -1,19 +1,21 @@
import { getDb } from '../db/connection.js';
import { pool, query } from '../db/connection.js';
import { ulid } from 'ulid';
function syncTags(db: any, recipeId: string, tags: string[]) {
db.prepare('DELETE FROM recipe_tags WHERE recipe_id = ?').run(recipeId);
async function syncTags(client: any, recipeId: string, tags: string[]) {
await client.query('DELETE FROM recipe_tags WHERE recipe_id = $1', [recipeId]);
for (const tagName of tags) {
const trimmed = tagName.trim();
if (!trimmed) continue;
const slug = trimmed.toLowerCase().replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
let tag = db.prepare('SELECT id FROM tags WHERE slug = ?').get(slug) as any;
if (!tag) {
const tagId = ulid();
db.prepare('INSERT INTO tags (id, name, slug) VALUES (?, ?, ?)').run(tagId, trimmed, slug);
tag = { id: tagId };
const { rows } = await client.query('SELECT id FROM tags WHERE slug = $1', [slug]);
let tagId: string;
if (rows.length === 0) {
tagId = ulid();
await client.query('INSERT INTO tags (id, name, slug) VALUES ($1, $2, $3)', [tagId, trimmed, slug]);
} else {
tagId = rows[0].id;
}
db.prepare('INSERT OR IGNORE INTO recipe_tags (recipe_id, tag_id) VALUES (?, ?)').run(recipeId, tag.id);
await client.query('INSERT INTO recipe_tags (recipe_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [recipeId, tagId]);
}
}
@@ -25,15 +27,14 @@ function slugify(text: string): string {
.replace(/^-|-$/g, '');
}
function ensureUniqueSlug(baseSlug: string, excludeId?: string): string {
const db = getDb();
async function ensureUniqueSlug(baseSlug: string, excludeId?: string): Promise<string> {
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;
const { rows } = excludeId
? await query('SELECT id FROM recipes WHERE slug = $1 AND id != $2', [slug, excludeId])
: await query('SELECT id FROM recipes WHERE slug = $1', [slug]);
if (rows.length === 0) return slug;
slug = `${baseSlug}-${i++}`;
}
}
@@ -61,225 +62,234 @@ function mapTimeFields(row: any) {
return row;
}
export function listRecipes(opts: {
export async function listRecipes(opts: {
page?: number; limit?: number; category_id?: string; category_slug?: string;
favorite?: boolean; difficulty?: string; maxTime?: number; userId?: string;
}) {
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 && opts.userId) {
conditions.push('uf.user_id IS ' + (opts.favorite ? 'NOT NULL' : 'NULL'));
}
if (opts.difficulty) { conditions.push('r.difficulty = ?'); params.push(opts.difficulty); }
if (opts.maxTime) { conditions.push('r.total_time <= ?'); params.push(opts.maxTime); }
let paramIdx = 1;
let joins = 'LEFT JOIN categories c ON r.category_id = c.id';
if (opts.userId) {
joins += ' LEFT JOIN user_favorites uf ON r.id = uf.recipe_id AND uf.user_id = ?';
params.unshift(opts.userId);
joins += ` LEFT JOIN user_favorites uf ON r.id = uf.recipe_id AND uf.user_id = $${paramIdx}`;
params.push(opts.userId);
paramIdx++;
}
if (opts.category_id) { conditions.push(`r.category_id = $${paramIdx}`); params.push(opts.category_id); paramIdx++; }
if (opts.category_slug) { conditions.push(`c.slug = $${paramIdx}`); params.push(opts.category_slug); paramIdx++; }
if (opts.favorite !== undefined && opts.userId) {
conditions.push('uf.user_id IS ' + (opts.favorite ? 'NOT NULL' : 'NULL'));
}
if (opts.difficulty) { conditions.push(`r.difficulty = $${paramIdx}`); params.push(opts.difficulty); paramIdx++; }
if (opts.maxTime) { conditions.push(`r.total_time <= $${paramIdx}`); params.push(opts.maxTime); paramIdx++; }
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
// Adjust parameter positions based on whether userId is included
const countParams = opts.userId ? [opts.userId, ...params.slice(1)] : params;
const dataParams = opts.userId ? [opts.userId, ...params.slice(1), limit, offset] : [...params, limit, offset];
const countRow = db.prepare(`SELECT COUNT(*) as total FROM recipes r ${joins} ${where}`).get(...countParams) as any;
const rows = db.prepare(
`SELECT r.*, c.name as category_name, c.slug as category_slug,
${opts.userId ? '(uf.user_id IS NOT NULL) as is_favorite' : 'r.is_favorite'}
FROM recipes r ${joins} ${where} ORDER BY r.created_at DESC LIMIT ? OFFSET ?`
).all(...dataParams);
const countResult = await query(`SELECT COUNT(*) as total FROM recipes r ${joins} ${where}`, params);
const total = parseInt(countResult.rows[0].total);
const data = rows.map(mapTimeFields);
return { data, total: countRow.total, page, limit, totalPages: Math.ceil(countRow.total / limit) };
const dataParams = [...params, limit, offset];
const selectFav = opts.userId ? '(uf.user_id IS NOT NULL) as is_favorite' : 'r.is_favorite';
const dataResult = await query(
`SELECT r.*, c.name as category_name, c.slug as category_slug, ${selectFav}
FROM recipes r ${joins} ${where} ORDER BY r.created_at DESC LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`,
dataParams
);
const data = dataResult.rows.map(mapTimeFields);
return { data, total, page, limit, totalPages: Math.ceil(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;
export async function getRecipeBySlug(slug: string) {
const { rows } = await query(
'SELECT r.*, c.name as category_name FROM recipes r LEFT JOIN categories c ON r.category_id = c.id WHERE r.slug = $1',
[slug]
);
if (rows.length === 0) return null;
const recipe = rows[0];
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);
const ingResult = await query('SELECT * FROM ingredients WHERE recipe_id = $1 ORDER BY sort_order', [recipe.id]);
recipe.ingredients = ingResult.rows;
const stepResult = await query('SELECT * FROM steps WHERE recipe_id = $1 ORDER BY step_number', [recipe.id]);
recipe.steps = stepResult.rows;
const noteResult = await query('SELECT * FROM notes WHERE recipe_id = $1 ORDER BY created_at DESC', [recipe.id]);
recipe.notes = noteResult.rows;
const tagResult = await query(
'SELECT t.name FROM tags t JOIN recipe_tags rt ON t.id = rt.tag_id WHERE rt.recipe_id = $1',
[recipe.id]
);
recipe.tags = tagResult.rows.map((t: any) => t.name);
mapTimeFields(recipe);
return recipe;
}
export function createRecipe(input: CreateRecipeInput) {
const db = getDb();
export async function createRecipe(input: CreateRecipeInput) {
const id = ulid();
const slug = ensureUniqueSlug(slugify(input.title));
const slug = await 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 client = await pool.connect();
try {
await client.query('BEGIN');
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);
await client.query(`
INSERT INTO recipes (id, title, slug, description, category_id, difficulty, prep_time, cook_time, total_time, servings, image_url, source_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`, [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);
await client.query(
'INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7)',
[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);
await client.query(
'INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes) VALUES ($1, $2, $3, $4, $5)',
[ulid(), id, step.step_number, step.instruction, step.duration_minutes || null]
);
}
}
if (input.tags && input.tags.length > 0) {
syncTags(db, id, input.tags);
await syncTags(client, id, input.tags);
}
});
transaction();
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
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;
export async function updateRecipe(id: string, input: Partial<CreateRecipeInput>) {
const { rows } = await query('SELECT * FROM recipes WHERE id = $1', [id]);
if (rows.length === 0) return null;
const existing = rows[0];
const slug = input.title ? ensureUniqueSlug(slugify(input.title), id) : existing.slug;
const slug = input.title ? await 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(
await query(`
UPDATE recipes SET title=$1, slug=$2, description=$3, category_id=$4, difficulty=$5, prep_time=$6, cook_time=$7, total_time=$8, servings=$9, image_url=$10, source_url=$11, updated_at=NOW()
WHERE id=$12
`, [
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
);
]);
// Replace ingredients if provided
if (input.ingredients) {
db.prepare('DELETE FROM ingredients WHERE recipe_id = ?').run(id);
await query('DELETE FROM ingredients WHERE recipe_id = $1', [id]);
for (let i = 0; i < input.ingredients.length; i++) {
const ing = input.ingredients[i];
db.prepare('INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(ulid(), id, ing.amount || null, ing.unit || null, ing.name, ing.group_name || null, ing.sort_order ?? i);
await query(
'INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7)',
[ulid(), id, ing.amount || null, ing.unit || null, ing.name, ing.group_name || null, ing.sort_order ?? i]
);
}
}
// Replace steps if provided
if (input.steps) {
db.prepare('DELETE FROM steps WHERE recipe_id = ?').run(id);
await query('DELETE FROM steps WHERE recipe_id = $1', [id]);
for (const step of input.steps) {
db.prepare('INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes) VALUES (?, ?, ?, ?, ?)')
.run(ulid(), id, step.step_number, step.instruction, step.duration_minutes || null);
await query(
'INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes) VALUES ($1, $2, $3, $4, $5)',
[ulid(), id, step.step_number, step.instruction, step.duration_minutes || null]
);
}
}
// Sync tags if provided
if (input.tags) {
syncTags(db, id, input.tags);
const client = await pool.connect();
try {
await syncTags(client, id, input.tags);
} finally {
client.release();
}
}
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 async function deleteRecipe(id: string): Promise<boolean> {
const result = await query('DELETE FROM recipes WHERE id = $1', [id]);
return (result.rowCount ?? 0) > 0;
}
export function toggleFavorite(id: string, userId?: string) {
const db = getDb();
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(id) as any;
if (!recipe) return null;
export async function toggleFavorite(id: string, userId?: string) {
const { rows } = await query('SELECT id, is_favorite FROM recipes WHERE id = $1', [id]);
if (rows.length === 0) return null;
// If no user authentication, fallback to old is_favorite column
if (!userId) {
const recipeWithFavorite = db.prepare('SELECT is_favorite FROM recipes WHERE id = ?').get(id) as any;
const newVal = recipeWithFavorite.is_favorite ? 0 : 1;
db.prepare('UPDATE recipes SET is_favorite = ? WHERE id = ?').run(newVal, id);
const newVal = rows[0].is_favorite ? 0 : 1;
await query('UPDATE recipes SET is_favorite = $1 WHERE id = $2', [newVal, id]);
return { id, is_favorite: newVal };
}
// Check if recipe is already favorited by user
const existing = db.prepare(
'SELECT id FROM user_favorites WHERE user_id = ? AND recipe_id = ?'
).get(userId, id) as any;
const { rows: favRows } = await query(
'SELECT id FROM user_favorites WHERE user_id = $1 AND recipe_id = $2', [userId, id]
);
if (existing) {
// Remove from favorites
db.prepare('DELETE FROM user_favorites WHERE user_id = ? AND recipe_id = ?').run(userId, id);
if (favRows.length > 0) {
await query('DELETE FROM user_favorites WHERE user_id = $1 AND recipe_id = $2', [userId, id]);
return { id, is_favorite: false };
} else {
// Add to favorites
const favoriteId = ulid();
db.prepare('INSERT INTO user_favorites (id, user_id, recipe_id) VALUES (?, ?, ?)').run(favoriteId, userId, id);
await query('INSERT INTO user_favorites (id, user_id, recipe_id) VALUES ($1, $2, $3)', [ulid(), userId, id]);
return { id, is_favorite: true };
}
}
export function getRandomRecipe() {
const db = getDb();
const recipe = db.prepare('SELECT slug FROM recipes ORDER BY RANDOM() LIMIT 1').get() as any;
if (!recipe) return null;
return getRecipeBySlug(recipe.slug);
export async function getRandomRecipe() {
const { rows } = await query('SELECT slug FROM recipes ORDER BY RANDOM() LIMIT 1');
if (rows.length === 0) return null;
return getRecipeBySlug(rows[0].slug);
}
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
export async function searchRecipes(q: string) {
const likePattern = `%${q}%`;
const { rows } = await query(`
SELECT r.*, c.name as category_name,
similarity(r.title, $1) + similarity(COALESCE(r.description,''), $1) as rank
FROM recipes r
LEFT JOIN categories c ON r.category_id = c.id
WHERE recipes_fts MATCH ?
ORDER BY rank
`).all(ftsQuery).map(mapTimeFields);
WHERE r.title ILIKE $2 OR r.description ILIKE $2
ORDER BY rank DESC
`, [q, likePattern]);
return rows.map(mapTimeFields);
}
export function listCategories() {
return getDb().prepare('SELECT * FROM categories ORDER BY sort_order').all();
export async function listCategories() {
const { rows } = await query('SELECT * FROM categories ORDER BY sort_order');
return rows;
}
export function createCategory(name: string) {
const db = getDb();
export async function createCategory(name: string) {
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);
const { rows } = await query('SELECT MAX(sort_order) as m FROM categories');
const maxOrder = rows[0].m || 0;
await query('INSERT INTO categories (id, name, slug, sort_order) VALUES ($1, $2, $3, $4)', [id, name, slug, maxOrder + 1]);
return { id, name, slug, sort_order: maxOrder + 1 };
}

View File

@@ -1,17 +1,14 @@
import { getDb } from '../db/connection.js';
import { pool, query } from '../db/connection.js';
import { ulid } from 'ulid';
export type ShoppingScope = 'personal' | 'household';
export function listItems(userId?: string, scope: ShoppingScope = 'personal') {
const db = getDb();
let query: string;
export async function listItems(userId?: string, scope: ShoppingScope = 'personal') {
let sql: string;
let params: any[];
if (!userId) {
// Legacy: no user authentication, return all items
query = `
sql = `
SELECT si.*, r.title as recipe_title
FROM shopping_items si
LEFT JOIN recipes r ON si.recipe_id = r.id
@@ -20,29 +17,27 @@ export function listItems(userId?: string, scope: ShoppingScope = 'personal') {
`;
params = [];
} else if (scope === 'household') {
// Get household shopping list
query = `
sql = `
SELECT si.*, r.title as recipe_title
FROM shopping_items si
LEFT JOIN recipes r ON si.recipe_id = r.id
LEFT JOIN household_members hm ON si.household_id = hm.household_id
WHERE hm.user_id = ? AND si.household_id IS NOT NULL
WHERE hm.user_id = $1 AND si.household_id IS NOT NULL
ORDER BY si.checked, si.created_at DESC
`;
params = [userId];
} else {
// Get personal shopping list
query = `
sql = `
SELECT si.*, r.title as recipe_title
FROM shopping_items si
LEFT JOIN recipes r ON si.recipe_id = r.id
WHERE si.user_id = ? AND si.household_id IS NULL
WHERE si.user_id = $1 AND si.household_id IS NULL
ORDER BY si.checked, si.created_at DESC
`;
params = [userId];
}
const items = db.prepare(query).all(...params) as any[];
const { rows: items } = await query(sql, params);
const grouped: Record<string, any> = {};
for (const item of items) {
@@ -55,183 +50,141 @@ export function listItems(userId?: string, scope: ShoppingScope = 'personal') {
return Object.values(grouped);
}
export function addFromRecipe(recipeId: string, userId?: string, scope: ShoppingScope = 'personal') {
const db = getDb();
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
if (!recipe) return null;
export async function addFromRecipe(recipeId: string, userId?: string, scope: ShoppingScope = 'personal') {
const { rows: recipeRows } = await query('SELECT id FROM recipes WHERE id = $1', [recipeId]);
if (recipeRows.length === 0) return null;
const { rows: ingredients } = await query('SELECT * FROM ingredients WHERE recipe_id = $1', [recipeId]);
const ingredients = db.prepare('SELECT * FROM ingredients WHERE recipe_id = ?').all(recipeId) as any[];
let householdId = null;
if (userId && scope === 'household') {
// Get user's household
const membership = db.prepare(`
SELECT household_id FROM household_members WHERE user_id = ?
`).get(userId) as { household_id: string } | undefined;
if (!membership) {
throw new Error('USER_NOT_IN_HOUSEHOLD');
}
householdId = membership.household_id;
const { rows } = await query('SELECT household_id FROM household_members WHERE user_id = $1', [userId]);
if (rows.length === 0) throw new Error('USER_NOT_IN_HOUSEHOLD');
householdId = rows[0].household_id;
}
const insert = db.prepare(`
INSERT INTO shopping_items (id, name, amount, unit, recipe_id, user_id, household_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const added: any[] = [];
const txn = db.transaction(() => {
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const ing of ingredients) {
const id = ulid();
insert.run(id, ing.name, ing.amount, ing.unit, recipeId, userId || null, householdId);
added.push({
id,
name: ing.name,
amount: ing.amount,
unit: ing.unit,
recipe_id: recipeId,
user_id: userId || null,
household_id: householdId,
checked: 0
});
await client.query(
'INSERT INTO shopping_items (id, name, amount, unit, recipe_id, user_id, household_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
[id, ing.name, ing.amount, ing.unit, recipeId, userId || null, householdId]
);
added.push({ id, name: ing.name, amount: ing.amount, unit: ing.unit, recipe_id: recipeId, user_id: userId || null, household_id: householdId, checked: false });
}
});
txn();
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
return added;
}
export function addItem(name: string, amount?: number, unit?: string, userId?: string, scope: ShoppingScope = 'personal') {
const db = getDb();
export async function addItem(name: string, amount?: number, unit?: string, userId?: string, scope: ShoppingScope = 'personal') {
let householdId = null;
if (userId && scope === 'household') {
// Get user's household
const membership = db.prepare(`
SELECT household_id FROM household_members WHERE user_id = ?
`).get(userId) as { household_id: string } | undefined;
if (!membership) {
throw new Error('USER_NOT_IN_HOUSEHOLD');
}
householdId = membership.household_id;
const { rows } = await query('SELECT household_id FROM household_members WHERE user_id = $1', [userId]);
if (rows.length === 0) throw new Error('USER_NOT_IN_HOUSEHOLD');
householdId = rows[0].household_id;
}
const id = ulid();
db.prepare(`
INSERT INTO shopping_items (id, name, amount, unit, user_id, household_id)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, name, amount ?? null, unit ?? null, userId || null, householdId);
return db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(id);
await query(
'INSERT INTO shopping_items (id, name, amount, unit, user_id, household_id) VALUES ($1, $2, $3, $4, $5, $6)',
[id, name, amount ?? null, unit ?? null, userId || null, householdId]
);
const { rows } = await query('SELECT * FROM shopping_items WHERE id = $1', [id]);
return rows[0];
}
export function toggleCheck(id: string, userId?: string) {
const db = getDb();
let query: string;
export async function toggleCheck(id: string, userId?: string) {
let sql: string;
let params: any[];
if (!userId) {
// Legacy: no user authentication
query = 'SELECT * FROM shopping_items WHERE id = ? AND user_id IS NULL AND household_id IS NULL';
sql = 'SELECT * FROM shopping_items WHERE id = $1 AND user_id IS NULL AND household_id IS NULL';
params = [id];
} else {
// Check if item belongs to user (personal) or their household
query = `
sql = `
SELECT si.* FROM shopping_items si
LEFT JOIN household_members hm ON si.household_id = hm.household_id
WHERE si.id = ? AND (si.user_id = ? OR hm.user_id = ?)
WHERE si.id = $1 AND (si.user_id = $2 OR hm.user_id = $3)
`;
params = [id, userId, userId];
}
const item = db.prepare(query).get(...params) as any;
if (!item) return null;
const newVal = item.checked ? 0 : 1;
db.prepare('UPDATE shopping_items SET checked = ? WHERE id = ?').run(newVal, id);
const { rows } = await query(sql, params);
if (rows.length === 0) return null;
const item = rows[0];
const newVal = !item.checked;
await query('UPDATE shopping_items SET checked = $1 WHERE id = $2', [newVal, id]);
return { ...item, checked: newVal };
}
export function deleteItem(id: string, userId?: string): boolean {
const db = getDb();
let query: string;
export async function deleteItem(id: string, userId?: string): Promise<boolean> {
let sql: string;
let params: any[];
if (!userId) {
// Legacy: no user authentication
query = 'DELETE FROM shopping_items WHERE id = ? AND user_id IS NULL AND household_id IS NULL';
sql = 'DELETE FROM shopping_items WHERE id = $1 AND user_id IS NULL AND household_id IS NULL';
params = [id];
} else {
// Delete only if item belongs to user (personal) or their household
query = `
DELETE FROM shopping_items
WHERE id = ? AND id IN (
sql = `
DELETE FROM shopping_items
WHERE id = $1 AND id IN (
SELECT si.id FROM shopping_items si
LEFT JOIN household_members hm ON si.household_id = hm.household_id
WHERE si.user_id = ? OR hm.user_id = ?
WHERE si.user_id = $2 OR hm.user_id = $3
)
`;
params = [id, userId, userId];
}
return db.prepare(query).run(...params).changes > 0;
const result = await query(sql, params);
return (result.rowCount ?? 0) > 0;
}
export function deleteAll(userId?: string, scope: ShoppingScope = 'personal'): number {
const db = getDb();
let query: string;
export async function deleteAll(userId?: string, scope: ShoppingScope = 'personal'): Promise<number> {
let sql: string;
let params: any[];
if (!userId) {
// Legacy: no user authentication
query = 'DELETE FROM shopping_items WHERE user_id IS NULL AND household_id IS NULL';
sql = 'DELETE FROM shopping_items WHERE user_id IS NULL AND household_id IS NULL';
params = [];
} else if (scope === 'household') {
// Delete all household items
query = `
DELETE FROM shopping_items
WHERE household_id IN (
SELECT household_id FROM household_members WHERE user_id = ?
)
`;
sql = 'DELETE FROM shopping_items WHERE household_id IN (SELECT household_id FROM household_members WHERE user_id = $1)';
params = [userId];
} else {
// Delete all personal items
query = 'DELETE FROM shopping_items WHERE user_id = ? AND household_id IS NULL';
sql = 'DELETE FROM shopping_items WHERE user_id = $1 AND household_id IS NULL';
params = [userId];
}
return db.prepare(query).run(...params).changes;
const result = await query(sql, params);
return result.rowCount ?? 0;
}
export function deleteChecked(userId?: string, scope: ShoppingScope = 'personal'): number {
const db = getDb();
let query: string;
export async function deleteChecked(userId?: string, scope: ShoppingScope = 'personal'): Promise<number> {
let sql: string;
let params: any[];
if (!userId) {
// Legacy: no user authentication
query = 'DELETE FROM shopping_items WHERE checked = 1 AND user_id IS NULL AND household_id IS NULL';
sql = 'DELETE FROM shopping_items WHERE checked = TRUE AND user_id IS NULL AND household_id IS NULL';
params = [];
} else if (scope === 'household') {
// Delete checked household items
query = `
DELETE FROM shopping_items
WHERE checked = 1 AND household_id IN (
SELECT household_id FROM household_members WHERE user_id = ?
)
`;
sql = 'DELETE FROM shopping_items WHERE checked = TRUE AND household_id IN (SELECT household_id FROM household_members WHERE user_id = $1)';
params = [userId];
} else {
// Delete checked personal items
query = 'DELETE FROM shopping_items WHERE checked = 1 AND user_id = ? AND household_id IS NULL';
sql = 'DELETE FROM shopping_items WHERE checked = TRUE AND user_id = $1 AND household_id IS NULL';
params = [userId];
}
return db.prepare(query).run(...params).changes;
const result = await query(sql, params);
return result.rowCount ?? 0;
}

View File

@@ -1,26 +1,27 @@
import { getDb } from '../db/connection.js';
import { query } from '../db/connection.js';
export function listTags() {
return getDb().prepare(`
export async function listTags() {
const { rows } = await query(`
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();
`);
return rows;
}
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(`
export async function getRecipesByTag(tagName: string) {
const { rows: tagRows } = await query('SELECT * FROM tags WHERE name = $1 OR slug = $1', [tagName]);
if (tagRows.length === 0) return null;
const tag = tagRows[0];
const { rows: recipes } = await query(`
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 = ?
WHERE rt.tag_id = $1
ORDER BY r.created_at DESC
`).all(tag.id);
`, [tag.id]);
return { tag, recipes };
}