feat(wishlist): per-user Wünsche + Header-Badge mit Gesamtzahl
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m16s

Schema-Änderung (Migration 005):
- Tabelle wishlist umgestellt auf PK (recipe_id, profile_id)
- wishlist_like-Tabelle zusammengelegt — Liken WAR schon "will ich auch",
  also werden alle bestehenden Likes Memberships auf der neuen Tabelle.
- Alt-Einträge mit added_by_profile_id werden migriert, anonyme gehen
  verloren (war inkonsistent, jetzt erzwingen wir profile_id NOT NULL).

Repository:
- listWishlist aggregiert pro Rezept: wanted_by_count, wanted_by_names
  (kommagetrennt), on_my_wishlist für das aktive Profil
- listWishlistProfileIds(recipeId) für den Recipe-Page-Loader
- countWishlistRecipes für das Header-Badge (DISTINCT recipe_id)
- addToWishlist/removeFromWishlist/isOnMyWishlist alle mit profile_id
  als Pflicht

API:
- POST /api/wishlist: profile_id jetzt Pflicht (nullable raus)
- DELETE /api/wishlist/[recipe_id]?profile_id=X (nur eigenes Entry)
- /api/wishlist/[recipe_id]/like komplett entfernt (Konzept obsolet)
- Neu: GET /api/wishlist/count → { count: <distinct recipes> }

UI:
- Header-Heart bekommt rotes Badge mit Zahl der Wunschliste-Rezepte.
  wishlistStore in $lib/client/wishlist.svelte.ts hält den Count reaktiv;
  Refresh auf Mount, nach Add/Remove, beim Öffnen der Wunschliste.
- Recipe-Detail: Loader liefert wishlist_profile_ids; onMyWishlist ist
  ein $derived. Toggle fragt aktives Profil (alertAction sonst), mutiert
  die lokale Liste + ruft wishlistStore.refresh.
- Wunschliste-Seite: Heart toggelt eigenen Wunsch, Count zeigt Gesamt-
  wünsche, kommagetrennte Namen zeigen "wer will". Trash-Button
  entfernt — Heart-off reicht jetzt.

Tests (99 → 99, 8 neu geschrieben):
- Per-User-Add/Remove, aggregierte Counts, on_my_wishlist, Cascades bei
  Recipe/Profile-Delete, countWishlistRecipes = DISTINCT.
This commit is contained in:
hsiegeln
2026-04-17 19:16:19 +02:00
parent 224352d051
commit 60021b879f
12 changed files with 282 additions and 207 deletions

View File

@@ -10,7 +10,7 @@ import {
const AddSchema = z.object({
recipe_id: z.number().int().positive(),
profile_id: z.number().int().positive().nullable().optional()
profile_id: z.number().int().positive()
});
const VALID_SORTS: readonly SortKey[] = ['popular', 'newest', 'oldest'] as const;
@@ -34,7 +34,7 @@ export const GET: RequestHandler = async ({ url }) => {
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json().catch(() => null);
const parsed = AddSchema.safeParse(body);
if (!parsed.success) error(400, { message: 'Invalid body' });
addToWishlist(getDb(), parsed.data.recipe_id, parsed.data.profile_id ?? null);
if (!parsed.success) error(400, { message: 'recipe_id and profile_id required' });
addToWishlist(getDb(), parsed.data.recipe_id, parsed.data.profile_id);
return json({ ok: true }, { status: 201 });
};

View File

@@ -3,14 +3,15 @@ import { json, error } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { removeFromWishlist } from '$lib/server/wishlist/repository';
function parseId(raw: string): number {
const id = Number(raw);
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid recipe_id' });
return id;
function parsePositiveInt(raw: string | null, field: string): number {
const n = raw === null ? NaN : Number(raw);
if (!Number.isInteger(n) || n <= 0) error(400, { message: `Invalid ${field}` });
return n;
}
export const DELETE: RequestHandler = async ({ params }) => {
const id = parseId(params.recipe_id!);
removeFromWishlist(getDb(), id);
export const DELETE: RequestHandler = async ({ params, url }) => {
const id = parsePositiveInt(params.recipe_id!, 'recipe_id');
const profileId = parsePositiveInt(url.searchParams.get('profile_id'), 'profile_id');
removeFromWishlist(getDb(), id, profileId);
return json({ ok: true });
};

View File

@@ -1,31 +0,0 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import { z } from 'zod';
import { getDb } from '$lib/server/db';
import { likeWish, unlikeWish } from '$lib/server/wishlist/repository';
const Schema = z.object({ profile_id: z.number().int().positive() });
function parseId(raw: string): number {
const id = Number(raw);
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid recipe_id' });
return id;
}
export const PUT: RequestHandler = async ({ params, request }) => {
const id = parseId(params.recipe_id!);
const body = await request.json().catch(() => null);
const parsed = Schema.safeParse(body);
if (!parsed.success) error(400, { message: 'Invalid body' });
likeWish(getDb(), id, parsed.data.profile_id);
return json({ ok: true });
};
export const DELETE: RequestHandler = async ({ params, request }) => {
const id = parseId(params.recipe_id!);
const body = await request.json().catch(() => null);
const parsed = Schema.safeParse(body);
if (!parsed.success) error(400, { message: 'Invalid body' });
unlikeWish(getDb(), id, parsed.data.profile_id);
return json({ ok: true });
};

View File

@@ -0,0 +1,8 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { countWishlistRecipes } from '$lib/server/wishlist/repository';
export const GET: RequestHandler = async () => {
return json({ count: countWishlistRecipes(getDb()) });
};