diff --git a/.env.example b/.env.example index 98c96d0..b660d27 100644 --- a/.env.example +++ b/.env.example @@ -6,14 +6,17 @@ # Database (Neon, Supabase, or any Postgres) DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DB?pgbouncer=true&connection_limit=1" -# App +# App (localhost here; use the deployed HTTPS origin in hosting configuration) APP_URL="http://localhost:3000" +NEXT_PUBLIC_SITE_URL="http://localhost:3000" # NextAuth (either AUTH_SECRET or NEXTAUTH_SECRET) NEXTAUTH_SECRET="your-nextauth-secret-here" NEXTAUTH_URL="http://localhost:3000" -# --- Payments: Paystack (SANDBOX/TEST keys only; never live charges in dev) --- +# --- Payments: disabled by default; enable only with valid Paystack test/live configuration --- +PAYMENTS_ENABLED="false" +# SANDBOX/TEST keys only in local development; never put live keys in .env. PAYSTACK_SECRET_KEY="sk_test_xxx" PAYSTACK_PUBLIC_KEY="pk_test_xxx" @@ -56,18 +59,29 @@ 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, # sample_credits, whatsapp_marketing, agentic_feed FEATURE_FLAGS="" -# --- Durable rate limiting / cache (optional; Upstash Redis REST — serverless-friendly) --- -# When set, rate limiting is durable across serverless instances. When absent, an in-memory -# per-instance limiter is used (documented limitation in docs/SECURITY_REVIEW.md). +# --- Durable rate limiting / cache (optional locally; required for production readiness) --- +# Without these values, local development falls back to an in-memory per-instance limiter. UPSTASH_REDIS_REST_URL="" UPSTASH_REDIS_REST_TOKEN="" +# Authenticates scheduler calls to internal background-job endpoints (required in production). +# 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/.github/workflows/production-readiness.yml b/.github/workflows/production-readiness.yml new file mode 100644 index 0000000..65abb15 --- /dev/null +++ b/.github/workflows/production-readiness.yml @@ -0,0 +1,92 @@ +name: Production readiness + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: production-readiness-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Validate application and database + runs-on: ubuntu-latest + timeout-minutes: 25 + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: ninthluxe_ci + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d ninthluxe_ci" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + CI: true + DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/ninthluxe_ci + APP_URL: http://127.0.0.1:3000 + NEXT_PUBLIC_SITE_URL: http://127.0.0.1:3000 + NEXTAUTH_URL: http://127.0.0.1:3000 + AUTH_SECRET: ci-only-auth-secret-at-least-32-characters + PAYSTACK_SECRET_KEY: sk_test_ci_placeholder + PAYSTACK_PUBLIC_KEY: pk_test_ci_placeholder + RESEND_API_KEY: re_ci_placeholder + NEWSLETTER_FROM_EMAIL: CI + CRON_SECRET: ci-only-cron-secret-at-least-32-characters + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install locked dependencies + run: npm ci + + - name: Audit production dependencies + run: npm audit --omit=dev --audit-level=high + + - name: Validate Prisma schema + run: npx prisma validate + + - name: Apply migrations to temporary PostgreSQL + run: npx prisma migrate deploy + + - name: Verify migration status + run: npx prisma migrate status + + - name: Validate seed + run: npm run seed + + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Run unit and database integration tests + run: npm test + + - name: Build production application + env: + UPSTASH_REDIS_REST_URL: https://redis.invalid + UPSTASH_REDIS_REST_TOKEN: ci-placeholder + run: npm run build diff --git a/app/account/orders/[id]/page.tsx b/app/account/orders/[id]/page.tsx index bee8cad..37063f2 100644 --- a/app/account/orders/[id]/page.tsx +++ b/app/account/orders/[id]/page.tsx @@ -51,6 +51,15 @@ const statusColors: Record = { PAID: "bg-info/15 text-info", SHIPPED: "bg-accent/15 text-accent", DELIVERED: "bg-success/15 text-success", + CANCELLED: "bg-destructive/15 text-destructive", + REFUND_PENDING: "bg-warning/15 text-warning", + REFUNDED: "bg-muted text-muted-foreground", +}; + +const exceptionalStatusDescriptions: Record = { + CANCELLED: "This order was cancelled and reserved stock was released.", + REFUND_PENDING: "Your refund is being processed by the payment provider.", + REFUNDED: "The payment provider has confirmed this order's refund.", }; function getProductImage(images: unknown): string { @@ -178,7 +187,8 @@ export default async function OrderDetailPage({

- {STATUS_META[order.status as OrderStatus]?.description} + {STATUS_META[order.status as OrderStatus]?.description || + exceptionalStatusDescriptions[order.status]}

Placed on{" "} diff --git a/app/account/orders/page.tsx b/app/account/orders/page.tsx index 3f3e206..7cef096 100644 --- a/app/account/orders/page.tsx +++ b/app/account/orders/page.tsx @@ -25,26 +25,44 @@ const statusColors: Record = { export const dynamic = "force-dynamic"; -export default async function OrdersPage() { +export default async function OrdersPage({ + searchParams, +}: { + searchParams?: Promise<{ page?: string }>; +}) { // Require authentication - will redirect if not signed in const user = await requireUser(); + const params = await searchParams; + const requestedPage = Number.parseInt(params?.page ?? "1", 10); + const page = Number.isFinite(requestedPage) && requestedPage > 0 + ? requestedPage + : 1; + const pageSize = 10; // Fetch orders from database for the current user - const orders = await prisma.order.findMany({ - where: { userId: user.id }, + const [orders, totalOrders] = await Promise.all([ + prisma.order.findMany({ + where: { userId: user.id }, + skip: (page - 1) * pageSize, + take: pageSize, include: { items: { include: { - product: true, + product: { + select: { id: true, name: true, images: true }, + }, }, }, }, - orderBy: { createdAt: "desc" }, - }); + orderBy: { createdAt: "desc" }, + }), + prisma.order.count({ where: { userId: user.id } }), + ]); + const totalPages = Math.max(1, Math.ceil(totalOrders / pageSize)); // Helper to get first product image const getProductImage = (product: any): string => { @@ -172,6 +190,25 @@ export default async function OrdersPage() { ))} + {totalPages > 1 && ( +

+

+ Page {Math.min(page, totalPages)} of {totalPages} +

+
+ {page > 1 && ( + + )} + {page < totalPages && ( + + )} +
+
+ )} ); } diff --git a/app/admin/orders/[id]/page.tsx b/app/admin/orders/[id]/page.tsx index 70c75ed..5f18b31 100644 --- a/app/admin/orders/[id]/page.tsx +++ b/app/admin/orders/[id]/page.tsx @@ -4,6 +4,7 @@ import { OrderStatus } from "@prisma/client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -23,6 +24,8 @@ import { getAdminOrderById, updateOrderStatus, } from "@/lib/services/order-service"; +import { requireAdmin } from "@/lib/admin"; +import { allowedAdminOrderTransitions } from "@/lib/orders/state-machine"; export const dynamic = "force-dynamic"; @@ -35,6 +38,9 @@ const statusOptions: { label: string; value: OrderStatus }[] = [ { label: "Paid", value: "PAID" }, { label: "Shipped", value: "SHIPPED" }, { label: "Delivered", value: "DELIVERED" }, + { label: "Cancelled", value: "CANCELLED" }, + { label: "Refund pending", value: "REFUND_PENDING" }, + { label: "Refunded", value: "REFUNDED" }, ]; const statusClasses: Record = { @@ -45,6 +51,12 @@ const statusClasses: Record = { "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200", DELIVERED: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + CANCELLED: + "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", + REFUND_PENDING: + "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200", + REFUNDED: + "bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200", }; export default async function OrderDetailPage({ @@ -67,13 +79,31 @@ export default async function OrderDetailPage({ async function updateStatusAction(formData: FormData) { "use server"; + const admin = await requireAdmin(); const { id: orderId } = await params; - const status = formData.get("status") as OrderStatus; - await updateOrderStatus(orderId, status); + const rawStatus = formData.get("status"); + const reason = String(formData.get("reason") || ""); + if ( + typeof rawStatus !== "string" || + !Object.values(OrderStatus).includes(rawStatus as OrderStatus) + ) { + throw new Error("Invalid order status"); + } + await updateOrderStatus({ + orderId, + status: rawStatus as OrderStatus, + actorId: admin.id, + reason, + }); redirect(`/admin/orders/${orderId}`); } + const allowedTransitions = allowedAdminOrderTransitions(order.status); + const transitionOptions = statusOptions.filter((option) => + allowedTransitions.includes(option.value), + ); + const itemsTotal = order.items.reduce( (total, item) => total + item.quantity, 0, @@ -95,9 +125,10 @@ export default async function OrderDetailPage({ })}

