From b840ccc9f5060c96ed1e50e43b82d0233fd478a3 Mon Sep 17 00:00:00 2001 From: timn Date: Fri, 5 Jun 2026 16:23:57 +0200 Subject: [PATCH 1/7] local kubernetes instructions --- README.md | 9 ++++++++- helm/bytebite/values-local.yaml | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 helm/bytebite/values-local.yaml diff --git a/README.md b/README.md index 6de601b..0663139 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,14 @@ 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 +``` #### Kubernetes Deployment to the AET cluster diff --git a/helm/bytebite/values-local.yaml b/helm/bytebite/values-local.yaml new file mode 100644 index 0000000..feae224 --- /dev/null +++ b/helm/bytebite/values-local.yaml @@ -0,0 +1,6 @@ +ingress: + enabled: false + +client: + service: + type: LoadBalancer From a9e4a634de0140564894efc59c4ab515b26249eb Mon Sep 17 00:00:00 2001 From: timn Date: Fri, 5 Jun 2026 16:44:06 +0200 Subject: [PATCH 2/7] add deployment link --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 0663139..ec08050 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ Requires Docker Desktop running. ```powershell $env:OPENAI_API_KEY="sk-..." docker compose up --build +docker compose down # To take down later ``` Open http://localhost:8081 @@ -135,8 +136,10 @@ Requires a local Kubernetes cluster running via Docker Desktop. 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 From 57e9279f25571400ce473d0ccb7208e8a177970f Mon Sep 17 00:00:00 2001 From: timn Date: Fri, 12 Jun 2026 14:52:58 +0200 Subject: [PATCH 3/7] fix kubernetes db deployment --- gen-ai/.env.example => .env.example | 0 .github/workflows/deploy-k8s.yml | 2 + README.md | 8 ++-- .../templates/grocery-db-service.yaml | 11 +++++ .../templates/grocery-db-statefulset.yaml | 43 +++++++++++++++++++ .../templates/grocery-service-deployment.yaml | 9 ++++ helm/bytebite/templates/secret.yaml | 10 +++++ helm/bytebite/templates/user-db-service.yaml | 11 +++++ .../templates/user-db-statefulset.yaml | 43 +++++++++++++++++++ .../templates/user-service-deployment.yaml | 10 +++++ helm/bytebite/values-local.yaml | 6 +++ helm/bytebite/values.yaml | 18 ++++++++ 12 files changed, 166 insertions(+), 5 deletions(-) rename gen-ai/.env.example => .env.example (100%) create mode 100644 helm/bytebite/templates/grocery-db-service.yaml create mode 100644 helm/bytebite/templates/grocery-db-statefulset.yaml create mode 100644 helm/bytebite/templates/user-db-service.yaml create mode 100644 helm/bytebite/templates/user-db-statefulset.yaml 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 78719fa..5deb800 100644 --- a/.github/workflows/deploy-k8s.yml +++ b/.github/workflows/deploy-k8s.yml @@ -62,4 +62,6 @@ jobs: --set groceryService.image.tag=${{ steps.vars.outputs.image_tag }} \ --set genai.image.tag=${{ steps.vars.outputs.image_tag }} \ --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 ec08050..5f26693 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,6 @@ Open http://localhost:5173 Requires Docker Desktop running. ```powershell -$env:OPENAI_API_KEY="sk-..." docker compose up --build docker compose down # To take down later ``` @@ -155,10 +154,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.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/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 d7dd22f..8248db1 100644 --- a/helm/bytebite/templates/secret.yaml +++ b/helm/bytebite/templates/secret.yaml @@ -6,3 +6,13 @@ metadata: type: Opaque data: 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 index feae224..3beb7b2 100644 --- a/helm/bytebite/values-local.yaml +++ b/helm/bytebite/values-local.yaml @@ -4,3 +4,9 @@ ingress: 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 977973b..5011979 100644 --- a/helm/bytebite/values.yaml +++ b/helm/bytebite/values.yaml @@ -57,6 +57,24 @@ genai: replicaCount: 1 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" From b69e772ea7cfdab4a995f7167a79c21b60f97905 Mon Sep 17 00:00:00 2001 From: timn Date: Fri, 12 Jun 2026 16:10:59 +0200 Subject: [PATCH 4/7] grocery and recipe list ui --- client/src/App.tsx | 120 +++++++++++- client/src/components/GroceryListView.tsx | 225 ++++++++++++++++++++++ client/src/components/RecipeCard.tsx | 12 +- client/src/components/RecipeListView.tsx | 182 +++++++++++++++++ client/src/components/Sidebar.tsx | 86 ++++++--- client/src/types.ts | 2 + 6 files changed, 589 insertions(+), 38 deletions(-) create mode 100644 client/src/components/GroceryListView.tsx create mode 100644 client/src/components/RecipeListView.tsx create mode 100644 client/src/types.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index d397812..0676cad 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -6,6 +6,11 @@ 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 } from './types' + +type View = 'home' | 'grocery-lists' | 'recipes' function getInitialDark(): boolean { const stored = localStorage.getItem('bytebite-dark') @@ -13,14 +18,27 @@ function getInitialDark(): boolean { return window.matchMedia('(prefers-color-scheme: dark)').matches } +function loadLists(userId: string): GroceryList[] { + const stored = localStorage.getItem(`bytebite-lists-${userId}`) + return stored ? JSON.parse(stored) as GroceryList[] : [] +} + +function saveLists(userId: string, lists: GroceryList[]) { + localStorage.setItem(`bytebite-lists-${userId}`, JSON.stringify(lists)) +} + 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 [savedLists, setSavedLists] = useState(() => + user ? loadLists(user.userId) : [] + ) useEffect(() => { document.documentElement.classList.toggle('dark', darkMode) @@ -39,6 +57,7 @@ function App() { .then(payload => { setToken(payload.token) setUser(payload.user) + setSavedLists(loadLists(payload.user.userId)) localStorage.setItem('bytebite-token', payload.token) localStorage.setItem('bytebite-user', JSON.stringify(payload.user)) }) @@ -53,17 +72,63 @@ function App() { 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) + setSavedLists(loadLists(payload.user.userId)) localStorage.setItem('bytebite-token', payload.token) localStorage.setItem('bytebite-user', JSON.stringify(payload.user)) } + const handleLogout = () => { localStorage.removeItem('bytebite-token') localStorage.removeItem('bytebite-user') setToken('') setUser(null) + setSavedLists([]) + setView('home') + } + + const handleListGenerated = (list: GroceryList) => { + if (!user) return + setSavedLists(prev => { + const next = [list, ...prev] + saveLists(user.userId, next) + return next + }) + } + + const handleToggleItem = (listId: string, itemIndex: number) => { + if (!user) return + setSavedLists(prev => { + const next = prev.map(list => + list.id === listId + ? { + ...list, + ingredients: list.ingredients.map((item, i) => + i === itemIndex ? { ...item, checked: !item.checked } : item + ), + } + : list + ) + saveLists(user.userId, next) + return next + }) + } + + const handleDeleteList = (listId: string) => { + if (!user) return + setSavedLists(prev => { + const next = prev.filter(list => list.id !== listId) + saveLists(user.userId, next) + return next + }) } if (!token || !user) { @@ -79,6 +144,8 @@ function App() { onClose={closeSidebar} user={user} onLogout={handleLogout} + activeView={view} + onNavigate={navigate} /> {/* Mobile backdrop */} @@ -96,7 +163,7 @@ function App() { {/* Main content */} -
+
{/* Mobile top bar */}
diff --git a/client/src/components/GroceryListView.tsx b/client/src/components/GroceryListView.tsx new file mode 100644 index 0000000..7e72fe0 --- /dev/null +++ b/client/src/components/GroceryListView.tsx @@ -0,0 +1,225 @@ +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { + ShoppingCart, ChevronDown, ShoppingBag, + Check, Copy, Trash2, CircleCheckBig, X, +} from 'lucide-react' +import type { GroceryList, Ingredient } from '../types' + +interface GroceryListViewProps { + lists: GroceryList[] + onToggleItem: (listId: string, itemIndex: number) => void + onDeleteList: (listId: string) => void +} + +function itemLabel(item: Ingredient) { + return item.quantity !== 'N/A' ? `${item.quantity} ${item.unit} ${item.name}` : item.name +} + +function listToText(list: GroceryList) { + const lines = [list.dish, ...list.ingredients.map(item => `- ${itemLabel(item)}`)] + return lines.join('\n') +} + +export function GroceryListView({ lists, onToggleItem, onDeleteList }: GroceryListViewProps) { + const [openId, setOpenId] = useState(lists[0]?.id ?? null) + const [copyState, setCopyState] = useState<{ id: string; ok: boolean } | null>(null) + + const showCopyResult = (id: string, ok: boolean) => { + setCopyState({ id, ok }) + setTimeout(() => setCopyState(s => (s?.id === id ? null : s)), 1800) + } + + const handleCopy = async (list: GroceryList) => { + try { + if (!navigator.clipboard) throw new Error('Clipboard unavailable') + await navigator.clipboard.writeText(listToText(list)) + showCopyResult(list.id, true) + } catch { + showCopyResult(list.id, false) + } + } + + 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 total = list.ingredients.length + const checkedCount = list.ingredients.filter(item => item.checked).length + const progress = total ? checkedCount / total : 0 + const complete = total > 0 && checkedCount === total + + const grouped = list.ingredients.reduce>( + (acc, item, index) => { + const key = item.category || 'Other' + ;(acc[key] ??= []).push({ item, index }) + return acc + }, + {} + ) + + return ( + + {/* Header row */} +
+ + +
+ + + +
+
+ + {/* Progress bar */} +
+
+ +
+
+ + + {isOpen && ( + +
+
+ {Object.entries(grouped).map(([category, entries]) => ( +
+

+ {category} +

+
+ {entries.map(({ item, index }) => { + const checked = !!item.checked + return ( + + ) + })} +
+
+ ))} +
+
+
+ )} +
+
+ ) + })} +
+
+ ) +} \ No newline at end of file diff --git a/client/src/components/RecipeCard.tsx b/client/src/components/RecipeCard.tsx index 9361e41..1917eb2 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 } 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 } - const CHIPS = [ { emoji: '🍝', label: 'Spaghetti Carbonara' }, { emoji: '🍛', label: 'Chicken Curry' }, @@ -24,9 +23,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') @@ -73,6 +73,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..840aaf5 --- /dev/null +++ b/client/src/components/RecipeListView.tsx @@ -0,0 +1,182 @@ +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { + ChefHat, ChevronDown, BookOpen, + Check, Copy, Trash2, X, +} from 'lucide-react' +import type { GroceryList, Ingredient } from '../types' + +interface RecipeListViewProps { + lists: GroceryList[] + onDeleteList: (listId: string) => void +} + +function itemLabel(item: Ingredient) { + return item.quantity !== 'N/A' ? `${item.quantity} ${item.unit} ${item.name}` : item.name +} + +function listToText(list: GroceryList) { + const lines = [list.dish, ...list.ingredients.map(item => `- ${itemLabel(item)}`)] + return lines.join('\n') +} + +export function RecipeListView({ lists, onDeleteList }: RecipeListViewProps) { + const [openId, setOpenId] = useState(lists[0]?.id ?? null) + const [copyState, setCopyState] = useState<{ id: string; ok: boolean } | null>(null) + + const showCopyResult = (id: string, ok: boolean) => { + setCopyState({ id, ok }) + setTimeout(() => setCopyState(s => (s?.id === id ? null : s)), 1800) + } + + const handleCopy = async (list: GroceryList) => { + try { + if (!navigator.clipboard) throw new Error('Clipboard unavailable') + await navigator.clipboard.writeText(listToText(list)) + showCopyResult(list.id, true) + } catch { + showCopyResult(list.id, false) + } + } + + if (lists.length === 0) { + return ( + +
+ +
+

No recipes yet

+

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

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

Recipes

+ + {lists.length} + +
+ +
+ {lists.map((list, idx) => { + const isOpen = openId === list.id + + const grouped = list.ingredients.reduce>((acc, item) => { + const key = item.category || 'Other' + ;(acc[key] ??= []).push(item) + return acc + }, {}) + + return ( + + {/* Header row */} +
+ + +
+ + + +
+
+ + + {isOpen && ( + +
+
+ {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..6749e72 --- /dev/null +++ b/client/src/types.ts @@ -0,0 +1,2 @@ +export type Ingredient = { name: string; quantity: string; unit: string; category: string; checked?: boolean } +export type GroceryList = { id: string; dish: string; createdAt: string; ingredients: Ingredient[] } From 2ce981cec6c2325e0856fbfcd780b19b84b9a950 Mon Sep 17 00:00:00 2001 From: timn Date: Fri, 12 Jun 2026 17:09:43 +0200 Subject: [PATCH 5/7] add recipe crud endpoints --- client/src/components/GroceryListView.tsx | 2 +- client/src/components/RecipeCard.tsx | 2 - client/src/components/RecipeListView.tsx | 2 +- client/src/types.ts | 2 +- compose.yaml | 2 + databases/grocery-db/init.sql | 7 +- server/grocery-service/pom.xml | 6 + .../com/bytebite/server/auth/JwtAuth.java | 114 +++++++++ .../com/bytebite/server/recipe/Recipe.java | 13 + .../server/recipe/RecipeController.java | 97 +++++++ .../bytebite/server/recipe/RecipeItem.java | 12 + .../server/recipe/RecipeRepository.java | 239 ++++++++++++++++++ .../bytebite/server/recipe/RecipeRequest.java | 9 + .../src/main/resources/application.properties | 4 + 14 files changed, 503 insertions(+), 8 deletions(-) create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/auth/JwtAuth.java create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/recipe/Recipe.java create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeController.java create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeItem.java create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeRepository.java create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeRequest.java diff --git a/client/src/components/GroceryListView.tsx b/client/src/components/GroceryListView.tsx index 7e72fe0..70ac5f5 100644 --- a/client/src/components/GroceryListView.tsx +++ b/client/src/components/GroceryListView.tsx @@ -22,7 +22,7 @@ function listToText(list: GroceryList) { } export function GroceryListView({ lists, onToggleItem, onDeleteList }: GroceryListViewProps) { - const [openId, setOpenId] = useState(lists[0]?.id ?? null) + const [openId, setOpenId] = useState(null) const [copyState, setCopyState] = useState<{ id: string; ok: boolean } | null>(null) const showCopyResult = (id: string, ok: boolean) => { diff --git a/client/src/components/RecipeCard.tsx b/client/src/components/RecipeCard.tsx index 4a05807..43e5ff2 100644 --- a/client/src/components/RecipeCard.tsx +++ b/client/src/components/RecipeCard.tsx @@ -6,8 +6,6 @@ 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' diff --git a/client/src/components/RecipeListView.tsx b/client/src/components/RecipeListView.tsx index 840aaf5..166bde0 100644 --- a/client/src/components/RecipeListView.tsx +++ b/client/src/components/RecipeListView.tsx @@ -21,7 +21,7 @@ function listToText(list: GroceryList) { } export function RecipeListView({ lists, onDeleteList }: RecipeListViewProps) { - const [openId, setOpenId] = useState(lists[0]?.id ?? null) + const [openId, setOpenId] = useState(null) const [copyState, setCopyState] = useState<{ id: string; ok: boolean } | null>(null) const showCopyResult = (id: string, ok: boolean) => { diff --git a/client/src/types.ts b/client/src/types.ts index 6749e72..7954953 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -1,2 +1,2 @@ -export type Ingredient = { name: string; quantity: string; unit: string; category: string; checked?: boolean } +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[] } diff --git a/compose.yaml b/compose.yaml index 9bc43ce..2b24c55 100644 --- a/compose.yaml +++ b/compose.yaml @@ -80,6 +80,8 @@ services: # matching `expose` and the api-gateway's GROCERY_SERVICE_BASE_URL. SERVER_PORT: "8080" GENAI_BASE_URL: http://gen-ai:8000 + # Shared with user-service so grocery-service can validate the same JWTs. + JWT_SECRET: ${JWT_SECRET:-bytebite-local-dev-secret-change-me} SPRING_DATASOURCE_URL: jdbc:postgresql://grocery-db:5432/${GROCERY_DB_NAME:-bytebite_grocery} SPRING_DATASOURCE_USERNAME: ${GROCERY_DB_USER:-bytebite_grocery} SPRING_DATASOURCE_PASSWORD: ${GROCERY_DB_PASSWORD:-bytebite_grocery_password} diff --git a/databases/grocery-db/init.sql b/databases/grocery-db/init.sql index 9316198..5f44cfa 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 ( @@ -43,9 +44,9 @@ 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 VARCHAR(50) NOT NULL, unit VARCHAR(50) NOT NULL, - category grocery_category NOT NULL DEFAULT 'OTHER', + category VARCHAR(50) NOT NULL DEFAULT 'OTHER', is_purchased BOOLEAN NOT NULL DEFAULT FALSE, recipe_id UUID, grocery_list_id UUID, diff --git a/server/grocery-service/pom.xml b/server/grocery-service/pom.xml index eb1b6bf..bb59173 100644 --- a/server/grocery-service/pom.xml +++ b/server/grocery-service/pom.xml @@ -42,6 +42,12 @@ ${springdoc-openapi.version} + + org.postgresql + postgresql + runtime + + org.springframework.boot spring-boot-starter-test diff --git a/server/grocery-service/src/main/java/com/bytebite/server/auth/JwtAuth.java b/server/grocery-service/src/main/java/com/bytebite/server/auth/JwtAuth.java new file mode 100644 index 0000000..795a780 --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/auth/JwtAuth.java @@ -0,0 +1,114 @@ +package com.bytebite.server.auth; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Verify-only counterpart to user-service's JwtTokenService. Validates the + * HS256 signature and expiry of a bearer token signed with the shared secret, + * and extracts the authenticated user id. grocery-service never issues tokens. + */ +@Component +public class JwtAuth { + private static final Base64.Decoder BASE64_URL_DECODER = Base64.getUrlDecoder(); + private static final Base64.Encoder BASE64_URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); + + private final byte[] secret; + + public JwtAuth(@Value("${auth.jwt.secret}") String secret) { + if (secret == null || secret.length() < 32) { + throw new IllegalStateException("JWT secret must be at least 32 characters."); + } + this.secret = secret.getBytes(StandardCharsets.UTF_8); + } + + /** Validates the {@code Authorization} header and returns the caller's user id. */ + public UUID requireUserId(String authorizationHeader) { + if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { + throw unauthorized(); + } + String token = authorizationHeader.substring("Bearer ".length()).trim(); + String[] parts = token.split("\\."); + if (parts.length != 3) { + throw unauthorized(); + } + + String unsigned = parts[0] + "." + parts[1]; + if (!constantTimeEquals(sign(unsigned), parts[2])) { + throw unauthorized(); + } + + Map payload = parseFlatJson( + new String(BASE64_URL_DECODER.decode(parts[1]), StandardCharsets.UTF_8)); + long expiresAt = Long.parseLong(payload.getOrDefault("exp", "0")); + if (expiresAt <= Instant.now().getEpochSecond()) { + throw unauthorized(); + } + + try { + return UUID.fromString(payload.get("sub")); + } catch (IllegalArgumentException | NullPointerException exception) { + throw unauthorized(); + } + } + + private String sign(String value) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret, "HmacSHA256")); + return BASE64_URL_ENCODER.encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { + throw new IllegalStateException("Could not verify JWT.", exception); + } + } + + private boolean constantTimeEquals(String expected, String actual) { + byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8); + byte[] actualBytes = actual.getBytes(StandardCharsets.UTF_8); + if (expectedBytes.length != actualBytes.length) { + return false; + } + int result = 0; + for (int i = 0; i < expectedBytes.length; i++) { + result |= expectedBytes[i] ^ actualBytes[i]; + } + return result == 0; + } + + private Map parseFlatJson(String json) { + Map values = new LinkedHashMap<>(); + String body = json.substring(1, json.length() - 1); + for (String pair : body.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)")) { + String[] parts = pair.split(":", 2); + if (parts.length == 2) { + values.put(unquote(parts[0]), unquote(parts[1])); + } + } + return values; + } + + private String unquote(String value) { + String trimmed = value.trim(); + if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + return trimmed.substring(1, trimmed.length() - 1) + .replace("\\\"", "\"") + .replace("\\\\", "\\"); + } + return trimmed; + } + + private ResponseStatusException unauthorized() { + return new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication is required."); + } +} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/recipe/Recipe.java b/server/grocery-service/src/main/java/com/bytebite/server/recipe/Recipe.java new file mode 100644 index 0000000..6e97e00 --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/recipe/Recipe.java @@ -0,0 +1,13 @@ +package com.bytebite.server.recipe; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +/** A saved recipe with its grocery items, owned by a user. */ +public record Recipe( + UUID recipeId, + String name, + OffsetDateTime createdAt, + List items +) {} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeController.java b/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeController.java new file mode 100644 index 0000000..947f7d1 --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeController.java @@ -0,0 +1,97 @@ +package com.bytebite.server.recipe; + +import com.bytebite.server.auth.JwtAuth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +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.server.ResponseStatusException; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/recipes") +@Tag(name = "Recipes", description = "Persisted recipes and their grocery items") +public class RecipeController { + + private final RecipeRepository repository; + private final JwtAuth jwtAuth; + + public RecipeController(RecipeRepository repository, JwtAuth jwtAuth) { + this.repository = repository; + this.jwtAuth = jwtAuth; + } + + @GetMapping + @Operation(summary = "List the authenticated user's saved recipes", + security = @SecurityRequirement(name = "bearerAuth")) + public List list(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization) { + UUID userId = jwtAuth.requireUserId(authorization); + return repository.findAllByUser(userId); + } + + @GetMapping("/{recipeId}") + @Operation(summary = "Get a single saved recipe", + security = @SecurityRequirement(name = "bearerAuth")) + public Recipe get(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, + @PathVariable UUID recipeId) { + UUID userId = jwtAuth.requireUserId(authorization); + return repository.findByIdForUser(recipeId, userId).orElseThrow(this::notFound); + } + + @PostMapping + @Operation(summary = "Save a new recipe", + security = @SecurityRequirement(name = "bearerAuth")) + public ResponseEntity create(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, + @RequestBody RecipeRequest request) { + UUID userId = jwtAuth.requireUserId(authorization); + Recipe recipe = repository.create(userId, requireName(request), request.items()); + return ResponseEntity.status(HttpStatus.CREATED).body(recipe); + } + + @PutMapping("/{recipeId}") + @Operation(summary = "Replace a saved recipe's name and items", + security = @SecurityRequirement(name = "bearerAuth")) + public Recipe update(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, + @PathVariable UUID recipeId, + @RequestBody RecipeRequest request) { + UUID userId = jwtAuth.requireUserId(authorization); + return repository.replace(recipeId, userId, requireName(request), request.items()) + .orElseThrow(this::notFound); + } + + @DeleteMapping("/{recipeId}") + @Operation(summary = "Delete a saved recipe", + security = @SecurityRequirement(name = "bearerAuth")) + public ResponseEntity delete(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, + @PathVariable UUID recipeId) { + UUID userId = jwtAuth.requireUserId(authorization); + if (!repository.deleteForUser(recipeId, userId)) { + throw notFound(); + } + return ResponseEntity.noContent().build(); + } + + private String requireName(RecipeRequest request) { + if (request == null || request.name() == null || request.name().isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Recipe name is required."); + } + return request.name(); + } + + private ResponseStatusException notFound() { + return new ResponseStatusException(HttpStatus.NOT_FOUND, "Recipe not found."); + } +} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeItem.java b/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeItem.java new file mode 100644 index 0000000..6b46fb0 --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeItem.java @@ -0,0 +1,12 @@ +package com.bytebite.server.recipe; + +import java.util.UUID; + +/** A single ingredient belonging to a recipe. */ +public record RecipeItem( + UUID itemId, + String name, + String quantity, + String unit, + String category +) {} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeRepository.java b/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeRepository.java new file mode 100644 index 0000000..34879cd --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeRepository.java @@ -0,0 +1,239 @@ +package com.bytebite.server.recipe; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Repository; +import org.springframework.web.server.ResponseStatusException; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +@Repository +public class RecipeRepository { + private final String datasourceUrl; + private final String datasourceUsername; + private final String datasourcePassword; + + public RecipeRepository( + @Value("${spring.datasource.url}") String datasourceUrl, + @Value("${spring.datasource.username}") String datasourceUsername, + @Value("${spring.datasource.password}") String datasourcePassword + ) { + this.datasourceUrl = datasourceUrl; + this.datasourceUsername = datasourceUsername; + this.datasourcePassword = datasourcePassword; + } + + /** Inserts a recipe and its items atomically, returning the persisted recipe. */ + public Recipe create(UUID userId, String name, List items) { + String insertRecipe = """ + INSERT INTO recipes (name, user_id) + VALUES (?, ?) + RETURNING recipe_id, created_at + """; + try (Connection connection = connect()) { + connection.setAutoCommit(false); + try { + UUID recipeId; + OffsetDateTime createdAt; + try (PreparedStatement statement = connection.prepareStatement(insertRecipe)) { + statement.setString(1, name); + statement.setObject(2, userId); + try (ResultSet resultSet = statement.executeQuery()) { + resultSet.next(); + recipeId = resultSet.getObject("recipe_id", UUID.class); + createdAt = resultSet.getObject("created_at", OffsetDateTime.class); + } + } + List persistedItems = insertItems(connection, recipeId, items); + connection.commit(); + return new Recipe(recipeId, name, createdAt, persistedItems); + } catch (SQLException exception) { + connection.rollback(); + throw exception; + } + } catch (SQLException exception) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not create recipe."); + } + } + + public List findAllByUser(UUID userId) { + return query(BASE_SELECT + " WHERE r.user_id = ? ORDER BY r.created_at DESC, i.name ASC", userId, null); + } + + public Optional findByIdForUser(UUID recipeId, UUID userId) { + List recipes = query( + BASE_SELECT + " WHERE r.user_id = ? AND r.recipe_id = ? ORDER BY i.name ASC", userId, recipeId); + return recipes.isEmpty() ? Optional.empty() : Optional.of(recipes.get(0)); + } + + /** Renames a recipe and replaces all of its items. Empty if the recipe is not owned by the user. */ + public Optional replace(UUID recipeId, UUID userId, String name, List items) { + try (Connection connection = connect()) { + connection.setAutoCommit(false); + try { + OffsetDateTime createdAt; + try (PreparedStatement statement = connection.prepareStatement( + "UPDATE recipes SET name = ? WHERE recipe_id = ? AND user_id = ? RETURNING created_at")) { + statement.setString(1, name); + statement.setObject(2, recipeId); + statement.setObject(3, userId); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + connection.rollback(); + return Optional.empty(); + } + createdAt = resultSet.getObject("created_at", OffsetDateTime.class); + } + } + try (PreparedStatement delete = connection.prepareStatement( + "DELETE FROM grocery_items WHERE recipe_id = ?")) { + delete.setObject(1, recipeId); + delete.executeUpdate(); + } + List persistedItems = insertItems(connection, recipeId, items); + connection.commit(); + return Optional.of(new Recipe(recipeId, name, createdAt, persistedItems)); + } catch (SQLException exception) { + connection.rollback(); + throw exception; + } + } catch (SQLException exception) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not update recipe."); + } + } + + /** Deletes a recipe (items cascade). Returns false if nothing was owned/deleted. */ + public boolean deleteForUser(UUID recipeId, UUID userId) { + try (Connection connection = connect(); + PreparedStatement statement = connection.prepareStatement( + "DELETE FROM recipes WHERE recipe_id = ? AND user_id = ?")) { + statement.setObject(1, recipeId); + statement.setObject(2, userId); + return statement.executeUpdate() > 0; + } catch (SQLException exception) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not delete recipe."); + } + } + + private List insertItems(Connection connection, UUID recipeId, List items) + throws SQLException { + List persisted = new ArrayList<>(); + if (items == null || items.isEmpty()) { + return persisted; + } + String sql = """ + INSERT INTO grocery_items (name, quantity, unit, category, recipe_id) + VALUES (?, ?, ?, ?, ?) + RETURNING item_id + """; + for (RecipeRequest.Item item : items) { + String itemName = require(item.name(), "Item name is required."); + String quantity = blankToDefault(item.quantity(), "N/A"); + String unit = blankToDefault(item.unit(), ""); + String category = blankToDefault(item.category(), "OTHER"); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, itemName); + statement.setString(2, quantity); + statement.setString(3, unit); + statement.setString(4, category); + statement.setObject(5, recipeId); + try (ResultSet resultSet = statement.executeQuery()) { + resultSet.next(); + persisted.add(new RecipeItem( + resultSet.getObject("item_id", UUID.class), + itemName, quantity, unit, category)); + } + } + } + return persisted; + } + + private static final String BASE_SELECT = """ + SELECT r.recipe_id, r.name AS recipe_name, r.created_at, + i.item_id, i.name AS item_name, i.quantity, i.unit, i.category + FROM recipes r + LEFT JOIN grocery_items i ON i.recipe_id = r.recipe_id + """; + + private List query(String sql, UUID userId, UUID recipeId) { + try (Connection connection = connect(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setObject(1, userId); + if (recipeId != null) { + statement.setObject(2, recipeId); + } + try (ResultSet resultSet = statement.executeQuery()) { + Map> itemsByRecipe = new LinkedHashMap<>(); + Map shells = new LinkedHashMap<>(); + while (resultSet.next()) { + UUID id = resultSet.getObject("recipe_id", UUID.class); + shells.computeIfAbsent(id, key -> new Recipe( + id, + sqlString(resultSet, "recipe_name"), + sqlOffsetDateTime(resultSet), + null)); + List items = itemsByRecipe.computeIfAbsent(id, key -> new ArrayList<>()); + UUID itemId = resultSet.getObject("item_id", UUID.class); + if (itemId != null) { + items.add(new RecipeItem( + itemId, + resultSet.getString("item_name"), + resultSet.getString("quantity"), + resultSet.getString("unit"), + resultSet.getString("category"))); + } + } + List recipes = new ArrayList<>(); + for (Recipe shell : shells.values()) { + recipes.add(new Recipe(shell.recipeId(), shell.name(), shell.createdAt(), + itemsByRecipe.get(shell.recipeId()))); + } + return recipes; + } + } catch (SQLException exception) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not read recipes."); + } + } + + private static String sqlString(ResultSet resultSet, String column) { + try { + return resultSet.getString(column); + } catch (SQLException exception) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not read recipes."); + } + } + + private static OffsetDateTime sqlOffsetDateTime(ResultSet resultSet) { + try { + return resultSet.getObject("created_at", OffsetDateTime.class); + } catch (SQLException exception) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not read recipes."); + } + } + + private static String require(String value, String message) { + if (value == null || value.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + return value; + } + + private static String blankToDefault(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + private Connection connect() throws SQLException { + return DriverManager.getConnection(datasourceUrl, datasourceUsername, datasourcePassword); + } +} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeRequest.java b/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeRequest.java new file mode 100644 index 0000000..9395f54 --- /dev/null +++ b/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeRequest.java @@ -0,0 +1,9 @@ +package com.bytebite.server.recipe; + +import java.util.List; + +/** Payload for creating or replacing a recipe. */ +public record RecipeRequest(String name, List items) { + + public record Item(String name, String quantity, String unit, String category) {} +} \ No newline at end of file diff --git a/server/grocery-service/src/main/resources/application.properties b/server/grocery-service/src/main/resources/application.properties index 6920a85..559503e 100644 --- a/server/grocery-service/src/main/resources/application.properties +++ b/server/grocery-service/src/main/resources/application.properties @@ -1,3 +1,7 @@ spring.application.name=grocery-service server.port=8082 genai.base-url=http://localhost:8000 +spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/bytebite_grocery} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME:bytebite_grocery} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:bytebite_grocery_password} +auth.jwt.secret=${JWT_SECRET:bytebite-local-dev-secret-change-me} From fd946f2160e7e72fa48f1ece600ecb9fc92ec0d4 Mon Sep 17 00:00:00 2001 From: timn Date: Fri, 12 Jun 2026 17:29:59 +0200 Subject: [PATCH 6/7] fix stale ingredient type --- client/src/App.tsx | 85 ++++++++++++++++++++++-- client/src/components/RecipeListView.tsx | 47 ++++++++++++- client/src/types.ts | 8 +++ 3 files changed, 133 insertions(+), 7 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 0676cad..cfc6213 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' @@ -8,9 +8,24 @@ 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 } from './types' +import type { GroceryList, ApiRecipe } from './types' type View = 'home' | 'grocery-lists' | 'recipes' +type RecipesStatus = 'loading' | 'ready' | 'error' + +function apiRecipeToList(recipe: ApiRecipe): GroceryList { + return { + id: recipe.recipeId, + dish: recipe.name, + createdAt: recipe.createdAt, + ingredients: recipe.items.map(item => ({ + name: item.name, + quantity: item.quantity, + unit: item.unit, + category: item.category, + })), + } +} function getInitialDark(): boolean { const stored = localStorage.getItem('bytebite-dark') @@ -39,6 +54,22 @@ function App() { const [savedLists, setSavedLists] = useState(() => user ? loadLists(user.userId) : [] ) + const [recipes, setRecipes] = useState([]) + const [recipesStatus, setRecipesStatus] = 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(apiRecipeToList)) + setRecipesStatus('ready') + }) + .catch(() => setRecipesStatus('error')) + }, []) useEffect(() => { document.documentElement.classList.toggle('dark', darkMode) @@ -60,6 +91,7 @@ function App() { setSavedLists(loadLists(payload.user.userId)) localStorage.setItem('bytebite-token', payload.token) localStorage.setItem('bytebite-user', JSON.stringify(payload.user)) + loadRecipes(payload.token) }) .catch(() => { localStorage.removeItem('bytebite-token') @@ -67,7 +99,7 @@ function App() { setToken('') setUser(null) }) - }, []) + }, [loadRecipes]) const toggleDark = () => setDarkMode(d => !d) const openSidebar = () => setSidebarOpen(true) @@ -84,6 +116,7 @@ function App() { setSavedLists(loadLists(payload.user.userId)) localStorage.setItem('bytebite-token', payload.token) localStorage.setItem('bytebite-user', JSON.stringify(payload.user)) + loadRecipes(payload.token) } const handleLogout = () => { @@ -92,16 +125,56 @@ function App() { setToken('') setUser(null) setSavedLists([]) + setRecipes([]) setView('home') } const handleListGenerated = (list: GroceryList) => { if (!user) return + // Grocery lists stay client-side for now; recipes are persisted server-side. setSavedLists(prev => { const next = [list, ...prev] saveLists(user.userId, next) return next }) + + 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: 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 => [apiRecipeToList(saved), ...prev])) + .catch(() => { + // Recipe persistence failed; the grocery list is still saved locally. + }) + } + + 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)) } const handleToggleItem = (listId: string, itemIndex: number) => { @@ -201,8 +274,10 @@ function App() { transition={{ duration: 0.15 }} > loadRecipes(token)} + onDeleteList={handleDeleteRecipe} /> ) : ( diff --git a/client/src/components/RecipeListView.tsx b/client/src/components/RecipeListView.tsx index 166bde0..0bd387a 100644 --- a/client/src/components/RecipeListView.tsx +++ b/client/src/components/RecipeListView.tsx @@ -2,12 +2,16 @@ import { useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { ChefHat, ChevronDown, BookOpen, - Check, Copy, Trash2, X, + Check, Copy, Trash2, X, Loader2, AlertTriangle, } from 'lucide-react' import type { GroceryList, Ingredient } from '../types' +type RecipesStatus = 'loading' | 'ready' | 'error' + interface RecipeListViewProps { lists: GroceryList[] + status: RecipesStatus + onRetry: () => void onDeleteList: (listId: string) => void } @@ -20,7 +24,7 @@ function listToText(list: GroceryList) { return lines.join('\n') } -export function RecipeListView({ lists, onDeleteList }: RecipeListViewProps) { +export function RecipeListView({ lists, status, onRetry, onDeleteList }: RecipeListViewProps) { const [openId, setOpenId] = useState(null) const [copyState, setCopyState] = useState<{ id: string; ok: boolean } | null>(null) @@ -39,6 +43,45 @@ export function RecipeListView({ lists, onDeleteList }: RecipeListViewProps) { } } + 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 (lists.length === 0) { return ( Date: Sun, 14 Jun 2026 15:53:45 +0200 Subject: [PATCH 7/7] align recipe/grocery list endpoints and add client implementation --- client/src/App.tsx | 240 ++++++++++++------ client/src/components/GroceryListView.tsx | 231 +++++++++++++---- client/src/components/RecipeListView.tsx | 164 ++++++++---- client/src/types.ts | 34 ++- compose.yaml | 2 - databases/grocery-db/init.sql | 4 +- .../server/JwtAuthenticationFilter.java | 7 +- server/grocery-service/pom.xml | 6 - .../server/GroceryListController.java | 91 +++++-- .../com/bytebite/server/RecipeController.java | 132 ++++++++++ .../com/bytebite/server/auth/JwtAuth.java | 114 --------- .../server/dto/GroceryItemPatchRequest.java | 4 + .../server/dto/GroceryItemRequestDTO.java | 2 +- .../server/dto/GroceryItemResponseDTO.java | 2 +- .../server/dto/GroceryListCreateRequest.java | 3 +- .../server/dto/GroceryListDetailDTO.java | 2 +- .../server/dto/GroceryListSummaryDTO.java | 3 +- .../com/bytebite/server/dto/ItemRequest.java | 9 + .../server/dto/RecipeCreateRequest.java | 5 + .../bytebite/server/dto/RecipeDetailDTO.java | 7 + .../server/dto/RecipeItemRequestDTO.java | 3 + .../server/dto/RecipeItemResponseDTO.java | 5 + .../bytebite/server/dto/RecipeSummaryDTO.java | 6 + .../bytebite/server/entity/GroceryItem.java | 16 +- .../com/bytebite/server/entity/Recipe.java | 40 +++ .../com/bytebite/server/recipe/Recipe.java | 13 - .../server/recipe/RecipeController.java | 97 ------- .../bytebite/server/recipe/RecipeItem.java | 12 - .../server/recipe/RecipeRepository.java | 239 ----------------- .../bytebite/server/recipe/RecipeRequest.java | 9 - .../repository/GroceryListRepository.java | 24 +- .../server/repository/RecipeRepository.java | 17 ++ .../server/service/GroceryItemMapper.java | 46 ++++ .../server/service/GroceryListService.java | 127 ++++----- .../server/service/RecipeService.java | 106 ++++++++ .../server/ServerApplicationTests.java | 7 +- 36 files changed, 1068 insertions(+), 761 deletions(-) create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/RecipeController.java delete mode 100644 server/grocery-service/src/main/java/com/bytebite/server/auth/JwtAuth.java create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemPatchRequest.java create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/dto/ItemRequest.java create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeCreateRequest.java create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeDetailDTO.java create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeItemRequestDTO.java create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeItemResponseDTO.java create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/dto/RecipeSummaryDTO.java create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/entity/Recipe.java delete mode 100644 server/grocery-service/src/main/java/com/bytebite/server/recipe/Recipe.java delete mode 100644 server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeController.java delete mode 100644 server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeItem.java delete mode 100644 server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeRepository.java delete mode 100644 server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeRequest.java create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/repository/RecipeRepository.java create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/service/GroceryItemMapper.java create mode 100644 server/grocery-service/src/main/java/com/bytebite/server/service/RecipeService.java diff --git a/client/src/App.tsx b/client/src/App.tsx index cfc6213..6e8faf5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -8,38 +8,76 @@ 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 } from './types' +import type { + GroceryList, ApiRecipe, ApiRecipeSummary, RecipeSummary, Ingredient, + ApiGroceryList, ApiGroceryListSummary, GroceryListSummary, GroceryItemDetail, +} from './types' type View = 'home' | 'grocery-lists' | 'recipes' -type RecipesStatus = 'loading' | 'ready' | 'error' +type LoadStatus = 'loading' | 'ready' | 'error' -function apiRecipeToList(recipe: ApiRecipe): GroceryList { +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: recipe.recipeId, - dish: recipe.name, - createdAt: recipe.createdAt, - ingredients: recipe.items.map(item => ({ - name: item.name, - quantity: item.quantity, - unit: item.unit, - category: item.category, - })), + id: summary.groceryListId, + dish: summary.name, + createdAt: summary.createdAt, + itemCount: summary.itemCount, + purchasedCount: summary.purchasedCount, } } -function getInitialDark(): boolean { - const stored = localStorage.getItem('bytebite-dark') - if (stored !== null) return stored === 'true' - return window.matchMedia('(prefers-color-scheme: dark)').matches +// 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 loadLists(userId: string): GroceryList[] { - const stored = localStorage.getItem(`bytebite-lists-${userId}`) - return stored ? JSON.parse(stored) as GroceryList[] : [] +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 saveLists(userId: string, lists: GroceryList[]) { - localStorage.setItem(`bytebite-lists-${userId}`, JSON.stringify(lists)) +function getInitialDark(): boolean { + const stored = localStorage.getItem('bytebite-dark') + if (stored !== null) return stored === 'true' + return window.matchMedia('(prefers-color-scheme: dark)').matches } function App() { @@ -51,26 +89,55 @@ function App() { const stored = localStorage.getItem('bytebite-user') return stored ? JSON.parse(stored) as AuthUser : null }) - const [savedLists, setSavedLists] = useState(() => - user ? loadLists(user.userId) : [] - ) - const [recipes, setRecipes] = useState([]) - const [recipesStatus, setRecipesStatus] = useState('loading') + 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 + return response.json() as Promise }) .then(data => { - setRecipes(data.map(apiRecipeToList)) + 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) localStorage.setItem('bytebite-dark', String(darkMode)) @@ -88,10 +155,10 @@ function App() { .then(payload => { setToken(payload.token) setUser(payload.user) - setSavedLists(loadLists(payload.user.userId)) 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') @@ -99,7 +166,7 @@ function App() { setToken('') setUser(null) }) - }, [loadRecipes]) + }, [loadRecipes, loadGroceryLists]) const toggleDark = () => setDarkMode(d => !d) const openSidebar = () => setSidebarOpen(true) @@ -113,10 +180,10 @@ function App() { const handleAuthenticated = (payload: AuthPayload) => { setToken(payload.token) setUser(payload.user) - setSavedLists(loadLists(payload.user.userId)) localStorage.setItem('bytebite-token', payload.token) localStorage.setItem('bytebite-user', JSON.stringify(payload.user)) loadRecipes(payload.token) + loadGroceryLists(payload.token) } const handleLogout = () => { @@ -124,31 +191,23 @@ function App() { localStorage.removeItem('bytebite-user') setToken('') setUser(null) - setSavedLists([]) 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 (!user) return - // Grocery lists stay client-side for now; recipes are persisted server-side. - setSavedLists(prev => { - const next = [list, ...prev] - saveLists(user.userId, next) - return next - }) + if (!token) return fetch('/api/recipes', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ name: list.dish, items: list.ingredients.map(item => ({ name: item.name, - quantity: item.quantity, + quantity: parseQuantity(item.quantity), unit: item.unit, category: item.category, })), @@ -158,9 +217,32 @@ function App() { if (!response.ok) throw new Error('Failed to save recipe') return response.json() as Promise }) - .then(saved => setRecipes(prev => [apiRecipeToList(saved), ...prev])) + .then(saved => setRecipes(prev => [apiSummaryToRecipe(saved), ...prev])) .catch(() => { - // Recipe persistence failed; the grocery list is still saved locally. + // 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. }) } @@ -177,31 +259,43 @@ function App() { .catch(() => setRecipes(previous)) } - const handleToggleItem = (listId: string, itemIndex: number) => { - if (!user) return - setSavedLists(prev => { - const next = prev.map(list => + // 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, - ingredients: list.ingredients.map((item, i) => - i === itemIndex ? { ...item, checked: !item.checked } : item - ), - } + ? { ...list, purchasedCount: Math.max(0, Math.min(list.itemCount, list.purchasedCount + delta)) } : list - ) - saveLists(user.userId, next) - return next - }) - } + )) + 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) => { - if (!user) return - setSavedLists(prev => { - const next = prev.filter(list => list.id !== listId) - saveLists(user.userId, next) - return next + 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) { @@ -260,9 +354,12 @@ function App() { transition={{ duration: 0.15 }} > loadGroceryLists(token)} + onToggleItem={handleToggleGroceryItem} onDeleteList={handleDeleteList} + fetchItems={fetchGroceryListItems} /> ) : view === 'recipes' ? ( @@ -274,10 +371,11 @@ function App() { transition={{ duration: 0.15 }} > loadRecipes(token)} - onDeleteList={handleDeleteRecipe} + onDeleteRecipe={handleDeleteRecipe} + fetchItems={fetchRecipeItems} /> ) : ( @@ -303,4 +401,4 @@ function App() { ) } -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 index 70ac5f5..42874f9 100644 --- a/client/src/components/GroceryListView.tsx +++ b/client/src/components/GroceryListView.tsx @@ -2,44 +2,135 @@ import { useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { ShoppingCart, ChevronDown, ShoppingBag, - Check, Copy, Trash2, CircleCheckBig, X, + Check, Copy, Trash2, CircleCheckBig, X, Loader2, AlertTriangle, } from 'lucide-react' -import type { GroceryList, Ingredient } from '../types' +import type { GroceryListSummary, GroceryItemDetail } from '../types' + +type LoadStatus = 'loading' | 'ready' | 'error' +type ItemState = { status: LoadStatus; items: GroceryItemDetail[] } interface GroceryListViewProps { - lists: GroceryList[] - onToggleItem: (listId: string, itemIndex: number) => void + 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: Ingredient) { +function itemLabel(item: GroceryItemDetail) { return item.quantity !== 'N/A' ? `${item.quantity} ${item.unit} ${item.name}` : item.name } -function listToText(list: GroceryList) { - const lines = [list.dish, ...list.ingredients.map(item => `- ${itemLabel(item)}`)] - return lines.join('\n') +function toText(dish: string, items: GroceryItemDetail[]) { + return [dish, ...items.map(item => `- ${itemLabel(item)}`)].join('\n') } -export function GroceryListView({ lists, onToggleItem, onDeleteList }: GroceryListViewProps) { +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: GroceryList) => { + const handleCopy = async (list: GroceryListSummary) => { try { if (!navigator.clipboard) throw new Error('Clipboard unavailable') - await navigator.clipboard.writeText(listToText(list)) + 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 ( {lists.map((list, idx) => { const isOpen = openId === list.id - const total = list.ingredients.length - const checkedCount = list.ingredients.filter(item => item.checked).length + 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 = list.ingredients.reduce>( - (acc, item, index) => { + const grouped = (loaded ? state.items : []).reduce>( + (acc, item) => { const key = item.category || 'Other' - ;(acc[key] ??= []).push({ item, index }) + ;(acc[key] ??= []).push(item) return acc }, {} @@ -100,7 +195,7 @@ export function GroceryListView({ lists, onToggleItem, onDeleteList }: GroceryLi {/* Header row */}
- ) - })} -
+
+ {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 ( + + ) + })} +
+
+ ))}
- ))} -
+ ) + )}
)} diff --git a/client/src/components/RecipeListView.tsx b/client/src/components/RecipeListView.tsx index 0bd387a..142e5a5 100644 --- a/client/src/components/RecipeListView.tsx +++ b/client/src/components/RecipeListView.tsx @@ -4,42 +4,71 @@ import { ChefHat, ChevronDown, BookOpen, Check, Copy, Trash2, X, Loader2, AlertTriangle, } from 'lucide-react' -import type { GroceryList, Ingredient } from '../types' +import type { RecipeSummary, Ingredient } from '../types' -type RecipesStatus = 'loading' | 'ready' | 'error' +type LoadStatus = 'loading' | 'ready' | 'error' +type ItemState = { status: LoadStatus; items: Ingredient[] } interface RecipeListViewProps { - lists: GroceryList[] - status: RecipesStatus + recipes: RecipeSummary[] + status: LoadStatus onRetry: () => void - onDeleteList: (listId: string) => 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 listToText(list: GroceryList) { - const lines = [list.dish, ...list.ingredients.map(item => `- ${itemLabel(item)}`)] - return lines.join('\n') +function toText(dish: string, items: Ingredient[]) { + return [dish, ...items.map(item => `- ${itemLabel(item)}`)].join('\n') } -export function RecipeListView({ lists, status, onRetry, onDeleteList }: RecipeListViewProps) { +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 (list: GroceryList) => { + const handleCopy = async (recipe: RecipeSummary) => { try { if (!navigator.clipboard) throw new Error('Clipboard unavailable') - await navigator.clipboard.writeText(listToText(list)) - showCopyResult(list.id, true) + 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(list.id, false) + showCopyResult(recipe.id, false) } } @@ -82,7 +111,7 @@ export function RecipeListView({ lists, status, onRetry, onDeleteList }: RecipeL ) } - if (lists.length === 0) { + if (recipes.length === 0) { return (

Recipes

- {lists.length} + {recipes.length}
- {lists.map((list, idx) => { - const isOpen = openId === list.id + {recipes.map((recipe, idx) => { + const isOpen = openId === recipe.id + const state = itemsById[recipe.id] - const grouped = list.ingredients.reduce>((acc, item) => { - const key = item.category || 'Other' - ;(acc[key] ??= []).push(item) - return acc - }, {}) + const grouped = (state?.status === 'ready' ? state.items : []).reduce>( + (acc, item) => { + const key = item.category || 'Other' + ;(acc[key] ??= []).push(item) + return acc + }, + {} + ) return (
+
+ )} + + {state?.status === 'ready' && ( + state.items.length === 0 ? ( +

No ingredients.

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

+ {category} +

+
+ {items.map((item, i) => ( + + {itemLabel(item)} + + ))} +
+
+ ))}
- ))} -
+ ) + )}
)} diff --git a/client/src/types.ts b/client/src/types.ts index 40b8e35..86b05c8 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -1,10 +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[] } -// Shape returned by grocery-service GET/POST /api/recipes +// 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: string; unit: string; category: 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/compose.yaml b/compose.yaml index 2b24c55..9bc43ce 100644 --- a/compose.yaml +++ b/compose.yaml @@ -80,8 +80,6 @@ services: # matching `expose` and the api-gateway's GROCERY_SERVICE_BASE_URL. SERVER_PORT: "8080" GENAI_BASE_URL: http://gen-ai:8000 - # Shared with user-service so grocery-service can validate the same JWTs. - JWT_SECRET: ${JWT_SECRET:-bytebite-local-dev-secret-change-me} SPRING_DATASOURCE_URL: jdbc:postgresql://grocery-db:5432/${GROCERY_DB_NAME:-bytebite_grocery} SPRING_DATASOURCE_USERNAME: ${GROCERY_DB_USER:-bytebite_grocery} SPRING_DATASOURCE_PASSWORD: ${GROCERY_DB_PASSWORD:-bytebite_grocery_password} diff --git a/databases/grocery-db/init.sql b/databases/grocery-db/init.sql index e601376..fac3ce2 100644 --- a/databases/grocery-db/init.sql +++ b/databases/grocery-db/init.sql @@ -45,9 +45,9 @@ 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 VARCHAR(50) NOT NULL, + quantity DOUBLE PRECISION, unit VARCHAR(50) NOT NULL, - category VARCHAR(50) NOT NULL DEFAULT 'OTHER', + category grocery_category NOT NULL DEFAULT 'OTHER', is_purchased BOOLEAN NOT NULL DEFAULT FALSE, recipe_id UUID, grocery_list_id UUID, 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 23d1d15..3393ced 100644 --- a/server/grocery-service/pom.xml +++ b/server/grocery-service/pom.xml @@ -53,12 +53,6 @@ spring-boot-starter-data-jpa - - org.postgresql - postgresql - runtime - - 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/auth/JwtAuth.java b/server/grocery-service/src/main/java/com/bytebite/server/auth/JwtAuth.java deleted file mode 100644 index 795a780..0000000 --- a/server/grocery-service/src/main/java/com/bytebite/server/auth/JwtAuth.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.bytebite.server.auth; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; -import org.springframework.web.server.ResponseStatusException; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.Base64; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.UUID; - -/** - * Verify-only counterpart to user-service's JwtTokenService. Validates the - * HS256 signature and expiry of a bearer token signed with the shared secret, - * and extracts the authenticated user id. grocery-service never issues tokens. - */ -@Component -public class JwtAuth { - private static final Base64.Decoder BASE64_URL_DECODER = Base64.getUrlDecoder(); - private static final Base64.Encoder BASE64_URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); - - private final byte[] secret; - - public JwtAuth(@Value("${auth.jwt.secret}") String secret) { - if (secret == null || secret.length() < 32) { - throw new IllegalStateException("JWT secret must be at least 32 characters."); - } - this.secret = secret.getBytes(StandardCharsets.UTF_8); - } - - /** Validates the {@code Authorization} header and returns the caller's user id. */ - public UUID requireUserId(String authorizationHeader) { - if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { - throw unauthorized(); - } - String token = authorizationHeader.substring("Bearer ".length()).trim(); - String[] parts = token.split("\\."); - if (parts.length != 3) { - throw unauthorized(); - } - - String unsigned = parts[0] + "." + parts[1]; - if (!constantTimeEquals(sign(unsigned), parts[2])) { - throw unauthorized(); - } - - Map payload = parseFlatJson( - new String(BASE64_URL_DECODER.decode(parts[1]), StandardCharsets.UTF_8)); - long expiresAt = Long.parseLong(payload.getOrDefault("exp", "0")); - if (expiresAt <= Instant.now().getEpochSecond()) { - throw unauthorized(); - } - - try { - return UUID.fromString(payload.get("sub")); - } catch (IllegalArgumentException | NullPointerException exception) { - throw unauthorized(); - } - } - - private String sign(String value) { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(secret, "HmacSHA256")); - return BASE64_URL_ENCODER.encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); - } catch (Exception exception) { - throw new IllegalStateException("Could not verify JWT.", exception); - } - } - - private boolean constantTimeEquals(String expected, String actual) { - byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8); - byte[] actualBytes = actual.getBytes(StandardCharsets.UTF_8); - if (expectedBytes.length != actualBytes.length) { - return false; - } - int result = 0; - for (int i = 0; i < expectedBytes.length; i++) { - result |= expectedBytes[i] ^ actualBytes[i]; - } - return result == 0; - } - - private Map parseFlatJson(String json) { - Map values = new LinkedHashMap<>(); - String body = json.substring(1, json.length() - 1); - for (String pair : body.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)")) { - String[] parts = pair.split(":", 2); - if (parts.length == 2) { - values.put(unquote(parts[0]), unquote(parts[1])); - } - } - return values; - } - - private String unquote(String value) { - String trimmed = value.trim(); - if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) { - return trimmed.substring(1, trimmed.length() - 1) - .replace("\\\"", "\"") - .replace("\\\\", "\\"); - } - return trimmed; - } - - private ResponseStatusException unauthorized() { - return new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication is required."); - } -} \ 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/recipe/Recipe.java b/server/grocery-service/src/main/java/com/bytebite/server/recipe/Recipe.java deleted file mode 100644 index 6e97e00..0000000 --- a/server/grocery-service/src/main/java/com/bytebite/server/recipe/Recipe.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.bytebite.server.recipe; - -import java.time.OffsetDateTime; -import java.util.List; -import java.util.UUID; - -/** A saved recipe with its grocery items, owned by a user. */ -public record Recipe( - UUID recipeId, - String name, - OffsetDateTime createdAt, - List items -) {} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeController.java b/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeController.java deleted file mode 100644 index 947f7d1..0000000 --- a/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeController.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.bytebite.server.recipe; - -import com.bytebite.server.auth.JwtAuth; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -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.server.ResponseStatusException; - -import java.util.List; -import java.util.UUID; - -@RestController -@RequestMapping("/api/recipes") -@Tag(name = "Recipes", description = "Persisted recipes and their grocery items") -public class RecipeController { - - private final RecipeRepository repository; - private final JwtAuth jwtAuth; - - public RecipeController(RecipeRepository repository, JwtAuth jwtAuth) { - this.repository = repository; - this.jwtAuth = jwtAuth; - } - - @GetMapping - @Operation(summary = "List the authenticated user's saved recipes", - security = @SecurityRequirement(name = "bearerAuth")) - public List list(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization) { - UUID userId = jwtAuth.requireUserId(authorization); - return repository.findAllByUser(userId); - } - - @GetMapping("/{recipeId}") - @Operation(summary = "Get a single saved recipe", - security = @SecurityRequirement(name = "bearerAuth")) - public Recipe get(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, - @PathVariable UUID recipeId) { - UUID userId = jwtAuth.requireUserId(authorization); - return repository.findByIdForUser(recipeId, userId).orElseThrow(this::notFound); - } - - @PostMapping - @Operation(summary = "Save a new recipe", - security = @SecurityRequirement(name = "bearerAuth")) - public ResponseEntity create(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, - @RequestBody RecipeRequest request) { - UUID userId = jwtAuth.requireUserId(authorization); - Recipe recipe = repository.create(userId, requireName(request), request.items()); - return ResponseEntity.status(HttpStatus.CREATED).body(recipe); - } - - @PutMapping("/{recipeId}") - @Operation(summary = "Replace a saved recipe's name and items", - security = @SecurityRequirement(name = "bearerAuth")) - public Recipe update(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, - @PathVariable UUID recipeId, - @RequestBody RecipeRequest request) { - UUID userId = jwtAuth.requireUserId(authorization); - return repository.replace(recipeId, userId, requireName(request), request.items()) - .orElseThrow(this::notFound); - } - - @DeleteMapping("/{recipeId}") - @Operation(summary = "Delete a saved recipe", - security = @SecurityRequirement(name = "bearerAuth")) - public ResponseEntity delete(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, - @PathVariable UUID recipeId) { - UUID userId = jwtAuth.requireUserId(authorization); - if (!repository.deleteForUser(recipeId, userId)) { - throw notFound(); - } - return ResponseEntity.noContent().build(); - } - - private String requireName(RecipeRequest request) { - if (request == null || request.name() == null || request.name().isBlank()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Recipe name is required."); - } - return request.name(); - } - - private ResponseStatusException notFound() { - return new ResponseStatusException(HttpStatus.NOT_FOUND, "Recipe not found."); - } -} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeItem.java b/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeItem.java deleted file mode 100644 index 6b46fb0..0000000 --- a/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeItem.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.bytebite.server.recipe; - -import java.util.UUID; - -/** A single ingredient belonging to a recipe. */ -public record RecipeItem( - UUID itemId, - String name, - String quantity, - String unit, - String category -) {} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeRepository.java b/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeRepository.java deleted file mode 100644 index 34879cd..0000000 --- a/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeRepository.java +++ /dev/null @@ -1,239 +0,0 @@ -package com.bytebite.server.recipe; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Repository; -import org.springframework.web.server.ResponseStatusException; - -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.time.OffsetDateTime; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - -@Repository -public class RecipeRepository { - private final String datasourceUrl; - private final String datasourceUsername; - private final String datasourcePassword; - - public RecipeRepository( - @Value("${spring.datasource.url}") String datasourceUrl, - @Value("${spring.datasource.username}") String datasourceUsername, - @Value("${spring.datasource.password}") String datasourcePassword - ) { - this.datasourceUrl = datasourceUrl; - this.datasourceUsername = datasourceUsername; - this.datasourcePassword = datasourcePassword; - } - - /** Inserts a recipe and its items atomically, returning the persisted recipe. */ - public Recipe create(UUID userId, String name, List items) { - String insertRecipe = """ - INSERT INTO recipes (name, user_id) - VALUES (?, ?) - RETURNING recipe_id, created_at - """; - try (Connection connection = connect()) { - connection.setAutoCommit(false); - try { - UUID recipeId; - OffsetDateTime createdAt; - try (PreparedStatement statement = connection.prepareStatement(insertRecipe)) { - statement.setString(1, name); - statement.setObject(2, userId); - try (ResultSet resultSet = statement.executeQuery()) { - resultSet.next(); - recipeId = resultSet.getObject("recipe_id", UUID.class); - createdAt = resultSet.getObject("created_at", OffsetDateTime.class); - } - } - List persistedItems = insertItems(connection, recipeId, items); - connection.commit(); - return new Recipe(recipeId, name, createdAt, persistedItems); - } catch (SQLException exception) { - connection.rollback(); - throw exception; - } - } catch (SQLException exception) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not create recipe."); - } - } - - public List findAllByUser(UUID userId) { - return query(BASE_SELECT + " WHERE r.user_id = ? ORDER BY r.created_at DESC, i.name ASC", userId, null); - } - - public Optional findByIdForUser(UUID recipeId, UUID userId) { - List recipes = query( - BASE_SELECT + " WHERE r.user_id = ? AND r.recipe_id = ? ORDER BY i.name ASC", userId, recipeId); - return recipes.isEmpty() ? Optional.empty() : Optional.of(recipes.get(0)); - } - - /** Renames a recipe and replaces all of its items. Empty if the recipe is not owned by the user. */ - public Optional replace(UUID recipeId, UUID userId, String name, List items) { - try (Connection connection = connect()) { - connection.setAutoCommit(false); - try { - OffsetDateTime createdAt; - try (PreparedStatement statement = connection.prepareStatement( - "UPDATE recipes SET name = ? WHERE recipe_id = ? AND user_id = ? RETURNING created_at")) { - statement.setString(1, name); - statement.setObject(2, recipeId); - statement.setObject(3, userId); - try (ResultSet resultSet = statement.executeQuery()) { - if (!resultSet.next()) { - connection.rollback(); - return Optional.empty(); - } - createdAt = resultSet.getObject("created_at", OffsetDateTime.class); - } - } - try (PreparedStatement delete = connection.prepareStatement( - "DELETE FROM grocery_items WHERE recipe_id = ?")) { - delete.setObject(1, recipeId); - delete.executeUpdate(); - } - List persistedItems = insertItems(connection, recipeId, items); - connection.commit(); - return Optional.of(new Recipe(recipeId, name, createdAt, persistedItems)); - } catch (SQLException exception) { - connection.rollback(); - throw exception; - } - } catch (SQLException exception) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not update recipe."); - } - } - - /** Deletes a recipe (items cascade). Returns false if nothing was owned/deleted. */ - public boolean deleteForUser(UUID recipeId, UUID userId) { - try (Connection connection = connect(); - PreparedStatement statement = connection.prepareStatement( - "DELETE FROM recipes WHERE recipe_id = ? AND user_id = ?")) { - statement.setObject(1, recipeId); - statement.setObject(2, userId); - return statement.executeUpdate() > 0; - } catch (SQLException exception) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not delete recipe."); - } - } - - private List insertItems(Connection connection, UUID recipeId, List items) - throws SQLException { - List persisted = new ArrayList<>(); - if (items == null || items.isEmpty()) { - return persisted; - } - String sql = """ - INSERT INTO grocery_items (name, quantity, unit, category, recipe_id) - VALUES (?, ?, ?, ?, ?) - RETURNING item_id - """; - for (RecipeRequest.Item item : items) { - String itemName = require(item.name(), "Item name is required."); - String quantity = blankToDefault(item.quantity(), "N/A"); - String unit = blankToDefault(item.unit(), ""); - String category = blankToDefault(item.category(), "OTHER"); - try (PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setString(1, itemName); - statement.setString(2, quantity); - statement.setString(3, unit); - statement.setString(4, category); - statement.setObject(5, recipeId); - try (ResultSet resultSet = statement.executeQuery()) { - resultSet.next(); - persisted.add(new RecipeItem( - resultSet.getObject("item_id", UUID.class), - itemName, quantity, unit, category)); - } - } - } - return persisted; - } - - private static final String BASE_SELECT = """ - SELECT r.recipe_id, r.name AS recipe_name, r.created_at, - i.item_id, i.name AS item_name, i.quantity, i.unit, i.category - FROM recipes r - LEFT JOIN grocery_items i ON i.recipe_id = r.recipe_id - """; - - private List query(String sql, UUID userId, UUID recipeId) { - try (Connection connection = connect(); - PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setObject(1, userId); - if (recipeId != null) { - statement.setObject(2, recipeId); - } - try (ResultSet resultSet = statement.executeQuery()) { - Map> itemsByRecipe = new LinkedHashMap<>(); - Map shells = new LinkedHashMap<>(); - while (resultSet.next()) { - UUID id = resultSet.getObject("recipe_id", UUID.class); - shells.computeIfAbsent(id, key -> new Recipe( - id, - sqlString(resultSet, "recipe_name"), - sqlOffsetDateTime(resultSet), - null)); - List items = itemsByRecipe.computeIfAbsent(id, key -> new ArrayList<>()); - UUID itemId = resultSet.getObject("item_id", UUID.class); - if (itemId != null) { - items.add(new RecipeItem( - itemId, - resultSet.getString("item_name"), - resultSet.getString("quantity"), - resultSet.getString("unit"), - resultSet.getString("category"))); - } - } - List recipes = new ArrayList<>(); - for (Recipe shell : shells.values()) { - recipes.add(new Recipe(shell.recipeId(), shell.name(), shell.createdAt(), - itemsByRecipe.get(shell.recipeId()))); - } - return recipes; - } - } catch (SQLException exception) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not read recipes."); - } - } - - private static String sqlString(ResultSet resultSet, String column) { - try { - return resultSet.getString(column); - } catch (SQLException exception) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not read recipes."); - } - } - - private static OffsetDateTime sqlOffsetDateTime(ResultSet resultSet) { - try { - return resultSet.getObject("created_at", OffsetDateTime.class); - } catch (SQLException exception) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not read recipes."); - } - } - - private static String require(String value, String message) { - if (value == null || value.isBlank()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); - } - return value; - } - - private static String blankToDefault(String value, String fallback) { - return value == null || value.isBlank() ? fallback : value; - } - - private Connection connect() throws SQLException { - return DriverManager.getConnection(datasourceUrl, datasourceUsername, datasourcePassword); - } -} \ No newline at end of file diff --git a/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeRequest.java b/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeRequest.java deleted file mode 100644 index 9395f54..0000000 --- a/server/grocery-service/src/main/java/com/bytebite/server/recipe/RecipeRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.bytebite.server.recipe; - -import java.util.List; - -/** Payload for creating or replacing a recipe. */ -public record RecipeRequest(String name, List items) { - - public record Item(String name, String quantity, String unit, String category) {} -} \ 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