From b61e7bc1a11106813611935b578ab5f8e18e42aa Mon Sep 17 00:00:00 2001 From: Bash Abdul Date: Thu, 23 Jul 2026 16:07:40 +0100 Subject: [PATCH 01/19] Harden checkout and payment integrity --- .env.example | 8 + app/api/cart/summary/route.ts | 1 + app/api/checkout/create-order/route.ts | 201 ++++- app/api/health/route.ts | 6 +- app/api/paystack/initialize/route.ts | 243 ++++-- app/api/paystack/webhook/route.ts | 341 ++++++--- app/api/products/route.ts | 2 +- app/api/recommendations/route.ts | 3 +- app/api/search/route.ts | 5 +- app/api/v1/back-in-stock/route.ts | 6 +- app/api/v1/discovery-sets/route.ts | 6 +- app/api/v1/reviews/summary/route.ts | 5 +- app/checkout/page.tsx | 5 + app/checkout/success/page.tsx | 184 ++--- app/collections/[brand]/page.tsx | 8 +- app/collections/featured/page.tsx | 1 + app/collections/page.tsx | 2 +- app/discovery/page.tsx | 7 +- app/drops/page.tsx | 6 +- app/help/shipping/page.tsx | 3 +- app/journal/[slug]/page.tsx | 6 +- app/page.tsx | 3 +- app/shop/page.tsx | 3 +- app/sitemap.ts | 2 +- components/checkout/checkout-content.tsx | 25 +- components/checkout/order-summary.tsx | 18 +- components/checkout/payment-form.tsx | 55 +- components/checkout/shipping-form.tsx | 7 +- docs/PRODUCTION_READINESS_PLAN.md | 723 ++++++++++++++++++ integrations/commerce/local/index.ts | 25 +- integrations/search/postgres.ts | 5 +- lib/catalogue/public-product.ts | 21 + lib/checkout/idempotency.ts | 42 + lib/config/commerce.ts | 19 + lib/config/payment-methods.ts | 19 + lib/env-diagnostics.ts | 14 + lib/env.ts | 8 + lib/payments/match.ts | 21 + lib/pdp/loader.ts | 2 +- lib/queries/products.ts | 16 +- lib/security/origin.ts | 19 + lib/services/product-service.ts | 5 +- next.config.js | 34 + .../migration.sql | 67 ++ prisma/schema.prisma | 55 +- tests/catalogue/public-product.test.ts | 24 + tests/checkout/idempotency.test.ts | 46 ++ tests/config/commerce.test.ts | 13 +- tests/payments/match.test.ts | 30 + tests/security/origin.test.ts | 34 + 50 files changed, 2028 insertions(+), 376 deletions(-) create mode 100644 docs/PRODUCTION_READINESS_PLAN.md create mode 100644 lib/catalogue/public-product.ts create mode 100644 lib/checkout/idempotency.ts create mode 100644 lib/config/payment-methods.ts create mode 100644 lib/payments/match.ts create mode 100644 lib/security/origin.ts create mode 100644 prisma/migrations/20260723150000_payment_attempts_idempotency/migration.sql create mode 100644 tests/catalogue/public-product.test.ts create mode 100644 tests/checkout/idempotency.test.ts create mode 100644 tests/payments/match.test.ts create mode 100644 tests/security/origin.test.ts diff --git a/.env.example b/.env.example index e50ec40..25fecd0 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,14 @@ TWILIO_FROM="" COMMERCE_CURRENCY="NGN" COMMERCE_FREE_SHIPPING_THRESHOLD_NGN="500000" COMMERCE_FLAT_SHIPPING_NGN="2500" +COMMERCE_EXPRESS_SHIPPING_NGN="35000" +COMMERCE_GIFT_WRAP_NGN="2500" + +# Manual bank transfer is hidden unless explicitly enabled with owner-approved details. +BANK_TRANSFER_ENABLED="false" +BANK_TRANSFER_ACCOUNT_NAME="" +BANK_TRANSFER_BANK_NAME="" +BANK_TRANSFER_ACCOUNT_NUMBER="" # --- Feature flags (comma-separated). Prefix with ! to force-disable a default-on flag. --- # Available: shopify_commerce, ai_concierge, concierge_v2, loyalty_rewards, referral_rewards, diff --git a/app/api/cart/summary/route.ts b/app/api/cart/summary/route.ts index da0c5c3..ca4cf07 100644 --- a/app/api/cart/summary/route.ts +++ b/app/api/cart/summary/route.ts @@ -15,6 +15,7 @@ export async function GET() { where: { id: { in: productIds }, deletedAt: null, // Exclude soft-deleted products from cart + publishStatus: "PUBLISHED", }, select: { id: true, diff --git a/app/api/checkout/create-order/route.ts b/app/api/checkout/create-order/route.ts index b7cc283..4db899e 100644 --- a/app/api/checkout/create-order/route.ts +++ b/app/api/checkout/create-order/route.ts @@ -2,6 +2,15 @@ import { NextRequest, NextResponse } from "next/server" import { auth } from "@/lib/auth" import { prisma } from "@/lib/prisma" import { z } from "zod" +import { computeCheckoutShipping } from "@/lib/config/commerce" +import { getBankTransferConfig } from "@/lib/config/payment-methods" +import { + checkoutRequestHash, + isValidIdempotencyKey, +} from "@/lib/checkout/idempotency" +import { consumeRateLimit } from "@/lib/middleware/limiter" +import { validateCoupon } from "@/lib/pricing" +import { hasTrustedOrigin } from "@/lib/security/origin" const createOrderSchema = z.object({ addressLine1: z.string().min(1, "Address is required"), @@ -15,11 +24,14 @@ const createOrderSchema = z.object({ priceNGN: z.number().int().min(0), }) ).min(1, "Cart is empty"), - subtotalNGN: z.number().int().min(0), - discountNGN: z.number().int().min(0).default(0), - shippingNGN: z.number().int().min(0), - totalNGN: z.number().int().min(0), + // Accepted temporarily for older clients, but all monetary values are recomputed below. + subtotalNGN: z.number().int().min(0).optional(), + discountNGN: z.number().int().min(0).optional(), + shippingNGN: z.number().int().min(0).optional(), + totalNGN: z.number().int().min(0).optional(), couponId: z.string().optional().nullable(), + couponCode: z.string().trim().max(100).optional().nullable(), + deliveryMethod: z.enum(["standard", "express"]).default("standard"), isGift: z.boolean().optional().default(false), giftMessage: z.string().max(500).optional().nullable(), giftWrapping: z.boolean().optional().default(false), @@ -28,6 +40,9 @@ const createOrderSchema = z.object({ export async function POST(req: NextRequest) { try { + if (!hasTrustedOrigin(req)) { + return NextResponse.json({ error: "Request origin could not be verified" }, { status: 403 }) + } const session = await auth() const email = session?.user?.email if (!email) { @@ -41,6 +56,17 @@ export async function POST(req: NextRequest) { if (!user) { return NextResponse.json({ error: "User not found" }, { status: 401 }) } + const limit = await consumeRateLimit( + `checkout:create-order:${user.id}`, + 10, + 10 * 60 * 1000, + ) + if (!limit.ok) { + return NextResponse.json( + { error: "Too many checkout attempts. Please wait and try again." }, + { status: 429 }, + ) + } const body = await req.json() const parsed = createOrderSchema.safeParse(body) @@ -49,12 +75,75 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: msg }, { status: 400 }) } - const { addressLine1, city, state, phone, items, subtotalNGN: _subtotalNGN, discountNGN, shippingNGN, totalNGN, couponId, isGift, giftMessage, giftWrapping, paymentMethod } = parsed.data + const idempotencyKey = req.headers.get("idempotency-key") + if (!isValidIdempotencyKey(idempotencyKey)) { + return NextResponse.json( + { error: "A valid Idempotency-Key header is required" }, + { status: 400 }, + ) + } + + const { + addressLine1, + city, + state, + phone, + items, + couponCode, + deliveryMethod, + isGift, + giftMessage, + giftWrapping, + paymentMethod, + } = parsed.data + + if (paymentMethod === "BANK_TRANSFER" && !getBankTransferConfig()) { + return NextResponse.json( + { error: "Bank transfer is not currently available" }, + { status: 400 }, + ) + } + + const requestHash = checkoutRequestHash({ + addressLine1, + city, + state, + phone, + items, + couponCode, + deliveryMethod, + isGift, + giftMessage, + giftWrapping, + paymentMethod, + }) + const priorAttempt = await prisma.checkoutAttempt.findUnique({ + where: { + userId_idempotencyKey: { + userId: user.id, + idempotencyKey, + }, + }, + select: { orderId: true, requestHash: true }, + }) + if (priorAttempt) { + if (priorAttempt.requestHash !== requestHash) { + return NextResponse.json( + { error: "That checkout key was already used for different order details" }, + { status: 409 }, + ) + } + return NextResponse.json({ orderId: priorAttempt.orderId, reused: true }) + } // Resolve product IDs and validate prices (use DB price for consistency) const productIds = [...new Set(items.map((i) => i.productId))] const products = await prisma.product.findMany({ - where: { id: { in: productIds }, deletedAt: null }, + where: { + id: { in: productIds }, + deletedAt: null, + publishStatus: "PUBLISHED", + }, select: { id: true, priceNGN: true, stock: true }, }) const productMap = new Map(products.map((p) => [p.id, p])) @@ -79,40 +168,78 @@ export async function POST(req: NextRequest) { } const computedSubtotal = orderItems.reduce((s, i) => s + i.priceNGN * i.quantity, 0) - const orderTotal = computedSubtotal - discountNGN + shippingNGN - if (orderTotal !== totalNGN) { - return NextResponse.json( - { error: "Total mismatch. Please refresh and try again." }, - { status: 400 } - ) + let computedDiscount = 0 + let validatedCouponId: string | null = null + if (couponCode) { + const coupon = await validateCoupon(couponCode, computedSubtotal) + if (!coupon.ok) { + return NextResponse.json({ error: coupon.message }, { status: 400 }) + } + computedDiscount = coupon.discountNGN + validatedCouponId = coupon.couponId } + const computedShipping = computeCheckoutShipping( + computedSubtotal, + deliveryMethod, + giftWrapping, + ) + const orderTotal = computedSubtotal - computedDiscount + computedShipping - const order = await prisma.order.create({ - data: { - userId: user.id, - status: "PENDING", - subtotalNGN: computedSubtotal, - discountNGN, - shippingNGN, - totalNGN: orderTotal, - couponId: couponId || null, - addressLine1, - city, - state, - phone, - isGift: isGift ?? false, - giftMessage: giftMessage || null, - giftWrapping: giftWrapping ?? false, - paymentMethod: paymentMethod ?? "CARD", - items: { - create: orderItems.map((i) => ({ - productId: i.productId, - quantity: i.quantity, - priceNGN: i.priceNGN, - })), + let order: { id: string } + try { + order = await prisma.$transaction(async (tx) => { + const created = await tx.order.create({ + data: { + userId: user.id, + status: "PENDING", + subtotalNGN: computedSubtotal, + discountNGN: computedDiscount, + shippingNGN: computedShipping, + totalNGN: orderTotal, + couponId: validatedCouponId, + addressLine1, + city, + state, + phone, + isGift, + giftMessage: giftMessage || null, + giftWrapping, + paymentMethod, + items: { + create: orderItems.map((i) => ({ + productId: i.productId, + quantity: i.quantity, + priceNGN: i.priceNGN, + })), + }, + }, + select: { id: true }, + }) + await tx.checkoutAttempt.create({ + data: { + userId: user.id, + orderId: created.id, + idempotencyKey, + requestHash, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }) + return created + }) + } catch (error) { + if ((error as { code?: string })?.code !== "P2002") throw error + const concurrent = await prisma.checkoutAttempt.findUnique({ + where: { + userId_idempotencyKey: { + userId: user.id, + idempotencyKey, + }, }, - }, - }) + select: { orderId: true, requestHash: true }, + }) + if (!concurrent || concurrent.requestHash !== requestHash) throw error + order = { id: concurrent.orderId } + } return NextResponse.json({ orderId: order.id }) } catch (e) { diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 66dfab2..ad11be2 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -9,13 +9,10 @@ export async function GET() { const env = getEnvDiagnostics() let database: "up" | "down" = "up" - let databaseError: string | null = null - try { await prisma.$queryRaw`SELECT 1` - } catch (error) { + } catch { database = "down" - databaseError = error instanceof Error ? error.message : "Unknown database error" } const ok = database === "up" && env.missingCritical.length === 0 @@ -30,7 +27,6 @@ export async function GET() { env: env.missingCritical.length === 0 ? "up" : "down", }, env, - ...(databaseError ? { databaseError } : {}), }, { status } ) diff --git a/app/api/paystack/initialize/route.ts b/app/api/paystack/initialize/route.ts index b06793d..57f6de6 100644 --- a/app/api/paystack/initialize/route.ts +++ b/app/api/paystack/initialize/route.ts @@ -1,80 +1,215 @@ -// app/api/paystack/initialize/route.ts -import { NextResponse } from 'next/server' +import crypto from "crypto" +import { NextResponse } from "next/server" +import { z } from "zod" -export const runtime = 'nodejs' +import { getPayments } from "@/integrations/registry" +import { auth } from "@/lib/auth" +import { isValidIdempotencyKey } from "@/lib/checkout/idempotency" +import { getCommerceConfig } from "@/lib/config/commerce" +import { env } from "@/lib/env" +import { AppError } from "@/lib/http/errors" +import { consumeRateLimit } from "@/lib/middleware/limiter" +import { logger } from "@/lib/observability/logger" +import { prisma } from "@/lib/prisma" +import { hasTrustedOrigin } from "@/lib/security/origin" -function looksLikePaystackSecretKey(v: string) { - return /^sk_(test|live)_/i.test(v.trim()) +export const runtime = "nodejs" + +const BodySchema = z.object({ + orderId: z.string().min(1), +}).strict() + +function retryResponse(attempt: { + orderId: string + authorizationUrl: string | null + providerReference: string + status: string + order: { user: { email: string } } +}, orderId: string, email: string) { + if ( + attempt.orderId !== orderId || + attempt.order.user.email.toLowerCase() !== email.toLowerCase() + ) { + return NextResponse.json( + { error: "That idempotency key belongs to a different payment" }, + { status: 409 }, + ) + } + if (attempt.status === "PENDING" && attempt.authorizationUrl) { + return NextResponse.json({ + authorization_url: attempt.authorizationUrl, + reference: attempt.providerReference, + }) + } + if (attempt.status === "SUCCEEDED") { + return NextResponse.json({ error: "This payment has already completed" }, { status: 409 }) + } + if (attempt.status === "FAILED") { + return NextResponse.json( + { error: "That payment attempt failed. Please try again." }, + { status: 409 }, + ) + } + return NextResponse.json( + { error: "Payment initialization is already in progress. Please retry shortly." }, + { status: 409 }, + ) } export async function POST(req: Request) { + let attemptId: string | null = null try { - const body = await req.json() - const { email, amountNGN, metadata, meta } = body - const paystackMeta = metadata ?? meta ?? {} - - const secret = (process.env.PAYSTACK_SECRET_KEY || '').trim() - const cleanSecret = secret.toLowerCase().includes('bearer') - ? secret.replace(/^bearer\s+/i, '').trim() - : secret - - if (!cleanSecret) { - return NextResponse.json( - { error: 'PAYSTACK_SECRET_KEY missing in .env' }, - { status: 400 } - ) + if (!hasTrustedOrigin(req)) { + return NextResponse.json({ error: "Request origin could not be verified" }, { status: 403 }) + } + const session = await auth() + const email = session?.user?.email + if (!email) { + return NextResponse.json({ error: "Sign in to pay for an order" }, { status: 401 }) } - if (!looksLikePaystackSecretKey(cleanSecret)) { + const payerHash = crypto.createHash("sha256").update(email.toLowerCase()).digest("hex") + const limit = await consumeRateLimit( + `payments:initialize:${payerHash}`, + 5, + 5 * 60 * 1000, + ) + if (!limit.ok) { return NextResponse.json( - { - error: - 'PAYSTACK_SECRET_KEY is not in the expected format (should start with sk_test_ or sk_live_). Copy the Secret Key from your Paystack Dashboard -> Settings -> API Keys & Webhooks. Current key format is invalid.', - }, - { status: 400 } + { error: "Too many payment attempts. Please wait and try again." }, + { status: 429 }, ) } - if (!email || !amountNGN || Number(amountNGN) <= 0) { + const parsed = BodySchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: "A valid order is required" }, { status: 400 }) + } + const idempotencyKey = req.headers.get("idempotency-key") + if (!isValidIdempotencyKey(idempotencyKey)) { return NextResponse.json( - { error: 'Invalid email or amount' }, - { status: 400 } + { error: "A valid Idempotency-Key header is required" }, + { status: 400 }, ) } - const amountKobo = Math.round(Number(amountNGN) * 100) + const prior = await prisma.paymentAttempt.findUnique({ + where: { idempotencyKey }, + select: { + orderId: true, + authorizationUrl: true, + providerReference: true, + status: true, + order: { select: { user: { select: { email: true } } } }, + }, + }) + if (prior) return retryResponse(prior, parsed.data.orderId, email) - const res = await fetch('https://api.paystack.co/transaction/initialize', { - method: 'POST', - headers: { - // ✅ must be "Bearer " - Authorization: `Bearer ${cleanSecret}`, - 'Content-Type': 'application/json', + const order = await prisma.order.findFirst({ + where: { + id: parsed.data.orderId, + status: "PENDING", + paymentMethod: "CARD", + user: { email }, + }, + select: { + id: true, + totalNGN: true, + user: { select: { email: true } }, }, - body: JSON.stringify({ - email, - amount: amountKobo, - currency: 'NGN', - metadata: paystackMeta, - }), }) + if (!order) { + return NextResponse.json( + { error: "That order is unavailable or already processed" }, + { status: 404 }, + ) + } - const data = await res.json().catch(() => ({} as any)) + const provider = getPayments() + if (provider.name !== "paystack") { + return NextResponse.json( + { error: "Card payments are not configured" }, + { status: 503 }, + ) + } - if (!res.ok || !data?.status || !data?.data?.authorization_url) { - // Paystack returns { status: false, message: '...' } on errors - const msg = data?.message || data?.error || 'Paystack init failed' - return NextResponse.json({ error: msg }, { status: 400 }) + const reference = `fade_${crypto.randomUUID().replaceAll("-", "")}` + const currency = getCommerceConfig().shipping.currency + try { + const attempt = await prisma.paymentAttempt.create({ + data: { + orderId: order.id, + provider: provider.name, + providerReference: reference, + idempotencyKey, + expectedAmountNGN: order.totalNGN, + expectedCurrency: currency, + }, + select: { id: true }, + }) + attemptId = attempt.id + } catch (error) { + if ((error as { code?: string })?.code === "P2002") { + const raced = await prisma.paymentAttempt.findUnique({ + where: { idempotencyKey }, + select: { + orderId: true, + authorizationUrl: true, + providerReference: true, + status: true, + order: { select: { user: { select: { email: true } } } }, + }, + }) + if (raced) return retryResponse(raced, order.id, email) + } + throw error } + const result = await provider.initialize({ + orderId: order.id, + reference, + amountNGN: order.totalNGN, + currency, + email: order.user.email, + callbackUrl: `${env.APP_URL.replace(/\/$/, "")}/checkout/success`, + metadata: { orderId: order.id }, + }) + + await prisma.paymentAttempt.update({ + where: { id: attemptId }, + data: { + providerReference: result.reference, + authorizationUrl: result.authorizationUrl, + status: "PENDING", + initializedAt: new Date(), + }, + }) + return NextResponse.json({ - authorization_url: data.data.authorization_url, - reference: data.data.reference, - access_code: data.data.access_code, + authorization_url: result.authorizationUrl, + reference: result.reference, }) - } catch (e: any) { - return NextResponse.json( - { error: e?.message || 'Init error' }, - { status: 500 } - ) + } catch (error) { + if (attemptId) { + await prisma.paymentAttempt + .update({ + where: { id: attemptId }, + data: { + status: "FAILED", + failureCode: error instanceof AppError ? error.code : "INTERNAL_ERROR", + }, + }) + .catch((updateError) => { + logger.error("payment_attempt_failure_record_failed", { + attemptId, + internal: String(updateError), + }) + }) + } + const message = + error instanceof AppError + ? error.safeMessage + : "We could not start your payment. Please try again." + const status = error instanceof AppError ? error.status : 500 + return NextResponse.json({ error: message }, { status }) } } diff --git a/app/api/paystack/webhook/route.ts b/app/api/paystack/webhook/route.ts index 7cb0724..b0edd75 100644 --- a/app/api/paystack/webhook/route.ts +++ b/app/api/paystack/webhook/route.ts @@ -1,132 +1,293 @@ -// app/api/paystack/webhook/route.ts -import { NextRequest, NextResponse } from 'next/server' -import crypto from 'crypto' -import { prisma } from '@/lib/prisma' -import { sendReceipt } from '@/emails/sendReceipt' -import { recordWebhookOnce } from '@/lib/webhooks/idempotency' -import { resolveLoyaltyTier } from '@/lib/config/commerce' -import { pointsForOrder } from '@/lib/loyalty/points' -import { reversePointsForOrder } from '@/lib/loyalty/service' -import { qualifyReferral } from '@/lib/referrals/service' - -function verify(reqBody: string, signature?: string) { - const secret = process.env.PAYSTACK_SECRET_KEY || '' - if (!secret || !signature) return false - const hash = crypto.createHmac('sha512', secret).update(reqBody).digest('hex') - return hash === signature -} +import { NextRequest, NextResponse } from "next/server" + +import { sendReceipt } from "@/emails/sendReceipt" +import { getPayments } from "@/integrations/registry" +import { resolveLoyaltyTier } from "@/lib/config/commerce" +import { AppError } from "@/lib/http/errors" +import { pointsForOrder } from "@/lib/loyalty/points" +import { reversePointsForOrder } from "@/lib/loyalty/service" +import { logger } from "@/lib/observability/logger" +import { matchesExpectedPayment } from "@/lib/payments/match" +import { prisma } from "@/lib/prisma" +import { qualifyReferral } from "@/lib/referrals/service" +import { recordWebhookOnce } from "@/lib/webhooks/idempotency" + +export const runtime = "nodejs" export async function POST(req: NextRequest) { const raw = await req.text() - const signature = req.headers.get('x-paystack-signature') || undefined - if (!verify(raw, signature)) return NextResponse.json({ error: 'invalid_signature' }, { status: 401 }) + const signature = req.headers.get("x-paystack-signature") + const provider = getPayments() - const evt = JSON.parse(raw) - if (evt?.event === 'charge.success') { - const ref = evt?.data?.reference as string | undefined - const orderId = evt?.data?.metadata?.orderId as string | undefined + if (provider.name !== "paystack") { + return NextResponse.json({ error: "payment_provider_unavailable" }, { status: 503 }) + } - // Durable replay protection: process each event id at most once. - const eventId = String(evt?.id ?? evt?.data?.id ?? ref ?? orderId ?? '') - if (eventId) { - const first = await recordWebhookOnce('paystack', eventId, evt.event) - if (!first) return NextResponse.json({ ok: true }) + const verified = provider.verifyWebhook(raw, signature) + if (!verified.valid) { + return NextResponse.json({ error: "invalid_signature" }, { status: 401 }) + } + + let rawEvent: any + try { + rawEvent = JSON.parse(raw) + } catch { + return NextResponse.json({ error: "invalid_payload" }, { status: 400 }) + } + + const eventId = String( + rawEvent?.id ?? + rawEvent?.data?.id ?? + verified.reference ?? + verified.orderId ?? + "", + ) + + if (verified.event === "charge.success") { + const { orderId, reference, amountNGN, currency, status } = verified + if (!orderId || !reference || amountNGN == null || !currency) { + logger.warn("paystack_webhook_incomplete", { eventId }) + return NextResponse.json({ ok: true, ignored: "incomplete" }) } - if (orderId) { - // Fetch order items first so we can decrement stock - const existingOrder = await prisma.order.findUnique({ - where: { id: orderId }, - select: { status: true, couponId: true, items: { select: { productId: true, quantity: true } } }, + const attempt = await prisma.paymentAttempt.findUnique({ + where: { providerReference: reference }, + include: { + order: { + select: { + id: true, + status: true, + reference: true, + totalNGN: true, + paymentMethod: true, + couponId: true, + items: { select: { productId: true, quantity: true } }, + }, + }, + }, + }) + + if (!attempt || attempt.orderId !== orderId) { + logger.warn("paystack_webhook_unknown_attempt", { eventId, reference, orderId }) + return NextResponse.json({ ok: true, ignored: "unknown_attempt" }) + } + + const matches = matchesExpectedPayment({ + expectedReference: attempt.providerReference, + receivedReference: reference, + expectedAmountNGN: attempt.expectedAmountNGN, + receivedAmountNGN: amountNGN, + expectedCurrency: attempt.expectedCurrency, + receivedCurrency: currency, + providerStatus: status, + paymentMethod: attempt.order.paymentMethod, + }) + + if ( + !matches || + attempt.expectedAmountNGN !== attempt.order.totalNGN || + attempt.status === "FAILED" || + attempt.status === "ABANDONED" + ) { + logger.error("paystack_webhook_payment_mismatch", { + eventId, + reference, + orderId, + attemptStatus: attempt.status, + expectedAmountNGN: attempt.expectedAmountNGN, + orderAmountNGN: attempt.order.totalNGN, + receivedAmountNGN: amountNGN, + expectedCurrency: attempt.expectedCurrency, + receivedCurrency: currency, }) + return NextResponse.json({ ok: true, ignored: "payment_mismatch" }) + } - // Guard: skip if already paid (duplicate webhook) - if (!existingOrder || existingOrder.status === 'PAID') { - return NextResponse.json({ ok: true }) - } + try { + const result = await prisma.$transaction(async (tx) => { + if (eventId) { + await tx.webhookReceipt.create({ + data: { + provider: "paystack", + eventId, + topic: verified.event, + }, + }) + } - // Atomically: mark PAID, decrement stock, increment coupon usage, update loyalty - const order = await prisma.$transaction(async (tx) => { - const updated = await tx.order.update({ - where: { id: orderId }, - data: { status: 'PAID', reference: ref ?? null }, - include: { user: true, items: { include: { product: true } }, coupon: true }, + await tx.paymentAttempt.updateMany({ + where: { + id: attempt.id, + status: { in: ["INITIALIZED", "PENDING"] }, + }, + data: { + status: "SUCCEEDED", + providerTransactionId: + rawEvent?.data?.id == null ? null : String(rawEvent.data.id), + verifiedAt: new Date(), + }, + }) + + const transitioned = await tx.order.updateMany({ + where: { + id: orderId, + status: "PENDING", + paymentMethod: "CARD", + }, + data: { + status: "PAID", + reference, + }, }) + if (transitioned.count !== 1) { + const currentOrder = await tx.order.findUnique({ + where: { id: orderId }, + select: { status: true, reference: true }, + }) + return { + order: null, + alreadyPaid: currentOrder?.status === "PAID", + paidReference: currentOrder?.reference ?? null, + } + } - // Decrement stock for each ordered product - await Promise.all( - existingOrder.items.map((item) => - tx.product.update({ - where: { id: item.productId }, - data: { stock: { decrement: item.quantity } }, + for (const item of attempt.order.items) { + const updated = await tx.product.updateMany({ + where: { + id: item.productId, + stock: { gte: item.quantity }, + }, + data: { stock: { decrement: item.quantity } }, + }) + if (updated.count !== 1) { + throw new AppError("INSUFFICIENT_STOCK", { + internal: { orderId, productId: item.productId }, }) - ) - ) + } + } - // Increment coupon usage if one was applied - if (existingOrder.couponId) { + if (attempt.order.couponId) { await tx.coupon.update({ - where: { id: existingOrder.couponId }, + where: { id: attempt.order.couponId }, data: { usedCount: { increment: 1 } }, }) } - // Update user loyalty tier and lifetime spend (thresholds from commerce config) + const paidOrder = await tx.order.findUniqueOrThrow({ + where: { id: orderId }, + include: { + user: true, + items: { include: { product: true } }, + coupon: true, + }, + }) + const updatedUser = await tx.user.update({ - where: { id: updated.userId }, - data: { totalLifetimeSpend: { increment: updated.totalNGN } }, + where: { id: paidOrder.userId }, + data: { totalLifetimeSpend: { increment: paidOrder.totalNGN } }, select: { totalLifetimeSpend: true }, }) await tx.user.update({ - where: { id: updated.userId }, - data: { loyaltyTier: resolveLoyaltyTier(updatedUser.totalLifetimeSpend) }, + where: { id: paidOrder.userId }, + data: { + loyaltyTier: resolveLoyaltyTier(updatedUser.totalLifetimeSpend), + }, }) - // Accrue loyalty points (earning is always recorded; redemption stays flag-gated). - // Idempotent: the already-PAID + WebhookReceipt guards prevent double-earn. - const points = pointsForOrder(updated.totalNGN) + const points = pointsForOrder(paidOrder.totalNGN) if (points > 0) { const prior = await tx.loyaltyLedger.aggregate({ - where: { userId: updated.userId }, + where: { userId: paidOrder.userId }, _sum: { delta: true }, }) - const balanceAfter = (prior._sum.delta ?? 0) + points await tx.loyaltyLedger.create({ - data: { userId: updated.userId, delta: points, reason: 'order_earn', balanceAfter, orderId: updated.id }, + data: { + userId: paidOrder.userId, + delta: points, + reason: "order_earn", + balanceAfter: (prior._sum.delta ?? 0) + points, + orderId: paidOrder.id, + }, }) } - return updated + return { order: paidOrder, alreadyPaid: false, paidReference: reference } }) - // send receipt (best-effort) - await sendReceipt(order).catch(() => {}) - - // Qualify any pending referral for this customer's first paid order (best-effort, idempotent). - await qualifyReferral(order.userId, order.id).catch(() => {}) - - // Create notification for admin - await prisma.notification.create({ - data: { - type: 'ORDER_PAID', - title: 'New Order Payment', - message: `Order #${order.reference || order.id.slice(0, 8)} has been paid. Total: ₦${order.totalNGN.toLocaleString()}`, - orderId: order.id, + if (!result.order) { + if (result.alreadyPaid && result.paidReference !== reference) { + logger.error("duplicate_successful_payment", { + orderId, + paymentAttemptId: attempt.id, + reference, + }) } - }).catch(() => {}) // Don't fail if notification creation fails + return NextResponse.json({ ok: true, duplicate: true }) + } + + await sendReceipt(result.order).catch((error) => { + logger.error("order_receipt_failed", { + orderId: result.order!.id, + internal: String(error), + }) + }) + await qualifyReferral(result.order.userId, result.order.id).catch((error) => { + logger.error("referral_qualification_failed", { + orderId: result.order!.id, + internal: String(error), + }) + }) + await prisma.notification + .create({ + data: { + type: "ORDER_PAID", + title: "New Order Payment", + message: `Order #${result.order.reference || result.order.id.slice(0, 8)} has been paid. Total: NGN ${result.order.totalNGN.toLocaleString()}`, + orderId: result.order.id, + }, + }) + .catch((error) => { + logger.error("order_notification_failed", { + orderId: result.order!.id, + internal: String(error), + }) + }) + } catch (error) { + if ((error as { code?: string })?.code === "P2002") { + return NextResponse.json({ ok: true, duplicate: true }) + } + logger.error("paystack_webhook_processing_failed", { + eventId, + orderId, + internal: String(error), + }) + return NextResponse.json({ error: "processing_failed" }, { status: 500 }) } - } else if (evt?.event === 'refund.processed' || evt?.event === 'charge.refunded') { - // Reverse loyalty points earned for the refunded order. Idempotent + replay-guarded. - const ref = (evt?.data?.transaction?.reference ?? evt?.data?.reference) as string | undefined - const eventId = String(evt?.id ?? evt?.data?.id ?? ref ?? '') + } else if ( + verified.event === "refund.processed" || + verified.event === "charge.refunded" + ) { + const reference = verified.reference if (eventId) { - const first = await recordWebhookOnce('paystack', eventId, evt.event) - if (!first) return NextResponse.json({ ok: true }) + const first = await recordWebhookOnce("paystack", eventId, verified.event) + if (!first) return NextResponse.json({ ok: true, duplicate: true }) } - if (ref) { - const order = await prisma.order.findUnique({ where: { reference: ref }, select: { id: true, userId: true } }) - if (order) { - await reversePointsForOrder(order.userId, order.id).catch(() => {}) + if (reference) { + const attempt = await prisma.paymentAttempt.findUnique({ + where: { providerReference: reference }, + select: { id: true, order: { select: { id: true, userId: true } } }, + }) + if (attempt) { + await prisma.paymentAttempt.update({ + where: { id: attempt.id }, + data: { status: "REFUNDED" }, + }) + await reversePointsForOrder(attempt.order.userId, attempt.order.id).catch((error) => { + logger.error("refund_loyalty_reversal_failed", { + orderId: attempt.order.id, + internal: String(error), + }) + }) } } } diff --git a/app/api/products/route.ts b/app/api/products/route.ts index 105f0d5..df44ef8 100644 --- a/app/api/products/route.ts +++ b/app/api/products/route.ts @@ -18,6 +18,7 @@ export async function GET(request: NextRequest) { // Build where clause const where: any = { deletedAt: null, // Exclude soft-deleted products + publishStatus: 'PUBLISHED', } if (category) { where.category = category @@ -82,4 +83,3 @@ export async function GET(request: NextRequest) { } - diff --git a/app/api/recommendations/route.ts b/app/api/recommendations/route.ts index a285170..0337c64 100644 --- a/app/api/recommendations/route.ts +++ b/app/api/recommendations/route.ts @@ -16,7 +16,7 @@ export async function GET(request: NextRequest) { if (notes.length === 0) { const products = await prisma.product.findMany({ - where: { deletedAt: null }, + where: { deletedAt: null, publishStatus: "PUBLISHED" }, orderBy: [{ ratingAvg: "desc" }, { createdAt: "desc" }], take: limit, }) @@ -35,6 +35,7 @@ export async function GET(request: NextRequest) { const products = await prisma.product.findMany({ where: { deletedAt: null, + publishStatus: "PUBLISHED", OR: orConditions, }, orderBy: [{ ratingAvg: "desc" }, { createdAt: "desc" }], diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 9d0020f..2cd15c8 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -8,10 +8,13 @@ export const dynamic = "force-dynamic" /** PostgreSQL: case-insensitive contains. */ function searchWhere(keywords: string[]) { const terms = keywords.map((t) => t.trim()).filter((t) => t.length > 0) - if (terms.length === 0) return { deletedAt: null } + if (terms.length === 0) { + return { deletedAt: null, publishStatus: "PUBLISHED" as const } + } return { deletedAt: null, + publishStatus: "PUBLISHED" as const, OR: terms.flatMap((term) => [ { name: { contains: term, mode: "insensitive" as const } }, { brand: { contains: term, mode: "insensitive" as const } }, diff --git a/app/api/v1/back-in-stock/route.ts b/app/api/v1/back-in-stock/route.ts index 13b0036..f34177d 100644 --- a/app/api/v1/back-in-stock/route.ts +++ b/app/api/v1/back-in-stock/route.ts @@ -22,7 +22,11 @@ export const POST = route(async ({ req }) => { if (!email) raise('VALIDATION_ERROR', 'An email is required to notify you.') const product = await prisma.product.findFirst({ - where: { id: body.productId, deletedAt: null }, + where: { + id: body.productId, + deletedAt: null, + publishStatus: "PUBLISHED", + }, select: { id: true, stock: true }, }) if (!product) raise('PRODUCT_NOT_FOUND') diff --git a/app/api/v1/discovery-sets/route.ts b/app/api/v1/discovery-sets/route.ts index 0fcee00..047dd74 100644 --- a/app/api/v1/discovery-sets/route.ts +++ b/app/api/v1/discovery-sets/route.ts @@ -50,7 +50,11 @@ export const POST = route(async ({ req }) => { // Price + stock come from the DB (never trust client prices). const productIds = [...new Set(body.items.map((i) => i.productId))] const products = await prisma.product.findMany({ - where: { id: { in: productIds }, deletedAt: null }, + where: { + id: { in: productIds }, + deletedAt: null, + publishStatus: "PUBLISHED", + }, select: { id: true, priceNGN: true, stock: true, variants: { select: { id: true, priceNGN: true, stock: true } } }, }) const pMap = new Map(products.map((p) => [p.id, p])) diff --git a/app/api/v1/reviews/summary/route.ts b/app/api/v1/reviews/summary/route.ts index 4075fe1..0624f59 100644 --- a/app/api/v1/reviews/summary/route.ts +++ b/app/api/v1/reviews/summary/route.ts @@ -16,7 +16,10 @@ export const GET = route(async ({ req }) => { if (!productId) raise('VALIDATION_ERROR', 'productId is required.') const [product, reviews] = await Promise.all([ - prisma.product.findUnique({ where: { id: productId }, select: { name: true } }), + prisma.product.findFirst({ + where: { id: productId, deletedAt: null, publishStatus: 'PUBLISHED' }, + select: { name: true }, + }), prisma.review.findMany({ where: { productId, approved: true, comment: { not: null } }, orderBy: { createdAt: 'desc' }, diff --git a/app/checkout/page.tsx b/app/checkout/page.tsx index 4d9dd9e..1c7f4bf 100644 --- a/app/checkout/page.tsx +++ b/app/checkout/page.tsx @@ -3,6 +3,7 @@ import type { Metadata } from "next" import { MainLayout } from "@/components/layout/main-layout" import { CheckoutContent } from "@/components/checkout/checkout-content" import { getCommerceConfig } from "@/lib/config/commerce" +import { getBankTransferConfig } from "@/lib/config/payment-methods" import { requireUser } from "@/lib/session" export const metadata: Metadata = { @@ -16,6 +17,7 @@ export default async function CheckoutPage() { await requireUser("/checkout") const { shipping } = getCommerceConfig() + const bankTransfer = getBankTransferConfig() return ( @@ -23,6 +25,9 @@ export default async function CheckoutPage() { items={[]} freeShippingThreshold={shipping.freeShippingThreshold} flatShippingFee={shipping.flatShippingFee} + expressShippingFee={shipping.expressShippingFee} + giftWrapFee={shipping.giftWrapFee} + bankTransfer={bankTransfer} /> ) diff --git a/app/checkout/success/page.tsx b/app/checkout/success/page.tsx index 2996e85..c4db993 100644 --- a/app/checkout/success/page.tsx +++ b/app/checkout/success/page.tsx @@ -1,156 +1,100 @@ -import { formatPrice } from "@/lib/format"; -import Link from "next/link"; -import { CheckCircle2, AlertCircle } from "lucide-react"; -import { prisma } from "@/lib/prisma"; -import { OrderStatus } from "@prisma/client"; -import { MainLayout } from "@/components/layout/main-layout"; -import { ClearCartOnSuccess } from "@/components/checkout/clear-cart-on-success"; +import Link from "next/link" +import { CheckCircle2, Clock3, AlertCircle } from "lucide-react" -export const runtime = "nodejs"; +import { MainLayout } from "@/components/layout/main-layout" +import { ClearCartOnSuccess } from "@/components/checkout/clear-cart-on-success" +import { auth } from "@/lib/auth" +import { formatPrice } from "@/lib/format" +import { prisma } from "@/lib/prisma" -type PageProps = { - searchParams: Promise<{ - mock?: string; - reference?: string; - }>; -}; +export const runtime = "nodejs" +export const dynamic = "force-dynamic" -async function verifyPaystack(reference: string) { - const secret = process.env.PAYSTACK_SECRET_KEY?.trim(); - if (!secret) { - return { ok: false, message: "PAYSTACK_SECRET_KEY missing", data: null }; - } - try { - const res = await fetch( - `https://api.paystack.co/transaction/verify/${encodeURIComponent(reference)}`, - { - headers: { Authorization: `Bearer ${secret}` }, - cache: "no-store", - }, - ); - const data = await res.json().catch(() => ({})); - if (!res.ok || !data?.status) { - return { - ok: false, - message: data?.message || "Verification failed", - data: null, - }; - } - return { ok: true, message: "Verified", data: data.data }; - } catch (e: any) { - return { - ok: false, - message: e?.message || "Verification error", - data: null, - }; - } +type PageProps = { + searchParams: Promise<{ reference?: string }> } export default async function SuccessPage({ searchParams }: PageProps) { - const params = await searchParams; - const isMock = !!params.mock; - const reference = params.reference?.trim(); - - let statusLine = ""; - let amountNGN: number | null = null; - - if (isMock) { - statusLine = "Mock success (no real charge)."; - // Cart is managed by Zustand store, no need to clear cookie - } else if (reference) { - // try to verify with Paystack - const res = await verifyPaystack(reference); - if (res.ok && res.data?.status === "success") { - // amount is in kobo from Paystack; convert to NGN - amountNGN = Math.round(Number(res.data.amount || 0) / 100); - statusLine = "Payment verified."; - - // Cart is cleared by Zustand store after successful checkout - // No need to clear cookie here - - // if you stored orders with a reference, mark it PAID - const order = await prisma.order.findFirst({ where: { reference } }); - if (order && order.status !== OrderStatus.PAID) { - const updatedOrder = await prisma.order.update({ - where: { id: order.id }, - data: { - status: OrderStatus.PAID, - totalNGN: order.totalNGN || amountNGN || order.totalNGN, - }, - }); - - // Create notification for admin - try { - await prisma.notification.create({ - data: { - type: "ORDER_PAID", - title: "New Order Payment", - message: `Order #${updatedOrder.reference || updatedOrder.id.slice(0, 8)} has been paid. Total: ₦${updatedOrder.totalNGN.toLocaleString()}`, - orderId: updatedOrder.id, + const { reference: rawReference } = await searchParams + const reference = rawReference?.trim() + const session = await auth() + const email = session?.user?.email + + const attempt = + reference && email + ? await prisma.paymentAttempt.findFirst({ + where: { providerReference: reference, order: { user: { email } } }, + select: { + providerReference: true, + order: { + select: { id: true, status: true, totalNGN: true }, }, - }); - } catch { - // Don't fail if notification creation fails - } - } - } else { - statusLine = `Payment not verified: ${res.message || "unknown error"}`; - } - } else { - statusLine = "No payment reference provided."; - } - - const verified = isMock || statusLine === "Payment verified."; + }, + }) + : null + const order = attempt?.order + + const paid = order?.status === "PAID" + const pending = order?.status === "PENDING" + const title = paid + ? "Order confirmed" + : pending + ? "Payment is processing" + : "Payment status unavailable" + const message = paid + ? "Your payment is verified. A receipt is on its way to your inbox." + : pending + ? "We are waiting for confirmation from Paystack. Your order will update automatically; please do not pay twice." + : "We could not match this payment to one of your orders. Check your orders or contact support if payment was taken." return (
- {(reference || isMock) && } + {paid && }
- {verified ? ( + {paid ? ( + ) : pending ? ( + ) : ( )}

- {verified ? "Order confirmed" : "Payment status"} + {title}

-

- {isMock - ? "This was a mock success (no real charge). Add Paystack keys in .env to enable live payments." - : verified - ? "Your payment is verified. A receipt is on its way to your inbox. Your fragrance follows shortly after." - : statusLine} + {message}

-
- {reference && ( + {order && ( +

Reference ·{" "} - {reference} + + {attempt.providerReference} +

- )} - {typeof amountNGN === "number" && amountNGN > 0 && (

Amount ·{" "} - {formatPrice(amountNGN)} + {formatPrice(order.totalNGN)}

- )} -
+
+ )}
- - {!isMock && !reference && ( -

- Tip: configure your Paystack callback_url to point - here with a ?reference=... so we can auto-verify and - update your order. -

- )}
- ); + ) } diff --git a/app/collections/[brand]/page.tsx b/app/collections/[brand]/page.tsx index 7f14ef6..b5ffbce 100644 --- a/app/collections/[brand]/page.tsx +++ b/app/collections/[brand]/page.tsx @@ -15,7 +15,7 @@ interface BrandPageProps { async function getDbBrands(): Promise { const rows = await prisma.product.findMany({ - where: { deletedAt: null }, + where: { deletedAt: null, publishStatus: "PUBLISHED" }, distinct: ["brand"], select: { brand: true }, }) @@ -51,7 +51,11 @@ export default async function BrandCollectionPage({ params }: BrandPageProps) { brandName = resolveBrandFromSlug(brand, await getDbBrands()) if (brandName) { const dbProducts = await prisma.product.findMany({ - where: { brand: brandName, deletedAt: null }, + where: { + brand: brandName, + deletedAt: null, + publishStatus: "PUBLISHED", + }, orderBy: [{ ratingAvg: "desc" }, { createdAt: "desc" }], }) brandProducts = dbProducts.map(mapPrismaProductToCard) diff --git a/app/collections/featured/page.tsx b/app/collections/featured/page.tsx index b8936c2..299bc12 100644 --- a/app/collections/featured/page.tsx +++ b/app/collections/featured/page.tsx @@ -20,6 +20,7 @@ export default async function FeaturedCollectionsPage() { dbProducts = await prisma.product.findMany({ where: { deletedAt: null, + publishStatus: "PUBLISHED", OR: [ { isBestseller: true }, { isNew: true }, diff --git a/app/collections/page.tsx b/app/collections/page.tsx index a0164e3..224ff8c 100644 --- a/app/collections/page.tsx +++ b/app/collections/page.tsx @@ -21,7 +21,7 @@ export default async function CollectionsPage() { let dbProducts: Awaited> = [] try { dbProducts = await prisma.product.findMany({ - where: { deletedAt: null }, + where: { deletedAt: null, publishStatus: "PUBLISHED" }, orderBy: [ { isFeatured: "desc" }, { isBestseller: "desc" }, diff --git a/app/discovery/page.tsx b/app/discovery/page.tsx index 1038df3..d841b58 100644 --- a/app/discovery/page.tsx +++ b/app/discovery/page.tsx @@ -19,13 +19,18 @@ export default async function DiscoveryPage() { try { const [sampleKitProducts, byNotes] = await Promise.all([ prisma.product.findMany({ - where: { deletedAt: null, isFeatured: true }, + where: { + deletedAt: null, + publishStatus: "PUBLISHED", + isFeatured: true, + }, orderBy: [{ ratingAvg: "desc" }], take: 6, }), prisma.product.findMany({ where: { deletedAt: null, + publishStatus: "PUBLISHED", OR: [ { notesTop: { contains: "bergamot", mode: "insensitive" } }, { notesHeart: { contains: "rose", mode: "insensitive" } }, diff --git a/app/drops/page.tsx b/app/drops/page.tsx index 0ad8c1c..96eb389 100644 --- a/app/drops/page.tsx +++ b/app/drops/page.tsx @@ -25,7 +25,11 @@ export default async function DropsPage() { try { const limited = await prisma.product.findMany({ - where: { isLimited: true, deletedAt: null }, + where: { + isLimited: true, + deletedAt: null, + publishStatus: "PUBLISHED", + }, orderBy: { dropDate: "asc" }, }) diff --git a/app/help/shipping/page.tsx b/app/help/shipping/page.tsx index 60d0218..049d11f 100644 --- a/app/help/shipping/page.tsx +++ b/app/help/shipping/page.tsx @@ -57,7 +57,8 @@ export default function ShippingPage() { Duration: 1-2 business days

- Cost: ₦35,000 + Cost:{" "} + {formatPrice(shipping.expressShippingFee)}

For urgent orders, choose express delivery. Available in diff --git a/app/journal/[slug]/page.tsx b/app/journal/[slug]/page.tsx index 9dcd466..02b4546 100644 --- a/app/journal/[slug]/page.tsx +++ b/app/journal/[slug]/page.tsx @@ -42,7 +42,11 @@ export default async function ArticlePage({ if (article.relatedProductSlugs?.length) { try { relatedProducts = await prisma.product.findMany({ - where: { slug: { in: article.relatedProductSlugs }, deletedAt: null }, + where: { + slug: { in: article.relatedProductSlugs }, + deletedAt: null, + publishStatus: "PUBLISHED", + }, }) } catch { // silently degrade diff --git a/app/page.tsx b/app/page.tsx index dbb6b39..3be08c7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -21,6 +21,7 @@ export default async function HomePage() { dbProducts = await prisma.product.findMany({ where: { deletedAt: null, // Exclude soft-deleted products + publishStatus: "PUBLISHED", OR: [ { isBestseller: true }, { isNew: true }, @@ -41,7 +42,7 @@ export default async function HomePage() { // homepage edit never renders empty. if (dbProducts.length === 0) { dbProducts = await prisma.product.findMany({ - where: { deletedAt: null }, + where: { deletedAt: null, publishStatus: "PUBLISHED" }, orderBy: [{ ratingAvg: "desc" }, { createdAt: "desc" }], take: 8, }) diff --git a/app/shop/page.tsx b/app/shop/page.tsx index 26b0ac1..53c5310 100644 --- a/app/shop/page.tsx +++ b/app/shop/page.tsx @@ -114,12 +114,13 @@ export default async function ShopPage({ searchParams }: { searchParams?: Promis where: { ...where, deletedAt: null, + publishStatus: 'PUBLISHED', }, orderBy: orderBy, take: 24, }), prisma.product.findMany({ - where: { deletedAt: null }, + where: { deletedAt: null, publishStatus: 'PUBLISHED' }, distinct: ['brand'], select: { brand: true }, }), diff --git a/app/sitemap.ts b/app/sitemap.ts index b148f7f..df6a645 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -13,6 +13,7 @@ export default async function sitemap(): Promise { products = await prisma.product.findMany({ where: { deletedAt: null, // Exclude soft-deleted products + publishStatus: "PUBLISHED", }, select: { slug: true, @@ -109,4 +110,3 @@ export default async function sitemap(): Promise { return [...staticPages, ...productPages] } - diff --git a/components/checkout/checkout-content.tsx b/components/checkout/checkout-content.tsx index d127d7c..facb687 100644 --- a/components/checkout/checkout-content.tsx +++ b/components/checkout/checkout-content.tsx @@ -20,6 +20,8 @@ import type { Product } from "@/components/ui/product-card"; import type { OrderPayload } from "./payment-form"; +import type { BankTransferConfig } from "@/lib/config/payment-methods"; + interface OrderItem { product: Product; @@ -32,12 +34,21 @@ interface CheckoutContentProps { freeShippingThreshold?: number; flatShippingFee?: number; + + expressShippingFee?: number; + + giftWrapFee?: number; + + bankTransfer?: BankTransferConfig | null; } export function CheckoutContent({ items: propItems = [], freeShippingThreshold = 500_000, flatShippingFee = 15000, + expressShippingFee = 35000, + giftWrapFee = 2500, + bankTransfer = null, }: CheckoutContentProps) { const router = useRouter(); @@ -101,10 +112,10 @@ export function CheckoutContent({ subtotalNGN >= freeShippingThreshold ? 0 : deliveryMethod === "express" - ? 35000 + ? expressShippingFee : flatShippingFee; - const giftWrappingNGN = formData.giftWrapping ? 2500 : 0; + const giftWrappingNGN = formData.giftWrapping ? giftWrapFee : 0; const shippingNGN = baseShippingNGN + giftWrappingNGN; @@ -129,6 +140,10 @@ export function CheckoutContent({ couponId: couponId || null, + couponCode: couponCode || null, + + deliveryMethod, + isGift: formData.isGift, giftMessage: formData.giftMessage || undefined, @@ -145,6 +160,9 @@ export function CheckoutContent({ formData.giftWrapping, freeShippingThreshold, flatShippingFee, + expressShippingFee, + giftWrapFee, + couponCode, ]); // Redirect to cart only once the server cart has hydrated and is truly empty. @@ -215,6 +233,7 @@ export function CheckoutContent({ setCurrentStep(2)} standardDeliveryFee={flatShippingFee} + expressDeliveryFee={expressShippingFee} freeShippingThreshold={freeShippingThreshold} deliveryMethod={deliveryMethod} onDeliveryMethodChange={(method) => { @@ -233,6 +252,7 @@ export function CheckoutContent({ onComplete={() => setCurrentStep(3)} total={total} orderPayload={orderPayload} + bankTransfer={bankTransfer} /> )} @@ -250,6 +270,7 @@ export function CheckoutContent({ couponCode={couponCode} applyCoupon={applyCoupon} removeCoupon={removeCoupon} + giftWrapFee={giftWrapFee} onPaymentClick={() => { // Trigger form submission in payment form diff --git a/components/checkout/order-summary.tsx b/components/checkout/order-summary.tsx index 4a3d0f8..efc0b47 100644 --- a/components/checkout/order-summary.tsx +++ b/components/checkout/order-summary.tsx @@ -58,6 +58,8 @@ interface OrderSummaryProps { removeCoupon?: () => void + giftWrapFee?: number + } @@ -84,6 +86,8 @@ export function OrderSummary({ removeCoupon: propRemoveCoupon, + giftWrapFee = 2500, + }: OrderSummaryProps) { const [inputCode, setInputCode] = React.useState("") @@ -308,7 +312,9 @@ export function OrderSummary({ Luxury gift wrapping -

Premium gold ribbon packaging +₦2,500

+

+ Premium gold ribbon packaging +{formatPrice(giftWrapFee)} +

@@ -460,7 +466,13 @@ export function OrderSummary({ Shipping - {shipping === 0 ? "Free" : formatPrice(formData.giftWrapping ? shipping - 2500 : shipping)} + + {shipping === 0 + ? "Free" + : formatPrice( + formData.giftWrapping ? shipping - giftWrapFee : shipping, + )} + @@ -476,7 +488,7 @@ export function OrderSummary({ - +{formatPrice(2500)} + +{formatPrice(giftWrapFee)} diff --git a/components/checkout/payment-form.tsx b/components/checkout/payment-form.tsx index 7682853..731eb97 100644 --- a/components/checkout/payment-form.tsx +++ b/components/checkout/payment-form.tsx @@ -17,6 +17,7 @@ import { useCheckoutStore } from "@/lib/stores/checkout-store"; import { formatPrice } from "@/lib/format"; import Link from "next/link"; import { ClearCartOnSuccess } from "@/components/checkout/clear-cart-on-success"; +import type { BankTransferConfig } from "@/lib/config/payment-methods"; export interface OrderPayload { items: { productId: string; quantity: number; priceNGN: number }[]; @@ -25,6 +26,8 @@ export interface OrderPayload { shippingNGN: number; totalNGN: number; couponId?: string | null; + couponCode?: string | null; + deliveryMethod: "standard" | "express"; isGift?: boolean; giftMessage?: string; giftWrapping?: boolean; @@ -35,14 +38,9 @@ interface PaymentFormProps { onComplete: () => void; total: number; orderPayload: OrderPayload; + bankTransfer?: BankTransferConfig | null; } -const BANK_DETAILS = { - accountName: "Fádé Essence Limited", - bank: "Guaranty Trust Bank (GTBank)", - accountNumber: "0123456789", -}; - function CopyButton({ text }: { text: string }) { const [copied, setCopied] = React.useState(false); const handleCopy = () => { @@ -72,8 +70,11 @@ export function PaymentForm({ onComplete: _onComplete, total, orderPayload, + bankTransfer = null, }: PaymentFormProps) { const { formData } = useCheckoutStore(); + const checkoutIdempotencyKey = React.useRef(crypto.randomUUID()); + const paymentIdempotencyKey = React.useRef(crypto.randomUUID()); const [isProcessing, setIsProcessing] = React.useState(false); const [paymentMethod, setPaymentMethod] = React.useState< "CARD" | "BANK_TRANSFER" @@ -97,6 +98,8 @@ export function PaymentForm({ shippingNGN: orderPayload.shippingNGN, totalNGN: orderPayload.totalNGN, couponId: orderPayload.couponId ?? null, + couponCode: orderPayload.couponCode ?? null, + deliveryMethod: orderPayload.deliveryMethod, isGift: orderPayload.isGift, giftMessage: orderPayload.giftMessage, giftWrapping: orderPayload.giftWrapping, @@ -133,7 +136,10 @@ export function PaymentForm({ // 1) Create order (PENDING) const createRes = await fetch("/api/checkout/create-order", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "Idempotency-Key": checkoutIdempotencyKey.current, + }, body: JSON.stringify({ ...payload, paymentMethod }), }); @@ -153,25 +159,16 @@ export function PaymentForm({ // 2) Card: Initialize Paystack const payRes = await fetch("/api/paystack/initialize", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: formData.email, - amountNGN: total, - metadata: { - orderId, - firstName: formData.firstName, - lastName: formData.lastName, - phone: formData.phone, - address: formData.address, - city: formData.city, - state: formData.state, - postalCode: formData.postalCode, - }, - }), + headers: { + "Content-Type": "application/json", + "Idempotency-Key": paymentIdempotencyKey.current, + }, + body: JSON.stringify({ orderId }), }); const payData = await payRes.json(); if (!payRes.ok || !payData.authorization_url) { + paymentIdempotencyKey.current = crypto.randomUUID(); throw new Error(payData.error || "Failed to initialize payment"); } @@ -213,7 +210,7 @@ export function PaymentForm({
Bank - {BANK_DETAILS.bank} + {bankTransfer?.bankName}
@@ -221,7 +218,7 @@ export function PaymentForm({ Account Name - {BANK_DETAILS.accountName} + {bankTransfer?.accountName}
@@ -230,9 +227,9 @@ export function PaymentForm({
- {BANK_DETAILS.accountNumber} + {bankTransfer?.accountNumber} - +
@@ -294,8 +291,8 @@ export function PaymentForm({ - {/* Bank Transfer */} - + } diff --git a/components/checkout/shipping-form.tsx b/components/checkout/shipping-form.tsx index c33e25c..c26634a 100644 --- a/components/checkout/shipping-form.tsx +++ b/components/checkout/shipping-form.tsx @@ -37,6 +37,8 @@ interface ShippingFormProps { standardDeliveryFee: number; + expressDeliveryFee: number; + freeShippingThreshold: number; } @@ -121,6 +123,7 @@ export function ShippingForm({ deliveryMethod: propDeliveryMethod, onDeliveryMethodChange, standardDeliveryFee, + expressDeliveryFee, freeShippingThreshold, }: ShippingFormProps) { const { formData, updateFormData } = useCheckoutStore(); @@ -384,7 +387,9 @@ export function ShippingForm({ - ₦35,000 + + {formatPrice(expressDeliveryFee)} + diff --git a/docs/PRODUCTION_READINESS_PLAN.md b/docs/PRODUCTION_READINESS_PLAN.md new file mode 100644 index 0000000..49a950a --- /dev/null +++ b/docs/PRODUCTION_READINESS_PLAN.md @@ -0,0 +1,723 @@ +# Production Readiness and System Architecture Plan + +## Executive summary + +The application should currently be treated as a feature-rich staging build, not a production-ready +commerce system. The most urgent risk is payment integrity: prices, discounts, shipping costs, +payment amounts, and Paystack metadata cross the client/server boundary without sufficient +server-side ownership and reconciliation. + +The recommended target is a **modular monolith**: + +- one Next.js application and deployment; +- one PostgreSQL database; +- clearly separated business modules inside the application; +- Redis for distributed rate limits, short-lived cache entries, and optionally job coordination; +- durable background processing through an outbox and worker; +- external providers behind adapters rather than called directly from pages and route handlers. + +This is the appropriate architecture for the current product and team size. Microservices would add +deployment, networking, observability, and consistency problems without solving the present trust +and reliability issues. + +## Implementation status + +Work started on branch `codex/production-readiness`. + +Completed in the first hardening milestone: + +- public catalogue queries now require published, non-deleted products; +- checkout recalculates product prices, coupons, shipping, gift wrapping, and totals on the server; +- Paystack initialization requires authentication and a customer-owned pending order; +- Paystack receives the stored order amount and a server-generated reference; +- the Paystack webhook validates signature, reference, status, amount, currency, and payment method; +- the paid transition and non-negative stock decrement are transactionally guarded; +- the checkout success page is read-only and scoped to the signed-in customer; +- placeholder bank-transfer details were removed and transfer is fail-closed until configured; +- checkout and payment initialization use the distributed rate-limiter abstraction; +- critical cookie-authenticated payment mutations validate request origin; +- production readiness now requires database, authentication, Paystack, Resend, and Redis settings; +- baseline security headers were added; +- checkout creation now uses a customer-scoped idempotency key and request fingerprint; +- Paystack initialization now uses a durable `PaymentAttempt` ledger, with safe retry behavior; +- webhook processing resolves the server-created payment attempt rather than trusting provider + metadata as the source of truth; +- each successful attempt is recorded before the order transition, including duplicate successful + payments that require operational review; +- payment-matching, checkout-idempotency, publication, origin, and shipping-policy regression tests + were added; +- Prisma schema validation, TypeScript, lint, 321 unit tests, and the production build pass on this + branch (the database-backed integration suite remains skipped until a test database is supplied). + +Still required before launch: + +- inventory reservations and reservation-expiry jobs; +- a transactional outbox and background worker; +- payment reconciliation and complete refund/order state handling; +- complete password-reset flow and broader authentication abuse protection; +- dependency remediation, full database integration tests, and production staging certification; +- owner-supplied provider credentials, business details, brand assets, and approved policies. + +## Audit findings + +This is the original audit list. Items completed on `codex/production-readiness` are marked +**resolved on branch**; they still require migration and staging certification before release. + +### Critical + +1. **Resolved on branch:** checkout previously accepted browser-supplied discount, shipping, coupon + ID, total, and payment amount. +2. **Resolved on branch:** Paystack initialization previously accepted arbitrary client metadata and + was not tied to a server-owned order. +3. **Resolved on branch:** the webhook previously trusted `metadata.orderId` without comparing the + paid amount and currency with the stored order and payment attempt. +4. **Resolved on branch:** the checkout success page previously mutated payment state during a GET + request. +5. Password-reset emails point to a reset route that does not exist, and there is no endpoint that + applies a new password. +6. Bank transfer displays the placeholder account number `0123456789`. +7. The production dependency audit reports seven high and four moderate vulnerabilities. + +### High priority + +1. There is no reliable stock reservation or atomic non-negative stock decrement. +2. **Resolved on branch:** public catalogue queries did not consistently require + `publishStatus = PUBLISHED`. +3. Custom state-changing endpoints do not consistently validate request origin or CSRF protections. +4. Signup, password reset, order creation, and payment initialization lack durable abuse controls. +5. Contact-form values are interpolated into HTML emails without HTML escaping. +6. Required production configuration does not fail fast at startup. +7. Missing favicon, PWA icons, and Open Graph image cause broken metadata assets. +8. CI does not gate releases on all unit, integration, browser, dependency, build, and migration + checks. + +## Target modular-monolith design + +```text +Browser + | + +-- Storefront + +-- Customer account + +-- Admin application + | + v +Next.js delivery layer + | + +-- Authentication and authorization middleware + +-- Request validation, origin checks, and rate limits + +-- Route handlers, server actions, and server-rendered pages + | + v +Application modules + | + +-- Identity + +-- Catalogue + +-- Cart + +-- Checkout + +-- Payments + +-- Inventory + +-- Orders + +-- Promotions + +-- Customers and loyalty + +-- Notifications + +-- Admin and audit + | + +------------------+--------------------+--------------------+ + | | | | + v v v v +PostgreSQL Redis Job worker Provider adapters +source of truth cache/limits outbox consumer Paystack/Resend/etc. +``` + +The modules are logical boundaries within one codebase. They should communicate through explicit +application services and domain events, not by importing arbitrary database operations from one +another. + +### Suggested module layout + +```text +modules/ + identity/ + application/ + domain/ + infrastructure/ + catalogue/ + checkout/ + payments/ + inventory/ + orders/ + promotions/ + notifications/ + admin/ + +integrations/ + paystack/ + resend/ + redis/ + +app/ + api/ Delivery layer only + ... Pages and layouts + +lib/platform/ + database/ + cache/ + jobs/ + logging/ + security/ +``` + +An immediate reorganization is not required. New and repaired flows can adopt these boundaries +incrementally, then older code can be moved as it is touched. + +## Where the system-design concepts belong + +### Caching + +Caching belongs primarily on read-heavy, non-authoritative data. + +Good cache candidates: + +- published product cards and collection pages; +- category and brand lists; +- product recommendations; +- approved review summaries; +- journal content; +- expensive, non-personalized search results; +- short-lived provider capability/status information. + +Data that should not be trusted from cache when making a transaction: + +- price used for checkout; +- stock availability used to accept an order; +- coupon validity and remaining uses; +- payment status; +- order ownership or authorization; +- loyalty balance used for redemption; +- user roles. + +Recommended policy: + +```text +Read catalogue + -> check framework/Redis cache + -> query PostgreSQL on miss + -> cache only the public DTO + +Create order + -> always read authoritative prices, stock, coupon, and user from PostgreSQL + -> never trust cached transactional values +``` + +Use short TTLs and explicit invalidation after product publication, price changes, stock changes, and +review moderation. Include a schema/version component in keys, for example +`catalogue:v2:product:{slug}`. Do not cache raw Prisma models containing private fields. + +### Background jobs + +Background jobs belong after a transaction when work is important but should not delay or break the +customer request. + +Suitable jobs: + +- order receipts and status emails; +- admin payment notifications; +- abandoned-checkout reminders; +- expired inventory-reservation release; +- payment reconciliation; +- newsletter batches; +- product-feed synchronization; +- low-stock alerts; +- search-index updates; +- analytics aggregation; +- image processing; +- webhook reprocessing. + +Use the transactional outbox pattern: + +```text +Database transaction + 1. Mark order PAID + 2. Finalize inventory + 3. Insert ORDER_PAID outbox event + 4. Commit + +Worker + 1. Claim event + 2. Send receipt + 3. Notify admin + 4. Retry transient failures + 5. Mark event completed +``` + +This avoids the dual-write problem where the database commits but the email or queue operation is +lost. Jobs need unique idempotency keys, bounded retries, exponential backoff with jitter, and a +dead-letter/manual-review state. + +### Rate limiting + +Rate limiting belongs at the delivery boundary, before expensive provider calls or database writes. +Redis-backed counters are required when the application has more than one process or serverless +instance. + +Apply limits to: + +- sign-in attempts by IP and normalized email; +- signup by IP and device/session; +- password-reset requests by IP and normalized email; +- password-reset token attempts; +- order creation by authenticated user and IP; +- payment initialization by order, user, and IP; +- coupon guessing by user and IP; +- contact and newsletter forms; +- review creation and reporting; +- search and AI endpoints; +- drop and back-in-stock subscriptions; +- admin exports and bulk operations. + +Rate limiting is not authorization. An allowed request must still pass authentication, ownership, +validation, and business rules. + +### Idempotency + +Idempotency belongs anywhere a request may be repeated because of double-clicks, retries, timeouts, +provider redelivery, or worker crashes. + +Required idempotency boundaries: + +- checkout/order creation; +- Paystack initialization; +- Paystack webhook handling; +- payment reconciliation; +- refund creation; +- inventory finalization and release; +- loyalty accrual and reversal; +- coupon use increment; +- email and notification jobs; +- admin bulk actions; +- external catalogue synchronization. + +Use database uniqueness as the final enforcement mechanism: + +```text +PaymentAttempt.providerReference UNIQUE +WebhookReceipt(provider, eventId) UNIQUE +OutboxEvent.idempotencyKey UNIQUE +InventoryMovement(sourceType, sourceId, productId) UNIQUE +LoyaltyLedger(reason, orderId, userId) UNIQUE where appropriate +``` + +For browser-initiated checkout, accept an `Idempotency-Key` generated once per checkout attempt. +Store the key, authenticated user, request fingerprint, and resulting order. Reusing the key with a +different payload must be rejected. + +### Transactions and consistency + +PostgreSQL transactions should protect business invariants that must succeed or fail together. + +The payment-confirmation transaction should include: + +1. lock or conditionally update the payment attempt; +2. conditionally transition the order from a payable state to `PAID`; +3. finalize or decrement stock without allowing it to become negative; +4. increment coupon usage once; +5. write loyalty movements once; +6. record inventory movements; +7. create outbox events; +8. commit. + +External network requests should generally occur outside database transactions. Verify Paystack +first, then open the short database transaction that records the verified result. + +### Inventory reservations and concurrency control + +The current check-now/decrement-later flow can oversell. The preferred design is: + +```text +AVAILABLE -> RESERVED -> SOLD + \-> RELEASED or EXPIRED +``` + +An `InventoryReservation` records the order, product, quantity, expiry, and state. Checkout reserves +stock for a limited period. Successful payment converts the reservation to sold; a scheduled job +releases expired reservations. + +For an initial simpler implementation, use an atomic conditional decrement and verify that the +number of affected rows is correct. Never read stock and later write a decrement without a database +condition or lock. + +### Retries, timeouts, and circuit breakers + +Every external call needs an explicit timeout. Retry only transient failures and only when the +operation is idempotent. + +- Paystack verification: short timeout, bounded retries, reconciliation fallback. +- Paystack initialization: idempotency key and bounded retry. +- Resend: background retry, not request blocking. +- Shopify/catalogue synchronization: cursor checkpointing and bounded retry. +- AI/search providers: strict timeout, budget, and graceful fallback. + +A simple in-process circuit breaker or shared provider-health state may be added once provider +failures become operationally significant. It is less urgent than correct timeouts and idempotency. + +### Event-driven design inside the monolith + +The system can use domain events without becoming microservices. Events should describe completed +facts, such as: + +- `OrderCreated` +- `PaymentConfirmed` +- `OrderPaid` +- `InventoryLow` +- `OrderShipped` +- `RefundCompleted` +- `ProductPublished` + +Durable events go through the outbox. Purely local, non-critical reactions can use in-process +handlers. Business-critical effects must not rely only on an in-memory event emitter. + +### Observability + +Observability cuts across every module. + +Required signals: + +- structured logs with request and correlation IDs; +- errors grouped by route, module, and provider; +- request latency and error rate; +- Paystack initialization and webhook success rates; +- payment amount/currency mismatch alerts; +- payment reconciliation backlog; +- job queue depth, age, retries, and dead letters; +- inventory reservation expiry and negative-stock alerts; +- authentication and rate-limit anomalies; +- database pool and query health; +- email delivery failures; +- deployment and migration markers. + +Logs must redact tokens, passwords, secrets, addresses, full webhook payloads, and unnecessary +personal information. + +### Audit logging + +Audit logging is different from diagnostic logging. It records who changed business state, what was +changed, and why. + +Audit: + +- refunds and payment overrides; +- order-status changes; +- manual bank-transfer confirmation; +- inventory adjustments; +- price and publication changes; +- coupon changes; +- admin role changes; +- customer-data exports; +- newsletter sends. + +Audit records should be append-only from the application and contain actor, action, resource, +before/after summary, timestamp, request ID, and optional reason. + +### Security boundaries + +Each request should pass through these layers: + +```text +TLS + -> security headers + -> request size limit + -> origin/CSRF validation + -> rate limit + -> authentication + -> authorization and ownership + -> schema validation + -> application service + -> transaction/business invariants + -> redacted response and logging +``` + +Admin UI protection is not sufficient; every admin route handler and server action must authorize +independently. + +### Health, readiness, and graceful degradation + +Use separate endpoints: + +- liveness: process is running; +- readiness: required database/configuration is available; +- dependency status: provider and worker health for admin/monitoring use. + +Missing `DATABASE_URL`, authentication secrets, production URL, or required payment configuration +must prevent a production deployment from becoming ready. Optional services may degrade gracefully, +but checkout should fail closed when payment or transactional storage is unavailable. + +### Backups and disaster recovery + +Production readiness includes: + +- automated PostgreSQL backups and point-in-time recovery; +- documented retention; +- a restore test, not just backup creation; +- migration rollback/forward-fix procedure; +- secret rotation procedure; +- defined recovery time and recovery point objectives; +- reconciliation after recovery so payment and order state can be compared with Paystack. + +## Payment system design + +### Correct request flow + +```text +1. Browser submits product IDs, quantities, address, delivery method, coupon code, and gift choices. +2. Server authenticates the customer. +3. Server loads published products and authoritative prices from PostgreSQL. +4. Server validates coupon, shipping, gift cost, stock, and order limits. +5. Server creates Order, OrderItems, reservations, and PaymentAttempt in a transaction. +6. Server initializes Paystack with the stored amount, currency, and server reference. +7. Browser redirects to the Paystack authorization URL. +8. Paystack sends a signed webhook. +9. Server verifies signature, reference, status, amount, and currency. +10. Server atomically marks payment/order paid, finalizes stock, and writes outbox events. +11. Worker sends receipt and admin notification. +12. Success page reads customer-owned order status and never mutates it. +13. Scheduled reconciliation checks unresolved payment attempts against Paystack. +``` + +### Recommended data model additions + +```text +PaymentAttempt +- id +- orderId +- provider +- providerReference UNIQUE +- idempotencyKey UNIQUE +- expectedAmount +- expectedCurrency +- status +- providerTransactionId +- initializedAt +- verifiedAt +- failureCode + +InventoryReservation +- id +- orderId +- productId +- quantity +- status +- expiresAt +- createdAt + +InventoryMovement +- id +- productId +- delta +- reason +- sourceType +- sourceId +- createdAt + +OutboxEvent +- id +- type +- aggregateType +- aggregateId +- payload +- idempotencyKey UNIQUE +- status +- attempts +- availableAt +- processedAt +- lastErrorRedacted + +PasswordResetToken +- id +- userId +- tokenHash UNIQUE +- expiresAt +- usedAt +- createdAt +``` + +Recommended order states: + +```text +PENDING_PAYMENT +PAYMENT_REVIEW +PAID +PROCESSING +SHIPPED +DELIVERED +CANCELLED +PAYMENT_FAILED +REFUND_PENDING +REFUNDED +``` + +State transitions must be validated centrally rather than allowing arbitrary status updates. + +## Implementation roadmap: easiest to hardest + +### Phase 0: Release lockdown and configuration + +Difficulty: easy +Estimated engineering time: 0.5 to 1 day + +- disable unsafe card payment until the redesigned flow is complete; +- disable bank transfer until genuine details and verification procedures are approved; +- add missing favicon, PWA icons, and Open Graph image; +- enforce required production environment variables; +- prevent localhost production metadata; +- self-host fonts; +- remove development mock behaviour from production. + +### Phase 1: Catalogue, SEO, and baseline security + +Difficulty: easy to medium +Estimated engineering time: 2 to 4 days + +- enforce published-product filtering everywhere; +- remove transactional pages from the sitemap; +- add security headers and origin checks; +- add request-size limits and common error handling; +- escape HTML email values; +- upgrade vulnerable production dependencies; +- establish Redis-backed rate limiting; +- add readiness checks. + +### Phase 2: Identity and account recovery + +Difficulty: medium +Estimated engineering time: 2 to 4 days + +- implement complete password reset; +- store reset-token hashes; +- normalize email consistently; +- align signup and login password policies; +- add login, signup, and reset abuse protection; +- remove process-global session-duration state; +- add security-event logging and session invalidation. + +### Phase 3: Payment and checkout redesign + +Difficulty: hard +Estimated engineering time: 5 to 8 days + +- make prices, coupons, shipping, and totals server-owned; +- add `PaymentAttempt` and checkout idempotency; +- initialize Paystack only from the stored order; +- validate webhook amount, currency, status, and reference; +- use constant-time signature comparison; +- make the success page read-only; +- add reconciliation and refund flows; +- add real Paystack test-mode integration tests. + +### Phase 4: Inventory and order reliability + +Difficulty: hard +Estimated engineering time: 3 to 6 days + +- add reservations or atomic conditional stock updates; +- add inventory movements; +- centralize order-state transitions; +- add expiry/release jobs; +- make coupon, loyalty, and stock effects idempotent; +- implement cancelled, failed, review, and refund states. + +### Phase 5: Durable background work and operations + +Difficulty: medium to hard +Estimated engineering time: 3 to 6 days + +- add outbox events and worker execution; +- move email and non-critical notifications out of webhooks; +- add retry and dead-letter handling; +- add audit logging; +- add provider timeouts and bounded retries; +- add operational dashboards and alerts; +- document payment, outage, oversell, and rollback runbooks. + +### Phase 6: CI/CD and release certification + +Difficulty: hard +Estimated engineering time: 4 to 7 days + +Require on every pull request: + +- clean dependency installation; +- Prisma validation and migrations against temporary PostgreSQL; +- seed validation; +- TypeScript and ESLint; +- unit and integration tests; +- payment security regression tests; +- production build; +- Playwright storefront and account flows; +- accessibility audit; +- production dependency audit. + +Then certify staging with Paystack test mode before enabling production credentials. + +### Phase 7: Business and compliance readiness + +Difficulty: primarily owner/legal/operations work +Estimated elapsed time: 3 to 10 days in parallel + +- legal identity and business address; +- real bank details and manual verification procedure; +- Paystack merchant approval; +- verified email domain with SPF, DKIM, and DMARC; +- support contacts and service hours; +- approved shipping, returns, cancellation, and refund policies; +- privacy, cookies, analytics, retention, and customer-rights review; +- product-authenticity claims and inventory source of truth; +- tax and invoice requirements. + +## Architecture decisions + +### Remain a modular monolith + +Keep the modular monolith while: + +- one team owns most of the system; +- the catalogue and transaction load fit a shared PostgreSQL deployment; +- modules can be deployed together; +- independent scaling is not an established bottleneck; +- operational simplicity is more valuable than independent services. + +### Consider extracting a service only when evidence exists + +A module becomes a service candidate when it has several of these properties: + +- a separate team and release cadence; +- materially different scaling needs; +- strong isolation or compliance requirements; +- a stable, explicit API boundary; +- repeated deployment contention in the monolith; +- operational maturity for distributed tracing, service authentication, and failure handling. + +Likely future candidates are notifications/workers, search, or AI workloads. Payments and orders +should not be separated merely for architectural fashion; their consistency is easier to preserve +inside the monolith and shared transaction boundary. + +## Production definition of done + +The store is ready for a controlled production launch only when: + +- the browser cannot control transactional prices or payment identity; +- payment amount, currency, status, and reference are verified server-side; +- webhook and job replays cannot duplicate business effects; +- stock cannot become negative through concurrent checkout; +- password reset works end to end; +- drafts and private product fields cannot reach public surfaces; +- required rate limits and authorization checks use production infrastructure; +- required configuration fails closed; +- production dependencies have no unaccepted high-severity findings; +- migrations, backups, restore, monitoring, and rollback have been tested; +- staging has completed a real Paystack test-mode purchase and refund; +- CI blocks regression of the critical invariants; +- the owner has approved all customer-facing commercial and legal information. + +The expected total for one experienced engineer is approximately four to seven weeks, excluding +external approvals and business/legal content. The safest launch is a small controlled release after +staging certification, followed by close monitoring and daily payment reconciliation. diff --git a/integrations/commerce/local/index.ts b/integrations/commerce/local/index.ts index 78be9af..28b0299 100644 --- a/integrations/commerce/local/index.ts +++ b/integrations/commerce/local/index.ts @@ -73,7 +73,11 @@ async function reprice(cartId: string): Promise { const productIds = [...new Set(lines.map((l) => l.productId))] const products = productIds.length ? await prisma.product.findMany({ - where: { id: { in: productIds }, deletedAt: null }, + where: { + id: { in: productIds }, + deletedAt: null, + publishStatus: 'PUBLISHED', + }, select: { id: true, name: true, priceNGN: true, stock: true }, }) : [] @@ -112,7 +116,10 @@ export const localCommerce: CommerceProvider = { catalog: { async listProducts(query: CatalogQuery): Promise> { const limit = Math.min(Math.max(query.limit ?? 24, 1), 60) - const and: any[] = [{ deletedAt: null }] + const and: any[] = [ + { deletedAt: null }, + { publishStatus: 'PUBLISHED' }, + ] if (query.brand) and.push({ brand: { equals: query.brand, mode: 'insensitive' } }) if (query.family) and.push({ fragranceFamily: { equals: query.family, mode: 'insensitive' } }) if (query.occasion) and.push({ occasion: { contains: query.occasion, mode: 'insensitive' } }) @@ -149,11 +156,15 @@ export const localCommerce: CommerceProvider = { return { items, nextCursor: hasMore ? rows[limit - 1].id : null } }, async getProductBySlug(slug: string): Promise { - const p = await prisma.product.findFirst({ where: { slug, deletedAt: null } }) + const p = await prisma.product.findFirst({ + where: { slug, deletedAt: null, publishStatus: 'PUBLISHED' }, + }) return p ? toProduct(p) : null }, async getProductById(id: string): Promise { - const p = await prisma.product.findFirst({ where: { id, deletedAt: null } }) + const p = await prisma.product.findFirst({ + where: { id, deletedAt: null, publishStatus: 'PUBLISHED' }, + }) return p ? toProduct(p) : null }, async listCollections() { @@ -260,7 +271,11 @@ export const localCommerce: CommerceProvider = { async revalidate(lines: CartLineInput[]) { const ids = [...new Set(lines.map((l) => l.productId))] const products = await prisma.product.findMany({ - where: { id: { in: ids }, deletedAt: null }, + where: { + id: { in: ids }, + deletedAt: null, + publishStatus: 'PUBLISHED', + }, select: { id: true, stock: true }, }) const map = new Map(products.map((p) => [p.id, p.stock])) diff --git a/integrations/search/postgres.ts b/integrations/search/postgres.ts index cc7bef4..8fc866c 100644 --- a/integrations/search/postgres.ts +++ b/integrations/search/postgres.ts @@ -16,7 +16,10 @@ export const postgresSearch: SearchProvider = { const f = query.filters ?? {} const q = query.q?.trim() - const and: any[] = [{ deletedAt: null }] + const and: any[] = [ + { deletedAt: null }, + { publishStatus: 'PUBLISHED' }, + ] if (f.brand) and.push({ brand: { equals: f.brand, mode: 'insensitive' } }) if (f.family) and.push({ fragranceFamily: { equals: f.family, mode: 'insensitive' } }) if (f.occasion) and.push({ occasion: { contains: f.occasion, mode: 'insensitive' } }) diff --git a/lib/catalogue/public-product.ts b/lib/catalogue/public-product.ts new file mode 100644 index 0000000..c99dd73 --- /dev/null +++ b/lib/catalogue/public-product.ts @@ -0,0 +1,21 @@ +import type { Prisma } from "@prisma/client" + +/** + * The minimum visibility boundary for every customer-facing product query. + * + * Keep this separate from availability: published products may remain visible + * while out of stock, but drafts, archived products, and soft-deleted products + * must never appear on public surfaces or enter a transactional cart. + */ +export const PUBLIC_PRODUCT_FILTER = { + deletedAt: null, + publishStatus: "PUBLISHED", +} satisfies Prisma.ProductWhereInput + +export function publicProductWhere( + conditions: Prisma.ProductWhereInput = {}, +): Prisma.ProductWhereInput { + return { + AND: [PUBLIC_PRODUCT_FILTER, conditions], + } +} diff --git a/lib/checkout/idempotency.ts b/lib/checkout/idempotency.ts new file mode 100644 index 0000000..b6e579a --- /dev/null +++ b/lib/checkout/idempotency.ts @@ -0,0 +1,42 @@ +import crypto from "crypto" + +export interface CheckoutFingerprintInput { + addressLine1: string + city: string + state: string + phone: string + items: Array<{ productId: string; quantity: number }> + couponCode?: string | null + deliveryMethod: "standard" | "express" + isGift: boolean + giftMessage?: string | null + giftWrapping: boolean + paymentMethod: "CARD" | "BANK_TRANSFER" +} + +export function isValidIdempotencyKey(value: string | null): value is string { + return Boolean(value && /^[A-Za-z0-9_-]{16,128}$/.test(value)) +} + +/** Hash only fields that affect the resulting order; browser-supplied display totals are excluded. */ +export function checkoutRequestHash(input: CheckoutFingerprintInput): string { + const canonical = { + addressLine1: input.addressLine1.trim(), + city: input.city.trim(), + state: input.state.trim(), + phone: input.phone.trim(), + items: input.items + .map((item) => ({ + productId: item.productId, + quantity: item.quantity, + })) + .sort((a, b) => a.productId.localeCompare(b.productId)), + couponCode: input.couponCode?.trim().toUpperCase() || null, + deliveryMethod: input.deliveryMethod, + isGift: input.isGift, + giftMessage: input.giftMessage?.trim() || null, + giftWrapping: input.giftWrapping, + paymentMethod: input.paymentMethod, + } + return crypto.createHash("sha256").update(JSON.stringify(canonical)).digest("hex") +} diff --git a/lib/config/commerce.ts b/lib/config/commerce.ts index ad340f3..2272597 100644 --- a/lib/config/commerce.ts +++ b/lib/config/commerce.ts @@ -8,6 +8,8 @@ export interface ShippingPolicy { currency: string freeShippingThreshold: number // in minor-unit-free NGN (whole naira), as used across the app flatShippingFee: number + expressShippingFee: number + giftWrapFee: number } export interface LoyaltyTier { @@ -34,6 +36,8 @@ export function getCommerceConfig(): CommerceConfig { currency: env.COMMERCE_CURRENCY, freeShippingThreshold: env.COMMERCE_FREE_SHIPPING_THRESHOLD_NGN, flatShippingFee: env.COMMERCE_FLAT_SHIPPING_NGN, + expressShippingFee: env.COMMERCE_EXPRESS_SHIPPING_NGN, + giftWrapFee: env.COMMERCE_GIFT_WRAP_NGN, }, // Preserves the thresholds already present in the Paystack webhook, now as config. loyaltyTiers: [ @@ -54,6 +58,21 @@ export function computeShipping(subtotalNGN: number): number { return shipping.flatShippingFee } +export function computeCheckoutShipping( + subtotalNGN: number, + deliveryMethod: 'standard' | 'express', + giftWrapping: boolean, +): number { + const { shipping } = getCommerceConfig() + const delivery = + subtotalNGN >= shipping.freeShippingThreshold + ? 0 + : deliveryMethod === 'express' + ? shipping.expressShippingFee + : shipping.flatShippingFee + return delivery + (giftWrapping ? shipping.giftWrapFee : 0) +} + /** Resolve loyalty tier for a lifetime spend using configured thresholds. */ export function resolveLoyaltyTier(lifetimeSpendNGN: number): LoyaltyTier['key'] { const tiers = getCommerceConfig().loyaltyTiers diff --git a/lib/config/payment-methods.ts b/lib/config/payment-methods.ts new file mode 100644 index 0000000..ea6d1eb --- /dev/null +++ b/lib/config/payment-methods.ts @@ -0,0 +1,19 @@ +import { env } from "@/lib/env" + +export interface BankTransferConfig { + accountName: string + bankName: string + accountNumber: string +} + +/** Manual transfer is hidden and rejected unless every owner-approved field is present. */ +export function getBankTransferConfig(): BankTransferConfig | null { + if (!env.BANK_TRANSFER_ENABLED) return null + + const accountName = env.BANK_TRANSFER_ACCOUNT_NAME?.trim() + const bankName = env.BANK_TRANSFER_BANK_NAME?.trim() + const accountNumber = env.BANK_TRANSFER_ACCOUNT_NUMBER?.trim() + + if (!accountName || !bankName || !accountNumber) return null + return { accountName, bankName, accountNumber } +} diff --git a/lib/env-diagnostics.ts b/lib/env-diagnostics.ts index f57ba6d..a22dad5 100644 --- a/lib/env-diagnostics.ts +++ b/lib/env-diagnostics.ts @@ -1,5 +1,14 @@ const STRICT_CRITICAL_ENV_KEYS = ["DATABASE_URL", "NEXTAUTH_URL"] as const +const PRODUCTION_CRITICAL_ENV_KEYS = [ + "APP_URL", + "PAYSTACK_SECRET_KEY", + "RESEND_API_KEY", + "NEWSLETTER_FROM_EMAIL", + "UPSTASH_REDIS_REST_URL", + "UPSTASH_REDIS_REST_TOKEN", +] as const + const OPTIONAL_ENV_KEYS = [ "NEXTAUTH_SECRET", "APP_URL", @@ -18,6 +27,11 @@ export function getEnvDiagnostics() { const missingCritical: string[] = STRICT_CRITICAL_ENV_KEYS.filter( (key) => !hasValue(process.env[key]) ) + if (process.env.NODE_ENV === "production") { + missingCritical.push( + ...PRODUCTION_CRITICAL_ENV_KEYS.filter((key) => !hasValue(process.env[key])), + ) + } const hasAuthSecret = hasValue(process.env.AUTH_SECRET) || hasValue(process.env.NEXTAUTH_SECRET) if (!hasAuthSecret) { diff --git a/lib/env.ts b/lib/env.ts index 7f09998..5d82686 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -60,6 +60,14 @@ const schema = z.object({ COMMERCE_CURRENCY: z.string().default('NGN'), COMMERCE_FREE_SHIPPING_THRESHOLD_NGN: z.coerce.number().int().nonnegative().default(500_000), COMMERCE_FLAT_SHIPPING_NGN: z.coerce.number().int().nonnegative().default(2_500), + COMMERCE_EXPRESS_SHIPPING_NGN: z.coerce.number().int().nonnegative().default(35_000), + COMMERCE_GIFT_WRAP_NGN: z.coerce.number().int().nonnegative().default(2_500), + + // Manual bank transfer is fail-closed until owner-approved details are configured. + BANK_TRANSFER_ENABLED: z.enum(['true', 'false']).transform((value) => value === 'true').default('false'), + BANK_TRANSFER_ACCOUNT_NAME: z.string().optional(), + BANK_TRANSFER_BANK_NAME: z.string().optional(), + BANK_TRANSFER_ACCOUNT_NUMBER: z.string().optional(), // Feature flags (comma-separated list of enabled flags) FEATURE_FLAGS: z.string().default(''), diff --git a/lib/payments/match.ts b/lib/payments/match.ts new file mode 100644 index 0000000..1f85429 --- /dev/null +++ b/lib/payments/match.ts @@ -0,0 +1,21 @@ +export interface PaymentMatchInput { + expectedReference: string | null + receivedReference: string + expectedAmountNGN: number + receivedAmountNGN: number + expectedCurrency: string + receivedCurrency: string + providerStatus: string | undefined + paymentMethod: string +} + +/** Pure payment invariant shared by webhook handling and security regression tests. */ +export function matchesExpectedPayment(input: PaymentMatchInput): boolean { + return ( + input.providerStatus === "success" && + input.paymentMethod === "CARD" && + input.expectedReference === input.receivedReference && + input.expectedAmountNGN === input.receivedAmountNGN && + input.expectedCurrency.toUpperCase() === input.receivedCurrency.toUpperCase() + ) +} diff --git a/lib/pdp/loader.ts b/lib/pdp/loader.ts index 65a4821..8d55111 100644 --- a/lib/pdp/loader.ts +++ b/lib/pdp/loader.ts @@ -270,7 +270,7 @@ function buildPerformance(summary: PdpReviewSummary | null): PdpPerformanceMetri export async function loadPdpData(slug: string): Promise { const p = await prisma.product.findFirst({ - where: { slug, deletedAt: null }, + where: { slug, deletedAt: null, publishStatus: "PUBLISHED" }, include: { variants: true, media: true }, }) if (!p) return null diff --git a/lib/queries/products.ts b/lib/queries/products.ts index 1792d92..d184145 100644 --- a/lib/queries/products.ts +++ b/lib/queries/products.ts @@ -53,7 +53,11 @@ export async function getProductsByCategory(categorySlug: string) { if (!category) return [] const products = await prisma.product.findMany({ - where: { category }, + where: { + category, + deletedAt: null, + publishStatus: "PUBLISHED", + }, orderBy: [{ ratingAvg: "desc" }, { createdAt: "desc" }], }) @@ -61,8 +65,8 @@ export async function getProductsByCategory(categorySlug: string) { } export async function getProductBySlug(slug: string) { - const product = await prisma.product.findUnique({ - where: { slug }, + const product = await prisma.product.findFirst({ + where: { slug, deletedAt: null, publishStatus: "PUBLISHED" }, include: { reviews: { where: { approved: true }, @@ -94,6 +98,8 @@ export async function getRelatedProducts( where: { id: { not: productId }, category, + deletedAt: null, + publishStatus: "PUBLISHED", ...(brand ? { brand } : {}), }, orderBy: [{ ratingAvg: "desc" }, { createdAt: "desc" }], @@ -105,6 +111,8 @@ export async function getRelatedProducts( where: { id: { notIn: [productId, ...products.map((p) => p.id)] }, category, + deletedAt: null, + publishStatus: "PUBLISHED", }, orderBy: [{ ratingAvg: "desc" }, { createdAt: "desc" }], take: limit - products.length, @@ -118,10 +126,10 @@ export async function getRelatedProducts( export async function getFeaturedProducts(limit = 8) { const products = await prisma.product.findMany({ + where: { deletedAt: null, publishStatus: "PUBLISHED" }, orderBy: [{ ratingAvg: "desc" }, { createdAt: "desc" }], take: limit, }) return products.map(mapPrismaProductToCard) } - diff --git a/lib/security/origin.ts b/lib/security/origin.ts new file mode 100644 index 0000000..47cc4c9 --- /dev/null +++ b/lib/security/origin.ts @@ -0,0 +1,19 @@ +import { env } from "@/lib/env" + +/** + * Protect cookie-authenticated JSON mutations from cross-site requests. + * Production fails closed when Origin is absent; development permits tooling + * that does not emulate a browser Origin header. + */ +export function hasTrustedOrigin(request: Request): boolean { + const origin = request.headers.get("origin") + if (!origin) return process.env.NODE_ENV !== "production" + + try { + const configured = new URL(env.APP_URL).origin + const requestOrigin = new URL(request.url).origin + return origin === configured || origin === requestOrigin + } catch { + return false + } +} diff --git a/lib/services/product-service.ts b/lib/services/product-service.ts index 354bc85..bd58916 100644 --- a/lib/services/product-service.ts +++ b/lib/services/product-service.ts @@ -124,7 +124,7 @@ export async function getAdminProductById(id: string): Promise { + it("requires products to be both published and not soft-deleted", () => { + expect(PUBLIC_PRODUCT_FILTER).toEqual({ + deletedAt: null, + publishStatus: "PUBLISHED", + }) + }) + + it("combines visibility with caller-owned query conditions", () => { + expect(publicProductWhere({ brand: "Fádé", stock: { gt: 0 } })).toEqual({ + AND: [ + PUBLIC_PRODUCT_FILTER, + { brand: "Fádé", stock: { gt: 0 } }, + ], + }) + }) +}) diff --git a/tests/checkout/idempotency.test.ts b/tests/checkout/idempotency.test.ts new file mode 100644 index 0000000..6b7d3d4 --- /dev/null +++ b/tests/checkout/idempotency.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest" + +import { + checkoutRequestHash, + isValidIdempotencyKey, +} from "@/lib/checkout/idempotency" + +const checkout = { + addressLine1: "1 Example Road", + city: "Lagos", + state: "Lagos", + phone: "+2348000000000", + items: [ + { productId: "product-b", quantity: 1 }, + { productId: "product-a", quantity: 2 }, + ], + couponCode: " fade10 ", + deliveryMethod: "standard" as const, + isGift: false, + giftMessage: null, + giftWrapping: false, + paymentMethod: "CARD" as const, +} + +describe("checkout idempotency", () => { + it("accepts opaque browser-generated keys and rejects weak keys", () => { + expect(isValidIdempotencyKey("d6a7ca3f-184c-4c40-b73e-a5e2a6df3501")).toBe(true) + expect(isValidIdempotencyKey("short")).toBe(false) + expect(isValidIdempotencyKey(null)).toBe(false) + }) + + it("produces the same hash regardless of item order and coupon casing", () => { + const reordered = { + ...checkout, + couponCode: "FADE10", + items: [...checkout.items].reverse(), + } + expect(checkoutRequestHash(reordered)).toBe(checkoutRequestHash(checkout)) + }) + + it("changes when an order-affecting field changes", () => { + expect( + checkoutRequestHash({ ...checkout, deliveryMethod: "express" }), + ).not.toBe(checkoutRequestHash(checkout)) + }) +}) diff --git a/tests/config/commerce.test.ts b/tests/config/commerce.test.ts index 318c060..7b0ecc6 100644 --- a/tests/config/commerce.test.ts +++ b/tests/config/commerce.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest' -import { computeShipping, resolveLoyaltyTier, isSupportedCurrency } from '@/lib/config/commerce' +import { + computeCheckoutShipping, + computeShipping, + resolveLoyaltyTier, + isSupportedCurrency, +} from '@/lib/config/commerce' describe('shipping policy (config-driven, no hard-coded threshold)', () => { it('is free at or above the configured threshold (default 500,000)', () => { @@ -10,6 +15,12 @@ describe('shipping policy (config-driven, no hard-coded threshold)', () => { expect(computeShipping(499_999)).toBe(2_500) expect(computeShipping(0)).toBe(2_500) }) + it('calculates delivery and gift wrapping entirely from server policy', () => { + expect(computeCheckoutShipping(100_000, 'standard', false)).toBe(2_500) + expect(computeCheckoutShipping(100_000, 'express', false)).toBe(35_000) + expect(computeCheckoutShipping(100_000, 'express', true)).toBe(37_500) + expect(computeCheckoutShipping(500_000, 'express', true)).toBe(2_500) + }) }) describe('loyalty tiers (config-driven)', () => { diff --git a/tests/payments/match.test.ts b/tests/payments/match.test.ts new file mode 100644 index 0000000..37e3d1e --- /dev/null +++ b/tests/payments/match.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest" + +import { matchesExpectedPayment } from "@/lib/payments/match" + +const valid = { + expectedReference: "fade_order_123", + receivedReference: "fade_order_123", + expectedAmountNGN: 125_000, + receivedAmountNGN: 125_000, + expectedCurrency: "NGN", + receivedCurrency: "ngn", + providerStatus: "success", + paymentMethod: "CARD", +} + +describe("payment matching invariant", () => { + it("accepts only a successful payment matching the stored order", () => { + expect(matchesExpectedPayment(valid)).toBe(true) + }) + + it.each([ + ["reference", { receivedReference: "another_order" }], + ["amount", { receivedAmountNGN: 100 }], + ["currency", { receivedCurrency: "USD" }], + ["status", { providerStatus: "pending" }], + ["method", { paymentMethod: "BANK_TRANSFER" }], + ])("rejects a mismatched %s", (_field, override) => { + expect(matchesExpectedPayment({ ...valid, ...override })).toBe(false) + }) +}) diff --git a/tests/security/origin.test.ts b/tests/security/origin.test.ts new file mode 100644 index 0000000..9b97b92 --- /dev/null +++ b/tests/security/origin.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it } from "vitest" + +import { hasTrustedOrigin } from "@/lib/security/origin" + +const previousNodeEnv = process.env.NODE_ENV +const mutableEnv = process.env as Record + +afterEach(() => { + if (previousNodeEnv === undefined) delete mutableEnv.NODE_ENV + else mutableEnv.NODE_ENV = previousNodeEnv +}) + +describe("state-changing request origin", () => { + it("accepts the request host", () => { + const request = new Request("http://localhost:3000/api/checkout", { + headers: { origin: "http://localhost:3000" }, + }) + expect(hasTrustedOrigin(request)).toBe(true) + }) + + it("rejects a cross-site origin", () => { + const request = new Request("http://localhost:3000/api/checkout", { + headers: { origin: "https://attacker.example" }, + }) + expect(hasTrustedOrigin(request)).toBe(false) + }) + + it("fails closed without Origin in production", () => { + mutableEnv.NODE_ENV = "production" + expect( + hasTrustedOrigin(new Request("https://shop.example/api/checkout")), + ).toBe(false) + }) +}) From ac67559ad958cf5fd07a23a9e4208e1295511e84 Mon Sep 17 00:00:00 2001 From: Bash Abdul Date: Thu, 23 Jul 2026 17:02:32 +0100 Subject: [PATCH 02/19] Align staging schema and seed tooling --- package-lock.json | 504 ++++++++++++++++++ package.json | 1 + .../migration.sql | 22 + 3 files changed, 527 insertions(+) create mode 100644 prisma/migrations/20260723163500_schema_alignment/migration.sql diff --git a/package-lock.json b/package-lock.json index f72225d..8e24f0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,7 @@ "postcss": "^8.5", "prisma": "^6.19.2", "tailwindcss": "^4.1.9", + "tsx": "^4.23.1", "tw-animate-css": "1.3.3", "typescript": "^5", "vite-tsconfig-paths": "^5.1.4", @@ -744,6 +745,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -761,6 +779,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -778,6 +813,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -10563,6 +10615,458 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/tw-animate-css": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.3.tgz", diff --git a/package.json b/package.json index da8055a..53f18c9 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "postcss": "^8.5", "prisma": "^6.19.2", "tailwindcss": "^4.1.9", + "tsx": "^4.23.1", "tw-animate-css": "1.3.3", "typescript": "^5", "vite-tsconfig-paths": "^5.1.4", diff --git a/prisma/migrations/20260723163500_schema_alignment/migration.sql b/prisma/migrations/20260723163500_schema_alignment/migration.sql new file mode 100644 index 0000000..528dd7b --- /dev/null +++ b/prisma/migrations/20260723163500_schema_alignment/migration.sql @@ -0,0 +1,22 @@ +-- Align columns that were added to the Prisma schema without a corresponding migration. +ALTER TABLE "Order" +ADD COLUMN "giftMessage" TEXT, +ADD COLUMN "giftWrapping" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "isGift" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "paymentMethod" TEXT NOT NULL DEFAULT 'CARD', +ADD COLUMN "shippingNGN" INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE "Product" +ALTER COLUMN "category" DROP DEFAULT; + +ALTER TABLE "User" +ADD COLUMN "loyaltyTier" TEXT NOT NULL DEFAULT 'STANDARD', +ADD COLUMN "referralCode" TEXT, +ADD COLUMN "referredBy" TEXT, +ADD COLUMN "totalLifetimeSpend" INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX "Order_userId_createdAt_idx" +ON "Order"("userId", "createdAt"); + +CREATE UNIQUE INDEX "User_referralCode_key" +ON "User"("referralCode"); From d47cd4b58bf99470ce3297fcfaf5e4041a30b8f8 Mon Sep 17 00:00:00 2001 From: Bash Abdul Date: Thu, 23 Jul 2026 17:26:50 +0100 Subject: [PATCH 03/19] Add atomic inventory reservations --- .env.example | 4 + app/api/checkout/create-order/route.ts | 24 +- .../jobs/release-reservations/route.ts | 28 +++ app/api/paystack/initialize/route.ts | 15 ++ app/api/paystack/webhook/route.ts | 17 +- docs/PRODUCTION_READINESS_PLAN.md | 15 +- lib/env-diagnostics.ts | 1 + lib/env.ts | 1 + lib/inventory/reservations.ts | 220 ++++++++++++++++++ lib/security/bearer.ts | 12 + .../migration.sql | 76 ++++++ prisma/schema.prisma | 80 ++++++- .../inventory-reservation.int.test.ts | 156 +++++++++++++ tests/inventory/reservations.test.ts | 35 +++ tests/security/bearer.test.ts | 18 ++ 15 files changed, 667 insertions(+), 35 deletions(-) create mode 100644 app/api/internal/jobs/release-reservations/route.ts create mode 100644 lib/inventory/reservations.ts create mode 100644 lib/security/bearer.ts create mode 100644 prisma/migrations/20260723170000_inventory_reservations/migration.sql create mode 100644 tests/integration/inventory-reservation.int.test.ts create mode 100644 tests/inventory/reservations.test.ts create mode 100644 tests/security/bearer.test.ts diff --git a/.env.example b/.env.example index 25fecd0..bfa4bac 100644 --- a/.env.example +++ b/.env.example @@ -76,6 +76,10 @@ FEATURE_FLAGS="" UPSTASH_REDIS_REST_URL="" UPSTASH_REDIS_REST_TOKEN="" +# Authenticates scheduler calls to internal background-job endpoints. +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" +CRON_SECRET="" + # --- Concierge V2 limits and cost controls --- CONCIERGE_GUEST_QUESTIONS="1" CONCIERGE_AUTH_PER_MINUTE="12" diff --git a/app/api/checkout/create-order/route.ts b/app/api/checkout/create-order/route.ts index 4db899e..bee1ac2 100644 --- a/app/api/checkout/create-order/route.ts +++ b/app/api/checkout/create-order/route.ts @@ -11,6 +11,12 @@ import { import { consumeRateLimit } from "@/lib/middleware/limiter" import { validateCoupon } from "@/lib/pricing" import { hasTrustedOrigin } from "@/lib/security/origin" +import { AppError } from "@/lib/http/errors" +import { + aggregateInventoryLines, + reservationExpiry, + reserveInventory, +} from "@/lib/inventory/reservations" const createOrderSchema = z.object({ addressLine1: z.string().min(1, "Address is required"), @@ -137,7 +143,8 @@ export async function POST(req: NextRequest) { } // Resolve product IDs and validate prices (use DB price for consistency) - const productIds = [...new Set(items.map((i) => i.productId))] + const inventoryLines = aggregateInventoryLines(items) + const productIds = inventoryLines.map((item) => item.productId) const products = await prisma.product.findMany({ where: { id: { in: productIds }, @@ -149,7 +156,7 @@ export async function POST(req: NextRequest) { const productMap = new Map(products.map((p) => [p.id, p])) const orderItems: { productId: string; quantity: number; priceNGN: number }[] = [] - for (const item of items) { + for (const item of inventoryLines) { const product = productMap.get(item.productId) if (!product) { return NextResponse.json({ error: `Product not found: ${item.productId}` }, { status: 400 }) @@ -224,6 +231,12 @@ export async function POST(req: NextRequest) { expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), }, }) + await reserveInventory( + tx, + created.id, + orderItems, + reservationExpiry(paymentMethod), + ) return created }) } catch (error) { @@ -242,8 +255,11 @@ export async function POST(req: NextRequest) { } return NextResponse.json({ orderId: order.id }) - } catch (e) { - console.error("Create order error:", e) + } catch (error) { + console.error("Create order error:", error) + if (error instanceof AppError) { + return NextResponse.json({ error: error.safeMessage }, { status: error.status }) + } return NextResponse.json({ error: "Failed to create order" }, { status: 500 }) } } diff --git a/app/api/internal/jobs/release-reservations/route.ts b/app/api/internal/jobs/release-reservations/route.ts new file mode 100644 index 0000000..8f2f8d9 --- /dev/null +++ b/app/api/internal/jobs/release-reservations/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server" + +import { env } from "@/lib/env" +import { releaseExpiredReservations } from "@/lib/inventory/reservations" +import { logger } from "@/lib/observability/logger" +import { hasValidBearerSecret } from "@/lib/security/bearer" + +export const runtime = "nodejs" + +export async function POST(req: Request) { + if (!env.CRON_SECRET) { + return NextResponse.json({ error: "job_not_configured" }, { status: 503 }) + } + if (!hasValidBearerSecret(req.headers.get("authorization"), env.CRON_SECRET)) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }) + } + + try { + const result = await releaseExpiredReservations() + logger.info("inventory_reservations_expired", result) + return NextResponse.json({ ok: true, ...result }) + } catch (error) { + logger.error("inventory_reservation_expiry_failed", { + internal: String(error), + }) + return NextResponse.json({ error: "job_failed" }, { status: 500 }) + } +} diff --git a/app/api/paystack/initialize/route.ts b/app/api/paystack/initialize/route.ts index 57f6de6..6890d68 100644 --- a/app/api/paystack/initialize/route.ts +++ b/app/api/paystack/initialize/route.ts @@ -115,6 +115,9 @@ export async function POST(req: Request) { id: true, totalNGN: true, user: { select: { email: true } }, + inventoryReservations: { + select: { status: true, expiresAt: true }, + }, }, }) if (!order) { @@ -123,6 +126,18 @@ export async function POST(req: Request) { { status: 404 }, ) } + if ( + order.inventoryReservations.length > 0 && + order.inventoryReservations.some( + (reservation) => + reservation.status !== "RESERVED" || reservation.expiresAt <= new Date(), + ) + ) { + return NextResponse.json( + { error: "This checkout reservation expired. Please return to your cart and try again." }, + { status: 409 }, + ) + } const provider = getPayments() if (provider.name !== "paystack") { diff --git a/app/api/paystack/webhook/route.ts b/app/api/paystack/webhook/route.ts index b0edd75..63a5c8b 100644 --- a/app/api/paystack/webhook/route.ts +++ b/app/api/paystack/webhook/route.ts @@ -3,7 +3,6 @@ import { NextRequest, NextResponse } from "next/server" import { sendReceipt } from "@/emails/sendReceipt" import { getPayments } from "@/integrations/registry" import { resolveLoyaltyTier } from "@/lib/config/commerce" -import { AppError } from "@/lib/http/errors" import { pointsForOrder } from "@/lib/loyalty/points" import { reversePointsForOrder } from "@/lib/loyalty/service" import { logger } from "@/lib/observability/logger" @@ -11,6 +10,7 @@ import { matchesExpectedPayment } from "@/lib/payments/match" import { prisma } from "@/lib/prisma" import { qualifyReferral } from "@/lib/referrals/service" import { recordWebhookOnce } from "@/lib/webhooks/idempotency" +import { finalizeInventoryForOrder } from "@/lib/inventory/reservations" export const runtime = "nodejs" @@ -151,20 +151,7 @@ export async function POST(req: NextRequest) { } } - for (const item of attempt.order.items) { - const updated = await tx.product.updateMany({ - where: { - id: item.productId, - stock: { gte: item.quantity }, - }, - data: { stock: { decrement: item.quantity } }, - }) - if (updated.count !== 1) { - throw new AppError("INSUFFICIENT_STOCK", { - internal: { orderId, productId: item.productId }, - }) - } - } + await finalizeInventoryForOrder(tx, orderId, attempt.order.items) if (attempt.order.couponId) { await tx.coupon.update({ diff --git a/docs/PRODUCTION_READINESS_PLAN.md b/docs/PRODUCTION_READINESS_PLAN.md index 49a950a..f46c6f1 100644 --- a/docs/PRODUCTION_READINESS_PLAN.md +++ b/docs/PRODUCTION_READINESS_PLAN.md @@ -44,18 +44,24 @@ Completed in the first hardening milestone: metadata as the source of truth; - each successful attempt is recorded before the order transition, including duplicate successful payments that require operational review; +- checkout now atomically reserves available stock and records an inventory movement; +- card reservations expire after 30 minutes and bank-transfer reservations after 24 hours; +- a protected, idempotent expiration job releases stock exactly once; +- successful payment finalizes the reservation without decrementing stock twice, while legacy + pending orders retain a safe conditional-decrement path; +- concurrent database tests prove only one checkout can claim the final available units; - payment-matching, checkout-idempotency, publication, origin, and shipping-policy regression tests were added; -- Prisma schema validation, TypeScript, lint, 321 unit tests, and the production build pass on this - branch (the database-backed integration suite remains skipped until a test database is supplied). +- Prisma schema validation, TypeScript, lint, 331 unit/integration tests, and the production build + pass on this branch; all staging migrations are applied with zero schema drift. Still required before launch: -- inventory reservations and reservation-expiry jobs; - a transactional outbox and background worker; - payment reconciliation and complete refund/order state handling; - complete password-reset flow and broader authentication abuse protection; - dependency remediation, full database integration tests, and production staging certification; +- scheduler configuration for the reservation-expiry endpoint; - owner-supplied provider credentials, business details, brand assets, and approved policies. ## Audit findings @@ -80,7 +86,8 @@ This is the original audit list. Items completed on `codex/production-readiness` ### High priority -1. There is no reliable stock reservation or atomic non-negative stock decrement. +1. **Resolved on branch:** there was no reliable stock reservation or atomic non-negative stock + decrement. 2. **Resolved on branch:** public catalogue queries did not consistently require `publishStatus = PUBLISHED`. 3. Custom state-changing endpoints do not consistently validate request origin or CSRF protections. diff --git a/lib/env-diagnostics.ts b/lib/env-diagnostics.ts index a22dad5..7bd79a6 100644 --- a/lib/env-diagnostics.ts +++ b/lib/env-diagnostics.ts @@ -7,6 +7,7 @@ const PRODUCTION_CRITICAL_ENV_KEYS = [ "NEWSLETTER_FROM_EMAIL", "UPSTASH_REDIS_REST_URL", "UPSTASH_REDIS_REST_TOKEN", + "CRON_SECRET", ] as const const OPTIONAL_ENV_KEYS = [ diff --git a/lib/env.ts b/lib/env.ts index 5d82686..967bb4c 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -76,6 +76,7 @@ const schema = z.object({ // When absent, rate limiting degrades to an in-memory per-instance limiter (documented limitation). UPSTASH_REDIS_REST_URL: z.string().url().optional(), UPSTASH_REDIS_REST_TOKEN: z.string().optional(), + CRON_SECRET: z.string().min(32).optional(), CONCIERGE_GUEST_QUESTIONS: z.coerce.number().int().min(0).max(10).default(1), CONCIERGE_AUTH_PER_MINUTE: z.coerce.number().int().positive().default(12), CONCIERGE_AUTH_DAILY: z.coerce.number().int().positive().default(100), diff --git a/lib/inventory/reservations.ts b/lib/inventory/reservations.ts new file mode 100644 index 0000000..917707a --- /dev/null +++ b/lib/inventory/reservations.ts @@ -0,0 +1,220 @@ +import type { Prisma } from "@prisma/client" + +import { AppError } from "@/lib/http/errors" +import { prisma } from "@/lib/prisma" + +export const CARD_RESERVATION_MINUTES = 30 +export const BANK_TRANSFER_RESERVATION_HOURS = 24 + +export interface InventoryLine { + productId: string + quantity: number +} + +export function aggregateInventoryLines(items: InventoryLine[]): InventoryLine[] { + const quantities = new Map() + for (const item of items) { + quantities.set(item.productId, (quantities.get(item.productId) ?? 0) + item.quantity) + } + return [...quantities.entries()] + .map(([productId, quantity]) => ({ productId, quantity })) + .sort((a, b) => a.productId.localeCompare(b.productId)) +} + +export function reservationExpiry( + paymentMethod: "CARD" | "BANK_TRANSFER", + now = new Date(), +): Date { + const durationMs = + paymentMethod === "BANK_TRANSFER" + ? BANK_TRANSFER_RESERVATION_HOURS * 60 * 60 * 1000 + : CARD_RESERVATION_MINUTES * 60 * 1000 + return new Date(now.getTime() + durationMs) +} + +export async function reserveInventory( + tx: Prisma.TransactionClient, + orderId: string, + rawItems: InventoryLine[], + expiresAt: Date, +) { + const items = aggregateInventoryLines(rawItems) + for (const item of items) { + const claimed = await tx.product.updateMany({ + where: { + id: item.productId, + stock: { gte: item.quantity }, + deletedAt: null, + publishStatus: "PUBLISHED", + }, + data: { stock: { decrement: item.quantity } }, + }) + if (claimed.count !== 1) { + throw new AppError("INSUFFICIENT_STOCK", { + internal: { orderId, productId: item.productId, quantity: item.quantity }, + }) + } + + await tx.inventoryReservation.create({ + data: { + orderId, + productId: item.productId, + quantity: item.quantity, + expiresAt, + }, + }) + await tx.inventoryMovement.create({ + data: { + productId: item.productId, + delta: -item.quantity, + reason: "RESERVATION_CREATED", + sourceType: "ORDER", + sourceId: orderId, + }, + }) + } +} + +export async function finalizeInventoryForOrder( + tx: Prisma.TransactionClient, + orderId: string, + rawItems: InventoryLine[], +) { + const items = aggregateInventoryLines(rawItems) + const reservations = await tx.inventoryReservation.findMany({ + where: { orderId }, + }) + + // Backward compatibility for pending orders created before reservations were introduced. + if (reservations.length === 0) { + for (const item of items) { + const sold = await tx.product.updateMany({ + where: { id: item.productId, stock: { gte: item.quantity } }, + data: { stock: { decrement: item.quantity } }, + }) + if (sold.count !== 1) { + throw new AppError("INSUFFICIENT_STOCK", { + internal: { orderId, productId: item.productId, quantity: item.quantity }, + }) + } + await tx.inventoryMovement.create({ + data: { + productId: item.productId, + delta: -item.quantity, + reason: "LEGACY_SALE", + sourceType: "ORDER", + sourceId: orderId, + }, + }) + } + return + } + + const byProduct = new Map(reservations.map((reservation) => [ + reservation.productId, + reservation, + ])) + for (const item of items) { + const reservation = byProduct.get(item.productId) + if (!reservation || reservation.quantity !== item.quantity) { + throw new AppError("INTERNAL_ERROR", { + internal: { + reason: "inventory_reservation_mismatch", + orderId, + productId: item.productId, + }, + }) + } + + if (reservation.status === "SOLD") continue + if (reservation.status === "RESERVED") { + const finalized = await tx.inventoryReservation.updateMany({ + where: { id: reservation.id, status: "RESERVED" }, + data: { status: "SOLD", finalizedAt: new Date() }, + }) + if (finalized.count === 1) continue + } + + const reacquired = await tx.product.updateMany({ + where: { id: item.productId, stock: { gte: item.quantity } }, + data: { stock: { decrement: item.quantity } }, + }) + if (reacquired.count !== 1) { + throw new AppError("INSUFFICIENT_STOCK", { + internal: { + reason: "paid_after_reservation_expired", + orderId, + productId: item.productId, + quantity: item.quantity, + }, + }) + } + const finalized = await tx.inventoryReservation.updateMany({ + where: { + id: reservation.id, + status: { in: ["EXPIRED", "RELEASED"] }, + }, + data: { status: "SOLD", finalizedAt: new Date() }, + }) + if (finalized.count !== 1) { + throw new AppError("INTERNAL_ERROR", { + internal: { + reason: "inventory_reservation_transition_race", + orderId, + reservationId: reservation.id, + }, + }) + } + await tx.inventoryMovement.create({ + data: { + productId: item.productId, + delta: -item.quantity, + reason: "SALE_AFTER_EXPIRY", + sourceType: "ORDER", + sourceId: orderId, + }, + }) + } +} + +export async function releaseExpiredReservations( + options: { now?: Date; limit?: number } = {}, +) { + const now = options.now ?? new Date() + const limit = Math.min(Math.max(options.limit ?? 100, 1), 500) + const candidates = await prisma.inventoryReservation.findMany({ + where: { status: "RESERVED", expiresAt: { lte: now } }, + orderBy: { expiresAt: "asc" }, + take: limit, + select: { id: true, orderId: true, productId: true, quantity: true }, + }) + + let released = 0 + for (const candidate of candidates) { + const didRelease = await prisma.$transaction(async (tx) => { + const transitioned = await tx.inventoryReservation.updateMany({ + where: { id: candidate.id, status: "RESERVED", expiresAt: { lte: now } }, + data: { status: "EXPIRED", releasedAt: now }, + }) + if (transitioned.count !== 1) return false + + await tx.product.update({ + where: { id: candidate.productId }, + data: { stock: { increment: candidate.quantity } }, + }) + await tx.inventoryMovement.create({ + data: { + productId: candidate.productId, + delta: candidate.quantity, + reason: "RESERVATION_EXPIRED", + sourceType: "ORDER", + sourceId: candidate.orderId, + }, + }) + return true + }) + if (didRelease) released += 1 + } + + return { examined: candidates.length, released } +} diff --git a/lib/security/bearer.ts b/lib/security/bearer.ts new file mode 100644 index 0000000..01d4a44 --- /dev/null +++ b/lib/security/bearer.ts @@ -0,0 +1,12 @@ +import crypto from "crypto" + +export function hasValidBearerSecret( + authorization: string | null, + expectedSecret: string | undefined, +): boolean { + if (!authorization?.startsWith("Bearer ") || !expectedSecret) return false + const received = authorization.slice("Bearer ".length) + const expected = Buffer.from(expectedSecret) + const actual = Buffer.from(received) + return expected.length === actual.length && crypto.timingSafeEqual(expected, actual) +} diff --git a/prisma/migrations/20260723170000_inventory_reservations/migration.sql b/prisma/migrations/20260723170000_inventory_reservations/migration.sql new file mode 100644 index 0000000..3acd10c --- /dev/null +++ b/prisma/migrations/20260723170000_inventory_reservations/migration.sql @@ -0,0 +1,76 @@ +CREATE TYPE "InventoryReservationStatus" AS ENUM ( + 'RESERVED', + 'SOLD', + 'RELEASED', + 'EXPIRED' +); + +CREATE TYPE "InventoryMovementReason" AS ENUM ( + 'RESERVATION_CREATED', + 'RESERVATION_EXPIRED', + 'SALE_AFTER_EXPIRY', + 'LEGACY_SALE', + 'RETURN', + 'ADJUSTMENT' +); + +CREATE TABLE "InventoryReservation" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + "status" "InventoryReservationStatus" NOT NULL DEFAULT 'RESERVED', + "expiresAt" TIMESTAMP(3) NOT NULL, + "reservedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "finalizedAt" TIMESTAMP(3), + "releasedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "InventoryReservation_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "InventoryMovement" ( + "id" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "delta" INTEGER NOT NULL, + "reason" "InventoryMovementReason" NOT NULL, + "sourceType" TEXT NOT NULL, + "sourceId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "InventoryMovement_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "InventoryReservation_orderId_productId_key" +ON "InventoryReservation"("orderId", "productId"); + +CREATE INDEX "InventoryReservation_status_expiresAt_idx" +ON "InventoryReservation"("status", "expiresAt"); + +CREATE INDEX "InventoryReservation_productId_status_idx" +ON "InventoryReservation"("productId", "status"); + +CREATE UNIQUE INDEX "InventoryMovement_productId_reason_sourceType_sourceId_key" +ON "InventoryMovement"("productId", "reason", "sourceType", "sourceId"); + +CREATE INDEX "InventoryMovement_productId_createdAt_idx" +ON "InventoryMovement"("productId", "createdAt"); + +CREATE INDEX "InventoryMovement_sourceType_sourceId_idx" +ON "InventoryMovement"("sourceType", "sourceId"); + +ALTER TABLE "InventoryReservation" +ADD CONSTRAINT "InventoryReservation_orderId_fkey" +FOREIGN KEY ("orderId") REFERENCES "Order"("id") +ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "InventoryReservation" +ADD CONSTRAINT "InventoryReservation_productId_fkey" +FOREIGN KEY ("productId") REFERENCES "Product"("id") +ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "InventoryMovement" +ADD CONSTRAINT "InventoryMovement_productId_fkey" +FOREIGN KEY ("productId") REFERENCES "Product"("id") +ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3106817..99d735a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,6 +29,22 @@ enum PaymentAttemptStatus { REFUNDED } +enum InventoryReservationStatus { + RESERVED + SOLD + RELEASED + EXPIRED +} + +enum InventoryMovementReason { + RESERVATION_CREATED + RESERVATION_EXPIRED + SALE_AFTER_EXPIRY + LEGACY_SALE + RETURN + ADJUSTMENT +} + model User { id String @id @default(cuid()) name String? @@ -160,14 +176,16 @@ model Product { ratingCount Int @default(0) // relations - collectionId String? - collection Collection? @relation(fields: [collectionId], references: [id]) - orderItems OrderItem[] - wishlists Wishlist[] - reviews Review[] - variants ProductVariant[] - media ProductMedia[] - backInStockSubs BackInStockSubscription[] + collectionId String? + collection Collection? @relation(fields: [collectionId], references: [id]) + orderItems OrderItem[] + wishlists Wishlist[] + reviews Review[] + variants ProductVariant[] + media ProductMedia[] + backInStockSubs BackInStockSubscription[] + inventoryReservations InventoryReservation[] + inventoryMovements InventoryMovement[] @@index([name]) @@index([category]) @@ -306,10 +324,11 @@ model Order { // Payment method paymentMethod String @default("CARD") // CARD | BANK_TRANSFER - reference String? @unique - items OrderItem[] - paymentAttempts PaymentAttempt[] - checkoutAttempt CheckoutAttempt? + reference String? @unique + items OrderItem[] + paymentAttempts PaymentAttempt[] + checkoutAttempt CheckoutAttempt? + inventoryReservations InventoryReservation[] // Shipping/contact snapshot addressLine1 String @@ -367,6 +386,43 @@ model PaymentAttempt { @@index([status, createdAt]) } +model InventoryReservation { + id String @id @default(cuid()) + orderId String + productId String + quantity Int + status InventoryReservationStatus @default(RESERVED) + expiresAt DateTime + reservedAt DateTime @default(now()) + finalizedAt DateTime? + releasedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + order Order @relation(fields: [orderId], references: [id]) + product Product @relation(fields: [productId], references: [id]) + + @@unique([orderId, productId]) + @@index([status, expiresAt]) + @@index([productId, status]) +} + +model InventoryMovement { + id String @id @default(cuid()) + productId String + delta Int + reason InventoryMovementReason + sourceType String + sourceId String + createdAt DateTime @default(now()) + + product Product @relation(fields: [productId], references: [id]) + + @@unique([productId, reason, sourceType, sourceId]) + @@index([productId, createdAt]) + @@index([sourceType, sourceId]) +} + model OrderItem { id String @id @default(cuid()) orderId String diff --git a/tests/integration/inventory-reservation.int.test.ts b/tests/integration/inventory-reservation.int.test.ts new file mode 100644 index 0000000..d6afc39 --- /dev/null +++ b/tests/integration/inventory-reservation.int.test.ts @@ -0,0 +1,156 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" + +import { + finalizeInventoryForOrder, + releaseExpiredReservations, + reserveInventory, +} from "@/lib/inventory/reservations" +import { prisma } from "@/lib/prisma" + +const hasDb = Boolean(process.env.DATABASE_URL) +const tag = `inventory_itest_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + +describe.skipIf(!hasDb)("inventory reservation lifecycle (DB)", () => { + let userId = "" + let productId = "" + const orderIds: string[] = [] + + beforeAll(async () => { + const user = await prisma.user.create({ + data: { + email: `${tag}@example.test`, + passwordHash: "x", + name: "Inventory Integration Test", + }, + select: { id: true }, + }) + userId = user.id + const product = await prisma.product.create({ + data: { + name: `${tag} Perfume`, + slug: `${tag}-perfume`, + description: "test", + images: ["/placeholder.png"], + priceNGN: 50_000, + category: "PERFUMES", + publishStatus: "PUBLISHED", + stock: 5, + }, + select: { id: true }, + }) + productId = product.id + }) + + beforeEach(async () => { + await prisma.inventoryMovement.deleteMany({ where: { productId } }) + await prisma.inventoryReservation.deleteMany({ where: { productId } }) + await prisma.product.update({ where: { id: productId }, data: { stock: 5 } }) + }) + + afterAll(async () => { + await prisma.inventoryMovement.deleteMany({ where: { productId } }).catch(() => {}) + await prisma.inventoryReservation.deleteMany({ where: { productId } }).catch(() => {}) + await prisma.orderItem.deleteMany({ where: { orderId: { in: orderIds } } }).catch(() => {}) + await prisma.order.deleteMany({ where: { id: { in: orderIds } } }).catch(() => {}) + await prisma.product.deleteMany({ where: { id: productId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: userId } }).catch(() => {}) + await prisma.$disconnect().catch(() => {}) + }) + + async function createOrder(quantity: number) { + const order = await prisma.order.create({ + data: { + userId, + totalNGN: quantity * 50_000, + subtotalNGN: quantity * 50_000, + addressLine1: "1 Test St", + city: "Lagos", + state: "Lagos", + phone: "08000000000", + items: { create: [{ productId, quantity, priceNGN: 50_000 }] }, + }, + select: { id: true }, + }) + orderIds.push(order.id) + return order.id + } + + it("returns expired reserved stock exactly once", async () => { + const orderId = await createOrder(2) + const expiredAt = new Date(Date.now() - 60_000) + await prisma.$transaction((tx) => + reserveInventory(tx, orderId, [{ productId, quantity: 2 }], expiredAt), + ) + + expect((await prisma.product.findUniqueOrThrow({ + where: { id: productId }, + select: { stock: true }, + })).stock).toBe(3) + + expect((await releaseExpiredReservations({ now: new Date(), limit: 100 })).released).toBe(1) + expect((await releaseExpiredReservations({ now: new Date(), limit: 100 })).released).toBe(0) + + const reservation = await prisma.inventoryReservation.findUniqueOrThrow({ + where: { orderId_productId: { orderId, productId } }, + select: { status: true }, + }) + const product = await prisma.product.findUniqueOrThrow({ + where: { id: productId }, + select: { stock: true }, + }) + expect(reservation.status).toBe("EXPIRED") + expect(product.stock).toBe(5) + }) + + it("finalizes a live reservation without decrementing stock twice", async () => { + const orderId = await createOrder(3) + await prisma.$transaction((tx) => + reserveInventory( + tx, + orderId, + [{ productId, quantity: 3 }], + new Date(Date.now() + 30 * 60_000), + ), + ) + await prisma.$transaction((tx) => + finalizeInventoryForOrder(tx, orderId, [{ productId, quantity: 3 }]), + ) + + const reservation = await prisma.inventoryReservation.findUniqueOrThrow({ + where: { orderId_productId: { orderId, productId } }, + select: { status: true }, + }) + const product = await prisma.product.findUniqueOrThrow({ + where: { id: productId }, + select: { stock: true }, + }) + expect(reservation.status).toBe("SOLD") + expect(product.stock).toBe(2) + }) + + it("allows only one concurrent checkout to claim the final stock", async () => { + await prisma.product.update({ where: { id: productId }, data: { stock: 2 } }) + const firstOrderId = await createOrder(2) + const secondOrderId = await createOrder(2) + const expiresAt = new Date(Date.now() + 30 * 60_000) + + const outcomes = await Promise.allSettled([ + prisma.$transaction((tx) => + reserveInventory(tx, firstOrderId, [{ productId, quantity: 2 }], expiresAt), + ), + prisma.$transaction((tx) => + reserveInventory(tx, secondOrderId, [{ productId, quantity: 2 }], expiresAt), + ), + ]) + + expect(outcomes.filter((outcome) => outcome.status === "fulfilled")).toHaveLength(1) + expect(outcomes.filter((outcome) => outcome.status === "rejected")).toHaveLength(1) + expect((await prisma.product.findUniqueOrThrow({ + where: { id: productId }, + select: { stock: true }, + })).stock).toBe(0) + expect(await prisma.inventoryReservation.count({ + where: { orderId: { in: [firstOrderId, secondOrderId] } }, + })).toBe(1) + }) +}) diff --git a/tests/inventory/reservations.test.ts b/tests/inventory/reservations.test.ts new file mode 100644 index 0000000..b45a775 --- /dev/null +++ b/tests/inventory/reservations.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest" + +import { + aggregateInventoryLines, + BANK_TRANSFER_RESERVATION_HOURS, + CARD_RESERVATION_MINUTES, + reservationExpiry, +} from "@/lib/inventory/reservations" + +describe("inventory reservation policy", () => { + it("aggregates duplicate product lines before checking or reserving stock", () => { + expect(aggregateInventoryLines([ + { productId: "b", quantity: 1 }, + { productId: "a", quantity: 2 }, + { productId: "a", quantity: 3 }, + ])).toEqual([ + { productId: "a", quantity: 5 }, + { productId: "b", quantity: 1 }, + ]) + }) + + it("uses a short card reservation window", () => { + const now = new Date("2026-07-23T12:00:00.000Z") + expect(reservationExpiry("CARD", now).getTime() - now.getTime()).toBe( + CARD_RESERVATION_MINUTES * 60 * 1000, + ) + }) + + it("allows a longer manual bank-transfer reservation window", () => { + const now = new Date("2026-07-23T12:00:00.000Z") + expect(reservationExpiry("BANK_TRANSFER", now).getTime() - now.getTime()).toBe( + BANK_TRANSFER_RESERVATION_HOURS * 60 * 60 * 1000, + ) + }) +}) diff --git a/tests/security/bearer.test.ts b/tests/security/bearer.test.ts new file mode 100644 index 0000000..76886fc --- /dev/null +++ b/tests/security/bearer.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest" + +import { hasValidBearerSecret } from "@/lib/security/bearer" + +describe("internal job bearer authentication", () => { + const secret = "a".repeat(32) + + it("accepts the exact bearer secret", () => { + expect(hasValidBearerSecret(`Bearer ${secret}`, secret)).toBe(true) + }) + + it("rejects missing, malformed, or different credentials", () => { + expect(hasValidBearerSecret(null, secret)).toBe(false) + expect(hasValidBearerSecret(secret, secret)).toBe(false) + expect(hasValidBearerSecret(`Bearer ${"b".repeat(32)}`, secret)).toBe(false) + expect(hasValidBearerSecret(`Bearer ${secret}`, undefined)).toBe(false) + }) +}) From 8da729460be6ebce9fb995dbfbc678aa9ab3997f Mon Sep 17 00:00:00 2001 From: Bash Abdul Date: Thu, 23 Jul 2026 18:02:14 +0100 Subject: [PATCH 04/19] Add transactional payment outbox --- app/api/internal/jobs/process-outbox/route.ts | 27 +++ app/api/paystack/webhook/route.ts | 31 +-- docs/PRODUCTION_READINESS_PLAN.md | 11 +- emails/sendReceipt.ts | 36 +-- lib/jobs/outbox.ts | 213 ++++++++++++++++++ lib/security/html.ts | 11 + .../migration.sql | 38 ++++ prisma/schema.prisma | 24 ++ tests/integration/outbox.int.test.ts | 122 ++++++++++ tests/jobs/outbox.test.ts | 12 + tests/security/html.test.ts | 15 ++ 11 files changed, 491 insertions(+), 49 deletions(-) create mode 100644 app/api/internal/jobs/process-outbox/route.ts create mode 100644 lib/jobs/outbox.ts create mode 100644 lib/security/html.ts create mode 100644 prisma/migrations/20260723174500_transactional_outbox/migration.sql create mode 100644 tests/integration/outbox.int.test.ts create mode 100644 tests/jobs/outbox.test.ts create mode 100644 tests/security/html.test.ts diff --git a/app/api/internal/jobs/process-outbox/route.ts b/app/api/internal/jobs/process-outbox/route.ts new file mode 100644 index 0000000..dd2efb7 --- /dev/null +++ b/app/api/internal/jobs/process-outbox/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server" + +import { env } from "@/lib/env" +import { processOutboxBatch } from "@/lib/jobs/outbox" +import { logger } from "@/lib/observability/logger" +import { hasValidBearerSecret } from "@/lib/security/bearer" + +export const runtime = "nodejs" +export const maxDuration = 30 + +export async function POST(req: Request) { + if (!env.CRON_SECRET) { + return NextResponse.json({ error: "job_not_configured" }, { status: 503 }) + } + if (!hasValidBearerSecret(req.headers.get("authorization"), env.CRON_SECRET)) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }) + } + + try { + const result = await processOutboxBatch({ limit: 20 }) + logger.info("outbox_batch_processed", result) + return NextResponse.json({ ok: true, ...result }) + } catch (error) { + logger.error("outbox_batch_failed", { internal: String(error) }) + return NextResponse.json({ error: "job_failed" }, { status: 500 }) + } +} diff --git a/app/api/paystack/webhook/route.ts b/app/api/paystack/webhook/route.ts index 63a5c8b..e7a4e22 100644 --- a/app/api/paystack/webhook/route.ts +++ b/app/api/paystack/webhook/route.ts @@ -1,14 +1,13 @@ import { NextRequest, NextResponse } from "next/server" -import { sendReceipt } from "@/emails/sendReceipt" import { getPayments } from "@/integrations/registry" import { resolveLoyaltyTier } from "@/lib/config/commerce" import { pointsForOrder } from "@/lib/loyalty/points" import { reversePointsForOrder } from "@/lib/loyalty/service" +import { enqueueOrderPaidEvents } from "@/lib/jobs/outbox" import { logger } from "@/lib/observability/logger" import { matchesExpectedPayment } from "@/lib/payments/match" import { prisma } from "@/lib/prisma" -import { qualifyReferral } from "@/lib/referrals/service" import { recordWebhookOnce } from "@/lib/webhooks/idempotency" import { finalizeInventoryForOrder } from "@/lib/inventory/reservations" @@ -198,6 +197,7 @@ export async function POST(req: NextRequest) { }) } + await enqueueOrderPaidEvents(tx, paidOrder.id) return { order: paidOrder, alreadyPaid: false, paidReference: reference } }) @@ -212,33 +212,6 @@ export async function POST(req: NextRequest) { return NextResponse.json({ ok: true, duplicate: true }) } - await sendReceipt(result.order).catch((error) => { - logger.error("order_receipt_failed", { - orderId: result.order!.id, - internal: String(error), - }) - }) - await qualifyReferral(result.order.userId, result.order.id).catch((error) => { - logger.error("referral_qualification_failed", { - orderId: result.order!.id, - internal: String(error), - }) - }) - await prisma.notification - .create({ - data: { - type: "ORDER_PAID", - title: "New Order Payment", - message: `Order #${result.order.reference || result.order.id.slice(0, 8)} has been paid. Total: NGN ${result.order.totalNGN.toLocaleString()}`, - orderId: result.order.id, - }, - }) - .catch((error) => { - logger.error("order_notification_failed", { - orderId: result.order!.id, - internal: String(error), - }) - }) } catch (error) { if ((error as { code?: string })?.code === "P2002") { return NextResponse.json({ ok: true, duplicate: true }) diff --git a/docs/PRODUCTION_READINESS_PLAN.md b/docs/PRODUCTION_READINESS_PLAN.md index f46c6f1..789afd8 100644 --- a/docs/PRODUCTION_READINESS_PLAN.md +++ b/docs/PRODUCTION_READINESS_PLAN.md @@ -50,18 +50,23 @@ Completed in the first hardening milestone: - successful payment finalizes the reservation without decrementing stock twice, while legacy pending orders retain a safe conditional-decrement path; - concurrent database tests prove only one checkout can claim the final available units; +- the payment transaction now writes separate durable outbox events for receipts, admin + notifications, and referral qualification; +- the protected outbox worker claims work safely, recovers stale locks, retries with exponential + backoff, and retains exhausted events in a failed state; +- receipt delivery uses provider idempotency, admin notifications use a database deduplication key, + and customer-controlled receipt fields are HTML escaped; - payment-matching, checkout-idempotency, publication, origin, and shipping-policy regression tests were added; -- Prisma schema validation, TypeScript, lint, 331 unit/integration tests, and the production build +- Prisma schema validation, TypeScript, lint, 337 unit/integration tests, and the production build pass on this branch; all staging migrations are applied with zero schema drift. Still required before launch: -- a transactional outbox and background worker; - payment reconciliation and complete refund/order state handling; - complete password-reset flow and broader authentication abuse protection; - dependency remediation, full database integration tests, and production staging certification; -- scheduler configuration for the reservation-expiry endpoint; +- scheduler configuration for the reservation-expiry and outbox-worker endpoints; - owner-supplied provider credentials, business details, brand assets, and approved policies. ## Audit findings diff --git a/emails/sendReceipt.ts b/emails/sendReceipt.ts index 6ce50d1..6b921c6 100644 --- a/emails/sendReceipt.ts +++ b/emails/sendReceipt.ts @@ -1,5 +1,7 @@ // emails/sendReceipt.ts import { Resend } from "resend" +import { escapeHtml } from "@/lib/security/html" +import { logger } from "@/lib/observability/logger" type OrderLike = { id: string @@ -13,29 +15,26 @@ type OrderLike = { coupon?: { code: string } | null } -export async function sendReceipt(order: OrderLike) { +export async function sendReceipt(order: OrderLike, idempotencyKey?: string) { const resendKey = process.env.RESEND_API_KEY if (!resendKey) { - console.log('[EMAIL] Receipt (no API key):', { - to: order.user.email, - subject: `Fádé Order #${order.reference || order.id.slice(-6)} receipt`, - summary: { - subtotal: order.subtotalNGN, - discount: order.discountNGN, - total: order.totalNGN, - items: order.items.map(i => `${i.quantity} × ${i.product.name}`) - } + logger.warn("receipt_email_skipped", { + orderId: order.id, + reason: "RESEND_API_KEY missing", }) return } const resend = new Resend(resendKey) const orderRef = order.reference || order.id.slice(0, 8) + const safeOrderRef = escapeHtml(orderRef) + const safeCustomerName = escapeHtml(order.user.name || "Customer") + const safeCouponCode = order.coupon ? escapeHtml(order.coupon.code) : null const itemsHtml = order.items .map( (item) => ` - ${item.quantity} × ${item.product.name} + ${item.quantity} × ${escapeHtml(item.product.name)} ₦${item.product.priceNGN.toLocaleString()} ₦${(item.quantity * item.product.priceNGN).toLocaleString()} @@ -56,11 +55,11 @@ export async function sendReceipt(order: OrderLike) {

Order Confirmation

-

Hello ${order.user.name || "Customer"},

+

Hello ${safeCustomerName},

Thank you for your order! We've received your payment and your order is being processed.

-

Order Reference: ${orderRef}

+

Order Reference: ${safeOrderRef}

Order Date: ${order.createdAt.toLocaleDateString()}

@@ -83,7 +82,7 @@ export async function sendReceipt(order: OrderLike) { ${order.discountNGN > 0 ? `
- Discount${order.coupon ? ` (${order.coupon.code})` : ""}: + Discount${safeCouponCode ? ` (${safeCouponCode})` : ""}: -₦${order.discountNGN.toLocaleString()}
` : ""} @@ -111,10 +110,13 @@ export async function sendReceipt(order: OrderLike) { to: order.user.email, subject: `Order Confirmation - Order #${orderRef}`, html, - }) - console.log("[EMAIL] Receipt sent:", order.user.email) + }, idempotencyKey ? { idempotencyKey } : undefined) + logger.info("receipt_email_sent", { orderId: order.id }) } catch (error) { - console.error("[EMAIL] Failed to send receipt:", error) + logger.error("receipt_email_failed", { + orderId: order.id, + internal: String(error), + }) throw error } } diff --git a/lib/jobs/outbox.ts b/lib/jobs/outbox.ts new file mode 100644 index 0000000..840f88e --- /dev/null +++ b/lib/jobs/outbox.ts @@ -0,0 +1,213 @@ +import crypto from "crypto" +import type { OutboxEvent, Prisma } from "@prisma/client" + +import { sendReceipt } from "@/emails/sendReceipt" +import { AppError } from "@/lib/http/errors" +import { logger } from "@/lib/observability/logger" +import { prisma } from "@/lib/prisma" +import { qualifyReferral } from "@/lib/referrals/service" + +export const OUTBOX_TYPES = { + ORDER_RECEIPT: "ORDER_RECEIPT", + ADMIN_ORDER_PAID_NOTIFICATION: "ADMIN_ORDER_PAID_NOTIFICATION", + REFERRAL_QUALIFICATION: "REFERRAL_QUALIFICATION", +} as const + +export function outboxRetryDelayMs(attempts: number): number { + const exponent = Math.max(0, Math.min(attempts - 1, 8)) + return Math.min(2 ** exponent * 30_000, 60 * 60 * 1000) +} + +export async function enqueueOrderPaidEvents( + tx: Prisma.TransactionClient, + orderId: string, +) { + await tx.outboxEvent.createMany({ + data: [ + { + type: OUTBOX_TYPES.ORDER_RECEIPT, + aggregateType: "Order", + aggregateId: orderId, + idempotencyKey: `order-receipt:${orderId}`, + }, + { + type: OUTBOX_TYPES.ADMIN_ORDER_PAID_NOTIFICATION, + aggregateType: "Order", + aggregateId: orderId, + idempotencyKey: `admin-order-paid:${orderId}`, + }, + { + type: OUTBOX_TYPES.REFERRAL_QUALIFICATION, + aggregateType: "Order", + aggregateId: orderId, + idempotencyKey: `referral-qualification:${orderId}`, + }, + ], + skipDuplicates: true, + }) +} + +async function loadPaidOrder(orderId: string) { + return prisma.order.findFirst({ + where: { id: orderId, status: { in: ["PAID", "SHIPPED", "DELIVERED"] } }, + include: { + user: true, + items: { include: { product: true } }, + coupon: true, + }, + }) +} + +export async function handleOutboxEvent(event: OutboxEvent) { + if (event.aggregateType !== "Order") { + throw new AppError("INTERNAL_ERROR", { + internal: { reason: "unsupported_outbox_aggregate", eventId: event.id }, + }) + } + const order = await loadPaidOrder(event.aggregateId) + if (!order) { + throw new AppError("NOT_FOUND", { + internal: { reason: "outbox_order_not_found_or_unpaid", eventId: event.id }, + }) + } + + switch (event.type) { + case OUTBOX_TYPES.ORDER_RECEIPT: + await sendReceipt(order, event.idempotencyKey) + return + case OUTBOX_TYPES.ADMIN_ORDER_PAID_NOTIFICATION: + await prisma.notification.upsert({ + where: { dedupeKey: event.idempotencyKey }, + update: {}, + create: { + type: "ORDER_PAID", + title: "New Order Payment", + message: `Order #${order.reference || order.id.slice(0, 8)} has been paid. Total: NGN ${order.totalNGN.toLocaleString()}`, + orderId: order.id, + dedupeKey: event.idempotencyKey, + }, + }) + return + case OUTBOX_TYPES.REFERRAL_QUALIFICATION: + await qualifyReferral(order.userId, order.id) + return + default: + throw new AppError("INTERNAL_ERROR", { + internal: { + reason: "unsupported_outbox_event_type", + eventId: event.id, + eventType: event.type, + }, + }) + } +} + +async function recoverStaleClaims(now: Date) { + const staleBefore = new Date(now.getTime() - 5 * 60 * 1000) + return prisma.outboxEvent.updateMany({ + where: { status: "RUNNING", lockedAt: { lte: staleBefore } }, + data: { + status: "PENDING", + lockedAt: null, + lockedBy: null, + availableAt: now, + }, + }) +} + +async function claimNextEvent(workerId: string, now: Date) { + for (let collision = 0; collision < 5; collision += 1) { + const candidate = await prisma.outboxEvent.findFirst({ + where: { status: "PENDING", availableAt: { lte: now } }, + orderBy: [{ availableAt: "asc" }, { createdAt: "asc" }], + }) + if (!candidate) return null + + const claimed = await prisma.outboxEvent.updateMany({ + where: { id: candidate.id, status: "PENDING", lockedAt: null }, + data: { + status: "RUNNING", + attempts: { increment: 1 }, + lockedAt: now, + lockedBy: workerId, + }, + }) + if (claimed.count === 1) { + return prisma.outboxEvent.findUniqueOrThrow({ where: { id: candidate.id } }) + } + } + return null +} + +function safeFailureCode(error: unknown): string { + if (error instanceof AppError) return error.code + if (error instanceof Error && error.name) return error.name.slice(0, 100) + return "UNKNOWN_ERROR" +} + +export async function processOutboxBatch( + options: { limit?: number; workerId?: string; now?: Date } = {}, +) { + const limit = Math.min(Math.max(options.limit ?? 10, 1), 50) + const workerId = options.workerId ?? `worker_${crypto.randomUUID()}` + const startedAt = options.now ?? new Date() + const recovered = await recoverStaleClaims(startedAt) + let processed = 0 + let retried = 0 + let failed = 0 + + for (let index = 0; index < limit; index += 1) { + const event = await claimNextEvent(workerId, new Date()) + if (!event) break + try { + await handleOutboxEvent(event) + await prisma.outboxEvent.updateMany({ + where: { id: event.id, status: "RUNNING", lockedBy: workerId }, + data: { + status: "SUCCEEDED", + processedAt: new Date(), + lockedAt: null, + lockedBy: null, + lastError: null, + }, + }) + processed += 1 + } catch (error) { + const terminal = event.attempts >= event.maxAttempts + const now = new Date() + await prisma.outboxEvent.updateMany({ + where: { id: event.id, status: "RUNNING", lockedBy: workerId }, + data: terminal + ? { + status: "FAILED", + lockedAt: null, + lockedBy: null, + lastError: safeFailureCode(error), + } + : { + status: "PENDING", + lockedAt: null, + lockedBy: null, + availableAt: new Date(now.getTime() + outboxRetryDelayMs(event.attempts)), + lastError: safeFailureCode(error), + }, + }) + logger.error("outbox_event_failed", { + eventId: event.id, + eventType: event.type, + attempts: event.attempts, + terminal, + internal: String(error), + }) + if (terminal) failed += 1 + else retried += 1 + } + } + + return { + processed, + retried, + failed, + recovered: recovered.count, + } +} diff --git a/lib/security/html.ts b/lib/security/html.ts new file mode 100644 index 0000000..90e332d --- /dev/null +++ b/lib/security/html.ts @@ -0,0 +1,11 @@ +const HTML_ENTITIES: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +} + +export function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (character) => HTML_ENTITIES[character]) +} diff --git a/prisma/migrations/20260723174500_transactional_outbox/migration.sql b/prisma/migrations/20260723174500_transactional_outbox/migration.sql new file mode 100644 index 0000000..9096d93 --- /dev/null +++ b/prisma/migrations/20260723174500_transactional_outbox/migration.sql @@ -0,0 +1,38 @@ +ALTER TABLE "Notification" +ADD COLUMN "dedupeKey" TEXT; + +CREATE UNIQUE INDEX "Notification_dedupeKey_key" +ON "Notification"("dedupeKey"); + +CREATE TABLE "OutboxEvent" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "aggregateType" TEXT NOT NULL, + "aggregateId" TEXT NOT NULL, + "payload" JSONB, + "idempotencyKey" TEXT NOT NULL, + "status" "JobStatus" NOT NULL DEFAULT 'PENDING', + "attempts" INTEGER NOT NULL DEFAULT 0, + "maxAttempts" INTEGER NOT NULL DEFAULT 8, + "availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lockedAt" TIMESTAMP(3), + "lockedBy" TEXT, + "processedAt" TIMESTAMP(3), + "lastError" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OutboxEvent_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "OutboxEvent_idempotencyKey_key" +ON "OutboxEvent"("idempotencyKey"); + +CREATE INDEX "OutboxEvent_status_availableAt_idx" +ON "OutboxEvent"("status", "availableAt"); + +CREATE INDEX "OutboxEvent_aggregateType_aggregateId_idx" +ON "OutboxEvent"("aggregateType", "aggregateId"); + +CREATE INDEX "OutboxEvent_lockedAt_idx" +ON "OutboxEvent"("lockedAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 99d735a..bdb1831 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -279,6 +279,7 @@ model Notification { title String message String orderId String? + dedupeKey String? @unique read Boolean @default(false) createdAt DateTime @default(now()) readAt DateTime? @@ -837,6 +838,29 @@ model JobRun { @@index([status, createdAt]) } +model OutboxEvent { + id String @id @default(cuid()) + type String + aggregateType String + aggregateId String + payload Json? + idempotencyKey String @unique + status JobStatus @default(PENDING) + attempts Int @default(0) + maxAttempts Int @default(8) + availableAt DateTime @default(now()) + lockedAt DateTime? + lockedBy String? + processedAt DateTime? + lastError String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status, availableAt]) + @@index([aggregateType, aggregateId]) + @@index([lockedAt]) +} + model FeatureFlag { id String @id @default(cuid()) key String @unique diff --git a/tests/integration/outbox.int.test.ts b/tests/integration/outbox.int.test.ts new file mode 100644 index 0000000..5475797 --- /dev/null +++ b/tests/integration/outbox.int.test.ts @@ -0,0 +1,122 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" + +import { + enqueueOrderPaidEvents, + OUTBOX_TYPES, + processOutboxBatch, +} from "@/lib/jobs/outbox" +import { prisma } from "@/lib/prisma" + +const hasDb = Boolean(process.env.DATABASE_URL) +const tag = `outbox_itest_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + +describe.skipIf(!hasDb)("transactional outbox (DB)", () => { + let userId = "" + let productId = "" + let orderId = "" + + beforeAll(async () => { + const user = await prisma.user.create({ + data: { + email: `${tag}@example.test`, + passwordHash: "x", + name: "Outbox ", + }, + select: { id: true }, + }) + userId = user.id + const product = await prisma.product.create({ + data: { + name: `${tag} Product`, + slug: `${tag}-product`, + description: "test", + images: ["/placeholder.png"], + priceNGN: 50_000, + category: "PERFUMES", + publishStatus: "PUBLISHED", + stock: 5, + }, + select: { id: true }, + }) + productId = product.id + const order = await prisma.order.create({ + data: { + userId, + status: "PAID", + reference: `ref_${tag}`, + subtotalNGN: 50_000, + totalNGN: 50_000, + addressLine1: "1 Test St", + city: "Lagos", + state: "Lagos", + phone: "08000000000", + items: { create: [{ productId, quantity: 1, priceNGN: 50_000 }] }, + }, + select: { id: true }, + }) + orderId = order.id + }) + + afterAll(async () => { + await prisma.notification.deleteMany({ where: { orderId } }).catch(() => {}) + await prisma.outboxEvent.deleteMany({ + where: { aggregateType: "Order", aggregateId: orderId }, + }).catch(() => {}) + await prisma.orderItem.deleteMany({ where: { orderId } }).catch(() => {}) + await prisma.order.deleteMany({ where: { id: orderId } }).catch(() => {}) + await prisma.product.deleteMany({ where: { id: productId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: userId } }).catch(() => {}) + }) + + it("processes order effects and deduplicates an event replay", async () => { + await prisma.$transaction((tx) => enqueueOrderPaidEvents(tx, orderId)) + const first = await processOutboxBatch({ limit: 10, workerId: `${tag}_worker` }) + expect(first.processed).toBe(3) + + const events = await prisma.outboxEvent.findMany({ + where: { aggregateType: "Order", aggregateId: orderId }, + select: { type: true, status: true }, + }) + expect(events).toHaveLength(3) + expect(events.every((event) => event.status === "SUCCEEDED")).toBe(true) + expect(await prisma.notification.count({ where: { orderId } })).toBe(1) + + await prisma.outboxEvent.update({ + where: { idempotencyKey: `admin-order-paid:${orderId}` }, + data: { + status: "PENDING", + availableAt: new Date(), + processedAt: null, + }, + }) + const replay = await processOutboxBatch({ limit: 1, workerId: `${tag}_replay` }) + expect(replay.processed).toBe(1) + expect(await prisma.notification.count({ where: { orderId } })).toBe(1) + }) + + it("moves an exhausted unsupported event to the failed state", async () => { + const event = await prisma.outboxEvent.create({ + data: { + type: "UNSUPPORTED_TEST_EVENT", + aggregateType: "Order", + aggregateId: orderId, + idempotencyKey: `unsupported:${orderId}`, + maxAttempts: 1, + }, + }) + const result = await processOutboxBatch({ limit: 1, workerId: `${tag}_failure` }) + expect(result.failed).toBe(1) + expect((await prisma.outboxEvent.findUniqueOrThrow({ + where: { id: event.id }, + select: { status: true, lastError: true }, + }))).toEqual({ status: "FAILED", lastError: "INTERNAL_ERROR" }) + }) + + it("uses distinct durable effects for each order-paid consequence", () => { + expect(Object.values(OUTBOX_TYPES)).toEqual([ + "ORDER_RECEIPT", + "ADMIN_ORDER_PAID_NOTIFICATION", + "REFERRAL_QUALIFICATION", + ]) + }) +}) diff --git a/tests/jobs/outbox.test.ts b/tests/jobs/outbox.test.ts new file mode 100644 index 0000000..7469994 --- /dev/null +++ b/tests/jobs/outbox.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest" + +import { outboxRetryDelayMs } from "@/lib/jobs/outbox" + +describe("outbox retry policy", () => { + it("uses bounded exponential backoff", () => { + expect(outboxRetryDelayMs(1)).toBe(30_000) + expect(outboxRetryDelayMs(2)).toBe(60_000) + expect(outboxRetryDelayMs(3)).toBe(120_000) + expect(outboxRetryDelayMs(99)).toBe(60 * 60 * 1000) + }) +}) diff --git a/tests/security/html.test.ts b/tests/security/html.test.ts new file mode 100644 index 0000000..891a9bc --- /dev/null +++ b/tests/security/html.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest" + +import { escapeHtml } from "@/lib/security/html" + +describe("HTML escaping", () => { + it("escapes all characters that can introduce markup or attributes", () => { + expect(escapeHtml(`&`)).toBe( + "<img src=x onerror="alert('x')">&", + ) + }) + + it("leaves ordinary customer text unchanged", () => { + expect(escapeHtml("Amina Okafor")).toBe("Amina Okafor") + }) +}) From fcddd33e9b12cabe9d3026f238074b79961ee05e Mon Sep 17 00:00:00 2001 From: Bash Abdul Date: Thu, 23 Jul 2026 18:25:17 +0100 Subject: [PATCH 05/19] Complete secure account recovery --- app/api/auth/[...nextauth]/route.ts | 32 +++- app/api/register/route.ts | 87 +++++++--- app/api/reset-password/confirm/route.ts | 64 +++++++ app/api/reset-password/route.ts | 164 +++++++++++------- app/auth/reset/page.tsx | 151 ++-------------- app/auth/signup/actions.ts | 23 ++- components/auth/reset-password-form.tsx | 161 +++++++++++++++++ docs/PRODUCTION_READINESS_PLAN.md | 19 +- lib/auth.ts | 21 +-- lib/auth/password-reset.ts | 51 ++++++ .../migration.sql | 29 ++++ .../migration.sql | 4 + prisma/schema.prisma | 34 ++-- tests/auth/password-reset.test.ts | 33 ++++ tests/integration/password-reset.int.test.ts | 70 ++++++++ 15 files changed, 679 insertions(+), 264 deletions(-) create mode 100644 app/api/reset-password/confirm/route.ts create mode 100644 components/auth/reset-password-form.tsx create mode 100644 lib/auth/password-reset.ts create mode 100644 prisma/migrations/20260723183000_secure_password_reset/migration.sql create mode 100644 prisma/migrations/20260723184500_single_active_password_reset/migration.sql create mode 100644 tests/auth/password-reset.test.ts create mode 100644 tests/integration/password-reset.int.test.ts diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 866b2be..ef4e3c1 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,3 +1,33 @@ +import crypto from "crypto" +import { NextRequest, NextResponse } from "next/server" + import { handlers } from "@/lib/auth" +import { clientIp, consumeRateLimit } from "@/lib/middleware/limiter" + +export const GET = handlers.GET -export const { GET, POST } = handlers +export async function POST(req: NextRequest) { + if (req.nextUrl.pathname.endsWith("/callback/credentials")) { + let emailHash = "unknown" + try { + const form = await req.clone().formData() + const email = String(form.get("email") ?? "").trim().toLowerCase() + if (email) { + emailHash = crypto.createHash("sha256").update(email).digest("hex") + } + } catch { + // NextAuth will reject malformed callback bodies. + } + const [ipLimit, emailLimit] = await Promise.all([ + consumeRateLimit(`signin:ip:${clientIp(req)}`, 20, 15 * 60 * 1000), + consumeRateLimit(`signin:email:${emailHash}`, 10, 15 * 60 * 1000), + ]) + if (!ipLimit.ok || !emailLimit.ok) { + return NextResponse.json( + { error: "Too many sign-in attempts. Please wait and try again." }, + { status: 429 }, + ) + } + } + return handlers.POST(req) +} diff --git a/app/api/register/route.ts b/app/api/register/route.ts index 14f839a..9b972b3 100644 --- a/app/api/register/route.ts +++ b/app/api/register/route.ts @@ -1,37 +1,80 @@ -import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import bcrypt from 'bcryptjs' -import { toSafeAuthErrorMessage } from '@/lib/prisma-error' -import { generateReferralCode } from '@/lib/referrals/code' -import { attributeReferral } from '@/lib/referrals/service' +import bcrypt from "bcryptjs" +import { NextRequest, NextResponse } from "next/server" +import { z } from "zod" + +import { newPasswordSchema } from "@/lib/auth/password-reset" +import { consumeRateLimit, clientIp } from "@/lib/middleware/limiter" +import { logger } from "@/lib/observability/logger" +import { prisma } from "@/lib/prisma" +import { toSafeAuthErrorMessage } from "@/lib/prisma-error" +import { generateReferralCode } from "@/lib/referrals/code" +import { attributeReferral } from "@/lib/referrals/service" +import { hasTrustedOrigin } from "@/lib/security/origin" + +const RegisterSchema = z.object({ + email: z.string().trim().email().max(254), + password: newPasswordSchema, + name: z.string().trim().max(120).optional(), + referredBy: z.string().trim().max(100).optional(), +}).strict() export async function POST(req: NextRequest) { try { - const { email, password, name, referredBy } = await req.json() - if (!email || !password) return NextResponse.json({ error: 'Email and password required' }, { status: 400 }) - const exists = await prisma.user.findUnique({ where: { email } }) - if (exists) return NextResponse.json({ error: 'Email already registered' }, { status: 400 }) - const hash = await bcrypt.hash(password, 10) + if (!hasTrustedOrigin(req)) { + return NextResponse.json({ error: "Request origin could not be verified" }, { status: 403 }) + } + const limit = await consumeRateLimit( + `signup:ip:${clientIp(req)}`, + 5, + 60 * 60 * 1000, + ) + if (!limit.ok) { + return NextResponse.json( + { error: "Too many signup attempts. Please wait and try again." }, + { status: 429 }, + ) + } + const parsed = RegisterSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json( + { error: "Valid email and a password of at least 8 characters are required" }, + { status: 400 }, + ) + } + const email = parsed.data.email.toLowerCase() + const exists = await prisma.user.findFirst({ + where: { email: { equals: email, mode: "insensitive" } }, + select: { id: true }, + }) + if (exists) { + return NextResponse.json({ error: "Email already registered" }, { status: 400 }) + } + const hash = await bcrypt.hash(parsed.data.password, 12) - // Generate unique referral code for the new user let referralCode = generateReferralCode() - let attempts = 0 - while (attempts < 5) { + for (let attempts = 0; attempts < 5; attempts += 1) { const existing = await prisma.user.findUnique({ where: { referralCode } }) if (!existing) break referralCode = generateReferralCode() - attempts++ } - const user = await prisma.user.create({ data: { email, name, passwordHash: hash, referralCode } }) - - // Attribute the referral (creates a Referral row + fraud guards). Best-effort: never blocks signup. - if (referredBy) { - await attributeReferral(user.id, String(referredBy)).catch(() => null) + const user = await prisma.user.create({ + data: { + email, + name: parsed.data.name || null, + passwordHash: hash, + referralCode, + }, + }) + if (parsed.data.referredBy) { + await attributeReferral(user.id, parsed.data.referredBy).catch(() => null) } return NextResponse.json({ ok: true }) } catch (error) { - console.error('Register API error:', error) - return NextResponse.json({ error: toSafeAuthErrorMessage(error) }, { status: 500 }) + logger.error("registration_failed", { internal: String(error) }) + return NextResponse.json( + { error: toSafeAuthErrorMessage(error) }, + { status: 500 }, + ) } } diff --git a/app/api/reset-password/confirm/route.ts b/app/api/reset-password/confirm/route.ts new file mode 100644 index 0000000..6471258 --- /dev/null +++ b/app/api/reset-password/confirm/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server" +import { z } from "zod" + +import { + consumePasswordResetToken, + newPasswordSchema, +} from "@/lib/auth/password-reset" +import { consumeRateLimit, clientIp } from "@/lib/middleware/limiter" +import { logger } from "@/lib/observability/logger" +import { hasTrustedOrigin } from "@/lib/security/origin" + +export const runtime = "nodejs" + +const ConfirmSchema = z.object({ + token: z.string().min(32).max(128), + password: newPasswordSchema, +}).strict() + +export async function POST(request: Request) { + try { + if (!hasTrustedOrigin(request)) { + return NextResponse.json({ error: "Request origin could not be verified" }, { status: 403 }) + } + const limit = await consumeRateLimit( + `password-reset:confirm:${clientIp(request)}`, + 10, + 60 * 60 * 1000, + ) + if (!limit.ok) { + return NextResponse.json( + { error: "Too many reset attempts. Please wait and try again." }, + { status: 429 }, + ) + } + + const parsed = ConfirmSchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json( + { error: "The reset link or password is invalid" }, + { status: 400 }, + ) + } + const changed = await consumePasswordResetToken( + parsed.data.token, + parsed.data.password, + ) + + if (!changed) { + return NextResponse.json( + { error: "This reset link is invalid or has expired" }, + { status: 400 }, + ) + } + return NextResponse.json({ message: "Your password has been updated" }) + } catch (error) { + logger.error("password_reset_confirmation_failed", { + internal: String(error), + }) + return NextResponse.json( + { error: "Failed to reset password" }, + { status: 500 }, + ) + } +} diff --git a/app/api/reset-password/route.ts b/app/api/reset-password/route.ts index 5475aa0..557c24f 100644 --- a/app/api/reset-password/route.ts +++ b/app/api/reset-password/route.ts @@ -1,87 +1,119 @@ -import { NextResponse } from 'next/server' -import { Resend } from 'resend' -import { prisma } from '@/lib/prisma' -import crypto from 'crypto' +import crypto from "crypto" +import { NextResponse } from "next/server" +import { Resend } from "resend" +import { z } from "zod" -const FROM_EMAIL = process.env.NEWSLETTER_FROM_EMAIL || 'Fádé Essence ' +import { + generatePasswordResetToken, + passwordResetExpiry, + passwordResetTokenHash, +} from "@/lib/auth/password-reset" +import { env } from "@/lib/env" +import { consumeRateLimit, clientIp } from "@/lib/middleware/limiter" +import { logger } from "@/lib/observability/logger" +import { prisma } from "@/lib/prisma" +import { hasTrustedOrigin } from "@/lib/security/origin" +import { escapeHtml } from "@/lib/security/html" + +export const runtime = "nodejs" + +const RequestSchema = z.object({ + email: z.string().trim().email().max(254), +}).strict() + +const GENERIC_MESSAGE = + "If an account exists for this email, a reset link has been sent." export async function POST(request: Request) { try { - const { email } = await request.json() - - if (!email) { - return NextResponse.json({ error: 'Email is required' }, { status: 400 }) + if (!hasTrustedOrigin(request)) { + return NextResponse.json({ error: "Request origin could not be verified" }, { status: 403 }) + } + const parsed = RequestSchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json({ error: "A valid email address is required" }, { status: 400 }) } - const normalizedEmail = String(email).toLowerCase().trim() + const normalizedEmail = parsed.data.email.toLowerCase() + const emailHash = crypto.createHash("sha256").update(normalizedEmail).digest("hex") + const [ipLimit, emailLimit] = await Promise.all([ + consumeRateLimit(`password-reset:ip:${clientIp(request)}`, 5, 60 * 60 * 1000), + consumeRateLimit(`password-reset:email:${emailHash}`, 3, 60 * 60 * 1000), + ]) + if (!ipLimit.ok || !emailLimit.ok) { + return NextResponse.json({ message: GENERIC_MESSAGE }) + } - // Check if user exists - const user = await prisma.user.findUnique({ - where: { email: normalizedEmail }, - select: { id: true, name: true }, + const user = await prisma.user.findFirst({ + where: { email: { equals: normalizedEmail, mode: "insensitive" } }, + select: { id: true, email: true, name: true }, }) - - // Always return success to prevent email enumeration if (!user) { - return NextResponse.json({ - message: 'If an account exists for this email, a reset link has been sent.', - }) + return NextResponse.json({ message: GENERIC_MESSAGE }) } - // Generate reset token - const resetToken = crypto.randomBytes(32).toString('hex') - const resetTokenExpiry = new Date(Date.now() + 3600000) // 1 hour - - // Store token in database (resetToken and resetTokenExpiry exist in User model) - await prisma.user.update({ - where: { email: normalizedEmail }, - data: { resetToken, resetTokenExpiry }, + const token = generatePasswordResetToken() + const tokenHash = passwordResetTokenHash(token) + const resetRecord = await prisma.passwordResetToken.upsert({ + where: { userId: user.id }, + update: { + tokenHash, + expiresAt: passwordResetExpiry(), + usedAt: null, + createdAt: new Date(), + }, + create: { + userId: user.id, + tokenHash, + expiresAt: passwordResetExpiry(), + }, + select: { id: true }, }) - const base = process.env.NEXTAUTH_URL || process.env.APP_URL || 'http://localhost:3000' - const resetUrl = `${base}/auth/reset/${resetToken}` - - if (process.env.RESEND_API_KEY) { - const resend = new Resend(process.env.RESEND_API_KEY) - await resend.emails.send({ - from: FROM_EMAIL, - to: normalizedEmail, - subject: 'Reset your Fádé Essence password', - html: ` -
-
-

Fádé Essence

-
-
-

Password Reset Request

-

Hello${user.name ? ` ${user.name}` : ''},

-

We received a request to reset your password. Click the button below to set a new password. This link expires in 1 hour.

-
- - Reset Password - + const resetUrl = `${env.APP_URL.replace(/\/$/, "")}/auth/reset?token=${encodeURIComponent(token)}` + if (env.RESEND_API_KEY) { + const resend = new Resend(env.RESEND_API_KEY) + const safeName = escapeHtml(user.name || "Customer") + const safeUrl = escapeHtml(resetUrl) + await resend.emails + .send( + { + from: + env.NEWSLETTER_FROM_EMAIL || + "Fádé Essence ", + to: user.email, + subject: "Reset your Fádé Essence password", + html: ` +
+

Password reset request

+

Hello ${safeName},

+

Use the link below to set a new password. It expires in one hour and can only be used once.

+

Reset password

+

If you did not request this change, you can ignore this email.

-

If you did not request a password reset, please ignore this email. Your password will remain unchanged.

-

- Or copy this link into your browser:
- ${resetUrl} -

-
-
- `, - }) + `, + }, + { idempotencyKey: `password-reset:${resetRecord.id}` }, + ) + .catch((error) => { + logger.error("password_reset_email_failed", { + userId: user.id, + internal: String(error), + }) + }) } else { - // Development: log the link instead of sending email - console.log('[PASSWORD RESET] Reset link (no RESEND_API_KEY):', resetUrl) + logger.warn("password_reset_email_skipped", { + userId: user.id, + reason: "RESEND_API_KEY missing", + }) } - return NextResponse.json({ - message: 'If an account exists for this email, a reset link has been sent.', - }) - } catch { + return NextResponse.json({ message: GENERIC_MESSAGE }) + } catch (error) { + logger.error("password_reset_request_failed", { internal: String(error) }) return NextResponse.json( - { error: 'Failed to process reset request' }, - { status: 500 } + { error: "Failed to process reset request" }, + { status: 500 }, ) } } diff --git a/app/auth/reset/page.tsx b/app/auth/reset/page.tsx index 2ba5884..98f4ca6 100644 --- a/app/auth/reset/page.tsx +++ b/app/auth/reset/page.tsx @@ -1,144 +1,17 @@ -"use client"; -import Link from "next/link"; -import { useState } from "react"; -import { Logo } from "@/components/logo"; +import type { Metadata } from "next" -export default function ResetPasswordPage() { - const [email, setEmail] = useState(""); - const [sent, setSent] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(""); - const [resetLink, setResetLink] = useState(""); +import { ResetPasswordForm } from "@/components/auth/reset-password-form" - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsLoading(true); - setError(""); - - try { - const response = await fetch("/api/reset-password", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), - }); - - const data = await response.json(); - - if (!response.ok) { - setError(data.error || "Failed to send reset email. Please try again."); - return; - } - - // Store the reset link for development - if (data.resetLink) { - setResetLink(data.resetLink); - } - - setSent(true); - } catch (err) { - setError("Failed to send reset email. Please try again."); - } finally { - setIsLoading(false); - } - }; - - return ( -
-
-
-
- -
-

Account recovery

-

- Reset your password -

-

- Enter your email address and we'll send you a link to reset your - password. -

-
- - {sent ? ( -
-
-

Check your email

-

- If an account exists for {email}, a password - reset link has been sent to your inbox. -

-
- - {/* Development: Show reset link */} - {resetLink && ( -
-

- Development mode reset link: -

- - {resetLink} - -
- )} -
- ) : ( -
- {error && ( -
- {error} -
- )} - -
- - setEmail(e.target.value)} - disabled={isLoading || sent} - /> -
+export const metadata: Metadata = { + title: "Reset Password | Fádé", + description: "Request a secure password reset or choose a new password.", +} -
- -
- - )} +type PageProps = { + searchParams: Promise<{ token?: string }> +} -
- - Back to sign in - -
-
-
- ); +export default async function ResetPasswordPage({ searchParams }: PageProps) { + const { token } = await searchParams + return } diff --git a/app/auth/signup/actions.ts b/app/auth/signup/actions.ts index 73a90fe..c5d2316 100644 --- a/app/auth/signup/actions.ts +++ b/app/auth/signup/actions.ts @@ -4,11 +4,14 @@ import { prisma } from "@/lib/prisma" import bcrypt from "bcryptjs" import { signIn } from "@/lib/auth" import { toSafeAuthErrorMessage } from "@/lib/prisma-error" +import { headers } from "next/headers" +import { clientIp, consumeRateLimit } from "@/lib/middleware/limiter" +import { newPasswordSchema } from "@/lib/auth/password-reset" export async function signUpAction(formData: FormData) { const firstName = formData.get("firstName") as string const lastName = formData.get("lastName") as string - const email = formData.get("email") as string + const email = String(formData.get("email") ?? "").trim().toLowerCase() const password = formData.get("password") as string // Validate inputs @@ -16,19 +19,30 @@ export async function signUpAction(formData: FormData) { return { error: "All fields are required" } } - if (password.length < 8) { + if (!newPasswordSchema.safeParse(password).success) { return { error: "Password must be at least 8 characters" } } try { + const requestHeaders = await headers() + const limit = await consumeRateLimit( + `signup:ip:${clientIp({ headers: requestHeaders })}`, + 5, + 60 * 60 * 1000, + ) + if (!limit.ok) { + return { error: "Too many signup attempts. Please wait and try again." } + } // Check if user already exists - const exists = await prisma.user.findUnique({ where: { email } }) + const exists = await prisma.user.findFirst({ + where: { email: { equals: email, mode: "insensitive" } }, + }) if (exists) { return { error: "Email already registered" } } // Hash password and create user - const hash = await bcrypt.hash(password, 10) + const hash = await bcrypt.hash(password, 12) const name = `${firstName} ${lastName}`.trim() await prisma.user.create({ @@ -63,4 +77,3 @@ export async function signUpAction(formData: FormData) { return { error: toSafeAuthErrorMessage(error) } } } - diff --git a/components/auth/reset-password-form.tsx b/components/auth/reset-password-form.tsx new file mode 100644 index 0000000..d4e7285 --- /dev/null +++ b/components/auth/reset-password-form.tsx @@ -0,0 +1,161 @@ +"use client" + +import Link from "next/link" +import { FormEvent, useState } from "react" + +import { Logo } from "@/components/logo" + +export function ResetPasswordForm({ token }: { token: string | null }) { + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [confirmation, setConfirmation] = useState("") + const [complete, setComplete] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState("") + + const onSubmit = async (event: FormEvent) => { + event.preventDefault() + setError("") + if (token && password !== confirmation) { + setError("Passwords do not match.") + return + } + setIsLoading(true) + try { + const response = await fetch( + token ? "/api/reset-password/confirm" : "/api/reset-password", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(token ? { token, password } : { email }), + }, + ) + const data = await response.json() + if (!response.ok) { + setError(data.error || "The request could not be completed.") + return + } + setComplete(true) + } catch { + setError("The request could not be completed. Please try again.") + } finally { + setIsLoading(false) + } + } + + const heading = token ? "Choose a new password" : "Reset your password" + const description = token + ? "Use at least eight characters. This reset link can only be used once." + : "Enter your email address and we will send you a secure, single-use reset link." + + return ( +
+
+
+
+ +
+

Account recovery

+

{heading}

+

{description}

+
+ + {complete ? ( +
+
+ {token + ? "Your password has been updated. You can now sign in." + : "If an account exists for that email, a reset link has been sent."} +
+ + Return to sign in + +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} + + {token ? ( + <> + + setPassword(event.target.value)} + className="h-12 w-full border border-input bg-background/60 px-3 outline-none focus:border-accent" + /> + + setConfirmation(event.target.value)} + className="h-12 w-full border border-input bg-background/60 px-3 outline-none focus:border-accent" + /> + + ) : ( + <> + + setEmail(event.target.value)} + className="h-12 w-full border border-input bg-background/60 px-3 outline-none focus:border-accent" + /> + + )} + + + + )} + +
+ + Back to sign in + +
+
+
+ ) +} diff --git a/docs/PRODUCTION_READINESS_PLAN.md b/docs/PRODUCTION_READINESS_PLAN.md index 789afd8..9c5e757 100644 --- a/docs/PRODUCTION_READINESS_PLAN.md +++ b/docs/PRODUCTION_READINESS_PLAN.md @@ -56,16 +56,21 @@ Completed in the first hardening milestone: backoff, and retains exhausted events in a failed state; - receipt delivery uses provider idempotency, admin notifications use a database deduplication key, and customer-controlled receipt fields are HTML escaped; +- password reset now uses hashed, single-use, one-hour tokens with one active token per customer; +- the reset request and confirmation flows validate origin, normalize email, and apply IP/email + rate limits without exposing whether an account exists; +- signup and credential login now apply abuse limits, normalize email case, and share the eight + character minimum password policy; +- process-global session-duration state was removed in favor of a deterministic 24-hour session; - payment-matching, checkout-idempotency, publication, origin, and shipping-policy regression tests were added; -- Prisma schema validation, TypeScript, lint, 337 unit/integration tests, and the production build +- Prisma schema validation, TypeScript, lint, 342 unit/integration tests, and the production build pass on this branch; all staging migrations are applied with zero schema drift. Still required before launch: - payment reconciliation and complete refund/order state handling; -- complete password-reset flow and broader authentication abuse protection; -- dependency remediation, full database integration tests, and production staging certification; +- dependency remediation, browser/Paystack integration tests, and production staging certification; - scheduler configuration for the reservation-expiry and outbox-worker endpoints; - owner-supplied provider credentials, business details, brand assets, and approved policies. @@ -84,8 +89,8 @@ This is the original audit list. Items completed on `codex/production-readiness` paid amount and currency with the stored order and payment attempt. 4. **Resolved on branch:** the checkout success page previously mutated payment state during a GET request. -5. Password-reset emails point to a reset route that does not exist, and there is no endpoint that - applies a new password. +5. **Resolved on branch:** password-reset emails pointed to a reset route that did not exist, and + there was no endpoint that applied a new password. 6. Bank transfer displays the placeholder account number `0123456789`. 7. The production dependency audit reports seven high and four moderate vulnerabilities. @@ -96,7 +101,9 @@ This is the original audit list. Items completed on `codex/production-readiness` 2. **Resolved on branch:** public catalogue queries did not consistently require `publishStatus = PUBLISHED`. 3. Custom state-changing endpoints do not consistently validate request origin or CSRF protections. -4. Signup, password reset, order creation, and payment initialization lack durable abuse controls. +4. **Partially resolved on branch:** signup, password reset, order creation, payment initialization, + and credential login now use the rate-limiter abstraction; production still requires the + configured Redis backend for cross-instance durability. 5. Contact-form values are interpolated into HTML emails without HTML escaping. 6. Required production configuration does not fail fast at startup. 7. Missing favicon, PWA icons, and Open Graph image cause broken metadata assets. diff --git a/lib/auth.ts b/lib/auth.ts index 9896f61..8b609a1 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -7,19 +7,14 @@ import { z } from 'zod' const credsSchema = z.object({ email: z.string().email(), - password: z.string().min(6), + password: z.string().min(1).max(128), rememberMe: z.string().optional(), }) -// Dynamic session maxAge: set in authorize, read by session config (remember me = 30 days, else 24h) -let dynamicSessionMaxAge = 30 * 24 * 60 * 60 // default 30 days - export const { auth, handlers, signIn, signOut } = NextAuth({ session: { strategy: 'jwt', - get maxAge() { - return dynamicSessionMaxAge - }, + maxAge: 24 * 60 * 60, }, trustHost: true, debug: process.env.NODE_ENV !== 'production', @@ -35,13 +30,11 @@ export const { auth, handlers, signIn, signOut } = NextAuth({ async authorize(credentials) { const parsed = credsSchema.safeParse(credentials) if (!parsed.success) return null - const { email, password, rememberMe } = parsed.data - - // Set session duration before returning (used by session.maxAge getter) - dynamicSessionMaxAge = - rememberMe === 'true' ? 30 * 24 * 60 * 60 : 24 * 60 * 60 // 30 days vs 24 hours - - const user = await prisma.user.findUnique({ where: { email } }) + const { password } = parsed.data + const email = parsed.data.email.toLowerCase().trim() + const user = await prisma.user.findFirst({ + where: { email: { equals: email, mode: 'insensitive' } }, + }) if (!user || !user.passwordHash) return null const ok = await bcrypt.compare(password, user.passwordHash) diff --git a/lib/auth/password-reset.ts b/lib/auth/password-reset.ts new file mode 100644 index 0000000..f687ea2 --- /dev/null +++ b/lib/auth/password-reset.ts @@ -0,0 +1,51 @@ +import crypto from "crypto" +import bcrypt from "bcryptjs" +import { z } from "zod" + +import { prisma } from "@/lib/prisma" + +export const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000 + +export const newPasswordSchema = z.string().min(8).max(128) + +export function generatePasswordResetToken(): string { + return crypto.randomBytes(32).toString("base64url") +} + +export function passwordResetTokenHash(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex") +} + +export function passwordResetExpiry(now = new Date()): Date { + return new Date(now.getTime() + PASSWORD_RESET_TTL_MS) +} + +export async function consumePasswordResetToken( + token: string, + newPassword: string, + now = new Date(), +): Promise { + const tokenHash = passwordResetTokenHash(token) + const passwordHash = await bcrypt.hash(newPassword, 12) + + return prisma.$transaction(async (tx) => { + const resetToken = await tx.passwordResetToken.findUnique({ + where: { tokenHash }, + select: { id: true, userId: true, expiresAt: true, usedAt: true }, + }) + if (!resetToken || resetToken.usedAt || resetToken.expiresAt <= now) return false + + const consumed = await tx.passwordResetToken.updateMany({ + where: { id: resetToken.id, usedAt: null, expiresAt: { gt: now } }, + data: { usedAt: now }, + }) + if (consumed.count !== 1) return false + + await tx.user.update({ + where: { id: resetToken.userId }, + data: { passwordHash }, + }) + await tx.session.deleteMany({ where: { userId: resetToken.userId } }) + return true + }) +} diff --git a/prisma/migrations/20260723183000_secure_password_reset/migration.sql b/prisma/migrations/20260723183000_secure_password_reset/migration.sql new file mode 100644 index 0000000..0d5cff3 --- /dev/null +++ b/prisma/migrations/20260723183000_secure_password_reset/migration.sql @@ -0,0 +1,29 @@ +-- Existing raw reset tokens are intentionally invalidated during the secure-token migration. +ALTER TABLE "User" +DROP COLUMN "resetToken", +DROP COLUMN "resetTokenExpiry"; + +CREATE TABLE "PasswordResetToken" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "PasswordResetToken_tokenHash_key" +ON "PasswordResetToken"("tokenHash"); + +CREATE INDEX "PasswordResetToken_userId_expiresAt_idx" +ON "PasswordResetToken"("userId", "expiresAt"); + +CREATE INDEX "PasswordResetToken_expiresAt_usedAt_idx" +ON "PasswordResetToken"("expiresAt", "usedAt"); + +ALTER TABLE "PasswordResetToken" +ADD CONSTRAINT "PasswordResetToken_userId_fkey" +FOREIGN KEY ("userId") REFERENCES "User"("id") +ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260723184500_single_active_password_reset/migration.sql b/prisma/migrations/20260723184500_single_active_password_reset/migration.sql new file mode 100644 index 0000000..d83e21b --- /dev/null +++ b/prisma/migrations/20260723184500_single_active_password_reset/migration.sql @@ -0,0 +1,4 @@ +CREATE UNIQUE INDEX "PasswordResetToken_userId_key" +ON "PasswordResetToken"("userId"); + +DROP INDEX "PasswordResetToken_userId_expiresAt_idx"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bdb1831..faf81c2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -46,19 +46,17 @@ enum InventoryMovementReason { } model User { - id String @id @default(cuid()) + id String @id @default(cuid()) name String? - email String @unique + email String @unique passwordHash String - role Role @default(USER) - marketingEmails Boolean @default(true) - smsNotifications Boolean @default(false) - orderUpdates Boolean @default(true) - wishlistAlerts Boolean @default(false) - resetToken String? - resetTokenExpiry DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + role Role @default(USER) + marketingEmails Boolean @default(true) + smsNotifications Boolean @default(false) + orderUpdates Boolean @default(true) + wishlistAlerts Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Loyalty & referral loyaltyTier String @default("STANDARD") // STANDARD | OBSIDIAN | GOLD | PLATINUM @@ -84,6 +82,7 @@ model User { backInStockSubs BackInStockSubscription[] conciergeConversations ConciergeConversation[] conciergeUsage ConciergeUsageEvent[] + passwordResetTokens PasswordResetToken[] conciergeFeedback ConciergeFeedback[] checkoutAttempts CheckoutAttempt[] } @@ -496,6 +495,19 @@ model VerificationToken { @@unique([identifier, token]) } +model PasswordResetToken { + id String @id @default(cuid()) + userId String @unique + tokenHash String @unique + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([expiresAt, usedAt]) +} + enum Role { USER ADMIN diff --git a/tests/auth/password-reset.test.ts b/tests/auth/password-reset.test.ts new file mode 100644 index 0000000..376cb6c --- /dev/null +++ b/tests/auth/password-reset.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest" + +import { + generatePasswordResetToken, + newPasswordSchema, + PASSWORD_RESET_TTL_MS, + passwordResetExpiry, + passwordResetTokenHash, +} from "@/lib/auth/password-reset" + +describe("password reset primitives", () => { + it("generates opaque tokens and stores only a deterministic hash", () => { + const token = generatePasswordResetToken() + const second = generatePasswordResetToken() + expect(token).not.toBe(second) + expect(token.length).toBeGreaterThanOrEqual(40) + expect(passwordResetTokenHash(token)).toMatch(/^[a-f0-9]{64}$/) + expect(passwordResetTokenHash(token)).not.toContain(token) + }) + + it("expires tokens after one hour", () => { + const now = new Date("2026-07-23T12:00:00.000Z") + expect(passwordResetExpiry(now).getTime() - now.getTime()).toBe( + PASSWORD_RESET_TTL_MS, + ) + }) + + it("enforces the shared password length policy", () => { + expect(newPasswordSchema.safeParse("short").success).toBe(false) + expect(newPasswordSchema.safeParse("long-enough").success).toBe(true) + expect(newPasswordSchema.safeParse("x".repeat(129)).success).toBe(false) + }) +}) diff --git a/tests/integration/password-reset.int.test.ts b/tests/integration/password-reset.int.test.ts new file mode 100644 index 0000000..d6dddf0 --- /dev/null +++ b/tests/integration/password-reset.int.test.ts @@ -0,0 +1,70 @@ +import bcrypt from "bcryptjs" +import { afterAll, beforeAll, describe, expect, it } from "vitest" + +import { + consumePasswordResetToken, + passwordResetTokenHash, +} from "@/lib/auth/password-reset" +import { prisma } from "@/lib/prisma" + +const hasDb = Boolean(process.env.DATABASE_URL) +const tag = `password_reset_itest_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + +describe.skipIf(!hasDb)("password reset token lifecycle (DB)", () => { + let userId = "" + + beforeAll(async () => { + const user = await prisma.user.create({ + data: { + email: `${tag}@example.test`, + passwordHash: await bcrypt.hash("old-password", 4), + }, + select: { id: true }, + }) + userId = user.id + }) + + afterAll(async () => { + await prisma.passwordResetToken.deleteMany({ where: { userId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: userId } }).catch(() => {}) + }) + + it("changes the password once and rejects replay", async () => { + const token = `token_${tag}` + await prisma.passwordResetToken.create({ + data: { + userId, + tokenHash: passwordResetTokenHash(token), + expiresAt: new Date(Date.now() + 60_000), + }, + }) + + expect(await consumePasswordResetToken(token, "new-password")).toBe(true) + expect(await consumePasswordResetToken(token, "other-password")).toBe(false) + + const user = await prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: { passwordHash: true }, + }) + expect(await bcrypt.compare("new-password", user.passwordHash)).toBe(true) + expect(await bcrypt.compare("other-password", user.passwordHash)).toBe(false) + }) + + it("rejects expired tokens without changing the password", async () => { + const token = `expired_${tag}` + await prisma.passwordResetToken.update({ + where: { userId }, + data: { + tokenHash: passwordResetTokenHash(token), + expiresAt: new Date(Date.now() - 60_000), + usedAt: null, + }, + }) + expect(await consumePasswordResetToken(token, "expired-password")).toBe(false) + const user = await prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: { passwordHash: true }, + }) + expect(await bcrypt.compare("new-password", user.passwordHash)).toBe(true) + }) +}) From eee35693986c5928bd01b6a1ae001a172cadffb1 Mon Sep 17 00:00:00 2001 From: Bash Abdul Date: Thu, 23 Jul 2026 18:50:33 +0100 Subject: [PATCH 06/19] Remediate production dependency advisories --- docs/PRODUCTION_READINESS_PLAN.md | 7 +- package-lock.json | 691 +++++++++++++++--------------- package.json | 17 +- 3 files changed, 352 insertions(+), 363 deletions(-) diff --git a/docs/PRODUCTION_READINESS_PLAN.md b/docs/PRODUCTION_READINESS_PLAN.md index 9c5e757..81cc420 100644 --- a/docs/PRODUCTION_READINESS_PLAN.md +++ b/docs/PRODUCTION_READINESS_PLAN.md @@ -62,6 +62,8 @@ Completed in the first hardening milestone: - signup and credential login now apply abuse limits, normalize email case, and share the eight character minimum password policy; - process-global session-duration state was removed in favor of a deterministic 24-hour session; +- Next.js, Auth.js, Prisma, Resend, PostCSS, Sharp, and lodash were upgraded or pinned to patched + versions; the production dependency audit now reports zero vulnerabilities; - payment-matching, checkout-idempotency, publication, origin, and shipping-policy regression tests were added; - Prisma schema validation, TypeScript, lint, 342 unit/integration tests, and the production build @@ -70,7 +72,7 @@ Completed in the first hardening milestone: Still required before launch: - payment reconciliation and complete refund/order state handling; -- dependency remediation, browser/Paystack integration tests, and production staging certification; +- browser/Paystack integration tests and production staging certification; - scheduler configuration for the reservation-expiry and outbox-worker endpoints; - owner-supplied provider credentials, business details, brand assets, and approved policies. @@ -92,7 +94,8 @@ This is the original audit list. Items completed on `codex/production-readiness` 5. **Resolved on branch:** password-reset emails pointed to a reset route that did not exist, and there was no endpoint that applied a new password. 6. Bank transfer displays the placeholder account number `0123456789`. -7. The production dependency audit reports seven high and four moderate vulnerabilities. +7. **Resolved on branch:** the production dependency audit previously reported high, moderate, and + critical vulnerabilities; it now reports zero production vulnerabilities. ### High priority diff --git a/package-lock.json b/package-lock.json index 8e24f0b..f197d38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "dependencies": { "@hookform/resolvers": "^3.10.0", - "@prisma/client": "^6.19.2", + "@prisma/client": "6.19.3", "@radix-ui/react-accordion": "1.2.2", "@radix-ui/react-alert-dialog": "1.1.4", "@radix-ui/react-aspect-ratio": "1.1.1", @@ -50,8 +50,8 @@ "input-otp": "1.4.1", "lucide-react": "^0.454.0", "motion": "^12.42.2", - "next": "16.0.7", - "next-auth": "^5.0.0-beta.30", + "next": "16.2.11", + "next-auth": "5.0.0-beta.32", "next-themes": "^0.4.6", "react": "19.2.0", "react-day-picker": "9.8.0", @@ -60,7 +60,7 @@ "react-icons": "^5.5.0", "react-resizable-panels": "^2.1.7", "recharts": "2.15.4", - "resend": "^6.5.2", + "resend": "6.18.0", "sonner": "^1.7.4", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", @@ -79,8 +79,8 @@ "@typescript-eslint/parser": "^8.56.1", "eslint": "^9.39.3", "eslint-config-next": "^16.1.6", - "postcss": "^8.5", - "prisma": "^6.19.2", + "postcss": "8.5.22", + "prisma": "6.19.3", "tailwindcss": "^4.1.9", "tsx": "^4.23.1", "tw-animate-css": "1.3.3", @@ -106,9 +106,9 @@ } }, "node_modules/@auth/core": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz", - "integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==", + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz", + "integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==", "license": "ISC", "dependencies": { "@panva/hkdf": "^1.2.1", @@ -120,7 +120,7 @@ "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", - "nodemailer": "^6.8.0" + "nodemailer": "^7.0.7 || ^8.0.5" }, "peerDependenciesMeta": { "@simplewebauthn/browser": { @@ -436,9 +436,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "license": "MIT", "optional": true, "dependencies": { @@ -1214,9 +1214,9 @@ } }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", "optional": true, "engines": { @@ -1224,9 +1224,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -1236,19 +1236,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -1258,19 +1258,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -1284,9 +1303,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -1300,9 +1319,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -1316,9 +1335,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -1332,9 +1351,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -1348,9 +1367,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -1364,9 +1383,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -1380,9 +1399,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -1396,9 +1415,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -1412,9 +1431,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -1428,9 +1447,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -1440,19 +1459,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -1462,19 +1481,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -1484,19 +1503,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -1506,19 +1525,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -1528,19 +1547,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -1550,19 +1569,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -1572,19 +1591,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -1594,38 +1613,54 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", "cpu": [ "wasm32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.3" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -1635,16 +1670,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -1654,16 +1689,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -1673,7 +1708,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1743,9 +1778,9 @@ } }, "node_modules/@next/env": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz", - "integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.11.tgz", + "integrity": "sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1759,9 +1794,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz", - "integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.11.tgz", + "integrity": "sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw==", "cpu": [ "arm64" ], @@ -1775,9 +1810,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz", - "integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.11.tgz", + "integrity": "sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg==", "cpu": [ "x64" ], @@ -1791,9 +1826,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz", - "integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.11.tgz", + "integrity": "sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g==", "cpu": [ "arm64" ], @@ -1807,9 +1842,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz", - "integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.11.tgz", + "integrity": "sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg==", "cpu": [ "arm64" ], @@ -1823,9 +1858,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz", - "integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.11.tgz", + "integrity": "sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w==", "cpu": [ "x64" ], @@ -1839,9 +1874,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz", - "integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.11.tgz", + "integrity": "sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw==", "cpu": [ "x64" ], @@ -1855,9 +1890,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz", - "integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.11.tgz", + "integrity": "sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g==", "cpu": [ "arm64" ], @@ -1871,9 +1906,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz", - "integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.11.tgz", + "integrity": "sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w==", "cpu": [ "x64" ], @@ -1961,9 +1996,9 @@ } }, "node_modules/@prisma/client": { - "version": "6.19.2", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz", - "integrity": "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", "hasInstallScript": true, "license": "Apache-2.0", "engines": { @@ -1983,37 +2018,37 @@ } }, "node_modules/@prisma/config": { - "version": "6.19.2", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz", - "integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", - "effect": "3.18.4", + "effect": "3.21.0", "empathic": "2.0.0" } }, "node_modules/@prisma/debug": { - "version": "6.19.2", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz", - "integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { - "version": "6.19.2", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz", - "integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.2", + "@prisma/debug": "6.19.3", "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", - "@prisma/fetch-engine": "6.19.2", - "@prisma/get-platform": "6.19.2" + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" } }, "node_modules/@prisma/engines-version": { @@ -2024,25 +2059,25 @@ "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { - "version": "6.19.2", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz", - "integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.2", + "@prisma/debug": "6.19.3", "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", - "@prisma/get-platform": "6.19.2" + "@prisma/get-platform": "6.19.3" } }, "node_modules/@prisma/get-platform": { - "version": "6.19.2", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz", - "integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.2" + "@prisma/debug": "6.19.3" } }, "node_modules/@radix-ui/number": { @@ -4258,7 +4293,9 @@ "version": "22.19.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5293,12 +5330,15 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.4.tgz", - "integrity": "sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bcryptjs": { @@ -5960,9 +6000,9 @@ } }, "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "devOptional": true, "license": "MIT" }, @@ -6041,9 +6081,9 @@ } }, "node_modules/effect": { - "version": "3.18.4", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", - "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -6301,12 +6341,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es6-promise": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", - "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", - "license": "MIT" - }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -6994,9 +7028,9 @@ } }, "node_modules/exsolve": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", "devOptional": true, "license": "MIT" }, @@ -8089,9 +8123,9 @@ } }, "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -8501,9 +8535,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -8670,9 +8704,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -8711,14 +8745,15 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz", - "integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.11.tgz", + "integrity": "sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ==", "license": "MIT", "peer": true, "dependencies": { - "@next/env": "16.0.7", + "@next/env": "16.2.11", "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -8730,15 +8765,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.0.7", - "@next/swc-darwin-x64": "16.0.7", - "@next/swc-linux-arm64-gnu": "16.0.7", - "@next/swc-linux-arm64-musl": "16.0.7", - "@next/swc-linux-x64-gnu": "16.0.7", - "@next/swc-linux-x64-musl": "16.0.7", - "@next/swc-win32-arm64-msvc": "16.0.7", - "@next/swc-win32-x64-msvc": "16.0.7", - "sharp": "^0.34.4" + "@next/swc-darwin-arm64": "16.2.11", + "@next/swc-darwin-x64": "16.2.11", + "@next/swc-linux-arm64-gnu": "16.2.11", + "@next/swc-linux-arm64-musl": "16.2.11", + "@next/swc-linux-x64-gnu": "16.2.11", + "@next/swc-linux-x64-musl": "16.2.11", + "@next/swc-win32-arm64-msvc": "16.2.11", + "@next/swc-win32-x64-msvc": "16.2.11", + "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -8764,18 +8799,18 @@ } }, "node_modules/next-auth": { - "version": "5.0.0-beta.30", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz", - "integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==", + "version": "5.0.0-beta.32", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.32.tgz", + "integrity": "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==", "license": "ISC", "dependencies": { - "@auth/core": "0.41.0" + "@auth/core": "0.41.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", - "nodemailer": "^7.0.7", + "nodemailer": "^7.0.7 || ^8.0.5", "react": "^18.2.0 || ^19.0.0" }, "peerDependenciesMeta": { @@ -8800,34 +8835,6 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -8880,15 +8887,15 @@ } }, "node_modules/nypm": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", - "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.8.tgz", + "integrity": "sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==", "devOptional": true, "license": "MIT", "dependencies": { - "citty": "^0.2.0", + "citty": "^0.2.2", "pathe": "^2.0.3", - "tinyexec": "^1.0.2" + "tinyexec": "^1.2.4" }, "bin": { "nypm": "dist/cli.mjs" @@ -8898,16 +8905,16 @@ } }, "node_modules/nypm/node_modules/citty": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.0.tgz", - "integrity": "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", "devOptional": true, "license": "MIT" }, "node_modules/oauth4webapi": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.3.tgz", - "integrity": "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==", + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -9194,14 +9201,14 @@ } }, "node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", "devOptional": true, "license": "MIT", "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", + "confbox": "^0.2.4", + "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, @@ -9263,10 +9270,16 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.5.tgz", + "integrity": "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==", + "license": "MIT-0" + }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", "funding": [ { "type": "opencollective", @@ -9284,7 +9297,7 @@ "license": "MIT", "peer": true, "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -9329,16 +9342,16 @@ } }, "node_modules/prisma": { - "version": "6.19.2", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz", - "integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "peer": true, "dependencies": { - "@prisma/config": "6.19.2", - "@prisma/engines": "6.19.2" + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" }, "bin": { "prisma": "build/index.js" @@ -9399,12 +9412,6 @@ ], "license": "MIT" }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "license": "MIT" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9713,19 +9720,14 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "license": "MIT" - }, "node_modules/resend": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/resend/-/resend-6.5.2.tgz", - "integrity": "sha512-Yl83UvS8sYsjgmF8dVbNPzlfpmb3DkLUk3VwsAbkaEFo9UMswpNuPGryHBXGk+Ta4uYMv5HmjVk3j9jmNkcEDg==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.18.0.tgz", + "integrity": "sha512-EjxZ9AVzywJgOlUoIJe9ytBWVrfbUtJbjeoLnRSvpU1sv97Hh9DSwhw+k8kiujrG4Rg4bzTBsjlmwWWuoOxSug==", "license": "MIT", "dependencies": { - "svix": "1.76.1" + "postal-mime": "2.7.5", + "standardwebhooks": "1.0.0" }, "engines": { "node": ">=20" @@ -9922,9 +9924,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "devOptional": true, "license": "ISC", "bin": { @@ -9990,48 +9992,53 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/shebang-command": { @@ -10173,6 +10180,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -10379,20 +10396,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/svix": { - "version": "1.76.1", - "resolved": "https://registry.npmjs.org/svix/-/svix-1.76.1.tgz", - "integrity": "sha512-CRuDWBTgYfDnBLRaZdKp9VuoPcNUq9An14c/k+4YJ15Qc5Grvf66vp0jvTltd4t7OIRj+8lM1DAgvSgvf7hdLw==", - "license": "MIT", - "dependencies": { - "@stablelib/base64": "^1.0.0", - "@types/node": "^22.7.5", - "es6-promise": "^4.2.8", - "fast-sha256": "^1.3.0", - "url-parse": "^1.5.10", - "uuid": "^10.0.0" - } - }, "node_modules/tailwind-merge": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", @@ -10447,9 +10450,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "devOptional": true, "license": "MIT", "engines": { @@ -11230,6 +11233,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -11307,16 +11311,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "license": "MIT", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -11369,19 +11363,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/vaul": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", diff --git a/package.json b/package.json index 53f18c9..5919f83 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,14 @@ "prisma": { "seed": "npx tsx prisma/seed.ts" }, + "overrides": { + "lodash": "4.18.1", + "postcss": "8.5.22", + "sharp": "0.35.3" + }, "dependencies": { "@hookform/resolvers": "^3.10.0", - "@prisma/client": "^6.19.2", + "@prisma/client": "6.19.3", "@radix-ui/react-accordion": "1.2.2", "@radix-ui/react-alert-dialog": "1.1.4", "@radix-ui/react-aspect-ratio": "1.1.1", @@ -68,8 +73,8 @@ "input-otp": "1.4.1", "lucide-react": "^0.454.0", "motion": "^12.42.2", - "next": "16.0.7", - "next-auth": "^5.0.0-beta.30", + "next": "16.2.11", + "next-auth": "5.0.0-beta.32", "next-themes": "^0.4.6", "react": "19.2.0", "react-day-picker": "9.8.0", @@ -78,7 +83,7 @@ "react-icons": "^5.5.0", "react-resizable-panels": "^2.1.7", "recharts": "2.15.4", - "resend": "^6.5.2", + "resend": "6.18.0", "sonner": "^1.7.4", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", @@ -97,8 +102,8 @@ "@typescript-eslint/parser": "^8.56.1", "eslint": "^9.39.3", "eslint-config-next": "^16.1.6", - "postcss": "^8.5", - "prisma": "^6.19.2", + "postcss": "8.5.22", + "prisma": "6.19.3", "tailwindcss": "^4.1.9", "tsx": "^4.23.1", "tw-animate-css": "1.3.3", From 28889957a4e3b633da81e61b4498a917ca772823 Mon Sep 17 00:00:00 2001 From: Bash Abdul Date: Thu, 23 Jul 2026 19:07:22 +0100 Subject: [PATCH 07/19] Fix cart hydration mismatch --- components/cart/cart-hydrator.tsx | 10 ++-------- components/layout/header.tsx | 6 ++++-- tests/e2e/theme.spec.ts | 10 ++++++++++ 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/components/cart/cart-hydrator.tsx b/components/cart/cart-hydrator.tsx index 8b2b1b2..66a68c0 100644 --- a/components/cart/cart-hydrator.tsx +++ b/components/cart/cart-hydrator.tsx @@ -1,19 +1,13 @@ "use client"; import { useEffect } from "react"; -import { usePathname } from "next/navigation"; import { useCartStore } from "@/lib/stores/cart-store"; /** Hydrates cart store from server cookie (GET /api/cart/summary). Single source of truth. */ export function CartHydrator() { const syncFromServer = useCartStore((s) => s.syncFromServer); - const pathname = usePathname(); useEffect(() => { - // Category URLs immediately redirect to /shop. Avoid starting a request - // that WebKit will cancel (and report as a CORS error) mid-navigation. - if (pathname.startsWith("/category/") || pathname.startsWith("/account")) - return; - syncFromServer(); - }, [pathname, syncFromServer]); + void syncFromServer(); + }, [syncFromServer]); return null; } diff --git a/components/layout/header.tsx b/components/layout/header.tsx index 09cdf97..93c1aab 100644 --- a/components/layout/header.tsx +++ b/components/layout/header.tsx @@ -60,9 +60,11 @@ function NavLink({ export function Header() { const pathname = usePathname(); const [isScrolled, setIsScrolled] = React.useState(false); - const cartItemCount = useCartStore((state) => state.getUniqueItemsCount()); + const [hasMounted, setHasMounted] = React.useState(false); + const cartItemCount = useCartStore((state) => state.items.length); React.useEffect(() => { + setHasMounted(true); const handleScroll = () => setIsScrolled(window.scrollY > 10); handleScroll(); window.addEventListener("scroll", handleScroll, { passive: true }); @@ -240,7 +242,7 @@ export function Header() { className="h-[18px] w-[18px] shrink-0" strokeWidth={1.75} /> - {cartItemCount > 0 && ( + {hasMounted && cartItemCount > 0 && ( {cartItemCount > 99 ? "99+" : cartItemCount} diff --git a/tests/e2e/theme.spec.ts b/tests/e2e/theme.spec.ts index 9f84f9e..11cedd9 100644 --- a/tests/e2e/theme.spec.ts +++ b/tests/e2e/theme.spec.ts @@ -194,6 +194,14 @@ test.describe("theme behavior", () => { }); test("theme switching preserves the active cart", async ({ page }) => { + const runtimeErrors: string[] = []; + page.on("pageerror", (error) => runtimeErrors.push(error.message)); + page.on("console", (message) => { + if (/hydration|did not match/i.test(message.text())) { + runtimeErrors.push(message.text()); + } + }); + await page.goto("/product/nocturne-eau-de-parfum", { waitUntil: "domcontentloaded", }); @@ -215,9 +223,11 @@ test.describe("theme behavior", () => { const response = await fetch("/api/cart/summary"); return response.json(); }); + await page.goto("/shop", { waitUntil: "networkidle" }); expect(before.items.length).toBeGreaterThan(0); expect(after.items).toEqual(before.items); + expect(runtimeErrors).toEqual([]); }); test("semantic section hierarchy reverses and product media is never inverted", async ({ From 9dfcb5e3e53e1b3c2412ff92502fcaee8feab2c1 Mon Sep 17 00:00:00 2001 From: Bash Abdul Date: Thu, 23 Jul 2026 19:23:20 +0100 Subject: [PATCH 08/19] Harden public mutation endpoints --- app/api/contact/route.ts | 157 +++++++++++------- app/api/coupons/apply/route.ts | 13 ++ app/api/drops/notify/route.ts | 38 ++++- app/api/newsletter/subscribe/route.ts | 21 ++- app/api/reviews/route.ts | 157 ++++++++++++++---- docs/PRODUCTION_READINESS_PLAN.md | 12 +- lib/notifications/contact-email.ts | 53 ++++++ .../migration.sql | 2 + prisma/schema.prisma | 1 + tests/integration/verified-review.int.test.ts | 25 +++ tests/notifications/contact-email.test.ts | 39 +++++ 11 files changed, 413 insertions(+), 105 deletions(-) create mode 100644 lib/notifications/contact-email.ts create mode 100644 prisma/migrations/20260723193000_unique_review_per_product/migration.sql create mode 100644 tests/notifications/contact-email.test.ts diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index 43d3783..46a36c1 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -1,89 +1,128 @@ +import crypto from "node:crypto" + import { NextRequest, NextResponse } from "next/server" import { Resend } from "resend" -import { rateLimit } from "@/lib/middleware/rate-limit" -import { emailSchema, nameSchema, validateAndSanitize } from "@/lib/middleware/validate-input" import { z } from "zod" +import { clientIp, consumeRateLimit } from "@/lib/middleware/limiter" +import { + emailSchema, + nameSchema, + validateAndSanitize, +} from "@/lib/middleware/validate-input" +import { + renderContactOwnerEmail, + renderContactReplyEmail, +} from "@/lib/notifications/contact-email" +import { logger } from "@/lib/observability/logger" +import { hasTrustedOrigin } from "@/lib/security/origin" + const contactSchema = z.object({ name: nameSchema, email: emailSchema, subject: z.string().min(1, "Subject is required").max(200), message: z.string().min(10, "Message too short").max(5000), -}) - -function getClientId(req: NextRequest): string { - return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "anonymous" -} +}).strict() const STORE_EMAIL = "fadeessencee@gmail.com" -const FROM_EMAIL = process.env.NEWSLETTER_FROM_EMAIL || "Fádé Essence " +const FROM_EMAIL = + process.env.NEWSLETTER_FROM_EMAIL || + "Fade Essence " export async function POST(req: NextRequest) { try { - if (!rateLimit(getClientId(req), 5, 60 * 1000)) { - return NextResponse.json({ error: "Too many requests. Try again later." }, { status: 429 }) + if (!hasTrustedOrigin(req)) { + return NextResponse.json( + { error: "Request origin could not be verified" }, + { status: 403 }, + ) } - const body = await req.json() - const validated = validateAndSanitize(contactSchema, body) + const ipLimit = await consumeRateLimit( + `contact:ip:${clientIp(req)}`, + 5, + 60 * 60 * 1000, + ) + if (!ipLimit.ok) { + return NextResponse.json( + { error: "Too many requests. Try again later." }, + { status: 429 }, + ) + } + + const validated = validateAndSanitize(contactSchema, await req.json()) if (!validated.success) { - return NextResponse.json({ error: validated.error }, { status: 400 }) + return NextResponse.json( + { error: validated.error }, + { status: 400 }, + ) } + const { name, email, subject, message } = validated.data + const normalizedEmail = email.toLowerCase().trim() + const emailHash = crypto + .createHash("sha256") + .update(normalizedEmail) + .digest("hex") + const emailLimit = await consumeRateLimit( + `contact:email:${emailHash}`, + 3, + 60 * 60 * 1000, + ) + if (!emailLimit.ok) { + return NextResponse.json( + { error: "Too many requests. Try again later." }, + { status: 429 }, + ) + } if (process.env.RESEND_API_KEY) { const resend = new Resend(process.env.RESEND_API_KEY) + await resend.emails + .send({ + from: FROM_EMAIL, + to: STORE_EMAIL, + replyTo: normalizedEmail, + subject: `Contact Form: ${subject}`, + html: renderContactOwnerEmail({ + name, + email: normalizedEmail, + subject, + message, + }), + }) + .catch((error) => + logger.error("contact_owner_email_failed", { + internal: String(error), + }), + ) - // Notify store owner - await resend.emails.send({ - from: FROM_EMAIL, - to: STORE_EMAIL, - replyTo: email, - subject: `Contact Form: ${subject}`, - html: ` -
-

New Contact Form Submission

-

From: ${name} <${email}>

-

Subject: ${subject}

-
-

${message}

-
- `, - }).catch((err) => console.error("[CONTACT] Failed to send store notification:", err)) - - // Auto-reply to sender - await resend.emails.send({ - from: FROM_EMAIL, - to: email, - subject: `We received your message · Fádé Essence`, - html: ` -
-
-

Fádé Essence

-
-
-

Thank you for reaching out, ${name}!

-

We've received your message and will get back to you within 24–48 hours.

-

Your message:

-
-

${message}

-
-

- If you need urgent assistance, you can also reach us at ${STORE_EMAIL} or +234 8160591348. -

-
-
- `, - }).catch((err) => console.error("[CONTACT] Failed to send auto-reply:", err)) + await resend.emails + .send({ + from: FROM_EMAIL, + to: normalizedEmail, + subject: "We received your message - Fade Essence", + html: renderContactReplyEmail({ name, message }, STORE_EMAIL), + }) + .catch((error) => + logger.error("contact_reply_email_failed", { + internal: String(error), + }), + ) } else { - console.log("[CONTACT] No RESEND_API_KEY, skipping email:", { name, email, subject }) + logger.warn("contact_email_skipped", { + reason: "RESEND_API_KEY missing", + }) } + return NextResponse.json({ + message: "Thank you for contacting us! We'll get back to you soon.", + }) + } catch (error) { + logger.error("contact_request_failed", { internal: String(error) }) return NextResponse.json( - { message: "Thank you for contacting us! We'll get back to you soon." }, - { status: 200 }, + { error: "Failed to send message. Please try again." }, + { status: 500 }, ) - } catch { - return NextResponse.json({ error: "Failed to send message. Please try again." }, { status: 500 }) } } diff --git a/app/api/coupons/apply/route.ts b/app/api/coupons/apply/route.ts index 41353e4..cc8bce0 100644 --- a/app/api/coupons/apply/route.ts +++ b/app/api/coupons/apply/route.ts @@ -1,9 +1,22 @@ // app/api/coupons/apply/route.ts import { NextRequest, NextResponse } from 'next/server' +import { clientIp, consumeRateLimit } from '@/lib/middleware/limiter' import { validateCoupon } from '@/lib/pricing' +import { hasTrustedOrigin } from '@/lib/security/origin' export async function POST(req: NextRequest) { try { + if (!hasTrustedOrigin(req)) { + return NextResponse.json({ error: 'Request origin could not be verified' }, { status: 403 }) + } + const limit = await consumeRateLimit( + `coupon-preview:ip:${clientIp(req)}`, + 20, + 15 * 60 * 1000, + ) + if (!limit.ok) { + return NextResponse.json({ error: 'Too many requests. Try again later.' }, { status: 429 }) + } const { code, subtotalNGN } = await req.json() if (!code || typeof subtotalNGN !== 'number') { return NextResponse.json({ error: 'bad_request' }, { status: 400 }) diff --git a/app/api/drops/notify/route.ts b/app/api/drops/notify/route.ts index ba63e00..c360750 100644 --- a/app/api/drops/notify/route.ts +++ b/app/api/drops/notify/route.ts @@ -1,13 +1,41 @@ import { NextRequest, NextResponse } from "next/server" +import { z } from "zod" + +import { clientIp, consumeRateLimit } from "@/lib/middleware/limiter" import { prisma } from "@/lib/prisma" +import { hasTrustedOrigin } from "@/lib/security/origin" + +const notifySchema = z.object({ + email: z.string().trim().email().max(254), + productSlug: z.string().trim().min(1).max(200).optional(), +}).strict() export async function POST(req: NextRequest) { try { - const { email, productSlug } = await req.json() + if (!hasTrustedOrigin(req)) { + return NextResponse.json( + { error: "Request origin could not be verified" }, + { status: 403 }, + ) + } + const limit = await consumeRateLimit( + `drop-notify:ip:${clientIp(req)}`, + 5, + 60 * 60 * 1000, + ) + if (!limit.ok) { + return NextResponse.json( + { error: "Too many requests. Try again later." }, + { status: 429 }, + ) + } - if (!email || typeof email !== "string" || !email.includes("@")) { + const parsed = notifySchema.safeParse(await req.json()) + if (!parsed.success) { return NextResponse.json({ error: "Valid email required" }, { status: 400 }) } + const email = parsed.data.email.toLowerCase() + const productSlug = parsed.data.productSlug // Upsert subscriber await prisma.newsletterSubscriber.upsert({ @@ -19,7 +47,11 @@ export async function POST(req: NextRequest) { // Increment notifyCount on the product if (productSlug && typeof productSlug === "string") { await prisma.product.updateMany({ - where: { slug: productSlug, deletedAt: null }, + where: { + slug: productSlug, + deletedAt: null, + publishStatus: "PUBLISHED", + }, data: { notifyCount: { increment: 1 } }, }) } diff --git a/app/api/newsletter/subscribe/route.ts b/app/api/newsletter/subscribe/route.ts index 6fb6b8b..7de1556 100644 --- a/app/api/newsletter/subscribe/route.ts +++ b/app/api/newsletter/subscribe/route.ts @@ -1,18 +1,26 @@ import { NextRequest, NextResponse } from "next/server" import { prisma } from "@/lib/prisma" -import { rateLimit } from "@/lib/middleware/rate-limit" +import { clientIp, consumeRateLimit } from "@/lib/middleware/limiter" import { emailSchema, validateAndSanitize } from "@/lib/middleware/validate-input" +import { hasTrustedOrigin } from "@/lib/security/origin" import { z } from "zod" const subscribeSchema = z.object({ email: emailSchema }) -function getClientId(req: NextRequest): string { - return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "anonymous" -} - export async function POST(req: NextRequest) { try { - if (!rateLimit(getClientId(req), 5, 60 * 1000)) { + if (!hasTrustedOrigin(req)) { + return NextResponse.json( + { error: "Request origin could not be verified" }, + { status: 403 }, + ) + } + const limit = await consumeRateLimit( + `newsletter-subscribe:ip:${clientIp(req)}`, + 5, + 60 * 60 * 1000, + ) + if (!limit.ok) { return NextResponse.json({ error: "Too many requests. Try again later." }, { status: 429 }) } @@ -48,4 +56,3 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Failed to subscribe. Please try again." }, { status: 500 }) } } - diff --git a/app/api/reviews/route.ts b/app/api/reviews/route.ts index 2ba5a02..ee91a01 100644 --- a/app/api/reviews/route.ts +++ b/app/api/reviews/route.ts @@ -1,24 +1,39 @@ -import { NextRequest, NextResponse } from 'next/server' -import type { Prisma } from '@prisma/client' +import crypto from "node:crypto" -import { auth } from '@/lib/auth' -import { prisma } from '@/lib/prisma' +import { NextRequest, NextResponse } from "next/server" +import type { Prisma } from "@prisma/client" +import { z } from "zod" -export const runtime = 'nodejs' +import { auth } from "@/lib/auth" +import { clientIp, consumeRateLimit } from "@/lib/middleware/limiter" +import { logger } from "@/lib/observability/logger" +import { prisma } from "@/lib/prisma" +import { findVerifyingOrder } from "@/lib/reviews/verify" +import { hasTrustedOrigin } from "@/lib/security/origin" + +export const runtime = "nodejs" + +const reviewSchema = z.object({ + productId: z.string().trim().min(1).max(100), + rating: z.coerce.number().int().min(1).max(5), + comment: z.string().trim().max(2000).optional(), + displayName: z.string().trim().max(80).optional(), + tag: z.string().trim().max(40).optional(), +}).strict() export async function GET(request: NextRequest) { - const productId = request.nextUrl.searchParams.get('productId') + const productId = request.nextUrl.searchParams.get("productId") if (!productId) { return NextResponse.json({ reviews: [] }) } - const filter = request.nextUrl.searchParams.get('filter') || 'recent' + const filter = request.nextUrl.searchParams.get("filter") || "recent" const orderBy: Prisma.ReviewOrderByWithRelationInput[] = - filter === 'highest' - ? [{ rating: 'desc' }, { createdAt: 'desc' }] - : filter === 'lowest' - ? [{ rating: 'asc' }, { createdAt: 'desc' }] - : [{ createdAt: 'desc' }] + filter === "highest" + ? [{ rating: "desc" }, { createdAt: "desc" }] + : filter === "lowest" + ? [{ rating: "asc" }, { createdAt: "desc" }] + : [{ createdAt: "desc" }] const reviews = await prisma.review.findMany({ where: { productId, approved: true }, @@ -32,49 +47,123 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { try { + if (!hasTrustedOrigin(request)) { + return NextResponse.json( + { success: false, error: "Request origin could not be verified" }, + { status: 403 }, + ) + } + const session = await auth() const email = session?.user?.email if (!email) { - return NextResponse.json({ success: false, error: 'You must sign in to submit a review.' }, { status: 401 }) + return NextResponse.json( + { success: false, error: "You must sign in to submit a review." }, + { status: 401 }, + ) + } + + const emailHash = crypto + .createHash("sha256") + .update(email.toLowerCase()) + .digest("hex") + const limit = await consumeRateLimit( + `review-submit:user:${emailHash}:ip:${clientIp(request)}`, + 5, + 60 * 60 * 1000, + ) + if (!limit.ok) { + return NextResponse.json( + { success: false, error: "Too many review attempts. Try again later." }, + { status: 429 }, + ) + } + + const parsed = reviewSchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json( + { + success: false, + error: "Product ID and rating (1-5) are required.", + }, + { status: 400 }, + ) } - const user = await prisma.user.findUnique({ where: { email } }) + const user = await prisma.user.findFirst({ + where: { email: { equals: email, mode: "insensitive" } }, + select: { id: true, name: true }, + }) if (!user) { - return NextResponse.json({ success: false, error: 'User account not found' }, { status: 404 }) + return NextResponse.json( + { success: false, error: "User account not found" }, + { status: 404 }, + ) } - const payload = await request.json() - const productId = String(payload.productId || '').trim() - const rating = Math.floor(Number(payload.rating) || 0) - const comment = typeof payload.comment === 'string' ? payload.comment.trim() : '' - const displayName = typeof payload.displayName === 'string' ? payload.displayName.trim() : user.name - const tag = typeof payload.tag === 'string' ? payload.tag.trim() : null + const orderId = await findVerifyingOrder(user.id, parsed.data.productId) + if (!orderId) { + return NextResponse.json( + { + success: false, + error: "Only verified purchasers can review this product.", + }, + { status: 403 }, + ) + } - if (!productId || rating < 1 || rating > 5) { - return NextResponse.json({ success: false, error: 'Product ID and rating (1-5) are required.' }, { status: 400 }) + const existing = await prisma.review.findFirst({ + where: { + userId: user.id, + productId: parsed.data.productId, + }, + select: { id: true }, + }) + if (existing) { + return NextResponse.json( + { + success: false, + error: "You have already reviewed this product.", + }, + { status: 409 }, + ) } await prisma.review.create({ data: { userId: user.id, - productId, - rating, - comment: comment || null, + productId: parsed.data.productId, + orderId, + verifiedPurchase: true, + rating: parsed.data.rating, + comment: parsed.data.comment || null, approved: true, - displayName: displayName || undefined, - tag: tag || undefined, + moderationStatus: "PENDING", + displayName: parsed.data.displayName || user.name || undefined, + tag: parsed.data.tag || undefined, }, }) return NextResponse.json({ success: true }) } catch (error) { - console.error('Create review error', error) + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "P2002" + ) { + return NextResponse.json( + { + success: false, + error: "You have already reviewed this product.", + }, + { status: 409 }, + ) + } + logger.error("review_creation_failed", { internal: String(error) }) return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Unable to store review', - }, - { status: 500 } + { success: false, error: "Unable to store review" }, + { status: 500 }, ) } } diff --git a/docs/PRODUCTION_READINESS_PLAN.md b/docs/PRODUCTION_READINESS_PLAN.md index 81cc420..6bdb8a8 100644 --- a/docs/PRODUCTION_READINESS_PLAN.md +++ b/docs/PRODUCTION_READINESS_PLAN.md @@ -61,6 +61,12 @@ Completed in the first hardening milestone: rate limits without exposing whether an account exists; - signup and credential login now apply abuse limits, normalize email case, and share the eight character minimum password policy; +- public contact, newsletter, drop-notification, coupon-preview, and review mutations now validate + request origin and use the distributed rate-limiter abstraction; +- contact emails now HTML-escape every customer-controlled field and use structured, redacted + failure logging; +- the storefront review endpoint now requires a verified paid purchase, rejects duplicate reviews, + and relies on a database unique constraint to close concurrent duplicate-submission races; - process-global session-duration state was removed in favor of a deterministic 24-hour session; - Next.js, Auth.js, Prisma, Resend, PostCSS, Sharp, and lodash were upgraded or pinned to patched versions; the production dependency audit now reports zero vulnerabilities; @@ -103,11 +109,13 @@ This is the original audit list. Items completed on `codex/production-readiness` decrement. 2. **Resolved on branch:** public catalogue queries did not consistently require `publishStatus = PUBLISHED`. -3. Custom state-changing endpoints do not consistently validate request origin or CSRF protections. +3. **Partially resolved on branch:** critical commerce/authentication and exposed public-form + mutations validate request origin; the remaining authenticated and admin mutation endpoints + still require a complete consistency pass. 4. **Partially resolved on branch:** signup, password reset, order creation, payment initialization, and credential login now use the rate-limiter abstraction; production still requires the configured Redis backend for cross-instance durability. -5. Contact-form values are interpolated into HTML emails without HTML escaping. +5. **Resolved on branch:** contact-form values are HTML escaped before email rendering. 6. Required production configuration does not fail fast at startup. 7. Missing favicon, PWA icons, and Open Graph image cause broken metadata assets. 8. CI does not gate releases on all unit, integration, browser, dependency, build, and migration diff --git a/lib/notifications/contact-email.ts b/lib/notifications/contact-email.ts new file mode 100644 index 0000000..7f97e80 --- /dev/null +++ b/lib/notifications/contact-email.ts @@ -0,0 +1,53 @@ +import { escapeHtml } from "@/lib/security/html" + +interface ContactEmailInput { + name: string + email: string + subject: string + message: string +} + +export function renderContactOwnerEmail(input: ContactEmailInput): string { + const name = escapeHtml(input.name) + const email = escapeHtml(input.email) + const subject = escapeHtml(input.subject) + const message = escapeHtml(input.message) + + return ` +
+

New Contact Form Submission

+

From: ${name} <${email}>

+

Subject: ${subject}

+
+

${message}

+
+ ` +} + +export function renderContactReplyEmail( + input: Pick, + storeEmail: string, +): string { + const name = escapeHtml(input.name) + const message = escapeHtml(input.message) + const safeStoreEmail = escapeHtml(storeEmail) + + return ` +
+
+

Fade Essence

+
+
+

Thank you for reaching out, ${name}!

+

We've received your message and will get back to you within 24-48 hours.

+

Your message:

+
+

${message}

+
+

+ If you need urgent assistance, you can also reach us at ${safeStoreEmail} or +234 8160591348. +

+
+
+ ` +} diff --git a/prisma/migrations/20260723193000_unique_review_per_product/migration.sql b/prisma/migrations/20260723193000_unique_review_per_product/migration.sql new file mode 100644 index 0000000..054278c --- /dev/null +++ b/prisma/migrations/20260723193000_unique_review_per_product/migration.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX "Review_userId_productId_key" +ON "Review"("userId", "productId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index faf81c2..810e2fc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -225,6 +225,7 @@ model Review { user User @relation(fields: [userId], references: [id]) product Product @relation(fields: [productId], references: [id]) + @@unique([userId, productId]) @@index([productId, createdAt]) @@index([userId]) @@index([moderationStatus]) diff --git a/tests/integration/verified-review.int.test.ts b/tests/integration/verified-review.int.test.ts index 3134165..a5433d9 100644 --- a/tests/integration/verified-review.int.test.ts +++ b/tests/integration/verified-review.int.test.ts @@ -51,6 +51,7 @@ describe.skipIf(!hasDb)('verified-purchase review path (DB)', () => { afterAll(async () => { // Delete children first to satisfy FKs. + await prisma.review.deleteMany({ where: { userId } }).catch(() => {}) await prisma.orderItem.deleteMany({ where: { orderId } }).catch(() => {}) await prisma.order.deleteMany({ where: { id: orderId } }).catch(() => {}) await prisma.product.deleteMany({ where: { id: { in: [productId, otherProductId] } } }).catch(() => {}) @@ -67,4 +68,28 @@ describe.skipIf(!hasDb)('verified-purchase review path (DB)', () => { expect(await isVerifiedPurchase(userId, otherProductId)).toBe(false) expect(await findVerifyingOrder(userId, otherProductId)).toBeNull() }) + + it('enforces one review per user and product at the database boundary', async () => { + await prisma.review.create({ + data: { + userId, + productId, + orderId, + rating: 5, + verifiedPurchase: true, + }, + }) + + await expect( + prisma.review.create({ + data: { + userId, + productId, + orderId, + rating: 4, + verifiedPurchase: true, + }, + }), + ).rejects.toMatchObject({ code: 'P2002' }) + }) }) diff --git a/tests/notifications/contact-email.test.ts b/tests/notifications/contact-email.test.ts new file mode 100644 index 0000000..4cac432 --- /dev/null +++ b/tests/notifications/contact-email.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest" + +import { + renderContactOwnerEmail, + renderContactReplyEmail, +} from "@/lib/notifications/contact-email" + +describe("contact email rendering", () => { + it("escapes every customer-controlled value in the owner email", () => { + const html = renderContactOwnerEmail({ + name: "", + email: "person@example.test", + subject: "", + message: "\"quoted\" & unsafe", + }) + + expect(html).not.toContain("