fix: align frontend API calls with backend routes and types

- Backend: rename routes /mealplan→/mealplans, /shoppinglist→/shopping
- Backend: simplify swap/reroll to entry-centric endpoints (by entryId)
- Frontend: fix all interfaces to use string GUIDs instead of numbers
- Frontend: fix field names (weekStart, date, totalAmount) to match backend JSON
- Frontend: shopping toggle by itemName instead of non-existent id
- Frontend: handle 204 No Content on DELETE responses
- Docker-compose: use env vars for DB credentials

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 06:49:41 +00:00
parent f58782774b
commit 1885abee64
10 changed files with 64 additions and 75 deletions

View File

@@ -5,7 +5,7 @@ using MealPlanner.Services;
namespace MealPlanner.Controllers; namespace MealPlanner.Controllers;
[ApiController] [ApiController]
[Route("api/mealplan")] [Route("api/mealplans")]
[Authorize] [Authorize]
public class MealPlanController(MealPlanService mealPlanService) : ControllerBase public class MealPlanController(MealPlanService mealPlanService) : ControllerBase
{ {
@@ -49,21 +49,12 @@ public class MealPlanController(MealPlanService mealPlanService) : ControllerBas
return Ok(plan); return Ok(plan);
} }
[HttpPut("{id:guid}/entry/{date}")] [HttpPut("entries/{entryId:guid}/swap")]
public async Task<IActionResult> SwapEntry(Guid id, string date, [FromBody] SwapRequest body) public async Task<IActionResult> SwapEntry(Guid entryId, [FromBody] SwapRequest body)
{ {
if (!DateOnly.TryParse(date, out var parsedDate)) return BadRequest("Invalid date format.");
// Find entry by plan id + date
var plan = await mealPlanService.GetPlanAsync(UserId, MealPlanService.GetWeekStart(parsedDate));
if (plan is null || plan.Id != id) return NotFound();
var entry = plan.Entries.FirstOrDefault(e => e.Date == parsedDate);
if (entry is null) return NotFound();
try try
{ {
var updated = await mealPlanService.SwapEntryAsync(entry.Id, body.RecipeId, UserId); var updated = await mealPlanService.SwapEntryAsync(entryId, body.RecipeId, UserId);
if (updated is null) return NotFound(); if (updated is null) return NotFound();
return Ok(updated); return Ok(updated);
} }
@@ -73,20 +64,12 @@ public class MealPlanController(MealPlanService mealPlanService) : ControllerBas
} }
} }
[HttpPost("{id:guid}/entry/{date}/reroll")] [HttpPost("entries/{entryId:guid}/reroll")]
public async Task<IActionResult> RerollEntry(Guid id, string date) public async Task<IActionResult> RerollEntry(Guid entryId)
{ {
if (!DateOnly.TryParse(date, out var parsedDate)) return BadRequest("Invalid date format.");
var plan = await mealPlanService.GetPlanAsync(UserId, MealPlanService.GetWeekStart(parsedDate));
if (plan is null || plan.Id != id) return NotFound();
var entry = plan.Entries.FirstOrDefault(e => e.Date == parsedDate);
if (entry is null) return NotFound();
try try
{ {
var updated = await mealPlanService.RerollEntryAsync(entry.Id, UserId); var updated = await mealPlanService.RerollEntryAsync(entryId, UserId);
if (updated is null) return StatusCode(503, "Could not fetch a new recipe. Try again."); if (updated is null) return StatusCode(503, "Could not fetch a new recipe. Try again.");
return Ok(updated); return Ok(updated);
} }

View File

@@ -5,7 +5,7 @@ using MealPlanner.Services;
namespace MealPlanner.Controllers; namespace MealPlanner.Controllers;
[ApiController] [ApiController]
[Route("api/shoppinglist")] [Route("api/shopping")]
[Authorize] [Authorize]
public class ShoppingListController(ShoppingListService shoppingListService) : ControllerBase public class ShoppingListController(ShoppingListService shoppingListService) : ControllerBase
{ {

View File

@@ -1,13 +1,12 @@
version: '3.8'
services: services:
app: app:
build: . build: .
ports: ports:
- "3000:80" - "3000:80"
environment: environment:
- ConnectionStrings__DefaultConnection=Host=host.docker.internal;Port=54320;Database=mealplanner;Username=mika;Password=${SHARED_POSTGRES_PASSWORD} - ConnectionStrings__DefaultConnection=Host=${DB_HOST:-host.docker.internal};Port=${DB_PORT:-5432};Database=mealplanner;Username=${DB_USER};Password=${DB_PASSWORD}
- Zitadel__Issuer=https://auth.kuns.dev - Zitadel__Issuer=https://auth.kuns.dev
- Zitadel__ClientId=${ZITADEL_CLIENT_ID} - Zitadel__ClientId=${ZITADEL_CLIENT_ID}
- AllowedOrigin=http://localhost:3000 - AllowedOrigin=${ALLOWED_ORIGIN:-https://essen.kuns.dev}
- ASPNETCORE_URLS=http://+:5000 - ASPNETCORE_URLS=http://+:5000
- ASPNETCORE_ENVIRONMENT=Production - ASPNETCORE_ENVIRONMENT=Production

View File

@@ -1,7 +1,7 @@
<template> <template>
<div <div
class="flex items-center gap-3 py-2 px-3 rounded-lg hover:bg-zinc-800/50 transition-colors group cursor-pointer" class="flex items-center gap-3 py-2 px-3 rounded-lg hover:bg-zinc-800/50 transition-colors group cursor-pointer"
@click="$emit('toggle', item.id)" @click="$emit('toggle', item.name)"
> >
<!-- Checkbox --> <!-- Checkbox -->
<div <div
@@ -25,7 +25,7 @@
<span <span
class="text-sm tabular-nums transition-colors" class="text-sm tabular-nums transition-colors"
:class="item.isChecked ? 'text-zinc-700' : 'text-zinc-400'" :class="item.isChecked ? 'text-zinc-700' : 'text-zinc-400'"
>{{ item.amount }} {{ item.unit }}</span> >{{ item.totalAmount != null ? item.totalAmount : '' }} {{ item.unit ?? '' }}</span>
</div> </div>
</template> </template>
@@ -37,6 +37,6 @@ defineProps<{
}>() }>()
defineEmits<{ defineEmits<{
toggle: [id: number] toggle: [name: string]
}>() }>()
</script> </script>

View File

@@ -75,7 +75,7 @@ const props = defineProps<{
defineEmits<{ defineEmits<{
close: [] close: []
select: [recipeId: number] select: [recipeId: string]
}>() }>()
const query = ref('') const query = ref('')

View File

@@ -11,6 +11,7 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
}) })
if (res.status === 401) { login(window.location.pathname); throw new Error('Unauthorized') } if (res.status === 401) { login(window.location.pathname); throw new Error('Unauthorized') }
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
if (res.status === 204) return undefined as T
return res.json() as Promise<T> return res.json() as Promise<T>
} }

