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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
select: [recipeId: number]
|
select: [recipeId: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user