diff --git a/src/lib/server/db/migrations/002_wishlist.sql b/src/lib/server/db/migrations/002_wishlist.sql new file mode 100644 index 0000000..3ba445d --- /dev/null +++ b/src/lib/server/db/migrations/002_wishlist.sql @@ -0,0 +1,19 @@ +-- Shared family wishlist: recipes someone wants to cook next. +-- Each recipe appears at most once; anyone can add/remove and like/unlike. + +CREATE TABLE IF NOT EXISTS wishlist ( + recipe_id INTEGER PRIMARY KEY REFERENCES recipe(id) ON DELETE CASCADE, + added_by_profile_id INTEGER REFERENCES profile(id) ON DELETE SET NULL, + added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS ix_wishlist_added_at ON wishlist(added_at DESC); + +CREATE TABLE IF NOT EXISTS wishlist_like ( + recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, + profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (recipe_id, profile_id) +); + +CREATE INDEX IF NOT EXISTS ix_wishlist_like_recipe ON wishlist_like(recipe_id); diff --git a/src/lib/server/wishlist/repository.ts b/src/lib/server/wishlist/repository.ts new file mode 100644 index 0000000..a25b330 --- /dev/null +++ b/src/lib/server/wishlist/repository.ts @@ -0,0 +1,98 @@ +import type Database from 'better-sqlite3'; + +export type WishlistEntry = { + recipe_id: number; + title: string; + image_path: string | null; + source_domain: string | null; + added_by_profile_id: number | null; + added_by_name: string | null; + added_at: string; + like_count: number; + liked_by_me: 0 | 1; + avg_stars: number | null; +}; + +export type SortKey = 'popular' | 'newest' | 'oldest'; + +export function listWishlist( + db: Database.Database, + activeProfileId: number | null, + sort: SortKey = 'popular' +): WishlistEntry[] { + const orderBy = { + popular: 'like_count DESC, w.added_at DESC', + newest: 'w.added_at DESC', + oldest: 'w.added_at ASC' + }[sort]; + + return db + .prepare( + `SELECT + w.recipe_id, + r.title, + r.image_path, + r.source_domain, + w.added_by_profile_id, + p.name AS added_by_name, + w.added_at, + (SELECT COUNT(*) FROM wishlist_like wl WHERE wl.recipe_id = w.recipe_id) AS like_count, + CASE + WHEN ? IS NULL THEN 0 + WHEN EXISTS (SELECT 1 FROM wishlist_like wl + WHERE wl.recipe_id = w.recipe_id AND wl.profile_id = ?) + THEN 1 + ELSE 0 + END AS liked_by_me, + (SELECT AVG(stars) FROM rating WHERE recipe_id = w.recipe_id) AS avg_stars + FROM wishlist w + JOIN recipe r ON r.id = w.recipe_id + LEFT JOIN profile p ON p.id = w.added_by_profile_id + ORDER BY ${orderBy}` + ) + .all(activeProfileId, activeProfileId) as WishlistEntry[]; +} + +export function isOnWishlist(db: Database.Database, recipeId: number): boolean { + return ( + db + .prepare('SELECT 1 AS ok FROM wishlist WHERE recipe_id = ?') + .get(recipeId) !== undefined + ); +} + +export function addToWishlist( + db: Database.Database, + recipeId: number, + profileId: number | null +): void { + db.prepare( + `INSERT INTO wishlist(recipe_id, added_by_profile_id) + VALUES (?, ?) + ON CONFLICT(recipe_id) DO NOTHING` + ).run(recipeId, profileId); +} + +export function removeFromWishlist(db: Database.Database, recipeId: number): void { + db.prepare('DELETE FROM wishlist WHERE recipe_id = ?').run(recipeId); +} + +export function likeWish( + db: Database.Database, + recipeId: number, + profileId: number +): void { + db.prepare( + 'INSERT OR IGNORE INTO wishlist_like(recipe_id, profile_id) VALUES (?, ?)' + ).run(recipeId, profileId); +} + +export function unlikeWish( + db: Database.Database, + recipeId: number, + profileId: number +): void { + db.prepare( + 'DELETE FROM wishlist_like WHERE recipe_id = ? AND profile_id = ?' + ).run(recipeId, profileId); +} diff --git a/tests/integration/db.test.ts b/tests/integration/db.test.ts index 2bb5f37..7fd6bbb 100644 --- a/tests/integration/db.test.ts +++ b/tests/integration/db.test.ts @@ -30,9 +30,14 @@ describe('db migrations', () => { it('is idempotent', () => { const db = openInMemoryForTest(); + const countBefore = ( + db.prepare('SELECT COUNT(*) AS c FROM schema_migration').get() as { c: number } + ).c; runMigrations(db); - const migs = db.prepare('SELECT COUNT(*) AS c FROM schema_migration').get() as { c: number }; - expect(migs.c).toBe(1); + const countAfter = ( + db.prepare('SELECT COUNT(*) AS c FROM schema_migration').get() as { c: number } + ).c; + expect(countAfter).toBe(countBefore); }); it('cascades recipe delete to ingredients and steps', () => { diff --git a/tests/integration/wishlist.test.ts b/tests/integration/wishlist.test.ts new file mode 100644 index 0000000..30b22c7 --- /dev/null +++ b/tests/integration/wishlist.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type Database from 'better-sqlite3'; +import { openInMemoryForTest } from '../../src/lib/server/db'; +import { createProfile } from '../../src/lib/server/profiles/repository'; +import { insertRecipe } from '../../src/lib/server/recipes/repository'; +import { + addToWishlist, + removeFromWishlist, + listWishlist, + isOnWishlist, + likeWish, + unlikeWish +} from '../../src/lib/server/wishlist/repository'; +import type { Recipe } from '../../src/lib/types'; + +const recipe = (title: string, id?: null): Recipe => ({ + id: id ?? null, + title, + description: null, + source_url: null, + source_domain: null, + image_path: null, + servings_default: 4, + servings_unit: null, + prep_time_min: null, + cook_time_min: null, + total_time_min: null, + cuisine: null, + category: null, + ingredients: [], + steps: [], + tags: [] +}); + +let db: Database.Database; + +beforeEach(() => { + db = openInMemoryForTest(); +}); + +describe('wishlist add/remove', () => { + it('adds and lists', () => { + const r1 = insertRecipe(db, recipe('Carbonara')); + const p = createProfile(db, 'Hendrik'); + addToWishlist(db, r1, p.id); + expect(isOnWishlist(db, r1)).toBe(true); + const list = listWishlist(db, p.id); + expect(list.length).toBe(1); + expect(list[0].title).toBe('Carbonara'); + expect(list[0].added_by_name).toBe('Hendrik'); + }); + + it('is idempotent on double-add', () => { + const r1 = insertRecipe(db, recipe('Pizza')); + const p = createProfile(db, 'A'); + addToWishlist(db, r1, p.id); + addToWishlist(db, r1, p.id); + expect(listWishlist(db, p.id).length).toBe(1); + }); + + it('removes', () => { + const r1 = insertRecipe(db, recipe('X')); + addToWishlist(db, r1, null); + removeFromWishlist(db, r1); + expect(listWishlist(db, null).length).toBe(0); + }); + + it('cascades with recipe delete', () => { + const r1 = insertRecipe(db, recipe('X')); + addToWishlist(db, r1, null); + db.prepare('DELETE FROM recipe WHERE id = ?').run(r1); + expect(listWishlist(db, null).length).toBe(0); + }); +}); + +describe('wishlist likes + sort', () => { + it('counts likes per entry and shows liked_by_me for active profile', () => { + const r1 = insertRecipe(db, recipe('R1')); + const r2 = insertRecipe(db, recipe('R2')); + const a = createProfile(db, 'A'); + const b = createProfile(db, 'B'); + const c = createProfile(db, 'C'); + addToWishlist(db, r1, a.id); + addToWishlist(db, r2, a.id); + likeWish(db, r1, a.id); + likeWish(db, r1, b.id); + likeWish(db, r1, c.id); + likeWish(db, r2, a.id); + + const listA = listWishlist(db, a.id, 'popular'); + expect(listA[0].title).toBe('R1'); + expect(listA[0].like_count).toBe(3); + expect(listA[0].liked_by_me).toBe(1); + expect(listA[1].title).toBe('R2'); + expect(listA[1].like_count).toBe(1); + + const listB = listWishlist(db, b.id); + expect(listB.find((e) => e.recipe_id === r1)!.liked_by_me).toBe(1); + expect(listB.find((e) => e.recipe_id === r2)!.liked_by_me).toBe(0); + }); + + it('unlike is idempotent and decrements count', () => { + const r = insertRecipe(db, recipe('R')); + const a = createProfile(db, 'A'); + addToWishlist(db, r, a.id); + likeWish(db, r, a.id); + unlikeWish(db, r, a.id); + unlikeWish(db, r, a.id); + const [entry] = listWishlist(db, a.id); + expect(entry.like_count).toBe(0); + expect(entry.liked_by_me).toBe(0); + }); + + it('sort=newest orders by added_at desc, oldest asc', () => { + const r1 = insertRecipe(db, recipe('First')); + // Force different timestamps via raw insert with explicit added_at + db.prepare("INSERT INTO wishlist(recipe_id, added_at) VALUES (?, '2026-01-01 10:00:00')").run(r1); + const r2 = insertRecipe(db, recipe('Second')); + db.prepare("INSERT INTO wishlist(recipe_id, added_at) VALUES (?, '2026-01-02 10:00:00')").run(r2); + + expect(listWishlist(db, null, 'newest').map((e) => e.title)).toEqual(['Second', 'First']); + expect(listWishlist(db, null, 'oldest').map((e) => e.title)).toEqual(['First', 'Second']); + }); + + it('handles anonymous (no active profile) — liked_by_me always 0', () => { + const r = insertRecipe(db, recipe('R')); + addToWishlist(db, r, null); + likeWish(db, r, createProfile(db, 'A').id); + const [entry] = listWishlist(db, null); + expect(entry.like_count).toBe(1); + expect(entry.liked_by_me).toBe(0); + }); +});