+ {transitionOptions.length > 0 && (
Status - - {statusOptions.map((option) => ( + {transitionOptions.map((option) => ( {option.label} ))} +
+ )}
@@ -202,22 +242,6 @@ export default async function OrderDetailPage({ {order.coupon.code}
)} - {order.paymentMethod === "BANK_TRANSFER" && - order.status === "PENDING" && ( -
- - -
- )} diff --git a/app/admin/orders/page.tsx b/app/admin/orders/page.tsx index 1ad8cab..63d8d74 100644 --- a/app/admin/orders/page.tsx +++ b/app/admin/orders/page.tsx @@ -20,7 +20,10 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { getAdminOrders } from "@/lib/services/order-service"; +import { + countAdminOrders, + getAdminOrders, +} from "@/lib/services/order-service"; import type { OrderStatus } from "@prisma/client"; export const dynamic = "force-dynamic"; @@ -29,6 +32,7 @@ interface AdminOrdersPageProps { searchParams?: Promise<{ q?: string; status?: string; + page?: string; }>; } @@ -38,6 +42,9 @@ const statusOptions: { label: string; value: "all" | OrderStatus }[] = [ { label: "Paid", value: "PAID" }, { label: "Shipped", value: "SHIPPED" }, { label: "Delivered", value: "DELIVERED" }, + { label: "Cancelled", value: "CANCELLED" }, + { label: "Refund pending", value: "REFUND_PENDING" }, + { label: "Refunded", value: "REFUNDED" }, ]; const statusClasses: Record = { @@ -45,6 +52,9 @@ const statusClasses: Record = { PAID: "bg-info/15 text-info", SHIPPED: "bg-accent/15 text-accent", DELIVERED: "bg-success/15 text-success", + CANCELLED: "bg-destructive/15 text-destructive", + REFUND_PENDING: "bg-warning/15 text-warning", + REFUNDED: "bg-muted text-muted-foreground", }; export default async function AdminOrdersPage({ @@ -53,15 +63,29 @@ export default async function AdminOrdersPage({ const params = await searchParams; const q = params?.q?.toString() ?? ""; const statusParam = params?.status?.toString() ?? "all"; + const requestedPage = Number.parseInt(params?.page ?? "1", 10); + const page = Number.isFinite(requestedPage) && requestedPage > 0 + ? requestedPage + : 1; + const pageSize = 25; const selectedStatus = statusOptions.find((option) => option.value === statusParam)?.value ?? "all"; - const orders = await getAdminOrders({ - search: q || undefined, - status: selectedStatus, - }); + const query = { search: q || undefined, status: selectedStatus }; + const [orders, total] = await Promise.all([ + getAdminOrders({ ...query, page, pageSize }), + countAdminOrders(query), + ]); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const pageHref = (nextPage: number) => { + const search = new URLSearchParams(); + if (q) search.set("q", q); + if (selectedStatus !== "all") search.set("status", selectedStatus); + search.set("page", String(nextPage)); + return `/admin/orders?${search.toString()}`; + }; const formatPrice = (amount: number) => new Intl.NumberFormat("en-NG", { @@ -204,6 +228,25 @@ export default async function AdminOrdersPage({ + {totalPages > 1 && ( +
+

+ Page {Math.min(page, totalPages)} of {totalPages} +

+
+ {page > 1 && ( + + )} + {page < totalPages && ( + + )} +
+
+ )} diff --git a/app/admin/products/[id]/edit/page.tsx b/app/admin/products/[id]/edit/page.tsx index d82c36b..9274d64 100644 --- a/app/admin/products/[id]/edit/page.tsx +++ b/app/admin/products/[id]/edit/page.tsx @@ -13,6 +13,7 @@ import { ensureScentTemplateColumn, isTemplateId, } from '@/lib/fragrance/template-store' +import { invalidateCatalogueCache } from '@/lib/cache/catalogue' /** Empty / sentinel string -> null, for optional text columns. */ function nn(v: FormDataEntryValue | null): string | null { @@ -102,6 +103,7 @@ export default async function EditProductPage({ params }: { params: Promise<{ id ...(publishStatus ? { publishStatus } : {}), }, }) + invalidateCatalogueCache() // Persist the chosen visual template (resilient: additive column, applied on demand). Empty // value clears the override so the storefront uses the recommended template. diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index 39102ca..50f6675 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -1,6 +1,8 @@ import { prisma } from '@/lib/prisma' import { requireAdmin } from '@/lib/admin' import { User, Mail, Calendar } from 'lucide-react' +import Link from 'next/link' +import { Button } from '@/components/ui/button' import { UserRoleSelect } from '@/components/admin/user-role-select' export const dynamic = 'force-dynamic' @@ -10,27 +12,41 @@ function effectiveRole(role: string, adminRole: string | null): string { return adminRole ?? 'SUPER_ADMIN' } -export default async function AdminUsersPage() { +export default async function AdminUsersPage({ + searchParams, +}: { + searchParams?: Promise<{ page?: string }> +}) { const me = await requireAdmin() - const users = await prisma.user.findMany({ - orderBy: { createdAt: 'desc' }, - select: { - id: true, - name: true, - email: true, - role: true, - adminRole: true, - createdAt: true, - _count: { - select: { - orders: true, + const params = await searchParams + const requestedPage = Number.parseInt(params?.page ?? '1', 10) + const page = Number.isFinite(requestedPage) && requestedPage > 0 + ? requestedPage + : 1 + const pageSize = 50 + const [users, totalUsers] = await Promise.all([ + prisma.user.findMany({ + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + select: { + id: true, + name: true, + email: true, + role: true, + adminRole: true, + createdAt: true, + _count: { + select: { + orders: true, + }, }, }, - }, - }) - - const totalUsers = await prisma.user.count() + }), + prisma.user.count(), + ]) + const totalPages = Math.max(1, Math.ceil(totalUsers / pageSize)) return (
@@ -102,10 +118,27 @@ export default async function AdminUsersPage() {
+ {totalPages > 1 && ( +
+

+ Page {Math.min(page, totalPages)} of {totalPages} +

+
+ {page > 1 && ( + + )} + {page < totalPages && ( + + )} +
+
+ )} )} ) } - - diff --git a/app/api/account/addresses/[id]/route.ts b/app/api/account/addresses/[id]/route.ts index 4551987..7a06355 100644 --- a/app/api/account/addresses/[id]/route.ts +++ b/app/api/account/addresses/[id]/route.ts @@ -3,6 +3,8 @@ import { auth } from "@/lib/auth" import { prisma } from "@/lib/prisma" import { z } from "zod" import { NIGERIAN_STATES } from "@/lib/constants/nigerian-states" +import { consumeRateLimit } from "@/lib/middleware/limiter" +import { hasTrustedOrigin } from "@/lib/security/origin" const addressSchema = z.object({ name: z.string().min(1, "Full name is required").max(200), @@ -33,7 +35,6 @@ export async function GET( if (!user) { return NextResponse.json({ error: "User not found" }, { status: 401 }) } - const { id } = await params const address = await prisma.address.findFirst({ where: { id, userId: user.id }, @@ -67,6 +68,9 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> } ) { try { + if (!hasTrustedOrigin(req)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } const session = await auth() const email = session?.user?.email if (!email) { @@ -80,6 +84,14 @@ export async function PATCH( if (!user) { return NextResponse.json({ error: "User not found" }, { status: 401 }) } + const limit = await consumeRateLimit( + `account:address:${user.id}`, + 30, + 60 * 60 * 1000, + ) + if (!limit.ok) { + return NextResponse.json({ error: "Too many address changes" }, { status: 429 }) + } const { id } = await params const existing = await prisma.address.findFirst({ @@ -99,24 +111,28 @@ export async function PATCH( const { name, line1, address: addressLine, city, state, postalCode, phone, isDefault } = parsed.data const line1Value = line1 ?? addressLine - if (isDefault === true) { - await prisma.address.updateMany({ - where: { userId: user.id }, - data: { isDefault: false }, + const address = await prisma.$transaction(async (tx) => { + if (isDefault === true) { + await tx.address.updateMany({ + where: { userId: user.id }, + data: { isDefault: false }, + }) + } + return tx.address.update({ + where: { id }, + data: { + ...(name !== undefined && { name: name || null }), + ...(line1Value !== undefined && + line1Value !== "" && { line1: line1Value }), + ...(city !== undefined && { city }), + ...(state !== undefined && { state }), + ...(postalCode !== undefined && { + postalCode: postalCode || null, + }), + ...(phone !== undefined && { phone }), + ...(isDefault !== undefined && { isDefault }), + }, }) - } - - const address = await prisma.address.update({ - where: { id }, - data: { - ...(name !== undefined && { name: name || null }), - ...(line1Value !== undefined && line1Value !== "" && { line1: line1Value }), - ...(city !== undefined && { city }), - ...(state !== undefined && { state }), - ...(postalCode !== undefined && { postalCode: postalCode || null }), - ...(phone !== undefined && { phone }), - ...(isDefault !== undefined && { isDefault }), - } as Parameters[0]["data"], }) const a = address as typeof address & { name?: string | null; postalCode?: string | null } @@ -140,10 +156,13 @@ export async function PATCH( } export async function DELETE( - _req: NextRequest, + req: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + if (!hasTrustedOrigin(req)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } const session = await auth() const email = session?.user?.email if (!email) { @@ -157,6 +176,14 @@ export async function DELETE( if (!user) { return NextResponse.json({ error: "User not found" }, { status: 401 }) } + const limit = await consumeRateLimit( + `account:address:${user.id}`, + 30, + 60 * 60 * 1000, + ) + if (!limit.ok) { + return NextResponse.json({ error: "Too many address changes" }, { status: 429 }) + } const { id } = await params const existing = await prisma.address.findFirst({ diff --git a/app/api/account/addresses/route.ts b/app/api/account/addresses/route.ts index 6094143..be44500 100644 --- a/app/api/account/addresses/route.ts +++ b/app/api/account/addresses/route.ts @@ -3,6 +3,8 @@ import { auth } from "@/lib/auth" import { prisma } from "@/lib/prisma" import { z } from "zod" import { NIGERIAN_STATES } from "@/lib/constants/nigerian-states" +import { consumeRateLimit } from "@/lib/middleware/limiter" +import { hasTrustedOrigin } from "@/lib/security/origin" const addressSchema = z.object({ name: z.string().min(1, "Full name is required").max(200), @@ -58,6 +60,9 @@ export async function GET() { export async function POST(req: NextRequest) { try { + if (!hasTrustedOrigin(req)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } const session = await auth() const email = session?.user?.email if (!email) { @@ -71,6 +76,17 @@ export async function POST(req: NextRequest) { if (!user) { return NextResponse.json({ error: "User not found" }, { status: 404 }) } + const limit = await consumeRateLimit( + `account:address:${user.id}`, + 30, + 60 * 60 * 1000, + ) + if (!limit.ok) { + return NextResponse.json( + { error: "Too many address changes. Please try again later." }, + { status: 429 }, + ) + } const body = await req.json() const parsed = addressSchema.safeParse(body) @@ -82,24 +98,25 @@ export async function POST(req: NextRequest) { const { name, line1, address: addressLine, city, state, postalCode, phone, isDefault } = parsed.data const line1Value = line1 ?? addressLine ?? "" - if (isDefault) { - await prisma.address.updateMany({ - where: { userId: user.id }, - data: { isDefault: false }, + const address = await prisma.$transaction(async (tx) => { + if (isDefault) { + await tx.address.updateMany({ + where: { userId: user.id }, + data: { isDefault: false }, + }) + } + return tx.address.create({ + data: { + userId: user.id, + name: name || null, + line1: line1Value, + city, + state, + postalCode: postalCode || null, + phone, + isDefault: isDefault ?? false, + }, }) - } - - const address = await prisma.address.create({ - data: { - userId: user.id, - name: name || null, - line1: line1Value, - city, - state, - postalCode: postalCode || null, - phone, - isDefault: isDefault ?? false, - } as Parameters[0]["data"], }) const a = address as typeof address & { name?: string | null; postalCode?: string | null } diff --git a/app/api/admin/categories/route.ts b/app/api/admin/categories/route.ts index 34a1625..80ad4d9 100644 --- a/app/api/admin/categories/route.ts +++ b/app/api/admin/categories/route.ts @@ -15,17 +15,20 @@ export async function GET() { orderBy: { name: "asc" }, }) - const results = await Promise.all( - categories.map(async (category) => { - let productCount = 0 - if (category.enumKey) { - productCount = await prisma.product.count({ - where: { category: category.enumKey }, - }) - } - return { ...category, productCount } - }) + const counts = await prisma.product.groupBy({ + by: ["category"], + where: { deletedAt: null }, + _count: { _all: true }, + }) + const countByCategory = new Map( + counts.map((row) => [row.category, row._count._all]), ) + const results = categories.map((category) => ({ + ...category, + productCount: category.enumKey + ? (countByCategory.get(category.enumKey) ?? 0) + : 0, + })) return NextResponse.json({ categories: results }) } catch (error) { @@ -64,4 +67,3 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Failed to create category" }, { status: 500 }) } } - diff --git a/app/api/admin/orders/[id]/refund/route.ts b/app/api/admin/orders/[id]/refund/route.ts new file mode 100644 index 0000000..3f440f6 --- /dev/null +++ b/app/api/admin/orders/[id]/refund/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server" +import { z } from "zod" + +import { getPayments } from "@/integrations/registry" +import { isPaymentCollectionEnabled } from "@/integrations/payments/policy" +import { getAdminUser } from "@/lib/admin" +import { isValidIdempotencyKey } from "@/lib/checkout/idempotency" +import { AppError } from "@/lib/http/errors" +import { consumeRateLimit } from "@/lib/middleware/limiter" +import { logger } from "@/lib/observability/logger" +import { requestFullRefund } from "@/lib/refunds/service" +import { hasTrustedOrigin } from "@/lib/security/origin" +import { env } from "@/lib/env" + +const bodySchema = z.object({ + reason: z.string().trim().min(3).max(500), +}).strict() + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + if (!hasTrustedOrigin(request)) { + return NextResponse.json( + { error: "Request origin could not be verified" }, + { status: 403 }, + ) + } + if (!isPaymentCollectionEnabled(env.PAYMENTS_ENABLED, env.PAYSTACK_SECRET_KEY)) { + return NextResponse.json( + { error: "Online payments are temporarily unavailable" }, + { status: 503 }, + ) + } + const admin = await getAdminUser() + if (!admin) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const limit = await consumeRateLimit( + `admin-refund:user:${admin.id}`, + 5, + 60 * 60 * 1000, + false, + ) + if (!limit.ok) { + return NextResponse.json( + { error: "Too many refund requests. Try again later." }, + { status: 429 }, + ) + } + + const idempotencyKey = request.headers.get("idempotency-key") + if (!isValidIdempotencyKey(idempotencyKey)) { + return NextResponse.json( + { error: "A valid Idempotency-Key header is required" }, + { status: 400 }, + ) + } + const parsed = bodySchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json( + { error: "A refund reason is required" }, + { status: 400 }, + ) + } + + const { id: orderId } = await params + const refund = await requestFullRefund({ + orderId, + idempotencyKey, + reason: parsed.data.reason, + actorId: admin.id, + provider: getPayments(), + }) + return NextResponse.json({ + ok: true, + refund: { + id: refund.id, + status: refund.status, + amountNGN: refund.amountNGN, + currency: refund.currency, + }, + }) + } catch (error) { + logger.error("admin_refund_request_failed", { + internal: String(error), + }) + if (error instanceof AppError) { + return NextResponse.json( + { error: error.safeMessage }, + { status: error.status }, + ) + } + return NextResponse.json( + { error: "Unable to request refund" }, + { status: 500 }, + ) + } +} 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/cart/add/route.ts b/app/api/cart/add/route.ts index 762a697..de31a8f 100644 --- a/app/api/cart/add/route.ts +++ b/app/api/cart/add/route.ts @@ -1,9 +1,18 @@ import { NextResponse } from 'next/server' import { addToCart } from '@/components/cartActions' +import { clientIp, consumeRateLimit } from '@/lib/middleware/limiter' +import { hasTrustedOrigin } from '@/lib/security/origin' export const runtime = 'nodejs' export async function POST(request: Request) { + if (!hasTrustedOrigin(request)) { + return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }) + } + const limit = await consumeRateLimit(`cart:${clientIp(request)}`, 120, 10 * 60 * 1000) + if (!limit.ok) { + return NextResponse.json({ success: false, error: 'Too many cart updates' }, { status: 429 }) + } const { productId, quantity } = await request.json() if (!productId) { return NextResponse.json({ success: false, error: 'Missing product ID' }, { status: 400 }) diff --git a/app/api/cart/clear/route.ts b/app/api/cart/clear/route.ts index 7278e9a..bcb3a00 100644 --- a/app/api/cart/clear/route.ts +++ b/app/api/cart/clear/route.ts @@ -1,9 +1,18 @@ import { NextResponse } from 'next/server' import { clearCart } from '@/components/cartActions' +import { clientIp, consumeRateLimit } from '@/lib/middleware/limiter' +import { hasTrustedOrigin } from '@/lib/security/origin' export const runtime = 'nodejs' -export async function POST() { +export async function POST(request: Request) { + if (!hasTrustedOrigin(request)) { + return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }) + } + const limit = await consumeRateLimit(`cart:${clientIp(request)}`, 120, 10 * 60 * 1000) + if (!limit.ok) { + return NextResponse.json({ success: false, error: 'Too many cart updates' }, { status: 429 }) + } await clearCart() return NextResponse.json({ success: true }) } diff --git a/app/api/cart/remove/route.ts b/app/api/cart/remove/route.ts index f65ef9e..41fd346 100644 --- a/app/api/cart/remove/route.ts +++ b/app/api/cart/remove/route.ts @@ -1,9 +1,18 @@ import { NextResponse } from 'next/server' import { removeFromCart } from '@/components/cartActions' +import { clientIp, consumeRateLimit } from '@/lib/middleware/limiter' +import { hasTrustedOrigin } from '@/lib/security/origin' export const runtime = 'nodejs' export async function POST(request: Request) { + if (!hasTrustedOrigin(request)) { + return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }) + } + const limit = await consumeRateLimit(`cart:${clientIp(request)}`, 120, 10 * 60 * 1000) + if (!limit.ok) { + return NextResponse.json({ success: false, error: 'Too many cart updates' }, { status: 429 }) + } const { productId } = await request.json() if (!productId) { return NextResponse.json({ success: false, error: 'Missing productId' }, { status: 400 }) 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/cart/update/route.ts b/app/api/cart/update/route.ts index fb70d7b..1fee1f8 100644 --- a/app/api/cart/update/route.ts +++ b/app/api/cart/update/route.ts @@ -1,9 +1,18 @@ import { NextResponse } from 'next/server' import { updateCartItem, removeFromCart } from '@/components/cartActions' +import { clientIp, consumeRateLimit } from '@/lib/middleware/limiter' +import { hasTrustedOrigin } from '@/lib/security/origin' export const runtime = 'nodejs' export async function POST(request: Request) { + if (!hasTrustedOrigin(request)) { + return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }) + } + const limit = await consumeRateLimit(`cart:${clientIp(request)}`, 120, 10 * 60 * 1000) + if (!limit.ok) { + return NextResponse.json({ success: false, error: 'Too many cart updates' }, { status: 429 }) + } const { productId, quantity } = await request.json() if (!productId) { return NextResponse.json({ success: false, error: 'Missing productId' }, { status: 400 }) diff --git a/app/api/checkout/create-order/route.ts b/app/api/checkout/create-order/route.ts index b7cc283..f13f6f1 100644 --- a/app/api/checkout/create-order/route.ts +++ b/app/api/checkout/create-order/route.ts @@ -2,6 +2,23 @@ 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" +import { AppError } from "@/lib/http/errors" +import { env } from "@/lib/env" +import { isPaymentCollectionEnabled } from "@/integrations/payments/policy" +import { + aggregateInventoryLines, + reservationExpiry, + reserveInventory, +} from "@/lib/inventory/reservations" const createOrderSchema = z.object({ addressLine1: z.string().min(1, "Address is required"), @@ -15,11 +32,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 +48,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 +64,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,18 +83,91 @@ 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 === "CARD" && + !isPaymentCollectionEnabled(env.PAYMENTS_ENABLED, env.PAYSTACK_SECRET_KEY) + ) { + return NextResponse.json( + { error: "Online payments are temporarily unavailable" }, + { status: 503 }, + ) + } + 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 inventoryLines = aggregateInventoryLines(items) + const productIds = inventoryLines.map((item) => item.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])) 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 }) @@ -79,44 +186,91 @@ 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), + }, + }) + await reserveInventory( + tx, + created.id, + orderItems, + reservationExpiry(paymentMethod), + ) + 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) { - 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/contact/route.ts b/app/api/contact/route.ts index cfd8286..d81787e 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -1,41 +1,81 @@ +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 { createContactSubmission } from "@/lib/forms/submissions" +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 " - -function escapeHtml(value: string): string { - const entities: Record = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" } - return value.replace(/[&<>"']/g, (char) => entities[char]) -} +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 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 body = await req.json() - const validated = validateAndSanitize(contactSchema, body) + 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 }, + ) + } // Keep the existing email flow operational during the staged migration rollout. Once the // FormSubmission migration is applied, every valid contact request is durably captured here. @@ -50,64 +90,53 @@ export async function POST(req: NextRequest) { } } - const safeName = escapeHtml(name) - const safeEmail = escapeHtml(email) - const safeSubject = escapeHtml(subject) - const safeMessage = escapeHtml(message) - 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: ${safeName} <${safeEmail}>

-

Subject: ${safeSubject}

-
-

${safeMessage}

-
- `, - }).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, ${safeName}!

-

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

-

Your message:

-
-

${safeMessage}

-
-

- 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/health/route.ts b/app/api/health/route.ts index 66dfab2..425f696 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server" import { prisma } from "@/lib/prisma" import { getEnvDiagnostics } from "@/lib/env-diagnostics" +import { + checkJobReadiness, + checkRedisReadiness, +} from "@/lib/readiness" export const runtime = "nodejs" export const dynamic = "force-dynamic" @@ -8,17 +12,20 @@ export const dynamic = "force-dynamic" export async function GET() { const env = getEnvDiagnostics() - let database: "up" | "down" = "up" - let databaseError: string | null = null + const [database, redis, jobs] = await Promise.all([ + prisma.$queryRaw`SELECT 1` + .then(() => "up" as const) + .catch(() => "down" as const), + checkRedisReadiness(), + checkJobReadiness(), + ]) - try { - await prisma.$queryRaw`SELECT 1` - } catch (error) { - database = "down" - databaseError = error instanceof Error ? error.message : "Unknown database error" - } - - const ok = database === "up" && env.missingCritical.length === 0 + const redisReady = redis === "up" || redis === "not_configured" + const ok = + database === "up" && + redisReady && + jobs.status !== "down" && + env.missingCritical.length === 0 const status = ok ? 200 : 503 return NextResponse.json( @@ -27,10 +34,11 @@ export async function GET() { timestamp: new Date().toISOString(), checks: { database, + redis, + jobs: jobs.status, env: env.missingCritical.length === 0 ? "up" : "down", }, env, - ...(databaseError ? { databaseError } : {}), }, { status } ) 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..7f8562d --- /dev/null +++ b/app/api/internal/jobs/process-outbox/route.ts @@ -0,0 +1,30 @@ +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 }) + } +} + +// Vercel Cron invokes configured routes with GET. Keep POST for external schedulers and runbooks. +export const GET = POST diff --git a/app/api/internal/jobs/reconcile-payments/route.ts b/app/api/internal/jobs/reconcile-payments/route.ts new file mode 100644 index 0000000..03f4017 --- /dev/null +++ b/app/api/internal/jobs/reconcile-payments/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server" + +import { getPayments } from "@/integrations/registry" +import { env } from "@/lib/env" +import { logger } from "@/lib/observability/logger" +import { + reconcilePendingPayments, + reconcilePendingRefunds, +} from "@/lib/payments/reconciliation" +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 }) + } + + const provider = getPayments() + if (provider.name !== "paystack") { + return NextResponse.json( + { error: "payment_provider_unavailable" }, + { status: 503 }, + ) + } + + try { + const [payments, refunds] = await Promise.all([ + reconcilePendingPayments({ provider, limit: 20 }), + reconcilePendingRefunds({ provider, limit: 20 }), + ]) + logger.info("payment_reconciliation_batch_processed", { + payments, + refunds, + }) + return NextResponse.json({ ok: true, payments, refunds }) + } catch (error) { + logger.error("payment_reconciliation_batch_failed", { + internal: String(error), + }) + return NextResponse.json({ error: "job_failed" }, { status: 500 }) + } +} + +// Vercel Cron invokes configured routes with GET. Keep POST for external schedulers and runbooks. +export const GET = POST 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..7dde3a0 --- /dev/null +++ b/app/api/internal/jobs/release-reservations/route.ts @@ -0,0 +1,31 @@ +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 }) + } +} + +// Vercel Cron invokes configured routes with GET. Keep POST for external schedulers and runbooks. +export const GET = POST 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/paystack/initialize/route.ts b/app/api/paystack/initialize/route.ts index b06793d..2455b40 100644 --- a/app/api/paystack/initialize/route.ts +++ b/app/api/paystack/initialize/route.ts @@ -1,80 +1,237 @@ -// 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 { isPaymentCollectionEnabled } from "@/integrations/payments/policy" +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) { + if (!hasTrustedOrigin(req)) { + return NextResponse.json({ error: "Request origin could not be verified" }, { status: 403 }) + } + if (!isPaymentCollectionEnabled(env.PAYMENTS_ENABLED, env.PAYSTACK_SECRET_KEY)) { return NextResponse.json( - { error: 'PAYSTACK_SECRET_KEY missing in .env' }, - { status: 400 } + { error: "Online payments are temporarily unavailable" }, + { status: 503 }, ) } - if (!looksLikePaystackSecretKey(cleanSecret)) { + const session = await auth() + const email = session?.user?.email + if (!email) { + return NextResponse.json({ error: "Sign in to pay for an order" }, { status: 401 }) + } + 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 } }, + inventoryReservations: { + select: { status: true, expiresAt: 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 }, + ) + } + 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 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..10a4854 100644 --- a/app/api/paystack/webhook/route.ts +++ b/app/api/paystack/webhook/route.ts @@ -1,133 +1,185 @@ -// 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' +import { NextRequest, NextResponse } from "next/server" -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 { getPayments } from "@/integrations/registry" +import type { ProviderRefundStatus } from "@/integrations/payments/types" +import { logger } from "@/lib/observability/logger" +import { settleSuccessfulPayment } from "@/lib/payments/settlement" +import { settleRefundStatus } from "@/lib/refunds/settlement" + +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() + + if (provider.name !== "paystack") { + return NextResponse.json( + { error: "payment_provider_unavailable" }, + { status: 503 }, + ) + } + + const verified = provider.verifyWebhook(raw, signature) + if (!verified.valid) { + return NextResponse.json({ error: "invalid_signature" }, { status: 401 }) + } - 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 + let rawEvent: Record + try { + rawEvent = JSON.parse(raw) + } catch { + return NextResponse.json({ error: "invalid_payload" }, { status: 400 }) + } - // 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 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 || + !status + ) { + 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 } } }, + try { + const result = await settleSuccessfulPayment({ + orderId, + reference, + amountNGN, + currency, + providerStatus: status, + providerTransactionId: + rawEvent?.data?.id == null ? null : String(rawEvent.data.id), + receipt: eventId + ? { provider: "paystack", eventId, topic: verified.event } + : undefined, }) - // Guard: skip if already paid (duplicate webhook) - if (!existingOrder || existingOrder.status === 'PAID') { - return NextResponse.json({ ok: true }) - } - - // 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 }, + if (result.outcome === "unknown") { + logger.warn("paystack_webhook_unknown_attempt", { + eventId, + reference, + orderId, }) - - // 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 } }, - }) - ) - ) - - // Increment coupon usage if one was applied - if (existingOrder.couponId) { - await tx.coupon.update({ - where: { id: existingOrder.couponId }, - data: { usedCount: { increment: 1 } }, - }) - } - - // Update user loyalty tier and lifetime spend (thresholds from commerce config) - const updatedUser = await tx.user.update({ - where: { id: updated.userId }, - data: { totalLifetimeSpend: { increment: updated.totalNGN } }, - select: { totalLifetimeSpend: true }, - }) - await tx.user.update({ - where: { id: updated.userId }, - data: { loyaltyTier: resolveLoyaltyTier(updatedUser.totalLifetimeSpend) }, + return NextResponse.json({ ok: true, ignored: "unknown_attempt" }) + } + if (result.outcome === "mismatch") { + logger.error("paystack_webhook_payment_mismatch", { + eventId, + reference, + orderId: result.orderId, + attemptStatus: result.attemptStatus, + expectedAmountNGN: result.expectedAmountNGN, + orderAmountNGN: result.orderAmountNGN, + receivedAmountNGN: amountNGN, + expectedCurrency: result.expectedCurrency, + receivedCurrency: currency, }) - - // 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) - if (points > 0) { - const prior = await tx.loyaltyLedger.aggregate({ - where: { userId: updated.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 }, + return NextResponse.json({ ok: true, ignored: "payment_mismatch" }) + } + if (result.outcome === "duplicate") { + if ( + result.paidReference && + result.paidReference !== reference + ) { + logger.error("duplicate_successful_payment", { + orderId: result.orderId, + reference, }) } - - return updated + return NextResponse.json({ ok: true, duplicate: true }) + } + } catch (error) { + logger.error("paystack_webhook_processing_failed", { + eventId, + orderId, + internal: String(error), }) - - // 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, - } - }).catch(() => {}) // Don't fail if notification creation fails + 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 ?? '') - if (eventId) { - const first = await recordWebhookOnce('paystack', eventId, evt.event) - if (!first) return NextResponse.json({ ok: true }) + } else if ( + verified.event?.startsWith("refund.") || + verified.event === "charge.refunded" + ) { + const refundStatus: ProviderRefundStatus = + verified.event === "charge.refunded" + ? "processed" + : verified.event === "refund.processed" + ? "processed" + : verified.event === "refund.failed" + ? "failed" + : verified.event === "refund.needs-attention" + ? "needs_attention" + : verified.event === "refund.processing" + ? "processing" + : "pending" + const data = rawEvent?.data ?? {} + const paymentReference = + data?.transaction?.reference ?? data?.reference ?? null + const amountNGN = + typeof data?.amount === "number" + ? Math.round(data.amount / 100) + : null + const currency = data?.currency + if (!eventId || !paymentReference || amountNGN == null || !currency) { + logger.warn("paystack_refund_webhook_incomplete", { + eventId, + topic: verified.event, + }) + return NextResponse.json({ ok: true, ignored: "incomplete" }) } - 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(() => {}) + + try { + const result = await settleRefundStatus({ + providerRefundId: + data?.id == null ? null : String(data.id), + paymentReference, + status: refundStatus, + amountNGN, + currency, + receipt: { + provider: "paystack", + eventId: `${eventId}:${verified.event}`, + topic: verified.event, + }, + }) + if (result.outcome === "mismatch" || result.outcome === "unknown") { + logger.error("paystack_refund_webhook_unmatched", { + eventId, + paymentReference, + outcome: result.outcome, + }) } + return NextResponse.json({ + ok: true, + duplicate: result.outcome === "duplicate", + outcome: result.outcome, + }) + } catch (error) { + logger.error("paystack_refund_webhook_processing_failed", { + eventId, + paymentReference, + internal: String(error), + }) + return NextResponse.json( + { error: "processing_failed" }, + { status: 500 }, + ) } } 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/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/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/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/settings/toggle-email/route.ts b/app/api/settings/toggle-email/route.ts index fa1a138..c8ac99f 100644 --- a/app/api/settings/toggle-email/route.ts +++ b/app/api/settings/toggle-email/route.ts @@ -1,21 +1,39 @@ import { NextResponse } from "next/server" import { prisma } from "@/lib/prisma" import { auth } from "@/lib/auth" +import crypto from "node:crypto" +import { consumeRateLimit } from "@/lib/middleware/limiter" +import { hasTrustedOrigin } from "@/lib/security/origin" export async function POST(request: Request) { try { + if (!hasTrustedOrigin(request)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } const session = await auth() const email = session?.user?.email if (!email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } + const identity = crypto.createHash("sha256").update(email).digest("hex") + const limit = await consumeRateLimit( + `account:email-setting:${identity}`, + 30, + 60 * 60 * 1000, + ) + if (!limit.ok) { + return NextResponse.json({ error: "Too many changes" }, { status: 429 }) + } const { enabled } = await request.json() + if (typeof enabled !== "boolean") { + return NextResponse.json({ error: "Invalid preference" }, { status: 400 }) + } await prisma.user.update({ where: { email }, - data: { marketingEmails: enabled } as any, + data: { marketingEmails: enabled }, }) return NextResponse.json({ success: true }) @@ -26,4 +44,3 @@ export async function POST(request: Request) { } - diff --git a/app/api/settings/toggle-sms/route.ts b/app/api/settings/toggle-sms/route.ts index 36f79be..de5cbe5 100644 --- a/app/api/settings/toggle-sms/route.ts +++ b/app/api/settings/toggle-sms/route.ts @@ -1,21 +1,39 @@ import { NextResponse } from "next/server" import { prisma } from "@/lib/prisma" import { auth } from "@/lib/auth" +import crypto from "node:crypto" +import { consumeRateLimit } from "@/lib/middleware/limiter" +import { hasTrustedOrigin } from "@/lib/security/origin" export async function POST(request: Request) { try { + if (!hasTrustedOrigin(request)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } const session = await auth() const email = session?.user?.email if (!email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } + const identity = crypto.createHash("sha256").update(email).digest("hex") + const limit = await consumeRateLimit( + `account:sms-setting:${identity}`, + 30, + 60 * 60 * 1000, + ) + if (!limit.ok) { + return NextResponse.json({ error: "Too many changes" }, { status: 429 }) + } const { enabled } = await request.json() + if (typeof enabled !== "boolean") { + return NextResponse.json({ error: "Invalid preference" }, { status: 400 }) + } await prisma.user.update({ where: { email }, - data: { smsNotifications: enabled } as any, + data: { smsNotifications: enabled }, }) return NextResponse.json({ success: true }) @@ -26,4 +44,3 @@ export async function POST(request: Request) { } - diff --git a/app/api/v1/admin/status/route.ts b/app/api/v1/admin/status/route.ts index bb9db12..73cd44a 100644 --- a/app/api/v1/admin/status/route.ts +++ b/app/api/v1/admin/status/route.ts @@ -7,6 +7,10 @@ import { hasCapability, resolveRole } from '@/lib/authz-core' import { integrationStatus } from '@/lib/env' import { providerStatus } from '@/integrations/registry' import { allFlags } from '@/lib/config/feature-flags' +import { + checkJobReadiness, + checkRedisReadiness, +} from '@/lib/readiness' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -15,11 +19,16 @@ export const GET = route(async () => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') if (!hasCapability(resolveRole(admin), 'dashboard:view')) raise('FORBIDDEN') + const [redis, jobs] = await Promise.all([ + checkRedisReadiness(), + checkJobReadiness(), + ]) return { data: { integrations: integrationStatus(), providers: providerStatus(), featureFlags: allFlags(), + readiness: { redis, jobs }, }, } }) 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/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/app/checkout/page.tsx b/app/checkout/page.tsx index 4d9dd9e..fbbea7e 100644 --- a/app/checkout/page.tsx +++ b/app/checkout/page.tsx @@ -3,7 +3,10 @@ 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" +import { env } from "@/lib/env" +import { isPaymentCollectionEnabled } from "@/integrations/payments/policy" export const metadata: Metadata = { title: "Checkout | Fádé", @@ -16,6 +19,11 @@ export default async function CheckoutPage() { await requireUser("/checkout") const { shipping } = getCommerceConfig() + const bankTransfer = getBankTransferConfig() + const paymentsEnabled = isPaymentCollectionEnabled( + env.PAYMENTS_ENABLED, + env.PAYSTACK_SECRET_KEY, + ) return ( @@ -23,6 +31,10 @@ export default async function CheckoutPage() { items={[]} freeShippingThreshold={shipping.freeShippingThreshold} flatShippingFee={shipping.flatShippingFee} + expressShippingFee={shipping.expressShippingFee} + giftWrapFee={shipping.giftWrapFee} + bankTransfer={bankTransfer} + paymentsEnabled={paymentsEnabled} /> ) 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 177764a..bbd4b6d 100644 --- a/app/drops/page.tsx +++ b/app/drops/page.tsx @@ -28,7 +28,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 7c79f38..3e1e982 100644 --- a/app/help/shipping/page.tsx +++ b/app/help/shipping/page.tsx @@ -65,7 +65,8 @@ export default async 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 adb4b9b..fd3b7a4 100644 --- a/app/journal/[slug]/page.tsx +++ b/app/journal/[slug]/page.tsx @@ -62,7 +62,11 @@ export default async function ArticlePage({ if (productSlugs.length) { try { relatedProducts = await prisma.product.findMany({ - where: { slug: { in: productSlugs }, deletedAt: null }, + where: { + slug: { in: productSlugs }, + deletedAt: null, + publishStatus: "PUBLISHED", + }, }) } catch { // silently degrade diff --git a/app/page.tsx b/app/page.tsx index 1ca1ac0..6618b04 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -10,12 +10,11 @@ import { BrandStorySection } from "@/components/home/brand-story-section" import { ConciergeInvitation } from "@/components/home/concierge-invitation" +import { getCachedHomepageProducts } from "@/lib/cache/catalogue" import { isFeatureEnabled } from "@/lib/config/feature-flags" import { getApprovedFusionHeroFragrance } from "@/lib/hero/fusion-config" -import { prisma } from "@/lib/prisma" - import { getHomepageLayout } from "@/lib/homepage/service" export const dynamic = "force-dynamic" @@ -26,36 +25,9 @@ export default async function HomePage() { : null // Fetch featured products from database (best-effort; allow the page to render even if DB isn't ready). - let dbProducts: Awaited> = [] + let dbProducts: Awaited> = [] try { - dbProducts = await prisma.product.findMany({ - where: { - deletedAt: null, // Exclude soft-deleted products - OR: [ - { isBestseller: true }, - { isNew: true }, - { isLimited: true }, - { isFeatured: true }, - ], - }, - orderBy: [ - { isFeatured: "desc" }, - { isBestseller: "desc" }, - { ratingAvg: "desc" }, - { createdAt: "desc" }, - ], - take: 8, - }) - - // No flagged products yet, fall back to the latest additions so the - // homepage edit never renders empty. - if (dbProducts.length === 0) { - dbProducts = await prisma.product.findMany({ - where: { deletedAt: null }, - orderBy: [{ ratingAvg: "desc" }, { createdAt: "desc" }], - take: 8, - }) - } + dbProducts = await getCachedHomepageProducts() } catch (err) { console.error("HomePage: failed to load featured products", err) dbProducts = [] diff --git a/app/shop/page.tsx b/app/shop/page.tsx index 26b0ac1..fcd8e1e 100644 --- a/app/shop/page.tsx +++ b/app/shop/page.tsx @@ -5,6 +5,7 @@ import { ProductCard } from '@/components/ui/product-card' import { mapPrismaProductToCard } from '@/lib/queries/products' import { ShopFiltersForm } from '@/components/shop/shop-filters-form' import type { Product } from '@prisma/client' +import { getCachedPublishedBrands } from '@/lib/cache/catalogue' const CATEGORY_MAP: Record = { perfumes: 'PERFUMES', @@ -114,18 +115,15 @@ export default async function ShopPage({ searchParams }: { searchParams?: Promis where: { ...where, deletedAt: null, + publishStatus: 'PUBLISHED', }, orderBy: orderBy, take: 24, }), - prisma.product.findMany({ - where: { deletedAt: null }, - distinct: ['brand'], - select: { brand: true }, - }), + getCachedPublishedBrands(), ]) products = productsResult - brands = brandRows.map((row) => row.brand).filter(Boolean) as string[] + brands = brandRows } catch (err) { console.error('Shop page data fetch failed:', err) fetchError = err instanceof Error ? err : new Error(String(err)) 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/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/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/cartActions.ts b/components/cartActions.ts index 06e8fee..5d89d57 100644 --- a/components/cartActions.ts +++ b/components/cartActions.ts @@ -25,9 +25,13 @@ function parse(raw?: string | null): CartItem[] { return json .map((i: any) => ({ productId: String(i?.productId || ''), - quantity: Math.max(1, Number.isFinite(+i?.quantity) ? +i.quantity : 1), + quantity: Math.min( + 99, + Math.max(1, Number.isFinite(+i?.quantity) ? +i.quantity : 1), + ), })) .filter((i) => i.productId) + .slice(0, 100) } catch { return [] } @@ -45,9 +49,10 @@ async function writeCart(items: CartItem[]) { const safe = items .map((i) => ({ productId: String(i.productId), - quantity: Math.max(1, Math.floor(i.quantity || 1)), + quantity: Math.min(99, Math.max(1, Math.floor(i.quantity || 1))), })) .filter((i) => i.productId) + .slice(0, 100) const value = encodeURIComponent(JSON.stringify(safe)) const cookieStore = await cookies() @@ -65,9 +70,9 @@ export async function addToCart(productId: string, qty = 1) { 'use server' const cart = await getCart() const idx = cart.findIndex((x) => x.productId === productId) - const addQty = Math.max(1, Math.floor(qty || 1)) + const addQty = Math.min(99, Math.max(1, Math.floor(qty || 1))) - if (idx >= 0) cart[idx].quantity += addQty + if (idx >= 0) cart[idx].quantity = Math.min(99, cart[idx].quantity + addQty) else cart.push({ productId, quantity: addQty }) await writeCart(cart) @@ -83,7 +88,9 @@ export async function updateCartItem(productId: string, qty: number) { newQty <= 0 ? cart.filter((i) => i.productId !== productId) : cart.map((i) => - i.productId === productId ? { ...i, quantity: Math.max(1, newQty) } : i + i.productId === productId + ? { ...i, quantity: Math.min(99, Math.max(1, newQty)) } + : i ) await writeCart(next) diff --git a/components/checkout/checkout-content.tsx b/components/checkout/checkout-content.tsx index 7fa4adb..1a42e19 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,24 @@ interface CheckoutContentProps { freeShippingThreshold?: number; flatShippingFee?: number; + + expressShippingFee?: number; + + giftWrapFee?: number; + + bankTransfer?: BankTransferConfig | null; + + paymentsEnabled?: boolean; } export function CheckoutContent({ items: propItems = [], freeShippingThreshold = 500_000, flatShippingFee = 15000, + expressShippingFee = 35000, + giftWrapFee = 2500, + bankTransfer = null, + paymentsEnabled = false, }: CheckoutContentProps) { const router = useRouter(); @@ -109,10 +123,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; @@ -137,6 +151,10 @@ export function CheckoutContent({ couponId: couponId || null, + couponCode: couponCode || null, + + deliveryMethod, + isGift: formData.isGift, giftMessage: formData.giftMessage || undefined, @@ -153,6 +171,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. @@ -222,6 +243,7 @@ export function CheckoutContent({ setCurrentStep(2)} standardDeliveryFee={flatShippingFee} + expressDeliveryFee={expressShippingFee} freeShippingThreshold={freeShippingThreshold} deliveryMethod={deliveryMethod} onDeliveryMethodChange={(method) => { @@ -240,6 +262,8 @@ export function CheckoutContent({ onComplete={() => setCurrentStep(3)} total={total} orderPayload={orderPayload} + bankTransfer={bankTransfer} + paymentsEnabled={paymentsEnabled} /> )}
@@ -257,6 +281,7 @@ export function CheckoutContent({ couponCode={couponCode} applyCoupon={applyCoupon} removeCoupon={removeCoupon} + giftWrapFee={giftWrapFee} onPaymentClick={() => { const form = document.querySelector( "form[data-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 87c3d84..7a4b832 100644 --- a/components/checkout/payment-form.tsx +++ b/components/checkout/payment-form.tsx @@ -3,9 +3,21 @@ import * as React from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Lock, ArrowLeft, Loader2, CreditCard } from "lucide-react"; +import { + Lock, + ArrowLeft, + Loader2, + Building2, + CreditCard, + Copy, + CheckCheck, +} from "lucide-react"; import { toast } from "sonner"; 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 }[]; @@ -14,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; @@ -24,6 +38,32 @@ interface PaymentFormProps { onComplete: () => void; total: number; orderPayload: OrderPayload; + bankTransfer?: BankTransferConfig | null; + paymentsEnabled?: boolean; +} + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = React.useState(false); + const handleCopy = () => { + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + return ( + + ); } export function PaymentForm({ @@ -31,9 +71,19 @@ export function PaymentForm({ onComplete: _onComplete, total, orderPayload, + bankTransfer = null, + paymentsEnabled = false, }: 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" + >("CARD"); + const [bankTransferOrder, setBankTransferOrder] = React.useState<{ + orderId: string; + } | null>(null); const buildPayload = () => { const addressLine1 = @@ -50,10 +100,11 @@ 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, - paymentMethod: "CARD" as const, }; }; @@ -86,8 +137,11 @@ export function PaymentForm({ try { const createRes = await fetch("/api/checkout/create-order", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + headers: { + "Content-Type": "application/json", + "Idempotency-Key": checkoutIdempotencyKey.current, + }, + body: JSON.stringify({ ...payload, paymentMethod }), }); const createData = await createRes.json(); @@ -97,27 +151,23 @@ export function PaymentForm({ const orderId = createData.orderId as string; if (!orderId) throw new Error("No order ID returned"); + if (paymentMethod === "BANK_TRANSFER") { + setBankTransferOrder({ orderId }); + return; + } + 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"); } @@ -130,23 +180,143 @@ export function PaymentForm({ } }; + // Bank Transfer Confirmation Screen + if (bankTransferOrder) { + return ( +
+ + + + + + Transfer Details + + + +

+ Please transfer the exact amount below to complete your order. + Your order will be confirmed once we verify the transfer. +

+ +
+
+ Amount + + {formatPrice(total)} + +
+
+
+ Bank + + {bankTransfer?.bankName} + +
+
+ + Account Name + + + {bankTransfer?.accountName} + +
+
+ + Account Number + +
+ + {bankTransfer?.accountNumber} + + +
+
+
+
+ +

+ Use your order ID as the transfer narration:{" "} + + {bankTransferOrder.orderId.slice(0, 12).toUpperCase()} + +

+
+
+ +
+ + +
+
+ ); + } + return (
Payment - -
- + + {/* Card Payment */} + + + {/* Bank transfer remains hidden until owner-approved details are configured. */} + {bankTransfer &&
+ }
@@ -166,7 +336,14 @@ export function PaymentForm({ Back -