View File

@@ -3,15 +3,15 @@ import { ref } from 'vue'
import { useApi } from '../composables/useApi' import { useApi } from '../composables/useApi'
export interface Ingredient { export interface Ingredient {
id?: number id?: string
name: string name: string
amount: number amount: number | null
unit: string unit: string | null
category: string category: string | null
} }
export interface Recipe { export interface Recipe {
id: number id: string
title: string title: string
imageUrl?: string imageUrl?: string
instructions?: string instructions?: string
@@ -19,25 +19,23 @@ export interface Recipe {
} }
export interface MealPlanEntry { export interface MealPlanEntry {
id: number id: string
date: string date: string
dayOfWeek: number recipeId: string
recipeId: number
recipe: Recipe recipe: Recipe
} }
export interface MealPlan { export interface MealPlan {
id: number id: string
weekStartDate: string weekStart: string
entries: MealPlanEntry[] entries: MealPlanEntry[]
} }
export interface ShoppingItem { export interface ShoppingItem {
id: number
name: string name: string
amount: number totalAmount: number | null
unit: string unit: string | null
category: string category: string | null
isChecked: boolean isChecked: boolean
} }
@@ -77,7 +75,7 @@ export const useMealPlanStore = defineStore('mealPlan', () => {
} }
} }
async function swapMeal(entryId: number, recipeId: number): Promise<void> { async function swapMeal(entryId: string, recipeId: string): Promise<void> {
error.value = null error.value = null
try { try {
const updated = await api.put<MealPlanEntry>(`/mealplans/entries/${entryId}/swap`, { recipeId }) const updated = await api.put<MealPlanEntry>(`/mealplans/entries/${entryId}/swap`, { recipeId })
@@ -92,7 +90,7 @@ export const useMealPlanStore = defineStore('mealPlan', () => {
} }
} }
async function rerollMeal(entryId: number): Promise<void> { async function rerollMeal(entryId: string): Promise<void> {
error.value = null error.value = null
try { try {
const updated = await api.post<MealPlanEntry>(`/mealplans/entries/${entryId}/reroll`) const updated = await api.post<MealPlanEntry>(`/mealplans/entries/${entryId}/reroll`)
@@ -116,13 +114,18 @@ export const useShoppingStore = defineStore('shopping', () => {
const items = ref<ShoppingItem[]>([]) const items = ref<ShoppingItem[]>([])
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const activeMealPlanId = ref<string | null>(null)
async function fetchItems(mealPlanId?: number | string): Promise<void> { async function fetchItems(mealPlanId?: string): Promise<void> {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const path = mealPlanId ? `/shopping/${mealPlanId}` : '/shopping/current' if (mealPlanId) {
items.value = await api.get<ShoppingItem[]>(path) activeMealPlanId.value = mealPlanId
items.value = await api.get<ShoppingItem[]>(`/shopping/${mealPlanId}`)
} else {
items.value = []
}
} catch (e) { } catch (e) {
if (e instanceof Error && e.message === 'HTTP 404') { if (e instanceof Error && e.message === 'HTTP 404') {
items.value = [] items.value = []
@@ -134,13 +137,13 @@ export const useShoppingStore = defineStore('shopping', () => {
} }
} }
async function toggleItem(id: number): Promise<void> { async function toggleItem(itemName: string): Promise<void> {
const item = items.value.find(i => i.id === id) const item = items.value.find(i => i.name === itemName)
if (!item) return if (!item || !activeMealPlanId.value) return
const prev = item.isChecked const prev = item.isChecked
item.isChecked = !prev item.isChecked = !prev
try { try {
await api.put(`/shopping/${id}/toggle`) await api.put(`/shopping/${activeMealPlanId.value}/check/${encodeURIComponent(itemName)}`)
} catch { } catch {
item.isChecked = prev item.isChecked = prev
error.value = 'Fehler beim Aktualisieren.' error.value = 'Fehler beim Aktualisieren.'
@@ -149,7 +152,7 @@ export const useShoppingStore = defineStore('shopping', () => {
const uncheckedCount = () => items.value.filter(i => !i.isChecked).length const uncheckedCount = () => items.value.filter(i => !i.isChecked).length
return { items, loading, error, fetchItems, toggleItem, uncheckedCount } return { items, loading, error, activeMealPlanId, fetchItems, toggleItem, uncheckedCount }
}) })
export const useRecipesStore = defineStore('recipes', () => { export const useRecipesStore = defineStore('recipes', () => {
@@ -177,13 +180,13 @@ export const useRecipesStore = defineStore('recipes', () => {
return created return created
} }
async function updateRecipe(id: number, data: Partial<Omit<Recipe, 'id'>>): Promise<void> { async function updateRecipe(id: string, data: Partial<Omit<Recipe, 'id'>>): Promise<void> {
const updated = await api.put<Recipe>(`/recipes/${id}`, data) const updated = await api.put<Recipe>(`/recipes/${id}`, data)
const idx = recipes.value.findIndex(r => r.id === id) const idx = recipes.value.findIndex(r => r.id === id)
if (idx !== -1) recipes.value[idx] = updated if (idx !== -1) recipes.value[idx] = updated
} }
async function deleteRecipe(id: number): Promise<void> { async function deleteRecipe(id: string): Promise<void> {
await api.del(`/recipes/${id}`) await api.del(`/recipes/${id}`)
recipes.value = recipes.value.filter(r => r.id !== id) recipes.value = recipes.value.filter(r => r.id !== id)
} }

View File

@@ -271,23 +271,25 @@ import type { Recipe, Ingredient } from '../stores/mealPlan'
const store = useRecipesStore() const store = useRecipesStore()
const selectedRecipe = ref<Recipe | null>(null) const selectedRecipe = ref<Recipe | null>(null)
const imgErrors = ref(new Set<number>()) const imgErrors = ref(new Set<string>())
const showForm = ref(false) const showForm = ref(false)
const editingId = ref<number | null>(null) const editingId = ref<string | null>(null)
const formSaving = ref(false) const formSaving = ref(false)
const formError = ref<string | null>(null) const formError = ref<string | null>(null)
interface FormIngredient {
name: string
amount: number | null
unit: string | null
category: string | null
}
interface FormState { interface FormState {
title: string title: string
imageUrl: string imageUrl: string
instructions: string instructions: string
ingredients: Array<{ ingredients: FormIngredient[]
name: string
amount: number
unit: string
category: string
}>
} }
const form = reactive<FormState>({ const form = reactive<FormState>({
@@ -328,7 +330,7 @@ function closeForm(): void {
} }
function addIngredient(): void { function addIngredient(): void {
form.ingredients.push({ name: '', amount: 1, unit: 'g', category: 'Sonstiges' }) form.ingredients.push({ name: '', amount: 1, unit: 'g', category: 'Sonstiges' } as FormIngredient)
} }
function removeIngredient(idx: number): void { function removeIngredient(idx: number): void {
@@ -358,7 +360,7 @@ async function handleSubmit(): Promise<void> {
} }
} }
async function handleDelete(id: number): Promise<void> { async function handleDelete(id: string): Promise<void> {
if (!confirm('Rezept wirklich löschen?')) return if (!confirm('Rezept wirklich löschen?')) return
await store.deleteRecipe(id) await store.deleteRecipe(id)
} }

View File

@@ -82,7 +82,7 @@
<div v-if="!collapsed.has(String(category))" class="px-2 py-1"> <div v-if="!collapsed.has(String(category))" class="px-2 py-1">
<ShoppingItem <ShoppingItem
v-for="item in groupItems" v-for="item in groupItems"
:key="item.id" :key="item.name"
:item="item" :item="item"
@toggle="store.toggleItem" @toggle="store.toggleItem"
/> />

View File

@@ -5,7 +5,7 @@
<div> <div>
<h1 class="text-2xl font-bold text-zinc-100">Wochenplan</h1> <h1 class="text-2xl font-bold text-zinc-100">Wochenplan</h1>
<p v-if="store.currentPlan" class="text-zinc-500 text-sm mt-0.5"> <p v-if="store.currentPlan" class="text-zinc-500 text-sm mt-0.5">
{{ formatWeek(store.currentPlan.weekStartDate) }} {{ formatWeek(store.currentPlan.weekStart) }}
</p> </p>
</div> </div>
<button <button
@@ -62,7 +62,7 @@
v-for="entry in sortedEntries" v-for="entry in sortedEntries"
:key="entry.id" :key="entry.id"
:entry="entry" :entry="entry"
:day-name="getDayName(entry.dayOfWeek)" :day-name="getDayName(entry.date)"
@swap="openSwapModal(entry)" @swap="openSwapModal(entry)"
@reroll="handleReroll(entry)" @reroll="handleReroll(entry)"
/> />
@@ -121,12 +121,13 @@ const swapEntry = ref<MealPlanEntry | null>(null)
const DAY_NAMES = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'] const DAY_NAMES = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']
function getDayName(dayOfWeek: number): string { function getDayName(dateStr: string): string {
return DAY_NAMES[dayOfWeek] ?? `Tag ${dayOfWeek}` const d = new Date(dateStr + 'T00:00:00')
return DAY_NAMES[d.getDay()] ?? dateStr
} }
const sortedEntries = computed(() => { const sortedEntries = computed(() => {
return [...(store.currentPlan?.entries ?? [])].sort((a, b) => a.dayOfWeek - b.dayOfWeek) return [...(store.currentPlan?.entries ?? [])].sort((a, b) => a.date.localeCompare(b.date))
}) })
function formatWeek(dateStr: string): string { function formatWeek(dateStr: string): string {
@@ -148,7 +149,7 @@ function openSwapModal(entry: MealPlanEntry): void {
} }
} }
async function handleSwapSelect(recipeId: number): Promise<void> { async function handleSwapSelect(recipeId: string): Promise<void> {
if (!swapEntry.value) return if (!swapEntry.value) return
await store.swapMeal(swapEntry.value.id, recipeId) await store.swapMeal(swapEntry.value.id, recipeId)
swapEntry.value = null swapEntry.value = null