Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"
Expand Down
92 changes: 92 additions & 0 deletions .github/workflows/production-readiness.yml
Original file line number Diff line number Diff line change
@@ -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 <ci@example.test>
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
12 changes: 11 additions & 1 deletion app/account/orders/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ const statusColors: Record<string, string> = {
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<string, string> = {
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 {
Expand Down Expand Up @@ -178,7 +187,8 @@ export default async function OrderDetailPage({
</ol>
</div>
<p className="text-sm text-muted-foreground text-center mt-6">
{STATUS_META[order.status as OrderStatus]?.description}
{STATUS_META[order.status as OrderStatus]?.description ||
exceptionalStatusDescriptions[order.status]}
</p>
<p className="text-xs text-center text-muted-foreground mt-1">
Placed on{" "}
Expand Down
49 changes: 43 additions & 6 deletions app/account/orders/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,26 +25,44 @@ const statusColors: Record<string, string> = {

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 => {
Expand Down Expand Up @@ -172,6 +190,25 @@ export default async function OrdersPage() {
</CardContent>
</Card>
))}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-2">
<p className="text-sm text-muted-foreground">
Page {Math.min(page, totalPages)} of {totalPages}
</p>
<div className="flex gap-2">
{page > 1 && (
<Button asChild variant="outline" size="sm">
<Link href={`/account/orders?page=${page - 1}`}>Previous</Link>
</Button>
)}
{page < totalPages && (
<Button asChild variant="outline" size="sm">
<Link href={`/account/orders?page=${page + 1}`}>Next</Link>
</Button>
)}
</div>
</div>
)}
</div>
);
}
64 changes: 44 additions & 20 deletions app/admin/orders/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";

Expand All @@ -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<OrderStatus, string> = {
Expand All @@ -45,6 +51,12 @@ const statusClasses: Record<OrderStatus, string> = {
"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({
Expand All @@ -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,
Expand All @@ -95,27 +125,37 @@ export default async function OrderDetailPage({
})}
</p>
</div>
{transitionOptions.length > 0 && (
<form action={updateStatusAction} className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">Status</span>
<Select name="status" defaultValue={order.status}>
<Select name="status" defaultValue={transitionOptions[0]?.value}>
<SelectTrigger
className="h-9 w-[180px] text-xs"
aria-label="Order status"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
{transitionOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
name="reason"
required
minLength={3}
maxLength={500}
placeholder="Reason or fulfilment note"
className="h-9 w-64"
/>
<Button type="submit" size="sm">
Update
</Button>
</form>
)}
</div>

<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
Expand Down Expand Up @@ -202,22 +242,6 @@ export default async function OrderDetailPage({
<span className="font-mono">{order.coupon.code}</span>
</div>
)}
{order.paymentMethod === "BANK_TRANSFER" &&
order.status === "PENDING" && (
<form
action={updateStatusAction}
className="pt-3 border-t border-border mt-2"
>
<input type="hidden" name="status" value="PAID" />
<Button
type="submit"
size="sm"
className="w-full bg-success text-success-foreground hover:bg-success/90"
>
✓ Mark as Paid (Bank Transfer Received)
</Button>
</form>
)}
</CardContent>
</Card>

Expand Down
Loading
Loading