diff --git a/gen-ai/.env.example b/.env.example similarity index 100% rename from gen-ai/.env.example rename to .env.example diff --git a/.github/workflows/deploy-k8s.yml b/.github/workflows/deploy-k8s.yml index fa09af5..763970d 100644 --- a/.github/workflows/deploy-k8s.yml +++ b/.github/workflows/deploy-k8s.yml @@ -63,4 +63,6 @@ jobs: --set genai.image.tag=${{ steps.vars.outputs.image_tag }} \ --set genai.logosKey="${{ secrets.LOGOS_KEY }}" \ --set genai.openaiApiKey="${{ secrets.OPENAI_API_KEY }}" \ + --set userDb.password="${{ secrets.USER_DB_PASSWORD }}" \ + --set groceryDb.password="${{ secrets.GROCERY_DB_PASSWORD }}" \ --atomic diff --git a/README.md b/README.md index 3959e7b..429a83e 100644 --- a/README.md +++ b/README.md @@ -125,9 +125,8 @@ The UI includes the User Service, Grocery Service, and Gen AI Service OpenAPI de Requires Docker Desktop running. ```powershell -$env:LOGOS_KEY="..." -$env:OPENAI_API_KEY="sk-..." # optional, only needed for the OpenAI switch docker compose up --build +docker compose down # To take down later ``` Open http://localhost:8081 @@ -139,8 +138,17 @@ Drop `--build` on subsequent starts if nothing has changed. To stop: `docker com ### Kubernetes #### Local Kubernetes Deployment -... +Requires a local Kubernetes cluster running via Docker Desktop. + +```powershell +kubectl config use-context docker-desktop +kubectl create namespace team-bytebite +helm upgrade --install bytebite ./helm/bytebite -f ./helm/bytebite/values-local.yaml --namespace team-bytebite --set genai.openaiApiKey="sk-..." --atomic +helm uninstall bytebite --namespace team-bytebite # To take down later +``` + +Open http://localhost:80 #### Kubernetes Deployment to the AET cluster @@ -156,11 +164,9 @@ Alternatively, you can do manual deployment with Helm: (Requires `helm` and a valid kubeconfig) ```bash -helm upgrade --install bytebite ./helm/bytebite \ - --namespace team-bytebite \ - --set genai.logosKey="..." \ - --set genai.openaiApiKey="sk-..." \ - --atomic +kubectl config use-context stud +helm upgrade --install bytebite ./helm/bytebite --namespace team-bytebite --set genai.openaiApiKey="sk-..." --atomic +helm uninstall bytebite --namespace team-bytebite # To take down later ``` The app is available at https://team-bytebite.stud.k8s.aet.cit.tum.de diff --git a/client/src/App.tsx b/client/src/App.tsx index d397812..6e8faf5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { Menu } from 'lucide-react' import { Sidebar, LogoMark } from './components/Sidebar' @@ -6,6 +6,73 @@ import { HeroSection } from './components/HeroSection' import { RecipeCard } from './components/RecipeCard' import { FeatureCards } from './components/FeatureCards' import { AuthCard, type AuthPayload, type AuthUser } from './components/AuthCard' +import { GroceryListView } from './components/GroceryListView' +import { RecipeListView } from './components/RecipeListView' +import type { + GroceryList, ApiRecipe, ApiRecipeSummary, RecipeSummary, Ingredient, + ApiGroceryList, ApiGroceryListSummary, GroceryListSummary, GroceryItemDetail, +} from './types' + +type View = 'home' | 'grocery-lists' | 'recipes' +type LoadStatus = 'loading' | 'ready' | 'error' + +function apiSummaryToRecipe(summary: ApiRecipeSummary): RecipeSummary { + return { id: summary.recipeId, dish: summary.name, createdAt: summary.createdAt } +} + +// The API uses the DB's numeric quantity (null = unspecified); the client keeps a display +// string, so convert at the API boundary. +function formatQuantity(quantity: number | null): string { + return quantity === null ? 'N/A' : String(quantity) +} + +function parseQuantity(quantity: string): number | null { + const trimmed = quantity?.trim() + if (!trimmed || trimmed === 'N/A') return null + const n = Number(trimmed) + return Number.isFinite(n) ? n : null +} + +function apiItemsToIngredients(recipe: ApiRecipe): Ingredient[] { + return recipe.items.map(item => ({ + name: item.name, + quantity: formatQuantity(item.quantity), + unit: item.unit, + category: item.category, + })) +} + +function apiSummaryToGroceryList(summary: ApiGroceryListSummary): GroceryListSummary { + return { + id: summary.groceryListId, + dish: summary.name, + createdAt: summary.createdAt, + itemCount: summary.itemCount, + purchasedCount: summary.purchasedCount, + } +} + +// POST/PUT return the full detail; derive the summary counts the list view needs from it. +function detailToGrocerySummary(detail: ApiGroceryList): GroceryListSummary { + return { + id: detail.groceryListId, + dish: detail.name, + createdAt: detail.createdAt, + itemCount: detail.items.length, + purchasedCount: detail.items.filter(item => item.purchased).length, + } +} + +function apiItemsToGroceryDetail(list: ApiGroceryList): GroceryItemDetail[] { + return list.items.map(item => ({ + itemId: item.itemId, + name: item.name, + quantity: formatQuantity(item.quantity), + unit: item.unit, + category: item.category, + purchased: item.purchased, + })) +} function getInitialDark(): boolean { const stored = localStorage.getItem('bytebite-dark') @@ -16,11 +83,60 @@ function getInitialDark(): boolean { function App() { const [darkMode, setDarkMode] = useState(getInitialDark) const [sidebarOpen, setSidebarOpen] = useState(false) + const [view, setView] = useState('home') const [token, setToken] = useState(() => localStorage.getItem('bytebite-token') ?? '') const [user, setUser] = useState(() => { const stored = localStorage.getItem('bytebite-user') return stored ? JSON.parse(stored) as AuthUser : null }) + const [recipes, setRecipes] = useState([]) + const [recipesStatus, setRecipesStatus] = useState('loading') + const [groceryLists, setGroceryLists] = useState([]) + const [groceryStatus, setGroceryStatus] = useState('loading') + + const loadRecipes = useCallback((authToken: string) => { + setRecipesStatus('loading') + fetch('/api/recipes', { headers: { Authorization: `Bearer ${authToken}` } }) + .then(response => { + if (!response.ok) throw new Error('Failed to load recipes') + return response.json() as Promise + }) + .then(data => { + setRecipes(data.map(apiSummaryToRecipe)) + setRecipesStatus('ready') + }) + .catch(() => setRecipesStatus('error')) + }, []) + + const loadGroceryLists = useCallback((authToken: string) => { + setGroceryStatus('loading') + fetch('/api/grocery-list', { headers: { Authorization: `Bearer ${authToken}` } }) + .then(response => { + if (!response.ok) throw new Error('Failed to load grocery lists') + return response.json() as Promise + }) + .then(data => { + setGroceryLists(data.map(apiSummaryToGroceryList)) + setGroceryStatus('ready') + }) + .catch(() => setGroceryStatus('error')) + }, []) + + const fetchRecipeItems = useCallback(async (recipeId: string): Promise => { + const response = await fetch(`/api/recipes/${recipeId}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!response.ok) throw new Error('Failed to load recipe items') + return apiItemsToIngredients(await response.json() as ApiRecipe) + }, [token]) + + const fetchGroceryListItems = useCallback(async (listId: string): Promise => { + const response = await fetch(`/api/grocery-list/${listId}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!response.ok) throw new Error('Failed to load grocery list items') + return apiItemsToGroceryDetail(await response.json() as ApiGroceryList) + }, [token]) useEffect(() => { document.documentElement.classList.toggle('dark', darkMode) @@ -41,6 +157,8 @@ function App() { setUser(payload.user) localStorage.setItem('bytebite-token', payload.token) localStorage.setItem('bytebite-user', JSON.stringify(payload.user)) + loadRecipes(payload.token) + loadGroceryLists(payload.token) }) .catch(() => { localStorage.removeItem('bytebite-token') @@ -48,22 +166,136 @@ function App() { setToken('') setUser(null) }) - }, []) + }, [loadRecipes, loadGroceryLists]) const toggleDark = () => setDarkMode(d => !d) const openSidebar = () => setSidebarOpen(true) const closeSidebar = () => setSidebarOpen(false) + + const navigate = (v: string) => { + setView(v as View) + setSidebarOpen(false) + } + const handleAuthenticated = (payload: AuthPayload) => { setToken(payload.token) setUser(payload.user) localStorage.setItem('bytebite-token', payload.token) localStorage.setItem('bytebite-user', JSON.stringify(payload.user)) + loadRecipes(payload.token) + loadGroceryLists(payload.token) } + const handleLogout = () => { localStorage.removeItem('bytebite-token') localStorage.removeItem('bytebite-user') setToken('') setUser(null) + setRecipes([]) + setGroceryLists([]) + setView('home') + } + + // A generated dish is persisted as both a recipe and a grocery list; each view reads its own resource. + const handleListGenerated = (list: GroceryList) => { + if (!token) return + + fetch('/api/recipes', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + name: list.dish, + items: list.ingredients.map(item => ({ + name: item.name, + quantity: parseQuantity(item.quantity), + unit: item.unit, + category: item.category, + })), + }), + }) + .then(response => { + if (!response.ok) throw new Error('Failed to save recipe') + return response.json() as Promise + }) + .then(saved => setRecipes(prev => [apiSummaryToRecipe(saved), ...prev])) + .catch(() => { + // Recipe persistence failed; the grocery list may still be saved. + }) + + fetch('/api/grocery-list', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + name: list.dish, + items: list.ingredients.map(item => ({ + name: item.name, + quantity: parseQuantity(item.quantity), + unit: item.unit, + category: item.category, + purchased: false, + })), + }), + }) + .then(response => { + if (!response.ok) throw new Error('Failed to save grocery list') + return response.json() as Promise + }) + .then(saved => setGroceryLists(prev => [detailToGrocerySummary(saved), ...prev])) + .catch(() => { + // Grocery-list persistence failed; the recipe may still be saved. + }) + } + + const handleDeleteRecipe = (recipeId: string) => { + const previous = recipes + setRecipes(prev => prev.filter(recipe => recipe.id !== recipeId)) + fetch(`/api/recipes/${recipeId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + .then(response => { + if (!response.ok && response.status !== 404) throw new Error('Failed to delete recipe') + }) + .catch(() => setRecipes(previous)) + } + + // Persists a single item's picked-up state via PATCH and keeps the summary counts in sync. + // Returns false if the server rejected the change so the view can revert its optimistic update. + const handleToggleGroceryItem = useCallback( + async (listId: string, itemId: string, purchased: boolean): Promise => { + const adjust = (delta: number) => setGroceryLists(prev => prev.map(list => + list.id === listId + ? { ...list, purchasedCount: Math.max(0, Math.min(list.itemCount, list.purchasedCount + delta)) } + : list + )) + adjust(purchased ? 1 : -1) + try { + const response = await fetch(`/api/grocery-list/${listId}/items/${itemId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ purchased }), + }) + if (!response.ok) throw new Error('Failed to update item') + return true + } catch { + adjust(purchased ? -1 : 1) + return false + } + }, + [token] + ) + + const handleDeleteList = (listId: string) => { + const previous = groceryLists + setGroceryLists(prev => prev.filter(list => list.id !== listId)) + fetch(`/api/grocery-list/${listId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + .then(response => { + if (!response.ok && response.status !== 404) throw new Error('Failed to delete grocery list') + }) + .catch(() => setGroceryLists(previous)) } if (!token || !user) { @@ -79,6 +311,8 @@ function App() { onClose={closeSidebar} user={user} onLogout={handleLogout} + activeView={view} + onNavigate={navigate} /> {/* Mobile backdrop */} @@ -96,7 +330,7 @@ function App() { {/* Main content */} -
+
{/* Mobile top bar */}
) } -export default App +export default App \ No newline at end of file diff --git a/client/src/components/GroceryListView.tsx b/client/src/components/GroceryListView.tsx new file mode 100644 index 0000000..42874f9 --- /dev/null +++ b/client/src/components/GroceryListView.tsx @@ -0,0 +1,348 @@ +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { + ShoppingCart, ChevronDown, ShoppingBag, + Check, Copy, Trash2, CircleCheckBig, X, Loader2, AlertTriangle, +} from 'lucide-react' +import type { GroceryListSummary, GroceryItemDetail } from '../types' + +type LoadStatus = 'loading' | 'ready' | 'error' +type ItemState = { status: LoadStatus; items: GroceryItemDetail[] } + +interface GroceryListViewProps { + lists: GroceryListSummary[] + status: LoadStatus + onRetry: () => void + onToggleItem: (listId: string, itemId: string, purchased: boolean) => Promise + onDeleteList: (listId: string) => void + fetchItems: (id: string) => Promise +} + +function itemLabel(item: GroceryItemDetail) { + return item.quantity !== 'N/A' ? `${item.quantity} ${item.unit} ${item.name}` : item.name +} + +function toText(dish: string, items: GroceryItemDetail[]) { + return [dish, ...items.map(item => `- ${itemLabel(item)}`)].join('\n') +} + +export function GroceryListView({ lists, status, onRetry, onToggleItem, onDeleteList, fetchItems }: GroceryListViewProps) { + const [openId, setOpenId] = useState(null) + const [copyState, setCopyState] = useState<{ id: string; ok: boolean } | null>(null) + const [itemsById, setItemsById] = useState>({}) + + // Fetches a list's items (once), caches them, and returns them; throws on failure. + const loadItems = async (id: string): Promise => { + setItemsById(prev => ({ ...prev, [id]: { status: 'loading', items: prev[id]?.items ?? [] } })) + try { + const items = await fetchItems(id) + setItemsById(prev => ({ ...prev, [id]: { status: 'ready', items } })) + return items + } catch (error) { + setItemsById(prev => ({ ...prev, [id]: { status: 'error', items: [] } })) + throw error + } + } + + const toggle = (id: string) => { + if (openId === id) { + setOpenId(null) + return + } + setOpenId(id) + const st = itemsById[id]?.status + if (st !== 'ready' && st !== 'loading') { + loadItems(id).catch(() => { /* surfaced via item state */ }) + } + } + + // Flips one item's purchased flag in the cache, then persists; reverts if the server rejects it. + const setPurchased = (listId: string, itemId: string, purchased: boolean) => { + setItemsById(prev => { + const st = prev[listId] + if (!st) return prev + return { + ...prev, + [listId]: { ...st, items: st.items.map(i => (i.itemId === itemId ? { ...i, purchased } : i)) }, + } + }) + } + + const handleToggleItem = async (listId: string, item: GroceryItemDetail) => { + const next = !item.purchased + setPurchased(listId, item.itemId, next) + const ok = await onToggleItem(listId, item.itemId, next) + if (!ok) setPurchased(listId, item.itemId, item.purchased) + } + + const showCopyResult = (id: string, ok: boolean) => { + setCopyState({ id, ok }) + setTimeout(() => setCopyState(s => (s?.id === id ? null : s)), 1800) + } + + const handleCopy = async (list: GroceryListSummary) => { + try { + if (!navigator.clipboard) throw new Error('Clipboard unavailable') + const cached = itemsById[list.id] + const items = cached?.status === 'ready' ? cached.items : await loadItems(list.id) + await navigator.clipboard.writeText(toText(list.dish, items)) + showCopyResult(list.id, true) + } catch { + showCopyResult(list.id, false) + } + } + + if (status === 'loading') { + return ( + + +

Loading your grocery lists…

+
+ ) + } + + if (status === 'error') { + return ( + +
+ +
+

Couldn't load grocery lists

+

+ Something went wrong reaching the server. Please try again. +

+ +
+ ) + } + + if (lists.length === 0) { + return ( + +
+ +
+

No grocery lists yet

+

+ Generate a shopping list from a recipe on the Home page and it will appear here. +

+
+ ) + } + + return ( + +
+

Grocery Lists

+ + {lists.length} + +
+ +
+ {lists.map((list, idx) => { + const isOpen = openId === list.id + const state = itemsById[list.id] + const loaded = state?.status === 'ready' + // Collapsed cards rely on the summary counts; once items are loaded, the live + // (optimistic) item state drives the progress so toggles update instantly. + const total = loaded ? state.items.length : list.itemCount + const checkedCount = loaded ? state.items.filter(item => item.purchased).length : list.purchasedCount + const progress = total ? checkedCount / total : 0 + const complete = total > 0 && checkedCount === total + + const grouped = (loaded ? state.items : []).reduce>( + (acc, item) => { + const key = item.category || 'Other' + ;(acc[key] ??= []).push(item) + return acc + }, + {} + ) + + return ( + + {/* Header row */} +
+ + +
+ + + +
+
+ + {/* Progress bar */} +
+
+ +
+
+ + + {isOpen && ( + +
+ {state?.status === 'loading' && ( +
+ + Loading items… +
+ )} + + {state?.status === 'error' && ( +
+ + + Couldn't load items. + + +
+ )} + + {state?.status === 'ready' && ( + state.items.length === 0 ? ( +

No items.

+ ) : ( +
+ {Object.entries(grouped).map(([category, items]) => ( +
+

+ {category} +

+
+ {items.map(item => { + const checked = item.purchased + return ( + + ) + })} +
+
+ ))} +
+ ) + )} +
+
+ )} +
+
+ ) + })} +
+
+ ) +} \ No newline at end of file diff --git a/client/src/components/RecipeCard.tsx b/client/src/components/RecipeCard.tsx index c0966b2..43e5ff2 100644 --- a/client/src/components/RecipeCard.tsx +++ b/client/src/components/RecipeCard.tsx @@ -2,11 +2,10 @@ import { useState, useEffect } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { ChefHat, ShoppingCart, ArrowRight, Loader2, AlertTriangle } from 'lucide-react' import { AlertBanner } from './AlertBanner' +import type { Ingredient, GroceryList } from '../types' type Status = 'idle' | 'loading' | 'success' | 'error' -type Ingredient = { name: string; quantity: string; unit: string; category: string; restricted: boolean; alternative: string | null } - type DietaryRestriction = 'Vegan' | 'Vegetarian' | 'Gluten Free' | 'Lactose Free' type LlmProvider = 'logos' | 'openai' @@ -29,9 +28,10 @@ const PLACEHOLDERS = [ interface RecipeCardProps { token: string + onListGenerated?: (list: GroceryList) => void } -export function RecipeCard({ token }: RecipeCardProps) { +export function RecipeCard({ token, onListGenerated }: RecipeCardProps) { const [input, setInput] = useState('') const [validationError, setValidationError] = useState('') const [status, setStatus] = useState('idle') @@ -80,6 +80,12 @@ export function RecipeCard({ token }: RecipeCardProps) { const data = await response.json() as { dish: string; ingredients: Ingredient[] } setIngredients(data.ingredients) setStatus('success') + onListGenerated?.({ + id: crypto.randomUUID(), + dish: data.dish, + createdAt: new Date().toISOString(), + ingredients: data.ingredients, + }) } catch { setStatus('error') } diff --git a/client/src/components/RecipeListView.tsx b/client/src/components/RecipeListView.tsx new file mode 100644 index 0000000..142e5a5 --- /dev/null +++ b/client/src/components/RecipeListView.tsx @@ -0,0 +1,285 @@ +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { + ChefHat, ChevronDown, BookOpen, + Check, Copy, Trash2, X, Loader2, AlertTriangle, +} from 'lucide-react' +import type { RecipeSummary, Ingredient } from '../types' + +type LoadStatus = 'loading' | 'ready' | 'error' +type ItemState = { status: LoadStatus; items: Ingredient[] } + +interface RecipeListViewProps { + recipes: RecipeSummary[] + status: LoadStatus + onRetry: () => void + onDeleteRecipe: (id: string) => void + fetchItems: (id: string) => Promise +} + +function itemLabel(item: Ingredient) { + return item.quantity !== 'N/A' ? `${item.quantity} ${item.unit} ${item.name}` : item.name +} + +function toText(dish: string, items: Ingredient[]) { + return [dish, ...items.map(item => `- ${itemLabel(item)}`)].join('\n') +} + +export function RecipeListView({ recipes, status, onRetry, onDeleteRecipe, fetchItems }: RecipeListViewProps) { + const [openId, setOpenId] = useState(null) + const [copyState, setCopyState] = useState<{ id: string; ok: boolean } | null>(null) + const [itemsById, setItemsById] = useState>({}) + + // Fetches a recipe's items (once), caches them, and returns them; throws on failure. + const loadItems = async (id: string): Promise => { + setItemsById(prev => ({ ...prev, [id]: { status: 'loading', items: prev[id]?.items ?? [] } })) + try { + const items = await fetchItems(id) + setItemsById(prev => ({ ...prev, [id]: { status: 'ready', items } })) + return items + } catch (error) { + setItemsById(prev => ({ ...prev, [id]: { status: 'error', items: [] } })) + throw error + } + } + + const toggle = (id: string) => { + if (openId === id) { + setOpenId(null) + return + } + setOpenId(id) + const st = itemsById[id]?.status + if (st !== 'ready' && st !== 'loading') { + loadItems(id).catch(() => { /* surfaced via item state */ }) + } + } + + const showCopyResult = (id: string, ok: boolean) => { + setCopyState({ id, ok }) + setTimeout(() => setCopyState(s => (s?.id === id ? null : s)), 1800) + } + + const handleCopy = async (recipe: RecipeSummary) => { + try { + if (!navigator.clipboard) throw new Error('Clipboard unavailable') + const cached = itemsById[recipe.id] + const items = cached?.status === 'ready' ? cached.items : await loadItems(recipe.id) + await navigator.clipboard.writeText(toText(recipe.dish, items)) + showCopyResult(recipe.id, true) + } catch { + showCopyResult(recipe.id, false) + } + } + + if (status === 'loading') { + return ( + + +

Loading your recipes…

+
+ ) + } + + if (status === 'error') { + return ( + +
+ +
+

Couldn't load recipes

+

+ Something went wrong reaching the server. Please try again. +

+ +
+ ) + } + + if (recipes.length === 0) { + return ( + +
+ +
+

No recipes yet

+

+ Generate a recipe from the Home page and it will be saved here for reference. +

+
+ ) + } + + return ( + +
+

Recipes

+ + {recipes.length} + +
+ +
+ {recipes.map((recipe, idx) => { + const isOpen = openId === recipe.id + const state = itemsById[recipe.id] + + const grouped = (state?.status === 'ready' ? state.items : []).reduce>( + (acc, item) => { + const key = item.category || 'Other' + ;(acc[key] ??= []).push(item) + return acc + }, + {} + ) + + return ( + + {/* Header row */} +
+ + +
+ + + +
+
+ + + {isOpen && ( + +
+ {state?.status === 'loading' && ( +
+ + Loading ingredients… +
+ )} + + {state?.status === 'error' && ( +
+ + + Couldn't load ingredients. + + +
+ )} + + {state?.status === 'ready' && ( + state.items.length === 0 ? ( +

No ingredients.

+ ) : ( +
+ {Object.entries(grouped).map(([category, items]) => ( +
+

+ {category} +

+
+ {items.map((item, i) => ( + + {itemLabel(item)} + + ))} +
+
+ ))} +
+ ) + )} +
+
+ )} +
+
+ ) + })} +
+
+ ) +} \ No newline at end of file diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 3fe897e..10a89b0 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -1,11 +1,17 @@ import { motion, AnimatePresence } from 'framer-motion' import { - Home, ShoppingCart, Heart, + Home, ShoppingCart, Heart, BookOpen, Settings, User, Sun, Moon, X, LogOut, - Leaf, + Leaf, type LucideIcon, } from 'lucide-react' import type { AuthUser } from './AuthCard' +type NavItem = { + icon: LucideIcon + label: string + view?: string +} + interface SidebarProps { darkMode: boolean onToggleDark: () => void @@ -13,15 +19,18 @@ interface SidebarProps { onClose: () => void user: AuthUser onLogout: () => void + activeView: string + onNavigate: (view: string) => void } -const navMain = [ - { icon: Home, label: 'Home', active: true }, - { icon: ShoppingCart, label: 'Shopping Lists' }, +const navMain: NavItem[] = [ + { icon: Home, label: 'Home', view: 'home' }, + { icon: BookOpen, label: 'Recipes', view: 'recipes' }, + { icon: ShoppingCart, label: 'Grocery Lists', view: 'grocery-lists' }, { icon: Heart, label: 'Favorites' }, ] -const navAccount = [ +const navAccount: NavItem[] = [ { icon: Settings, label: 'Settings' }, { icon: User, label: 'Profile' }, ] @@ -45,27 +54,44 @@ export function LogoMark({ scale = 'lg' }: { scale?: 'sm' | 'lg' }) { ) } -function NavSection({ label, items }: { label: string; items: typeof navMain }) { +function NavSection({ + label, items, activeView, onNavigate, +}: { + label: string + items: NavItem[] + activeView: string + onNavigate: (view: string) => void +}) { return (

{label}

- {items.map(({ icon: Icon, label: itemLabel, active }) => ( - - - {itemLabel} - - ))} + {items.map(({ icon: Icon, label: itemLabel, view }) => { + const active = view !== undefined && activeView === view + const clickable = view !== undefined + return ( + clickable && onNavigate(view!)} + disabled={!clickable} + className={`w-full flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-medium transition-colors ${ + !clickable ? 'opacity-40 cursor-default' : 'cursor-pointer' + } ${ + active + ? 'bg-green-50 text-green-800 dark:bg-green-900/30 dark:text-green-400' + : clickable + ? 'text-gray-600 dark:text-gray-400 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 hover:text-gray-900 dark:hover:text-gray-200' + : 'text-gray-600 dark:text-gray-400' + }`} + > + + {itemLabel} + + ) + })}
) } @@ -75,11 +101,15 @@ function SidebarContent({ onToggleDark, user, onLogout, + activeView, + onNavigate, }: { darkMode: boolean onToggleDark: () => void user: AuthUser onLogout: () => void + activeView: string + onNavigate: (view: string) => void }) { return (
@@ -90,11 +120,11 @@ function SidebarContent({ {/* Nav */} - {/* Dark mode toggle */} + {/* Bottom section */}

{user.name}

@@ -130,12 +160,12 @@ function SidebarContent({ ) } -export function Sidebar({ darkMode, onToggleDark, isOpen, onClose, user, onLogout }: SidebarProps) { +export function Sidebar({ darkMode, onToggleDark, isOpen, onClose, user, onLogout, activeView, onNavigate }: SidebarProps) { return ( <> {/* Desktop sidebar */} {/* Mobile sidebar */} @@ -154,7 +184,7 @@ export function Sidebar({ darkMode, onToggleDark, isOpen, onClose, user, onLogou > - + )} diff --git a/client/src/types.ts b/client/src/types.ts new file mode 100644 index 0000000..86b05c8 --- /dev/null +++ b/client/src/types.ts @@ -0,0 +1,40 @@ +export type Ingredient = { name: string; quantity: string; unit: string; category: string; checked?: boolean; restricted?: boolean; alternative?: string | null } +export type GroceryList = { id: string; dish: string; createdAt: string; ingredients: Ingredient[] } + +// Detail shape returned by grocery-service POST /api/recipes and GET /api/recipes/{id} +export type ApiRecipe = { + recipeId: string + name: string + createdAt: string + items: { itemId: string; name: string; quantity: number | null; unit: string; category: string }[] +} + +// Summary shape returned by GET /api/recipes (no items — fetched lazily per recipe) +export type ApiRecipeSummary = { recipeId: string; name: string; createdAt: string } + +// Client-side recipe summary used by the Recipes list +export type RecipeSummary = { id: string; dish: string; createdAt: string } + +// Detail shape returned by grocery-service POST /api/grocery-list and GET /api/grocery-list/{id} +export type ApiGroceryList = { + groceryListId: string + name: string + createdAt: string + items: { itemId: string; name: string; quantity: number | null; unit: string; category: string; purchased: boolean }[] +} + +// Summary shape returned by GET /api/grocery-list — item counts instead of items, so the +// collapsed cards can show progress without fetching every item. +export type ApiGroceryListSummary = { + groceryListId: string + name: string + createdAt: string + itemCount: number + purchasedCount: number +} + +// Client-side grocery-list summary used by the Grocery Lists view +export type GroceryListSummary = { id: string; dish: string; createdAt: string; itemCount: number; purchasedCount: number } + +// A grocery item with its server id + purchased state, for the lazily-loaded detail view +export type GroceryItemDetail = { itemId: string; name: string; quantity: string; unit: string; category: string; purchased: boolean } diff --git a/databases/grocery-db/init.sql b/databases/grocery-db/init.sql index 34d3cda..fac3ce2 100644 --- a/databases/grocery-db/init.sql +++ b/databases/grocery-db/init.sql @@ -16,7 +16,8 @@ CREATE TYPE grocery_category AS ENUM ( CREATE TABLE IF NOT EXISTS recipes ( recipe_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL, - user_id UUID NOT NULL + user_id UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS grocery_lists ( @@ -44,7 +45,7 @@ CREATE TABLE IF NOT EXISTS grocery_list_recipes ( CREATE TABLE IF NOT EXISTS grocery_items ( item_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL, - quantity DOUBLE PRECISION NOT NULL, + quantity DOUBLE PRECISION, unit VARCHAR(50) NOT NULL, category grocery_category NOT NULL DEFAULT 'OTHER', is_purchased BOOLEAN NOT NULL DEFAULT FALSE, diff --git a/helm/bytebite/templates/grocery-db-service.yaml b/helm/bytebite/templates/grocery-db-service.yaml new file mode 100644 index 0000000..c3c53f2 --- /dev/null +++ b/helm/bytebite/templates/grocery-db-service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: grocery-db + namespace: {{ .Values.namespace }} +spec: + selector: + app: bytebite-grocery-db + ports: + - port: 5432 + targetPort: 5432 \ No newline at end of file diff --git a/helm/bytebite/templates/grocery-db-statefulset.yaml b/helm/bytebite/templates/grocery-db-statefulset.yaml new file mode 100644 index 0000000..50ac2e8 --- /dev/null +++ b/helm/bytebite/templates/grocery-db-statefulset.yaml @@ -0,0 +1,43 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: bytebite-grocery-db + namespace: {{ .Values.namespace }} +spec: + serviceName: grocery-db + replicas: 1 + selector: + matchLabels: + app: bytebite-grocery-db + template: + metadata: + labels: + app: bytebite-grocery-db + spec: + containers: + - name: grocery-db + image: "{{ .Values.groceryDb.image.repository }}:{{ .Values.groceryDb.image.tag }}" + imagePullPolicy: {{ .Values.groceryDb.image.pullPolicy }} + ports: + - containerPort: 5432 + env: + - name: POSTGRES_DB + value: {{ .Values.groceryDb.name }} + - name: POSTGRES_USER + value: {{ .Values.groceryDb.user }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: db-secret + key: grocery-db-password + volumeMounts: + - name: grocery-db-data + mountPath: /var/lib/postgresql/data + volumeClaimTemplates: + - metadata: + name: grocery-db-data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 1Gi \ No newline at end of file diff --git a/helm/bytebite/templates/grocery-service-deployment.yaml b/helm/bytebite/templates/grocery-service-deployment.yaml index 1e759f2..cbab032 100644 --- a/helm/bytebite/templates/grocery-service-deployment.yaml +++ b/helm/bytebite/templates/grocery-service-deployment.yaml @@ -29,3 +29,12 @@ spec: env: - name: GENAI_BASE_URL value: "http://genai-service:{{ .Values.genai.service.port }}" + - name: SPRING_DATASOURCE_URL + value: "jdbc:postgresql://grocery-db:5432/{{ .Values.groceryDb.name }}" + - name: SPRING_DATASOURCE_USERNAME + value: {{ .Values.groceryDb.user }} + - name: SPRING_DATASOURCE_PASSWORD + valueFrom: + secretKeyRef: + name: db-secret + key: grocery-db-password diff --git a/helm/bytebite/templates/secret.yaml b/helm/bytebite/templates/secret.yaml index 2959ffc..76976eb 100644 --- a/helm/bytebite/templates/secret.yaml +++ b/helm/bytebite/templates/secret.yaml @@ -7,3 +7,13 @@ type: Opaque data: logos-key: {{ .Values.genai.logosKey | b64enc | quote }} openai-api-key: {{ .Values.genai.openaiApiKey | b64enc | quote }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: db-secret + namespace: {{ .Values.namespace }} +type: Opaque +data: + user-db-password: {{ .Values.userDb.password | b64enc | quote }} + grocery-db-password: {{ .Values.groceryDb.password | b64enc | quote }} \ No newline at end of file diff --git a/helm/bytebite/templates/user-db-service.yaml b/helm/bytebite/templates/user-db-service.yaml new file mode 100644 index 0000000..d0b68da --- /dev/null +++ b/helm/bytebite/templates/user-db-service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: user-db + namespace: {{ .Values.namespace }} +spec: + selector: + app: bytebite-user-db + ports: + - port: 5432 + targetPort: 5432 \ No newline at end of file diff --git a/helm/bytebite/templates/user-db-statefulset.yaml b/helm/bytebite/templates/user-db-statefulset.yaml new file mode 100644 index 0000000..e8f21c2 --- /dev/null +++ b/helm/bytebite/templates/user-db-statefulset.yaml @@ -0,0 +1,43 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: bytebite-user-db + namespace: {{ .Values.namespace }} +spec: + serviceName: user-db + replicas: 1 + selector: + matchLabels: + app: bytebite-user-db + template: + metadata: + labels: + app: bytebite-user-db + spec: + containers: + - name: user-db + image: "{{ .Values.userDb.image.repository }}:{{ .Values.userDb.image.tag }}" + imagePullPolicy: {{ .Values.userDb.image.pullPolicy }} + ports: + - containerPort: 5432 + env: + - name: POSTGRES_DB + value: {{ .Values.userDb.name }} + - name: POSTGRES_USER + value: {{ .Values.userDb.user }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: db-secret + key: user-db-password + volumeMounts: + - name: user-db-data + mountPath: /var/lib/postgresql/data + volumeClaimTemplates: + - metadata: + name: user-db-data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 1Gi \ No newline at end of file diff --git a/helm/bytebite/templates/user-service-deployment.yaml b/helm/bytebite/templates/user-service-deployment.yaml index 64e5a56..34561db 100644 --- a/helm/bytebite/templates/user-service-deployment.yaml +++ b/helm/bytebite/templates/user-service-deployment.yaml @@ -26,3 +26,13 @@ spec: memory: "256Mi" ports: - containerPort: {{ .Values.userService.service.targetPort }} + env: + - name: SPRING_DATASOURCE_URL + value: "jdbc:postgresql://user-db:5432/{{ .Values.userDb.name }}" + - name: SPRING_DATASOURCE_USERNAME + value: {{ .Values.userDb.user }} + - name: SPRING_DATASOURCE_PASSWORD + valueFrom: + secretKeyRef: + name: db-secret + key: user-db-password diff --git a/helm/bytebite/values-local.yaml b/helm/bytebite/values-local.yaml new file mode 100644 index 0000000..3beb7b2 --- /dev/null +++ b/helm/bytebite/values-local.yaml @@ -0,0 +1,12 @@ +ingress: + enabled: false + +client: + service: + type: LoadBalancer + +userDb: + password: bytebite_user_password + +groceryDb: + password: bytebite_grocery_password diff --git a/helm/bytebite/values.yaml b/helm/bytebite/values.yaml index 12afb44..7cc5417 100644 --- a/helm/bytebite/values.yaml +++ b/helm/bytebite/values.yaml @@ -58,6 +58,24 @@ genai: logosKey: "" openaiApiKey: "" +userDb: + image: + repository: ghcr.io/aet-devops26/team-bytebite/user-db + tag: latest + pullPolicy: Always + name: bytebite_user + user: bytebite_user + password: "" + +groceryDb: + image: + repository: ghcr.io/aet-devops26/team-bytebite/grocery-db + tag: latest + pullPolicy: Always + name: bytebite_grocery + user: bytebite_grocery + password: "" + ingress: enabled: true className: "nginx" diff --git a/server/api-gateway/src/main/java/com/bytebite/server/JwtAuthenticationFilter.java b/server/api-gateway/src/main/java/com/bytebite/server/JwtAuthenticationFilter.java index e2aeef5..4b5c2e8 100644 --- a/server/api-gateway/src/main/java/com/bytebite/server/JwtAuthenticationFilter.java +++ b/server/api-gateway/src/main/java/com/bytebite/server/JwtAuthenticationFilter.java @@ -47,9 +47,12 @@ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { return exchange.getResponse().setComplete(); } + // Use set() so any client-supplied X-User-* headers are overwritten, never trusted. ServerHttpRequest request = exchange.getRequest().mutate() - .header("X-User-Id", payload.userId()) - .header("X-User-Email", payload.email()) + .headers(headers -> { + headers.set("X-User-Id", payload.userId()); + headers.set("X-User-Email", payload.email()); + }) .build(); return chain.filter(exchange.mutate().request(request).build()); } diff --git a/server/grocery-service/pom.xml b/server/grocery-service/pom.xml index ef8b7f7..3393ced 100644 --- a/server/grocery-service/pom.xml +++ b/server/grocery-service/pom.xml @@ -42,17 +42,17 @@ ${springdoc-openapi.version} - - org.springframework.boot - spring-boot-starter-data-jpa - - org.postgresql postgresql runtime + + org.springframework.boot + spring-boot-starter-data-jpa + + org.springframework.boot spring-boot-starter-test diff --git a/server/grocery-service/src/main/java/com/bytebite/server/GroceryListController.java b/server/grocery-service/src/main/java/com/bytebite/server/GroceryListController.java index de76558..9ff0689 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/GroceryListController.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/GroceryListController.java @@ -1,5 +1,7 @@ package com.bytebite.server; +import com.bytebite.server.dto.GroceryItemPatchRequest; +import com.bytebite.server.dto.GroceryItemResponseDTO; import com.bytebite.server.dto.GroceryListCreateRequest; import com.bytebite.server.dto.GroceryListDetailDTO; import com.bytebite.server.dto.GroceryListSummaryDTO; @@ -11,13 +13,18 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @@ -38,21 +45,22 @@ public GroceryListController(GroceryListService service) { this.service = service; } - @GetMapping(value = "/history", produces = MediaType.APPLICATION_JSON_VALUE) + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) @Operation( - summary = "List recent grocery lists", - description = "Returns a summary of the 20 most recently created grocery lists, newest first.", + summary = "List grocery lists", + description = "Returns a summary of all the caller's grocery lists, newest first.", security = @SecurityRequirement(name = "bearerAuth"), responses = { @ApiResponse(responseCode = "200", description = "Grocery-list summaries"), @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content) } ) - public List getHistory() { - return service.getHistory(); + public List list( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId) { + return service.getAll(userId); } - @GetMapping(value = "/history/{id}", produces = MediaType.APPLICATION_JSON_VALUE) + @GetMapping(value = "/{groceryListId}", produces = MediaType.APPLICATION_JSON_VALUE) @Operation( summary = "Get a grocery list by id", description = "Returns a single grocery list including its items.", @@ -64,12 +72,13 @@ public List getHistory() { } ) public GroceryListDetailDTO getById( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, @Parameter(description = "Identifier of the grocery list", required = true) - @PathVariable UUID id) { - return service.getById(id); + @PathVariable UUID groceryListId) { + return service.getById(groceryListId, userId); } - @PostMapping(value = "/history", + @PostMapping( consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) @Operation( @@ -82,16 +91,67 @@ public GroceryListDetailDTO getById( @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content) } ) - public ResponseEntity create(@RequestBody GroceryListCreateRequest request) { - GroceryListDetailDTO created = service.create(request); + public ResponseEntity create( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, + @RequestBody GroceryListCreateRequest request) { + GroceryListDetailDTO created = service.create(userId, request); URI location = ServletUriComponentsBuilder.fromCurrentRequest() .path("/{id}") - .buildAndExpand(created.id()) + .buildAndExpand(created.groceryListId()) .toUri(); return ResponseEntity.created(location).body(created); } - @DeleteMapping(value = "/history/{id}") + @PutMapping(value = "/{groceryListId}", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Replace a grocery list's name and items", + description = "Renames the grocery list and replaces all of its items.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "Grocery list updated"), + @ApiResponse(responseCode = "400", description = "Invalid request body", content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content), + @ApiResponse(responseCode = "404", description = "Grocery list not found", content = @Content(schema = @Schema(implementation = Map.class))) + } + ) + public GroceryListDetailDTO update( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, + @Parameter(description = "Identifier of the grocery list", required = true) + @PathVariable UUID groceryListId, + @RequestBody GroceryListCreateRequest request) { + return service.update(groceryListId, userId, request); + } + + @PatchMapping(value = "/{groceryListId}/items/{itemId}", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Update a grocery item's purchased flag", + description = "Marks a single item in the list as picked up (or not) without resending the whole list.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "Item updated"), + @ApiResponse(responseCode = "400", description = "Missing purchased flag", content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content), + @ApiResponse(responseCode = "404", description = "Grocery list or item not found", content = @Content(schema = @Schema(implementation = Map.class))) + } + ) + public GroceryItemResponseDTO updateItem( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, + @Parameter(description = "Identifier of the grocery list", required = true) + @PathVariable UUID groceryListId, + @Parameter(description = "Identifier of the item", required = true) + @PathVariable UUID itemId, + @RequestBody GroceryItemPatchRequest request) { + if (request == null || request.purchased() == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "purchased is required"); + } + return service.setItemPurchased(groceryListId, itemId, userId, request.purchased()); + } + + @DeleteMapping(value = "/{groceryListId}") @Operation( summary = "Delete a grocery list", description = "Deletes the grocery list identified by the given id along with its items.", @@ -103,9 +163,10 @@ public ResponseEntity create(@RequestBody GroceryListCreat } ) public ResponseEntity delete( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, @Parameter(description = "Identifier of the grocery list", required = true) - @PathVariable UUID id) { - service.delete(id); + @PathVariable UUID groceryListId) { + service.delete(groceryListId, userId); return ResponseEntity.noContent().build(); } } diff --git a/server/grocery-service/src/main/java/com/bytebite/server/RecipeController.java b/server/grocery-service/src/main/java/com/bytebite/server/RecipeController.java new file mode 100644 index 0000000..3844e12 --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/RecipeController.java @@ -0,0 +1,132 @@ +package com.bytebite.server; + +import com.bytebite.server.dto.RecipeCreateRequest; +import com.bytebite.server.dto.RecipeDetailDTO; +import com.bytebite.server.dto.RecipeSummaryDTO; +import com.bytebite.server.service.RecipeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/recipes") +@Tag(name = "Recipes", description = "Stored recipes and their items") +public class RecipeController { + + private final RecipeService service; + + public RecipeController(RecipeService service) { + this.service = service; + } + + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "List saved recipes", + description = "Returns the caller's recipes as summaries, newest first. Use GET /{recipeId} for items.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "Recipe summaries"), + @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content) + } + ) + public List list( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId) { + return service.getAll(userId); + } + + @GetMapping(value = "/{recipeId}", produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Get a recipe by id", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "Recipe with items"), + @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content), + @ApiResponse(responseCode = "404", description = "Recipe not found", content = @Content(schema = @Schema(implementation = Map.class))) + } + ) + public RecipeDetailDTO getById( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, + @Parameter(description = "Identifier of the recipe", required = true) + @PathVariable UUID recipeId) { + return service.getById(recipeId, userId); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Save a recipe", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "201", description = "Recipe created"), + @ApiResponse(responseCode = "400", description = "Invalid request body", content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content) + } + ) + public ResponseEntity create( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, + @RequestBody RecipeCreateRequest request) { + RecipeDetailDTO created = service.create(userId, request); + URI location = ServletUriComponentsBuilder.fromCurrentRequest() + .path("/{id}") + .buildAndExpand(created.recipeId()) + .toUri(); + return ResponseEntity.created(location).body(created); + } + + @PutMapping(value = "/{recipeId}", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Replace a recipe's name and items", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "Recipe updated"), + @ApiResponse(responseCode = "400", description = "Invalid request body", content = @Content(schema = @Schema(implementation = Map.class))), + @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content), + @ApiResponse(responseCode = "404", description = "Recipe not found", content = @Content(schema = @Schema(implementation = Map.class))) + } + ) + public RecipeDetailDTO update( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, + @Parameter(description = "Identifier of the recipe", required = true) + @PathVariable UUID recipeId, + @RequestBody RecipeCreateRequest request) { + return service.update(recipeId, userId, request); + } + + @DeleteMapping("/{recipeId}") + @Operation( + summary = "Delete a recipe", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "Recipe deleted"), + @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content), + @ApiResponse(responseCode = "404", description = "Recipe not found", content = @Content(schema = @Schema(implementation = Map.class))) + } + ) + public ResponseEntity delete( + @Parameter(hidden = true) @RequestHeader("X-User-Id") UUID userId, + @Parameter(description = "Identifier of the recipe", required = true) + @PathVariable UUID recipeId) { + service.delete(recipeId, userId); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemPatchRequest.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemPatchRequest.java new file mode 100644 index 0000000..4b98030 --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemPatchRequest.java @@ -0,0 +1,4 @@ +package com.bytebite.server.dto; + +/** Partial update for a single grocery item — currently only the purchased flag. */ +public record GroceryItemPatchRequest(Boolean purchased) {} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemRequestDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemRequestDTO.java index a5b20e6..3349b57 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemRequestDTO.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemRequestDTO.java @@ -1,3 +1,3 @@ package com.bytebite.server.dto; -public record GroceryItemRequestDTO(String name, double quantity, String unit, String category, boolean purchased) {} +public record GroceryItemRequestDTO(String name, Double quantity, String unit, String category, boolean purchased) implements ItemRequest {} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemResponseDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemResponseDTO.java index d5c10f4..38ef3b6 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemResponseDTO.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemResponseDTO.java @@ -2,4 +2,4 @@ import java.util.UUID; -public record GroceryItemResponseDTO(UUID id, String name, double quantity, String unit, String category, boolean purchased) {} +public record GroceryItemResponseDTO(UUID itemId, String name, Double quantity, String unit, String category, boolean purchased) {} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListCreateRequest.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListCreateRequest.java index e6b01d6..f86553f 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListCreateRequest.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListCreateRequest.java @@ -1,6 +1,5 @@ package com.bytebite.server.dto; import java.util.List; -import java.util.UUID; -public record GroceryListCreateRequest(String name, UUID userId, List items) {} +public record GroceryListCreateRequest(String name, List items) {} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListDetailDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListDetailDTO.java index a3970c3..e3a3d97 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListDetailDTO.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListDetailDTO.java @@ -4,4 +4,4 @@ import java.util.List; import java.util.UUID; -public record GroceryListDetailDTO(UUID id, String name, Instant createdAt, List items) {} +public record GroceryListDetailDTO(UUID groceryListId, String name, Instant createdAt, List items) {} diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListSummaryDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListSummaryDTO.java index 06fd30b..6fb16fd 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListSummaryDTO.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListSummaryDTO.java @@ -3,4 +3,5 @@ import java.time.Instant; import java.util.UUID; -public record GroceryListSummaryDTO(UUID id, String name, Instant createdAt) {} +public record GroceryListSummaryDTO(UUID groceryListId, String name, Instant createdAt, + long itemCount, long purchasedCount) {} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/ItemRequest.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/ItemRequest.java new file mode 100644 index 0000000..297c1e0 --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/ItemRequest.java @@ -0,0 +1,9 @@ +package com.bytebite.server.dto; + +/** Common fields every item-create payload shares, regardless of its owning resource. */ +public interface ItemRequest { + String name(); + Double quantity(); + String unit(); + String category(); +} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeCreateRequest.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeCreateRequest.java new file mode 100644 index 0000000..77c1404 --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeCreateRequest.java @@ -0,0 +1,5 @@ +package com.bytebite.server.dto; + +import java.util.List; + +public record RecipeCreateRequest(String name, List items) {} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeDetailDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeDetailDTO.java new file mode 100644 index 0000000..f8aec20 --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeDetailDTO.java @@ -0,0 +1,7 @@ +package com.bytebite.server.dto; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record RecipeDetailDTO(UUID recipeId, String name, Instant createdAt, List items) {} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeItemRequestDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeItemRequestDTO.java new file mode 100644 index 0000000..755bf84 --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeItemRequestDTO.java @@ -0,0 +1,3 @@ +package com.bytebite.server.dto; + +public record RecipeItemRequestDTO(String name, Double quantity, String unit, String category) implements ItemRequest {} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeItemResponseDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeItemResponseDTO.java new file mode 100644 index 0000000..cfc0157 --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeItemResponseDTO.java @@ -0,0 +1,5 @@ +package com.bytebite.server.dto; + +import java.util.UUID; + +public record RecipeItemResponseDTO(UUID itemId, String name, Double quantity, String unit, String category) {} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeSummaryDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeSummaryDTO.java new file mode 100644 index 0000000..053ec64 --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeSummaryDTO.java @@ -0,0 +1,6 @@ +package com.bytebite.server.dto; + +import java.time.Instant; +import java.util.UUID; + +public record RecipeSummaryDTO(UUID recipeId, String name, Instant createdAt) {} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryItem.java b/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryItem.java index 20cf3b8..5fcacf4 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryItem.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryItem.java @@ -16,8 +16,9 @@ public class GroceryItem { @Column(nullable = false) private String name; - @Column(nullable = false) - private double quantity; + // Nullable: recipe ingredients may have an unspecified quantity (e.g. "N/A", "to taste"). + @Column + private Double quantity; @Column(nullable = false) private String unit; @@ -30,23 +31,30 @@ public class GroceryItem { @Column(name = "is_purchased", nullable = false) private boolean purchased; + // A grocery item belongs to either a grocery list or a recipe (see chk_grocery_items_owner). @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "grocery_list_id") private GroceryList groceryList; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recipe_id") + private Recipe recipe; + public UUID getId() { return id; } public String getName() { return name; } - public double getQuantity() { return quantity; } + public Double getQuantity() { return quantity; } public String getUnit() { return unit; } public GroceryCategory getCategory() { return category; } public boolean isPurchased() { return purchased; } public GroceryList getGroceryList() { return groceryList; } + public Recipe getRecipe() { return recipe; } public void setId(UUID id) { this.id = id; } public void setName(String name) { this.name = name; } - public void setQuantity(double quantity) { this.quantity = quantity; } + public void setQuantity(Double quantity) { this.quantity = quantity; } public void setUnit(String unit) { this.unit = unit; } public void setCategory(GroceryCategory category) { this.category = category; } public void setPurchased(boolean purchased) { this.purchased = purchased; } public void setGroceryList(GroceryList groceryList) { this.groceryList = groceryList; } + public void setRecipe(Recipe recipe) { this.recipe = recipe; } } diff --git a/server/grocery-service/src/main/java/com/bytebite/server/entity/Recipe.java b/server/grocery-service/src/main/java/com/bytebite/server/entity/Recipe.java new file mode 100644 index 0000000..a5f0cc9 --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/entity/Recipe.java @@ -0,0 +1,40 @@ +package com.bytebite.server.entity; + +import jakarta.persistence.*; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "recipes") +public class Recipe { + + @Id + @Column(name = "recipe_id") + private UUID id; + + @Column(nullable = false) + private String name; + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List items = new ArrayList<>(); + + public UUID getId() { return id; } + public String getName() { return name; } + public UUID getUserId() { return userId; } + public Instant getCreatedAt() { return createdAt; } + public List getItems() { return items; } + + public void setId(UUID id) { this.id = id; } + public void setName(String name) { this.name = name; } + public void setUserId(UUID userId) { this.userId = userId; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + public void setItems(List items) { this.items = items; } +} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/repository/GroceryListRepository.java b/server/grocery-service/src/main/java/com/bytebite/server/repository/GroceryListRepository.java index 3038a99..c6d911e 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/repository/GroceryListRepository.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/repository/GroceryListRepository.java @@ -1,14 +1,36 @@ package com.bytebite.server.repository; +import com.bytebite.server.dto.GroceryListSummaryDTO; import com.bytebite.server.entity.GroceryList; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; import java.util.UUID; @Repository public interface GroceryListRepository extends JpaRepository { - List findTop20ByOrderByCreatedAtDesc(); + List findAllByUserIdOrderByCreatedAtDesc(UUID userId); + + Optional findByIdAndUserId(UUID id, UUID userId); + + /** + * Returns each list as a summary with item totals computed in a single aggregate query, + * so the collapsed cards can show progress without loading every item. + */ + @Query(""" + select new com.bytebite.server.dto.GroceryListSummaryDTO( + gl.id, gl.name, gl.createdAt, + count(i), + coalesce(sum(case when i.purchased = true then 1L else 0L end), 0L)) + from GroceryList gl + left join gl.items i + where gl.userId = :userId + group by gl.id, gl.name, gl.createdAt + order by gl.createdAt desc + """) + List findSummariesByUserId(UUID userId); } diff --git a/server/grocery-service/src/main/java/com/bytebite/server/repository/RecipeRepository.java b/server/grocery-service/src/main/java/com/bytebite/server/repository/RecipeRepository.java new file mode 100644 index 0000000..aea7f03 --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/repository/RecipeRepository.java @@ -0,0 +1,17 @@ +package com.bytebite.server.repository; + +import com.bytebite.server.entity.Recipe; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface RecipeRepository extends JpaRepository { + + List findAllByUserIdOrderByCreatedAtDesc(UUID userId); + + Optional findByIdAndUserId(UUID id, UUID userId); +} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryItemMapper.java b/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryItemMapper.java new file mode 100644 index 0000000..5bf3c9d --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryItemMapper.java @@ -0,0 +1,46 @@ +package com.bytebite.server.service; + +import com.bytebite.server.dto.ItemRequest; +import com.bytebite.server.entity.GroceryCategory; +import com.bytebite.server.entity.GroceryItem; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.util.UUID; + +/** + * Builds {@link GroceryItem} entities from request payloads. Recipes and grocery lists + * share the same item table, so they share the same field handling here; callers set the + * owning association (and {@code purchased}) afterwards. + */ +final class GroceryItemMapper { + + private GroceryItemMapper() { + } + + /** Creates an item with the common fields populated, validating name and normalizing unit/category. */ + static GroceryItem newItem(ItemRequest dto) { + if (dto.name() == null || dto.name().isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Item name is required"); + } + GroceryItem item = new GroceryItem(); + item.setId(UUID.randomUUID()); + item.setName(dto.name()); + item.setQuantity(dto.quantity()); + item.setUnit(dto.unit() == null ? "" : dto.unit()); + item.setCategory(parseCategory(dto.category())); + return item; + } + + /** Maps a free-form category onto a valid grocery_category enum, defaulting to OTHER. */ + static GroceryCategory parseCategory(String value) { + if (value == null || value.isBlank()) { + return GroceryCategory.OTHER; + } + try { + return GroceryCategory.valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException exception) { + return GroceryCategory.OTHER; + } + } +} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryListService.java b/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryListService.java index e33e46a..63de467 100644 --- a/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryListService.java +++ b/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryListService.java @@ -5,7 +5,6 @@ import com.bytebite.server.dto.GroceryListCreateRequest; import com.bytebite.server.dto.GroceryListDetailDTO; import com.bytebite.server.dto.GroceryListSummaryDTO; -import com.bytebite.server.entity.GroceryCategory; import com.bytebite.server.entity.GroceryItem; import com.bytebite.server.entity.GroceryList; import com.bytebite.server.repository.GroceryListRepository; @@ -29,86 +28,96 @@ public GroceryListService(GroceryListRepository repository) { } @Transactional(readOnly = true) - public List getHistory() { - return repository.findTop20ByOrderByCreatedAtDesc().stream() - .map(gl -> new GroceryListSummaryDTO(gl.getId(), gl.getName(), gl.getCreatedAt())) - .toList(); + public List getAll(UUID userId) { + return repository.findSummariesByUserId(userId); } @Transactional(readOnly = true) - public GroceryListDetailDTO getById(UUID id) { - GroceryList groceryList = repository.findById(id) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, - "Grocery list not found: " + id)); - return toDetail(groceryList); + public GroceryListDetailDTO getById(UUID id, UUID userId) { + return toDetail(requireOwned(id, userId)); } @Transactional - public GroceryListDetailDTO create(GroceryListCreateRequest request) { - if (request.name() == null || request.name().isBlank()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Grocery list name is required"); - } - if (request.userId() == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "userId is required"); - } - + public GroceryListDetailDTO create(UUID userId, GroceryListCreateRequest request) { + String name = requireName(request); GroceryList groceryList = new GroceryList(); groceryList.setId(UUID.randomUUID()); - groceryList.setName(request.name()); - groceryList.setUserId(request.userId()); + groceryList.setUserId(userId); + groceryList.setName(name); groceryList.setOutdated(false); groceryList.setCreatedAt(Instant.now()); + groceryList.setItems(buildItems(request.items(), groceryList)); + return toDetail(repository.save(groceryList)); + } - List items = new ArrayList<>(); - if (request.items() != null) { - for (GroceryItemRequestDTO itemDto : request.items()) { - GroceryItem item = new GroceryItem(); - item.setId(UUID.randomUUID()); - item.setName(itemDto.name()); - item.setQuantity(itemDto.quantity()); - item.setUnit(itemDto.unit()); - item.setCategory(parseCategory(itemDto.category())); - item.setPurchased(itemDto.purchased()); - item.setGroceryList(groceryList); - items.add(item); - } - } - groceryList.setItems(items); - + @Transactional + public GroceryListDetailDTO update(UUID id, UUID userId, GroceryListCreateRequest request) { + String name = requireName(request); + GroceryList groceryList = requireOwned(id, userId); + groceryList.setName(name); + groceryList.getItems().clear(); + groceryList.getItems().addAll(buildItems(request.items(), groceryList)); return toDetail(repository.save(groceryList)); } @Transactional - public void delete(UUID id) { - if (!repository.existsById(id)) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Grocery list not found: " + id); + public void delete(UUID id, UUID userId) { + repository.delete(requireOwned(id, userId)); + } + + @Transactional + public GroceryItemResponseDTO setItemPurchased(UUID id, UUID itemId, UUID userId, boolean purchased) { + GroceryList groceryList = requireOwned(id, userId); + GroceryItem item = groceryList.getItems().stream() + .filter(candidate -> candidate.getId().equals(itemId)) + .findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Item not found: " + itemId)); + item.setPurchased(purchased); + return toItemDTO(item); + } + + private List buildItems(List itemDtos, GroceryList groceryList) { + List items = new ArrayList<>(); + if (itemDtos == null) { + return items; + } + for (GroceryItemRequestDTO dto : itemDtos) { + GroceryItem item = GroceryItemMapper.newItem(dto); + item.setPurchased(dto.purchased()); + item.setGroceryList(groceryList); + items.add(item); + } + return items; + } + + private String requireName(GroceryListCreateRequest request) { + if (request == null || request.name() == null || request.name().isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Grocery list name is required"); } - repository.deleteById(id); + return request.name(); + } + + private GroceryList requireOwned(UUID id, UUID userId) { + return repository.findByIdAndUserId(id, userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Grocery list not found: " + id)); } private GroceryListDetailDTO toDetail(GroceryList groceryList) { List items = groceryList.getItems().stream() - .map(item -> new GroceryItemResponseDTO( - item.getId(), - item.getName(), - item.getQuantity(), - item.getUnit(), - item.getCategory().name(), - item.isPurchased() - )) + .map(this::toItemDTO) .toList(); - return new GroceryListDetailDTO(groceryList.getId(), groceryList.getName(), groceryList.getCreatedAt(), items); } - private GroceryCategory parseCategory(String category) { - if (category == null || category.isBlank()) { - return GroceryCategory.OTHER; - } - try { - return GroceryCategory.valueOf(category.trim().toUpperCase()); - } catch (IllegalArgumentException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown grocery category: " + category); - } + private GroceryItemResponseDTO toItemDTO(GroceryItem item) { + return new GroceryItemResponseDTO( + item.getId(), + item.getName(), + item.getQuantity(), + item.getUnit(), + item.getCategory().name(), + item.isPurchased()); } -} +} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/service/RecipeService.java b/server/grocery-service/src/main/java/com/bytebite/server/service/RecipeService.java new file mode 100644 index 0000000..19b6f84 --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/service/RecipeService.java @@ -0,0 +1,106 @@ +package com.bytebite.server.service; + +import com.bytebite.server.dto.RecipeCreateRequest; +import com.bytebite.server.dto.RecipeDetailDTO; +import com.bytebite.server.dto.RecipeItemRequestDTO; +import com.bytebite.server.dto.RecipeItemResponseDTO; +import com.bytebite.server.dto.RecipeSummaryDTO; +import com.bytebite.server.entity.GroceryItem; +import com.bytebite.server.entity.Recipe; +import com.bytebite.server.repository.RecipeRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +public class RecipeService { + + private final RecipeRepository repository; + + public RecipeService(RecipeRepository repository) { + this.repository = repository; + } + + @Transactional(readOnly = true) + public List getAll(UUID userId) { + return repository.findAllByUserIdOrderByCreatedAtDesc(userId).stream() + .map(recipe -> new RecipeSummaryDTO(recipe.getId(), recipe.getName(), recipe.getCreatedAt())) + .toList(); + } + + @Transactional(readOnly = true) + public RecipeDetailDTO getById(UUID id, UUID userId) { + return toDetail(requireOwned(id, userId)); + } + + @Transactional + public RecipeDetailDTO create(UUID userId, RecipeCreateRequest request) { + String name = requireName(request); + Recipe recipe = new Recipe(); + recipe.setId(UUID.randomUUID()); + recipe.setUserId(userId); + recipe.setName(name); + recipe.setCreatedAt(Instant.now()); + recipe.setItems(buildItems(request.items(), recipe)); + return toDetail(repository.save(recipe)); + } + + @Transactional + public RecipeDetailDTO update(UUID id, UUID userId, RecipeCreateRequest request) { + String name = requireName(request); + Recipe recipe = requireOwned(id, userId); + recipe.setName(name); + recipe.getItems().clear(); + recipe.getItems().addAll(buildItems(request.items(), recipe)); + return toDetail(repository.save(recipe)); + } + + @Transactional + public void delete(UUID id, UUID userId) { + repository.delete(requireOwned(id, userId)); + } + + private List buildItems(List itemDtos, Recipe recipe) { + List items = new ArrayList<>(); + if (itemDtos == null) { + return items; + } + for (RecipeItemRequestDTO dto : itemDtos) { + GroceryItem item = GroceryItemMapper.newItem(dto); + item.setPurchased(false); + item.setRecipe(recipe); + items.add(item); + } + return items; + } + + private String requireName(RecipeCreateRequest request) { + if (request == null || request.name() == null || request.name().isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Recipe name is required"); + } + return request.name(); + } + + private Recipe requireOwned(UUID id, UUID userId) { + return repository.findByIdAndUserId(id, userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Recipe not found: " + id)); + } + + private RecipeDetailDTO toDetail(Recipe recipe) { + List items = recipe.getItems().stream() + .map(item -> new RecipeItemResponseDTO( + item.getId(), + item.getName(), + item.getQuantity(), + item.getUnit(), + item.getCategory().name())) + .toList(); + return new RecipeDetailDTO(recipe.getId(), recipe.getName(), recipe.getCreatedAt(), items); + } +} \ No newline at end of file diff --git a/server/grocery-service/src/test/java/com/bytebite/server/ServerApplicationTests.java b/server/grocery-service/src/test/java/com/bytebite/server/ServerApplicationTests.java index 2d6d85b..62c77ea 100644 --- a/server/grocery-service/src/test/java/com/bytebite/server/ServerApplicationTests.java +++ b/server/grocery-service/src/test/java/com/bytebite/server/ServerApplicationTests.java @@ -2,12 +2,17 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; @SpringBootTest +// JPA/Hibernate would otherwise open a JDBC connection at startup to read DB metadata, +// which fails in CI where no database is available. The dialect is configured explicitly +// in application.properties, so Hibernate can bootstrap without probing the database. +@TestPropertySource(properties = "spring.jpa.properties.hibernate.boot.allow_jdbc_metadata_access=false") class ServerApplicationTests { @Test void contextLoads() { } -} +} \ No newline at end of file