diff --git a/TEKNISK_BESKRIVNING.md b/TEKNISK_BESKRIVNING.md index fe888603..08de4ee2 100644 --- a/TEKNISK_BESKRIVNING.md +++ b/TEKNISK_BESKRIVNING.md @@ -49,6 +49,80 @@ docker exec recipe-db mariadb -uroot -p"LÖSENORD" recipe_app -e "SHOW TABLES;" --- +## Caddy-konfiguration (reverse proxy) + +Caddy sitter framför applikationen och distribuerar trafik. Ordningen på `handle`-blocken är kritisk — Caddy väljer det första matchande blocket. + +```caddy +recept.gynther.se { + import common + + # === IMPORT SERVICE (Document Converter) === + # Måste komma FÖRE backend-reglerna + handle /api/recipes/import* { + reverse_proxy recipe-import-service:3000 + } + + # === NEXT.JS PROXY-ROUTES (frontend-hanterade) === + # Dessa routes innehåller server-side logic (auth, aggregering, mm) + # och måste gå till frontend, INTE backend + handle /api/inventory-history-proxy { + reverse_proxy recipe-frontend:3000 + } + + handle /api/admin/merge-preview-proxy { + reverse_proxy recipe-frontend:3000 + } + + handle /api/recipe-preview-proxy { + reverse_proxy recipe-frontend:3000 + } + + # === BACKEND API-ENDPOINTS === + handle /api/products* { + reverse_proxy recipe-api:8080 + } + + handle /api/inventory* { + reverse_proxy recipe-api:8080 + } + + handle /api/recipes* { + reverse_proxy recipe-api:8080 + } + + handle /health { + reverse_proxy recipe-api:8080 + } + + # === CATCH-ALL: Övriga /api/* → frontend === + # Fångar upp alla Next.js API routes som inte explicit + # listats ovan, t.ex. /api/admin/*, /api/auth/*, /api/categories, mm. + handle /api/* { + reverse_proxy recipe-frontend:3000 + } + + # Alla övriga requests → frontend + reverse_proxy /* recipe-frontend:3000 +} +``` + +### Viktig regel: Caddy-routing och Next.js API routes + +> **Caddy-regeln `handle /api/products*` fångar upp ALLT som börjar med `/api/products`** — inklusive sökvägar som `/api/products-create` eller `/api/products-update`. Det innebär att Next.js API routes som ska hanteras av frontend-containern **INTE** får ha sökvägar som börjar med `products`, `inventory`, `recipes` eller andra prefix som Caddy skickar till backend. + +Next.js API routes som kräver server-side auth och ska gå via frontend måste ha prefix som hamnar i catch-all-blocket `handle /api/* → recipe-frontend:3000`. Exempel på säkra prefix: + +| Prefix | Caddy-regel | Destination | +|--------|-------------|-------------| +| `/api/admin/*` | catch-all | `recipe-frontend:3000` | +| `/api/categories` | catch-all | `recipe-frontend:3000` | +| `/api/auth/*` | catch-all | `recipe-frontend:3000` | +| `/api/products*` | explicit regel | `recipe-api:8080` ⚠️ | +| `/api/inventory*` | explicit regel | `recipe-api:8080` ⚠️ | + + + ## Frontend - **Framework:** Next.js 16.2 (App Router, server + client components) diff --git a/frontend/app/admin/products/AdminProductList.tsx b/frontend/app/admin/products/AdminProductList.tsx index 0068cd0b..84daa7d3 100644 --- a/frontend/app/admin/products/AdminProductList.tsx +++ b/frontend/app/admin/products/AdminProductList.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useMemo, useEffect, useTransition } from 'react'; +import { useRouter } from 'next/navigation'; import type { Product, Category } from '../../../features/inventory/types'; import EditProductForm from './EditProductForm'; import { bulkSetCategory, suggestBulkCategories } from './actions'; @@ -39,6 +40,7 @@ function flattenTree(nodes: CategoryNode[], depth = 0): { id: number; label: str } export default function AdminProductList({ products }: Props) { + const router = useRouter(); const [search, setSearch] = useState(''); const [sort, setSort] = useState('createdDesc'); const [showUncategorizedOnly, setShowUncategorizedOnly] = useState(false); @@ -127,6 +129,7 @@ export default function AdminProductList({ products }: Props) { await bulkSetCategory(ids, categoryId); setSelectedIds(new Set()); setBulkCategoryId(''); + router.refresh(); } catch (err) { setBulkError(err instanceof Error ? err.message : 'Fel vid uppdatering'); } diff --git a/frontend/app/admin/products/EditProductForm.tsx b/frontend/app/admin/products/EditProductForm.tsx index d5119adb..31e630ed 100644 --- a/frontend/app/admin/products/EditProductForm.tsx +++ b/frontend/app/admin/products/EditProductForm.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useTransition, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; import type { Product } from '../../../features/inventory/types'; import { updateProductWithTags, deleteProduct, suggestProductCategory } from './actions'; @@ -33,6 +34,7 @@ const inputStyle: React.CSSProperties = { }; export default function EditProductForm({ product }: Props) { + const router = useRouter(); const [isOpen, setIsOpen] = useState(false); const [isPending, startTransition] = useTransition(); const [error, setError] = useState(null); @@ -107,6 +109,7 @@ export default function EditProductForm({ product }: Props) { await updateProductWithTags(formData, rawTags); setSuccess(true); setIsOpen(false); + router.refresh(); } catch (err) { setError(err instanceof Error ? err.message : 'Okänt fel'); } @@ -120,6 +123,7 @@ export default function EditProductForm({ product }: Props) { startTransition(async () => { try { await deleteProduct(product.id); + router.refresh(); } catch (err) { setError(err instanceof Error ? err.message : 'Okänt fel'); } diff --git a/frontend/app/admin/products/actions.ts b/frontend/app/admin/products/actions.ts index bc8cfca9..c184923e 100644 --- a/frontend/app/admin/products/actions.ts +++ b/frontend/app/admin/products/actions.ts @@ -108,8 +108,6 @@ export async function updateProductWithTags(formData: FormData, tags: string[]) const text = await tagsRes.text(); throw new Error(`Kunde inte uppdatera taggar: ${text}`); } - - revalidatePath('/admin/products'); } export async function deleteProduct(id: number) { @@ -123,8 +121,6 @@ export async function deleteProduct(id: number) { const text = await res.text(); throw new Error(`Kunde inte ta bort produkt: ${text}`); } - - revalidatePath('/admin/products'); } export async function resetAllProducts() { @@ -155,8 +151,6 @@ export async function bulkSetCategory(ids: number[], categoryId: number | null) const text = await res.text(); throw new Error(`Kunde inte uppdatera produkter: ${text}`); } - - revalidatePath('/admin/products'); } export async function suggestProductCategory(productId: number) { diff --git a/frontend/app/api/admin/create-product/route.ts b/frontend/app/api/admin/create-product/route.ts new file mode 100644 index 00000000..ea637feb --- /dev/null +++ b/frontend/app/api/admin/create-product/route.ts @@ -0,0 +1,46 @@ +import { auth } from '../../../../auth'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + +async function getAuthHeaders(): Promise> { + const session = await auth(); + if (!session?.accessToken) { + return {}; + } + return { Authorization: `Bearer ${session.accessToken}` }; +} + +export async function POST(req: Request) { + try { + const body = await req.json(); + const { name } = body; + + if (!name || typeof name !== 'string') { + return Response.json({ error: 'Name is required' }, { status: 400 }); + } + + const authHeaders = await getAuthHeaders(); + const res = await fetch(`${API_BASE}/api/products`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders }, + body: JSON.stringify({ name }), + }); + + if (!res.ok) { + const e = await res.json().catch(() => ({})); + return Response.json({ error: e.message ?? `HTTP ${res.status}` }, { status: res.status }); + } + + const product = await res.json(); + return Response.json({ + id: product.id, + name: product.name, + canonicalName: product.canonicalName ?? null, + }); + } catch (err) { + return Response.json( + { error: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 }, + ); + } +} diff --git a/frontend/app/api/admin/update-product/[id]/route.ts b/frontend/app/api/admin/update-product/[id]/route.ts new file mode 100644 index 00000000..d63dcb5b --- /dev/null +++ b/frontend/app/api/admin/update-product/[id]/route.ts @@ -0,0 +1,48 @@ +import { auth } from '../../../../../auth'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + +async function getAuthHeaders(): Promise> { + const session = await auth(); + if (!session?.accessToken) { + return {}; + } + return { Authorization: `Bearer ${session.accessToken}` }; +} + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const productId = parseInt(id, 10); + const body = await req.json(); + const { categoryId } = body; + + const authHeaders = await getAuthHeaders(); + const res = await fetch(`${API_BASE}/api/products/${productId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...authHeaders }, + body: JSON.stringify({ categoryId }), + }); + + if (!res.ok) { + const e = await res.json().catch(() => ({})); + return Response.json({ error: e.message ?? `HTTP ${res.status}` }, { status: res.status }); + } + + const product = await res.json(); + return Response.json({ + id: product.id, + name: product.name, + canonicalName: product.canonicalName ?? null, + categoryId: product.categoryId ?? null, + }); + } catch (err) { + return Response.json( + { error: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 }, + ); + } +} diff --git a/frontend/app/kvitto/ReceiptImportClient.tsx b/frontend/app/kvitto/ReceiptImportClient.tsx index 2d6f3ebe..3f10b8f3 100644 --- a/frontend/app/kvitto/ReceiptImportClient.tsx +++ b/frontend/app/kvitto/ReceiptImportClient.tsx @@ -196,7 +196,7 @@ export default function ReceiptImportClient({ isAdmin }: { isAdmin: boolean }) { console.log('handleCreateProduct: isAdmin =', isAdmin, 'endpoint = /api/products-create'); try { // Admin skapar aktiv produkt via API route - const res = await fetch('/api/products-create', { + const res = await fetch('/api/admin/create-product', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: row.rawName }), @@ -212,7 +212,7 @@ export default function ReceiptImportClient({ isAdmin }: { isAdmin: boolean }) { // Sätt kategori: AI-förslag har prioritet, annars manuellt val const categoryId = row.categorySuggestion?.categoryId ?? (row.selectedCategoryId !== '' ? row.selectedCategoryId : null); if (categoryId) { - const patchRes = await fetch(`/api/products-update/${product.id}`, { + const patchRes = await fetch(`/api/admin/update-product/${product.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ categoryId }),