From 87eab4d0ca0485d484aeb20330c119931544c188 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Tue, 21 Apr 2026 13:30:44 +0200 Subject: [PATCH] feat: add functionality for managing deleted products, including restoration and permanent deletion --- backend/src/products/products.controller.ts | 12 ++ backend/src/products/products.service.ts | 33 ++-- .../admin/products/DeletedProductsView.tsx | 180 ++++++++++++++++++ .../api/admin/deleted-products/[id]/route.ts | 29 +++ .../app/api/admin/deleted-products/route.ts | 11 ++ frontend/app/profil/tabs/DatabsTab.tsx | 69 ++++++- frontend/lib/error-handler.ts | 11 +- 7 files changed, 323 insertions(+), 22 deletions(-) create mode 100644 frontend/app/admin/products/DeletedProductsView.tsx create mode 100644 frontend/app/api/admin/deleted-products/[id]/route.ts create mode 100644 frontend/app/api/admin/deleted-products/route.ts diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts index 708aac87..25cc595d 100644 --- a/backend/src/products/products.controller.ts +++ b/backend/src/products/products.controller.ts @@ -186,6 +186,18 @@ export class ProductsController { return this.productsService.update(id, body); } + @Roles('admin') + @Get('deleted') + findDeleted() { + return this.productsService.findDeleted(); + } + + @Roles('admin') + @Delete(':id/permanent') + permanentDelete(@Param('id', ParseIntPipe) id: number) { + return this.productsService.permanentDelete(id); + } + @Roles('admin') @Delete(':id') remove(@Param('id', ParseIntPipe) id: number) { diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index 467e9035..5265eac1 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -127,19 +127,8 @@ export class ProductsService { }); if (existing && existing.id !== id) { - if (!existing.isActive) { - return this.prisma.product.update({ - where: { id: existing.id }, - data: { - isActive: true, - deletedAt: null, - name, - canonicalName: name, - }, - }); - } - - return existing; + // Om en annan produkt har samma namn, returnera ett tydligt fel + throw new Error('Det finns redan en annan produkt med detta namn. Välj ett unikt namn.'); } updateData.name = name; @@ -186,6 +175,13 @@ export class ProductsService { }); } + async findDeleted() { + return this.prisma.product.findMany({ + where: { isActive: false }, + orderBy: { deletedAt: 'desc' }, + }); + } + async remove(id: number) { await this.findOne(id); @@ -198,6 +194,17 @@ export class ProductsService { }); } + async permanentDelete(id: number) { + const product = await this.prisma.product.findUnique({ where: { id } }); + if (!product) { + throw new NotFoundException(`Product with id ${id} not found`); + } + // Ta bort beroenden först + await this.prisma.productTag.deleteMany({ where: { productId: id } }); + await this.prisma.userProduct.deleteMany({ where: { productId: id } }); + return this.prisma.product.delete({ where: { id } }); + } + async restore(id: number) { const product = await this.findOne(id); diff --git a/frontend/app/admin/products/DeletedProductsView.tsx b/frontend/app/admin/products/DeletedProductsView.tsx new file mode 100644 index 00000000..f7c57026 --- /dev/null +++ b/frontend/app/admin/products/DeletedProductsView.tsx @@ -0,0 +1,180 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; + +type DeletedProduct = { + id: number; + name: string; + normalizedName: string; + canonicalName?: string | null; + category?: string | null; + brand?: string | null; + deletedAt?: string | null; +}; + +export default function DeletedProductsView() { + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [pendingId, setPendingId] = useState(null); + const [search, setSearch] = useState(''); + + const fetchDeleted = useCallback(() => { + setLoading(true); + setError(null); + fetch('/api/admin/deleted-products') + .then((r) => r.json()) + .then((data) => { + if (Array.isArray(data)) setProducts(data); + else setError('Kunde inte ladda raderade produkter.'); + }) + .catch(() => setError('Nätverksfel. Försök igen.')) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { fetchDeleted(); }, [fetchDeleted]); + + const handleRestore = async (id: number) => { + if (!confirm('Återställ produkten?')) return; + setPendingId(id); + try { + const res = await fetch(`/api/admin/deleted-products/${id}`, { method: 'POST' }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + setError(d?.error ?? 'Kunde inte återställa produkten.'); + } else { + setProducts((prev) => prev.filter((p) => p.id !== id)); + } + } catch { + setError('Nätverksfel. Försök igen.'); + } finally { + setPendingId(null); + } + }; + + const handlePermanentDelete = async (id: number, name: string) => { + if (!confirm(`Radera "${name}" permanent? Detta kan inte ångras.`)) return; + setPendingId(id); + try { + const res = await fetch(`/api/admin/deleted-products/${id}`, { method: 'DELETE' }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + setError(d?.error ?? 'Kunde inte radera produkten permanent.'); + } else { + setProducts((prev) => prev.filter((p) => p.id !== id)); + } + } catch { + setError('Nätverksfel. Försök igen.'); + } finally { + setPendingId(null); + } + }; + + const filtered = products.filter((p) => + p.name.toLowerCase().includes(search.toLowerCase()) || + (p.canonicalName ?? '').toLowerCase().includes(search.toLowerCase()) + ); + + if (loading) return

Laddar raderade produkter...

; + + return ( +
+

+ Här visas produkter som mjukraderats. Du kan återställa dem eller radera dem permanent. +

+ + {error && ( +
+ {error} +
+ )} + +
+ setSearch(e.target.value)} + style={{ padding: '0.4rem 0.75rem', borderRadius: 6, border: '1px solid #ccc', fontSize: '0.9rem', minWidth: 220 }} + /> + + {filtered.length} av {products.length} produkter + + +
+ + {filtered.length === 0 ? ( +

Inga raderade produkter hittades.

+ ) : ( +
+ + + + + + + + + + + + + {filtered.map((p) => ( + + + + + + + + + ))} + +
IDNamnCanonical nameKategoriRaderadesÅtgärder
{p.id}{p.name}{p.canonicalName || '–'}{p.category || '–'} + {p.deletedAt ? new Date(p.deletedAt).toLocaleDateString('sv-SE') : '–'} + +
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/app/api/admin/deleted-products/[id]/route.ts b/frontend/app/api/admin/deleted-products/[id]/route.ts new file mode 100644 index 00000000..90836740 --- /dev/null +++ b/frontend/app/api/admin/deleted-products/[id]/route.ts @@ -0,0 +1,29 @@ +import { withAuth } from '../../../../../../lib/with-auth'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + +export const POST = withAuth(async (_req, session, context) => { + const { id } = await context.params; + const productId = Number(id); + if (!productId) return Response.json({ error: 'Ogiltigt id' }, { status: 400 }); + + const res = await fetch(`${API_BASE}/api/products/${productId}/restore`, { + method: 'POST', + headers: { Authorization: `Bearer ${session.accessToken}` }, + }); + const data = await res.json().catch(() => ({})); + return Response.json(data, { status: res.status }); +}); + +export const DELETE = withAuth(async (_req, session, context) => { + const { id } = await context.params; + const productId = Number(id); + if (!productId) return Response.json({ error: 'Ogiltigt id' }, { status: 400 }); + + const res = await fetch(`${API_BASE}/api/products/${productId}/permanent`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${session.accessToken}` }, + }); + const data = await res.json().catch(() => ({})); + return Response.json(data, { status: res.status }); +}); diff --git a/frontend/app/api/admin/deleted-products/route.ts b/frontend/app/api/admin/deleted-products/route.ts new file mode 100644 index 00000000..f276de4a --- /dev/null +++ b/frontend/app/api/admin/deleted-products/route.ts @@ -0,0 +1,11 @@ +import { withAuth } from '../../../../lib/with-auth'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + +export const GET = withAuth(async (_req, session) => { + const res = await fetch(`${API_BASE}/api/products/deleted`, { + headers: { Authorization: `Bearer ${session.accessToken}` }, + }); + const data = await res.json().catch(() => ([])); + return Response.json(data, { status: res.status }); +}); diff --git a/frontend/app/profil/tabs/DatabsTab.tsx b/frontend/app/profil/tabs/DatabsTab.tsx index 3ce51179..1bb6f9d6 100644 --- a/frontend/app/profil/tabs/DatabsTab.tsx +++ b/frontend/app/profil/tabs/DatabsTab.tsx @@ -1,18 +1,71 @@ +'use client'; + +import { useState } from 'react'; import MergePreviewForm from '../../admin/products/MergePreviewForm'; import AdminProductList from '../../admin/products/AdminProductList'; import ExpandableCreateProductSection from '../../admin/products/ExpandableCreateProductSection'; import ResetProductsButton from '../../admin/products/ResetProductsButton'; +import DeletedProductsView from '../../admin/products/DeletedProductsView'; + +const subTabs = [ + { id: 'varor', label: '📦 Varor' }, + { id: 'skapa-merge', label: '➕ Skapa / Slå ihop' }, + { id: 'papperskorg', label: '🗑️ Papperskorg' }, +] as const; + +type SubTabId = typeof subTabs[number]['id']; + +const tabStyle = (active: boolean): React.CSSProperties => ({ + padding: '0.4rem 1rem', + border: 'none', + borderBottom: active ? '2.5px solid #333' : '2.5px solid transparent', + background: 'none', + cursor: 'pointer', + fontWeight: active ? 600 : 400, + color: active ? '#111' : '#666', + fontSize: '0.95rem', + transition: 'color 0.15s, border-color 0.15s', +}); + +export default function DatabsTab() { + const [activeSubTab, setActiveSubTab] = useState('varor'); -export default async function DatabsTab() { return (
-

- Granska och standardisera produktnamn, slå ihop dubbletter och hantera kategorier. -

- - - - + {/* Undertabbar */} +
+ {subTabs.map((tab) => ( + + ))} +
+ + {activeSubTab === 'varor' && ( +
+

+ Granska och standardisera produktnamn samt hantera kategorier. +

+ +
+ )} + + {activeSubTab === 'skapa-merge' && ( +
+

+ Skapa ny produkt, återställ produktdatabas eller slå ihop dubbletter. +

+ + + +
+ )} + + {activeSubTab === 'papperskorg' && }
); } diff --git a/frontend/lib/error-handler.ts b/frontend/lib/error-handler.ts index 7280bd9a..e84679e1 100644 --- a/frontend/lib/error-handler.ts +++ b/frontend/lib/error-handler.ts @@ -8,7 +8,11 @@ export async function parseErrorResponse(response: Response): Promise { const data = await response.json(); // Om backend skickade ett felmeddelande - if (data.message) { + if (typeof data.message === 'string') { + // Produktnamns-dubblett + if (data.message.includes('Det finns redan en annan produkt med detta namn')) { + return 'Det finns redan en annan produkt med detta namn. Välj ett unikt namn.'; + } return data.message; } if (data.error) { @@ -43,3 +47,8 @@ export async function parseErrorResponse(response: Response): Promise { return defaultMessages[status] || `Fel (${status}). Försök igen senare.`; } + +// Prisma unique constraint: email +if (typeof data.message === 'string' && data.message.includes('User_email_key')) { + return 'E-postadressen används redan av en annan användare.'; +}