diff --git a/app/api/shopping-list/route.ts b/app/api/shopping-list/route.ts new file mode 100644 index 0000000..3dae844 --- /dev/null +++ b/app/api/shopping-list/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession } from "@/lib/auth"; +import prisma from "@/lib/prisma"; + +export async function POST(request: NextRequest) { + const session = await getSession(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const {start, end} = await request.json(); + + if (!start || !end) { + return NextResponse.json( + { error: "Brak daty startowej lub końcowej" }, + { status: 400 } + ) + } + + const startDate = new Date(start) + const endDate = new Date(end) + + + // getting meal plans within date range + + try { + const mealPlans = await prisma.mealPlan.findMany({ + where: { + userId: session.user.id, + date: { + gte: startDate, + lte: endDate, + }, + }, + include: { meal : true }, + }); + + + const shoppingListMap = new Map() + + for (const plan of mealPlans) { + const ingredients = plan.meal.ingredients as Record + for (const [name, amount] of Object.entries(ingredients)) { + shoppingListMap.set( + name, + (shoppingListMap.get(name) ?? 0) + amount + ) + } + } + + const shoppingListItems = Array.from(shoppingListMap.entries()) + .map(([name, amount]) => ({name,amount,})) + .sort((a, b) => a.name.localeCompare(b.name)) + + // + + const shoppingList = await prisma.shoppingList.create({ + data: { + userId: session.user.id, + startDate: startDate, + endDate: endDate, + items: shoppingListItems, + }, + }); + + return NextResponse.json({ shoppingList }, { status: 201 }); + + + } catch (error) { + return NextResponse.json( + { error: "Błąd podczas tworzenia listy zakupów" }, + { status: 500 } + ); + } + + +} \ No newline at end of file diff --git a/app/planner/PlannerClient.tsx b/app/planner/PlannerClient.tsx index cfa0a13..05f5bb2 100644 --- a/app/planner/PlannerClient.tsx +++ b/app/planner/PlannerClient.tsx @@ -7,6 +7,7 @@ import type { MealPlan, Meal } from "../generated/prisma/client"; import { useFlashStore } from "@/lib/flashStore"; import FlashNotices from "../components/FlashNotices"; import { type FailedToCreate } from '../api/meal-plan-generator/services'; +import { useRouter } from "next/navigation" type MealPlanWithMeal = MealPlan & { meal: Meal }; @@ -18,6 +19,8 @@ export default function PlannerClient({ meals }: PlannerClientProps) { const [showWeeklyForm, setShowWeeklyForm] = useState(false); const { flash } = useFlashStore(); + const router = useRouter() + return (
{ @@ -42,6 +45,23 @@ export default function PlannerClient({ meals }: PlannerClientProps) { {showWeeklyForm && ( setShowWeeklyForm(false)} /> )} + + { /* Przycisk do generowania listy zakupów */ } + +
+ +
+
+ + ); } diff --git a/app/profile/page.tsx b/app/profile/page.tsx index 7799444..cdfd93e 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -47,6 +47,9 @@ export default async function ProfilePage() { return (
+

+ Witaj, {session.user.name}! +

Szablony preferencji na dni tygodnia

diff --git a/app/shopping-list/[id]/page.tsx b/app/shopping-list/[id]/page.tsx new file mode 100644 index 0000000..6a216a4 --- /dev/null +++ b/app/shopping-list/[id]/page.tsx @@ -0,0 +1,43 @@ +import prisma from "@/lib/prisma" +import { getSession } from "@/lib/auth" + +type Props = { + params : Promise<{ id: string }> +} + +export default async function ShoppingListDetailPage({ params }: Props) { + const session = await getSession() + if (!session?.user?.id) return

Musisz być zalogowany

+ + const corParams = await params + const id = parseInt(corParams.id, 10) + console.log("ID listy zakupów:", id) + + const list = await prisma.shoppingList.findUnique({ + where: { id }, + }) + + if (!list || list.userId !== session.user.id) { + return

Lista zakupów nie znaleziona

+ } + + const shoppingListItems = (list.items as { name: string; amount: number }[]) + + return ( +
+
+

+ Lista zakupów: od {list.startDate.toISOString().slice(0,10)} do {list.endDate.toISOString().slice(0,10)} +

+ +
    + {shoppingListItems.map((item: any) => ( +
  • + {item.name}: {item.amount}g +
  • + ))} +
+
+
+ ) +} diff --git a/app/shopping-list/new/page.tsx b/app/shopping-list/new/page.tsx new file mode 100644 index 0000000..cfda346 --- /dev/null +++ b/app/shopping-list/new/page.tsx @@ -0,0 +1,61 @@ +"use client" + +import { useRouter } from "next/navigation" +import { useState } from "react" + +export default function NewShoppingListPage() { + const router = useRouter() + const [start, setStart] = useState("") + const [end, setEnd] = useState("") + + async function handleGenerate() { + if (!start || !end) { + alert("Wybierz zarówno datę początkową, jak i końcową") + return + } + + try { + const res = await fetch("/api/shopping-list", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ start, end }), + cache: "no-store", + }) + + if (!res.ok) { + throw new Error("Nie udało się wygenerować listy zakupów") + } + + router.push("/shopping-list") + } catch (err) { + console.error(err) + alert("Wystąpił błąd przy tworzeniu listy zakupów") + } + } + + return ( +
+

+ Nowa lista zakupów +

+ + {/* Proste wprowadzanie dat*/} +
+ setStart(e.target.value)} + /> + setEnd(e.target.value)} + /> + + +
+
+ ) +} diff --git a/app/shopping-list/page.tsx b/app/shopping-list/page.tsx index 4f4df47..67d26ea 100644 --- a/app/shopping-list/page.tsx +++ b/app/shopping-list/page.tsx @@ -1,12 +1,38 @@ -export default function ShoppingListPage() { +import prisma from "@/lib/prisma"; +import { getSession } from "@/lib/auth"; + +export default async function ShoppingListPage() { + const session = await getSession(); + if (!session?.user?.id) return

Musisz być zalogowany

; + + const lists = await prisma.shoppingList.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: "desc" }, + }); + + return (

- Lista zakupów + Listy zakupowe

-

- Tu będzie strona z listą zakupów. -

+ + {lists.length === 0 ? ( +

Brak list zakupów. Wygeneruj nową listę zakupów.

+ ) : ( + + // bardzo proste przekierowanie do list + + + )}
); } diff --git a/prisma/migrations/20251228224915_add_shopping_list/migration.sql b/prisma/migrations/20251228224915_add_shopping_list/migration.sql new file mode 100644 index 0000000..7042736 --- /dev/null +++ b/prisma/migrations/20251228224915_add_shopping_list/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "ShoppingList" ( + "id" SERIAL NOT NULL, + "userId" TEXT NOT NULL, + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3) NOT NULL, + "items" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ShoppingList_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "ShoppingList" ADD CONSTRAINT "ShoppingList_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7f38bc6..5029826 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -45,14 +45,15 @@ model Tag { } model User { - id String @id @default(cuid()) - email String @unique - password String - name String? - createdAt DateTime @default(now()) - mealPlans MealPlan[] //one to many - preferences UserPreference[] - meals Meal[] // user-created meals + id String @id @default(cuid()) + email String @unique + password String + name String? + createdAt DateTime @default(now()) + mealPlans MealPlan[] //one to many + preferences UserPreference[] + meals Meal[] // user-created meals + shoppingLists ShoppingList[] } enum MealType { @@ -81,3 +82,13 @@ model UserPreference { @@id([userId, dayOfWeek, tagId]) } + +model ShoppingList { + id Int @id @default(autoincrement()) + userId String + user User @relation(fields: [userId], references: [id]) + startDate DateTime + endDate DateTime + items Json + createdAt DateTime @default(now()) +} diff --git a/test/api/shoppinglist.test.ts b/test/api/shoppinglist.test.ts new file mode 100644 index 0000000..d4d78c3 --- /dev/null +++ b/test/api/shoppinglist.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import prisma from "@/lib/prisma"; +import { getSession } from "@/lib/auth"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/auth", () => ({ + getSession: vi.fn(), +})); + +describe("POST /api/shopping-list", () => { + beforeEach(async () => { + vi.resetAllMocks(); + vi.resetModules(); + + await prisma.shoppingList.deleteMany(); + await prisma.mealPlan.deleteMany(); + await prisma.meal.deleteMany(); + await prisma.user.deleteMany(); + }); + + // test dla sumowania skladnikow listy + + it("tworzy liste zakupow z poprawnie zsumowanymi skladnikami", async () => { + + const userId = "user_test_shop"; + await prisma.user.create({ + data: { + id: userId, + email: "liketoshop@gmail.com", + password: "hashed-password", + name: "Shopper", + }, + }); + + const meal1 = await prisma.meal.create({ + data: { + name: "Kanapki z serem i ogórkiem", + ingredients: { "chleb": 100, "ser": 40, "ogórek": 20 }, + recipe: ["Posmaruj chleb", "Dodaj ser i ogórek"], + userId: userId, + suggestedMealType: ["BREAKFAST"], + }, + }); + + const meal2 = await prisma.meal.create({ + data: { + name: "Tosty ala pizza", + ingredients: { "chleb": 140, "ser": 60, "szynka": 50, "pieczarki": 30, "ketchup": 10 }, + recipe: ["Ułóż składniki na chlebie", "Podpiecz w tosterze"], + userId: userId, + suggestedMealType: ["DINNER"], + }, + }); + + const meal3 = await prisma.meal.create({ + data: { + name: "Makaron mac and cheese", + ingredients: { "makaron": 150, "ser": 200, "przyprawy": 10 }, + recipe: ["Ugotuj makaron", "Dodaj ser i przyprawy"], + userId: userId, + suggestedMealType: ["LUNCH"], + }, + }); + + await prisma.mealPlan.create({ + data: { date: new Date("2025-12-22"), type: "BREAKFAST", mealId: meal1.id, userId } + }); + await prisma.mealPlan.create({ + data: { date: new Date("2025-12-22"), type: "DINNER", mealId: meal2.id, userId } + }); + await prisma.mealPlan.create({ + data: { date: new Date("2025-12-22"), type: "LUNCH", mealId: meal3.id, userId } + }); + + // mokowanie zalogowanego uzytkownika + vi.mocked(getSession).mockResolvedValue({ user: { id: userId } } as any); + + // import funkcji POST z api + const { POST } = await import("@/app/api/shopping-list/route"); + + const req = new NextRequest("http://localhost/api/shopping-list", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + start: "2025-12-21", + end: "2025-12-23", + }), + }); + + const res = await POST(req); + expect(res.status).toBe(201); + + const payload = await res.json(); + expect(payload.shoppingList.userId).toBe(userId); + + const savedList = await prisma.shoppingList.findFirst({ + where: { userId }, + }); + + const expectedItems = [ + { name: "chleb", amount: 240 }, + { name: "ser", amount: 300 }, + { name: "ketchup", amount: 10 }, + { name: "makaron", amount: 150 }, + { name: "ogórek", amount: 20 }, + { name: "pieczarki", amount: 30 }, + { name: "przyprawy", amount: 10 }, + { name: "szynka", amount: 50 }, + ]; + + // sortujemy po nazwie + const sortByName = (a: any, b: any) => a.name.localeCompare(b.name); + + expect(payload.shoppingList.items.sort(sortByName)).toEqual( + expectedItems.sort(sortByName) + ); + + }); + + it("zwraca 401 jeśli użytkownik niezalogowany", async () => { + vi.mocked(getSession).mockResolvedValue(null as any); + + const { POST } = await import("@/app/api/shopping-list/route"); + + const req = new NextRequest("http://localhost/api/shopping-list", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ start: "2025-12-22", end: "2025-12-22" }), + }); + + const res = await POST(req); + expect(res.status).toBe(401); + + const payload = await res.json(); + expect(payload.error).toBe("Unauthorized"); + }); + +}); \ No newline at end of file