diff --git a/.env.example b/.env.example index e50ec40..98c96d0 100644 --- a/.env.example +++ b/.env.example @@ -79,3 +79,8 @@ CONCIERGE_MAX_OUTPUT_TOKENS="1400" CONCIERGE_DAILY_SPEND_USD="25" CONCIERGE_MONTHLY_SPEND_USD="300" CONCIERGE_CATALOGUE_ONLY="false" +# Authenticates the Vercel scheduled-publishing cron endpoint. +CRON_SECRET= +BLOB_READ_WRITE_TOKEN= +# Optional: force "blob" or "local". With no override, a Blob token selects Vercel Blob. +MEDIA_STORAGE= diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..0b6c529 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +## Summary + +Describe the customer or operator outcome and the main implementation choices. + +## Verification + +- [ ] Typecheck, lint, and relevant tests pass +- [ ] Database changes are additive and include migration/rollback notes +- [ ] Admin mutations are server-authorized and audited + +## Future feature parity + +- [ ] Every new public-facing feature includes its data model, admin interface, server-side validation and authorization, frontend rendering, preview/publication state where relevant, error handling, audit logging, tests, and a coverage-matrix update. + +If an item is intentionally not applicable, explain why in the PR description. diff --git a/.gitignore b/.gitignore index 0c5c2a2..7a3949a 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ prisma/dev.db-journal .claude .cursor .gstack/ + +# Runtime-uploaded media (local storage adapter) +public/uploads/ diff --git a/app/[...path]/page.tsx b/app/[...path]/page.tsx new file mode 100644 index 0000000..fbe3355 --- /dev/null +++ b/app/[...path]/page.tsx @@ -0,0 +1,15 @@ +import { notFound, permanentRedirect, redirect } from "next/navigation" +import { prisma } from "@/lib/prisma" + +export const dynamic = "force-dynamic" + +export default async function RedirectResolver({ params }: { params: Promise<{ path: string[] }> }) { + const source = `/${(await params).path.join("/")}` + let item = null + try { + item = await prisma.redirect.findUnique({ where: { source } }) + } catch {} + if (!item?.active) notFound() + if (item.permanent) permanentRedirect(item.destination) + redirect(item.destination) +} diff --git a/app/about/page.tsx b/app/about/page.tsx index fd17030..2f6db7e 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -3,13 +3,19 @@ import Link from "next/link" import { ArrowRight } from "lucide-react" import { MainLayout } from "@/components/layout/main-layout" +import { ManagedPage } from "@/components/pages/managed-page" +import { getManagedPageMetadata, getPublishedPage } from "@/lib/pages/queries" -export const metadata: Metadata = { +export const dynamic = "force-dynamic" + +const fallbackMetadata: Metadata = { title: "About Fádé", description: "Fádé is a Lagos house for rare and coveted perfumes, curated with care, described honestly, delivered nationwide.", } +export function generateMetadata() { return getManagedPageMetadata("about", fallbackMetadata) } + const PRINCIPLES = [ { title: "Sourced deliberately", @@ -29,7 +35,9 @@ const PRINCIPLES = [ }, ] -export default function AboutPage() { +export default async function AboutPage() { + const managed = await getPublishedPage("about") + if (managed) return return ( {/* Manifesto hero */} diff --git a/app/admin/audit/page.tsx b/app/admin/audit/page.tsx new file mode 100644 index 0000000..8e7709c --- /dev/null +++ b/app/admin/audit/page.tsx @@ -0,0 +1,251 @@ +import Link from "next/link" + +import { prisma } from "@/lib/prisma" +import { + AUDIT_PAGE_SIZE, + buildAuditWhere, + pageCount, + pageSkip, + parsePage, +} from "@/lib/audit/filters" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +export const dynamic = "force-dynamic" + +interface AdminAuditPageProps { + searchParams?: Promise<{ + actorId?: string + action?: string + targetType?: string + targetId?: string + page?: string + }> +} + +interface AuditRow { + id: string + actorId: string | null + actorRole: string | null + action: string + targetType: string + targetId: string | null + metadata: unknown + createdAt: Date +} + +function formatWhen(value: Date): string { + // ASCII-only, locale-stable. Example: 2026-07-20 14:03:22 + const iso = value.toISOString() + return `${iso.slice(0, 10)} ${iso.slice(11, 19)}` +} + +function metadataPreview(metadata: unknown): string { + if (metadata == null) return "" + try { + const s = JSON.stringify(metadata) + return s.length > 160 ? `${s.slice(0, 157)}...` : s + } catch { + return "" + } +} + +export default async function AdminAuditPage({ searchParams }: AdminAuditPageProps) { + const params = (await searchParams) ?? {} + const filters = { + actorId: params.actorId ?? "", + action: params.action ?? "", + targetType: params.targetType ?? "", + targetId: params.targetId ?? "", + } + const page = parsePage(params.page) + const where = buildAuditWhere(filters) + + let entries: AuditRow[] = [] + let total = 0 + let loadError = false + try { + const [rows, count] = await Promise.all([ + prisma.auditLog.findMany({ + where, + orderBy: { createdAt: "desc" }, + take: AUDIT_PAGE_SIZE, + skip: pageSkip(page), + }), + prisma.auditLog.count({ where }), + ]) + entries = rows as AuditRow[] + total = count + } catch { + loadError = true + } + + const totalPages = pageCount(total) + const currentPage = Math.min(page, totalPages) + + const pageHref = (target: number) => { + const sp = new URLSearchParams() + if (filters.actorId) sp.set("actorId", filters.actorId) + if (filters.action) sp.set("action", filters.action) + if (filters.targetType) sp.set("targetType", filters.targetType) + if (filters.targetId) sp.set("targetId", filters.targetId) + if (target > 1) sp.set("page", String(target)) + const qs = sp.toString() + return qs ? `/admin/audit?${qs}` : "/admin/audit" + } + + return ( +
+
+

Audit log

+

+ Append-only trail of every administrative change. Read-only: entries are written + automatically and cannot be edited or deleted from here. +

+
+ + + +
+ + + + +
+ + {(filters.action || filters.targetType || filters.targetId || filters.actorId) && ( + + )} +
+
+
+
+ + {loadError ? ( + + + The audit log could not be loaded. The audit table may not exist in this environment yet. + + + ) : ( + + +
+ + {total} {total === 1 ? "entry" : "entries"} + + + Page {currentPage} of {totalPages} + +
+ + + + When (UTC) + Action + Target + Actor + Details + + + + {entries.length === 0 ? ( + + + No audit entries match these filters. + + + ) : ( + entries.map((entry) => ( + + + {formatWhen(entry.createdAt)} + + + {entry.action} + + + {entry.targetType} + {entry.targetId ? ( + + {entry.targetId} + + ) : null} + + + {entry.actorRole ? ( + {entry.actorRole} + ) : ( + system + )} + {entry.actorId ? ( + + {entry.actorId} + + ) : null} + + + + {metadataPreview(entry.metadata)} + + + + )) + )} + +
+ + {totalPages > 1 && ( +
+ {currentPage > 1 ? ( + + ) : ( + + )} + {currentPage < totalPages ? ( + + ) : ( + + )} +
+ )} +
+
+ )} +
+ ) +} diff --git a/app/admin/campaigns/page.tsx b/app/admin/campaigns/page.tsx new file mode 100644 index 0000000..4980211 --- /dev/null +++ b/app/admin/campaigns/page.tsx @@ -0,0 +1,4 @@ +import { prisma } from "@/lib/prisma" +import { CampaignManager } from "@/components/admin/campaign-manager" +export const dynamic = "force-dynamic" +export default async function CampaignsPage() { let campaigns: any[] = [], coupons: any[] = [], products: { id: string; name: string }[] = []; try { [campaigns, coupons, products] = await Promise.all([prisma.campaign.findMany({ orderBy: { createdAt: "desc" } }), prisma.coupon.findMany({ orderBy: { createdAt: "desc" } }), prisma.product.findMany({ where: { deletedAt: null }, select: { id: true, name: true }, orderBy: { name: "asc" } })]) } catch {} return

Campaigns and discounts

Schedule storefront launches and manage promo codes.

} diff --git a/app/admin/collections/page.tsx b/app/admin/collections/page.tsx index 45742a4..6e3bef9 100644 --- a/app/admin/collections/page.tsx +++ b/app/admin/collections/page.tsx @@ -36,6 +36,8 @@ export default async function AdminCollectionsPage({ searchParams }: AdminCollec name: (formData.get("name") as string) ?? "", slug: (formData.get("slug") as string) ?? "", description: (formData.get("description") as string) || undefined, + seoTitle: (formData.get("seoTitle") as string) || undefined, + seoDescription: (formData.get("seoDescription") as string) || undefined, }) redirect("/admin/collections?success=created") @@ -51,6 +53,8 @@ export default async function AdminCollectionsPage({ searchParams }: AdminCollec name: (formData.get("name") as string) ?? "", slug: (formData.get("slug") as string) ?? "", description: (formData.get("description") as string) || undefined, + seoTitle: (formData.get("seoTitle") as string) || undefined, + seoDescription: (formData.get("seoDescription") as string) || undefined, }) redirect("/admin/collections?success=updated") @@ -121,6 +125,8 @@ export default async function AdminCollectionsPage({ searchParams }: AdminCollec placeholder="Short description shown in the UI." /> +
+
@@ -162,6 +168,8 @@ export default async function AdminCollectionsPage({ searchParams }: AdminCollec +
+
diff --git a/app/admin/customers/page.tsx b/app/admin/customers/page.tsx index 7a5f6cc..9c939be 100644 --- a/app/admin/customers/page.tsx +++ b/app/admin/customers/page.tsx @@ -1,282 +1,10 @@ -import { Search, MoreHorizontal, Mail, Ban } from "lucide-react"; - -import { Button } from "@/components/ui/button"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; - -import { Input } from "@/components/ui/input"; - -import { Badge } from "@/components/ui/badge"; - -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; - -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; - -const customers = [ - { - id: "1", - - name: "John Doe", - - email: "john@example.com", - - orders: 12, - - totalSpent: 25000000, - - status: "active", - - joinDate: "2024-01-01", - }, - - { - id: "2", - - name: "Sarah Johnson", - - email: "sarah@example.com", - - orders: 8, - - totalSpent: 4500000, - - status: "active", - - joinDate: "2024-01-05", - }, - - { - id: "3", - - name: "Michael Adeyemi", - - email: "michael@example.com", - - orders: 5, - - totalSpent: 15000000, - - status: "active", - - joinDate: "2024-01-10", - }, - - { - id: "4", - - name: "Aisha Mohammed", - - email: "aisha@example.com", - - orders: 3, - - totalSpent: 750000, - - status: "inactive", - - joinDate: "2024-01-15", - }, - - { - id: "5", - - name: "Chidi Okonkwo", - - email: "chidi@example.com", - - orders: 15, - - totalSpent: 35000000, - - status: "active", - - joinDate: "2023-12-01", - }, -]; - -export default function AdminCustomersPage() { - const formatPrice = (amount: number) => { - return new Intl.NumberFormat("en-NG", { - style: "currency", - - currency: "NGN", - - minimumFractionDigits: 0, - }).format(amount); - }; - - return ( -
-
-

- Customers -

- -

- View and manage your customer base -

-
- - - -
- All Customers - -
- - - -
-
-
- - -
- - - - Customer - - Orders - - Total Spent - - Status - - Joined - - - - - - - {customers.map((customer) => ( - - -
- - - {customer.name - - .split(" ") - - .map((n) => n[0]) - - .join("")} - - - -
-

{customer.name}

- -

- {customer.email} -

-
-
-
- - {customer.orders} - - {formatPrice(customer.totalSpent)} - - - - {customer.status} - - - - - {new Date(customer.joinDate).toLocaleDateString("en-NG", { - month: "short", - - day: "numeric", - - year: "numeric", - })} - - - - - - - - - - - - Send Email - - - - - Suspend - - - - -
- ))} -
-
-
- - {/* Pagination */} - -
-

- Showing 1-5 of 5 customers -

- -
- - - -
-
-
-
-
- ); +import { prisma } from "@/lib/prisma" +import { CustomerManager } from "@/components/admin/customer-manager" +export const dynamic = "force-dynamic" +export default async function CustomersPage({ searchParams }: { searchParams: Promise<{ q?: string; segment?: string }> }) { + const { q = "", segment = "" } = await searchParams + let users: any[] = [] + try { users = await prisma.user.findMany({ where: { role: "USER", ...(segment ? { customerSegment: segment } : {}), ...(q ? { OR: [{ name: { contains: q, mode: "insensitive" } }, { email: { contains: q, mode: "insensitive" } }, { customerTags: { contains: q, mode: "insensitive" } }] } : {}) }, include: { orders: { select: { totalNGN: true } } }, orderBy: { createdAt: "desc" }, take: 200 }) } catch {} + const customers = users.map((user) => ({ id: user.id, name: user.name, email: user.email, tags: user.customerTags, notes: user.customerNotes, segment: user.customerSegment, status: user.customerStatus, createdAt: user.createdAt, orderCount: user.orders.length, totalSpent: user.orders.reduce((sum: number, order: { totalNGN: number }) => sum + order.totalNGN, 0) })) + return } diff --git a/app/admin/email-templates/page.tsx b/app/admin/email-templates/page.tsx new file mode 100644 index 0000000..0026b63 --- /dev/null +++ b/app/admin/email-templates/page.tsx @@ -0,0 +1,5 @@ +import { prisma } from "@/lib/prisma" +import { EMAIL_TEMPLATE_CATALOGUE } from "@/lib/email-templates/service" +import { EmailTemplateManager } from "@/components/admin/email-template-manager" +export const dynamic = "force-dynamic" +export default async function EmailTemplatesPage() { let templates: Awaited> = []; try { templates = await prisma.emailTemplate.findMany() } catch {} return

Transactional email templates

Safe plain-text overrides with a fixed variable allowlist. Disabled templates use the built-in design.

} diff --git a/app/admin/enquiries/export/route.ts b/app/admin/enquiries/export/route.ts new file mode 100644 index 0000000..f4205c5 --- /dev/null +++ b/app/admin/enquiries/export/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { exportFormSubmissions, isFormStatus } from "@/lib/forms/submissions" + +export async function GET(request: NextRequest) { + const authz = await getAuthorizedUser("support:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const rawStatus = request.nextUrl.searchParams.get("status") + const csv = await exportFormSubmissions({ query: request.nextUrl.searchParams.get("q") ?? undefined, status: isFormStatus(rawStatus) ? rawStatus : undefined }) + return new NextResponse(csv, { headers: { "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": `attachment; filename="enquiries-${new Date().toISOString().slice(0, 10)}.csv"` } }) +} diff --git a/app/admin/enquiries/page.tsx b/app/admin/enquiries/page.tsx new file mode 100644 index 0000000..05908d0 --- /dev/null +++ b/app/admin/enquiries/page.tsx @@ -0,0 +1,13 @@ +import { EnquiriesInbox } from "@/components/admin/enquiries-inbox" +import { isFormStatus, listFormSubmissions } from "@/lib/forms/submissions" + +export const dynamic = "force-dynamic" + +export default async function EnquiriesPage({ searchParams }: { searchParams: Promise<{ q?: string; status?: string }> }) { + const params = await searchParams + let result: Awaited> = { submissions: [], counts: {} } + let error = false + try { result = await listFormSubmissions({ query: params.q, status: isFormStatus(params.status) ? params.status : undefined }) } catch { error = true } + if (error) return

Enquiries

The FormSubmission table is unavailable. Apply the authored migration to a development database before verification.

+ return +} diff --git a/app/admin/feature-flags/page.tsx b/app/admin/feature-flags/page.tsx new file mode 100644 index 0000000..83d106a --- /dev/null +++ b/app/admin/feature-flags/page.tsx @@ -0,0 +1,144 @@ +import { prisma } from "@/lib/prisma" +import { allFlags, flagDefault, flagKeys, flagSource } from "@/lib/config/feature-flags" +import { + FLAG_META, + GROUP_LABELS, + GROUP_ORDER, + flagMeta, + type FlagGroup, +} from "@/lib/config/feature-flag-meta" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" + +export const dynamic = "force-dynamic" + +const SOURCE_LABEL: Record, string> = { + "enabled-by-env": "Enabled by FEATURE_FLAGS", + "disabled-by-env": "Disabled by FEATURE_FLAGS", + default: "Built-in default", +} + +export default async function AdminFeatureFlagsPage() { + const effective = allFlags() + + // Informational only: the resolver reads the FEATURE_FLAGS env var, not this table, so any rows + // here are surfaced honestly as "not consulted" rather than shown as live toggles. + let persisted: { key: string; enabled: boolean; description: string | null }[] = [] + try { + persisted = await prisma.featureFlag.findMany({ + select: { key: true, enabled: true, description: true }, + orderBy: { key: "asc" }, + }) + } catch { + persisted = [] + } + + const keys = flagKeys() + const grouped = GROUP_ORDER.map((group) => ({ + group, + flags: keys.filter((k) => flagMeta(k).group === group), + })).filter((g) => g.flags.length > 0) + + return ( +
+
+

Feature flags

+

+ Read-only view of every storefront feature flag and its effective state. Flags are + controlled by the FEATURE_FLAGS{" "} + environment variable so behaviour stays reproducible across deploys. Financial and + approval-gated flags are owner-controlled and are never toggled from the admin panel. +

+
+ + {grouped.map(({ group, flags }) => ( + ({ + key, + meta: FLAG_META[key], + enabled: effective[key], + isDefault: flagDefault(key), + source: flagSource(key), + }))} + /> + ))} + + {persisted.length > 0 && ( + + + Persisted rows in the database + + These FeatureFlag rows + exist in the database but are not consulted by the flag resolver. + They are shown for transparency only. Change behaviour via the FEATURE_FLAGS env var. + + + + {persisted.map((row) => ( +
+ {row.key} + {row.enabled ? "row: on" : "row: off"} +
+ ))} +
+
+ )} +
+ ) +} + +interface FlagView { + key: string + meta: (typeof FLAG_META)[keyof typeof FLAG_META] + enabled: boolean + isDefault: boolean + source: ReturnType +} + +function FlagGroupCard({ group, flags }: { group: FlagGroup; flags: FlagView[] }) { + return ( + + + {GROUP_LABELS[group]} + + + {flags.map((flag) => ( +
+
+
+ {flag.meta.label} + + {flag.key} + + {flag.meta.ownerControlled && ( + + Owner-controlled + + )} +
+

{flag.meta.description}

+

+ Default {flag.isDefault ? "on" : "off"} · {SOURCE_LABEL[flag.source]} +

+
+
+ {flag.enabled ? ( + Enabled + ) : ( + Disabled + )} +
+
+ ))} +
+
+ ) +} diff --git a/app/admin/homepage/page.tsx b/app/admin/homepage/page.tsx new file mode 100644 index 0000000..1b9d4c8 --- /dev/null +++ b/app/admin/homepage/page.tsx @@ -0,0 +1,27 @@ +import { getHomepageLayout } from "@/lib/homepage/service" +import { HOMEPAGE_SECTIONS } from "@/lib/homepage/registry" +import { HomepageEditor } from "@/components/admin/homepage-editor" + +export const dynamic = "force-dynamic" + +export default async function AdminHomepagePage() { + let sections: Awaited> + try { + sections = await getHomepageLayout() + } catch { + sections = [] + } + + return ( +
+
+

Homepage

+

+ Reorder sections, show or hide them, and edit the copy. Leave a field blank to keep the + built-in default. Products and fragrance families are managed elsewhere. +

+
+ +
+ ) +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index cfd1dbd..cd30750 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -1,10 +1,19 @@ import type React from "react"; import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; import { AdminSidebar } from "@/components/admin/admin-sidebar"; import { AdminHeader } from "@/components/admin/admin-header"; import { requireAdmin } from "@/lib/admin"; +import { + capabilityForPath, + capabilitiesFor, + hasCapability, + resolveRole, + ROLE_LABELS, +} from "@/lib/authz"; export const metadata: Metadata = { title: "Fádé Admin Dashboard", @@ -16,14 +25,25 @@ export default async function AdminLayout({ }: { children: React.ReactNode; }) { - // Restrict all admin routes to authenticated ADMIN users + // Restrict all admin routes to authenticated ADMIN users. const user = await requireAdmin(); + const role = resolveRole(user); + + // Enforce the capability required by the page being viewed (single choke point for every admin + // page). The pathname is supplied by middleware via the x-pathname header. + const pathname = (await headers()).get("x-pathname") || "/admin"; + const requiredCapability = capabilityForPath(pathname); + if (!role || !hasCapability(role, requiredCapability)) { + redirect("/admin?error=forbidden"); + } + + const capabilities = capabilitiesFor(role); return (
- +
- +
{children}
diff --git a/app/admin/media/page.tsx b/app/admin/media/page.tsx new file mode 100644 index 0000000..beafd0e --- /dev/null +++ b/app/admin/media/page.tsx @@ -0,0 +1,27 @@ +import { listMedia } from "@/lib/media/service" +import { MediaLibrary } from "@/components/admin/media-library" + +export const dynamic = "force-dynamic" + +export default async function AdminMediaPage() { + let assets: Awaited> = [] + let loadError = false + try { + assets = await listMedia() + } catch { + loadError = true + } + + return ( +
+
+

Media library

+

+ Upload images and videos, or register existing URLs. Reuse assets across products, + stories and the homepage. Referenced assets are protected from deletion. +

+
+ +
+ ) +} diff --git a/app/admin/navigation/page.tsx b/app/admin/navigation/page.tsx new file mode 100644 index 0000000..4aab908 --- /dev/null +++ b/app/admin/navigation/page.tsx @@ -0,0 +1,27 @@ +import { getNavigationForAdmin } from "@/lib/navigation/service" +import { NAV_LOCATIONS } from "@/lib/navigation/defaults" +import { NavigationEditor } from "@/components/admin/navigation-editor" + +export const dynamic = "force-dynamic" + +export default async function AdminNavigationPage() { + let menus: Awaited> + try { + menus = await getNavigationForAdmin() + } catch { + menus = {} as Awaited> + } + + return ( +
+
+

Navigation

+

+ Manage the header and footer menus. Empty menus fall back to the built-in defaults until + you add items. +

+
+ +
+ ) +} diff --git a/app/admin/orders/export/route.ts b/app/admin/orders/export/route.ts index de0a33f..57d55bf 100644 --- a/app/admin/orders/export/route.ts +++ b/app/admin/orders/export/route.ts @@ -1,13 +1,16 @@ // app/admin/orders/export/route.ts import { NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' -import { requireAdmin } from '@/lib/admin' +import { getAuthorizedUser } from '@/lib/authz' import { OrderStatus } from '@prisma/client' export const runtime = 'nodejs' export async function GET(req: Request) { - await requireAdmin() + const authz = await getAuthorizedUser('orders:view') + if (!authz.ok) { + return new Response(authz.status === 403 ? 'Forbidden' : 'Unauthorized', { status: authz.status }) + } const url = new URL(req.url) const q = (url.searchParams.get('q') || '').trim() const status = (url.searchParams.get('status') || '') as OrderStatus | '' diff --git a/app/admin/pages/[id]/edit/page.tsx b/app/admin/pages/[id]/edit/page.tsx new file mode 100644 index 0000000..8acf081 --- /dev/null +++ b/app/admin/pages/[id]/edit/page.tsx @@ -0,0 +1,22 @@ +import { notFound } from "next/navigation" +import { getPageForAdmin } from "@/lib/pages/service" +import { PageEditor, type PageEditorInitial } from "@/components/admin/page-editor" + +function localDate(value: Date | null) { + if (!value) return "" + const offset = value.getTimezoneOffset() * 60_000 + return new Date(value.getTime() - offset).toISOString().slice(0, 16) +} + +export default async function EditPage({ params }: { params: Promise<{ id: string }> }) { + let page + try { page = await getPageForAdmin((await params).id) } catch { notFound() } + const initial: PageEditorInitial = { + id: page.id, updatedAt: page.updatedAt.toISOString(), slug: page.slug, title: page.title, + eyebrow: page.eyebrow ?? "", excerpt: page.excerpt ?? "", seoTitle: page.seoTitle ?? "", + seoDescription: page.seoDescription ?? "", status: page.status, + scheduledFor: localDate(page.scheduledFor), unpublishAt: localDate(page.unpublishAt), + blocks: page.blocks.map((block) => ({ type: block.type as PageEditorInitial["blocks"][number]["type"], data: block.data as Record })), + } + return +} diff --git a/app/admin/pages/new/page.tsx b/app/admin/pages/new/page.tsx new file mode 100644 index 0000000..b513d5c --- /dev/null +++ b/app/admin/pages/new/page.tsx @@ -0,0 +1,5 @@ +import { PageEditor, type PageEditorInitial } from "@/components/admin/page-editor" + +const EMPTY: PageEditorInitial = { slug: "", title: "", eyebrow: "", excerpt: "", seoTitle: "", seoDescription: "", status: "DRAFT", scheduledFor: "", unpublishAt: "", blocks: [] } + +export default function NewPage() { return } diff --git a/app/admin/pages/page.tsx b/app/admin/pages/page.tsx new file mode 100644 index 0000000..c8f9566 --- /dev/null +++ b/app/admin/pages/page.tsx @@ -0,0 +1,32 @@ +import Link from "next/link" +import { Pencil, Plus } from "lucide-react" +import { listPages } from "@/lib/pages/service" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { PageRowActions } from "@/components/admin/page-row-actions" + +export const dynamic = "force-dynamic" + +export default async function AdminPagesPage() { + let pages: Awaited> = [] + let loadError = false + try { pages = await listPages(true) } catch { loadError = true } + return ( +
+
+

Pages

Manage about, FAQ, and help content. Privacy and terms remain source-controlled.

+ +
+ {loadError && The Page tables are unavailable. Storefront routes continue to use their built-in content until the migration is applied and pages are published.} + Managed routes + {pages.length === 0 ?

No managed pages yet. Run the page seed after applying the migration, or create one here.

:
{pages.map((page) =>
+
{page.title}

/{page.slug} · {page._count.blocks} blocks

+ {page.status} + {!page.deletedAt && } + +
)}
} +
+
+ ) +} diff --git a/app/admin/products/[id]/page.tsx b/app/admin/products/[id]/page.tsx index 87de13a..88a3390 100644 --- a/app/admin/products/[id]/page.tsx +++ b/app/admin/products/[id]/page.tsx @@ -52,6 +52,12 @@ export default async function EditProductPage({ params }: EditProductPageProps) images, collectionId: product.collectionId, fragranceFamily: product.fragranceFamily, + sku: product.sku, barcode: product.barcode, launchYear: product.launchYear, perfumer: product.perfumer, countryOfOrigin: product.countryOfOrigin, + concentration: product.concentration, longevity: product.longevity, sillage: product.sillage, intensity: product.intensity, sprayGuidance: product.sprayGuidance, + climate: product.climate, season: product.season, timeOfDay: product.timeOfDay, occasion: product.occasion, + weightGrams: product.weightGrams, shippingClass: product.shippingClass, reorderPoint: product.reorderPoint, dropDate: product.dropDate ? product.dropDate.toISOString().slice(0,16) : null, + seoTitle: product.seoTitle, seoDescription: product.seoDescription, publishStatus: product.publishStatus, + beginnerFriendly: Boolean(product.beginnerFriendly), returnEligible: product.returnEligible, isPreorder: product.isPreorder, isWaitlist: product.isWaitlist, } async function handleUpdate(formData: FormData) { diff --git a/app/admin/redirects/page.tsx b/app/admin/redirects/page.tsx new file mode 100644 index 0000000..a1fc55a --- /dev/null +++ b/app/admin/redirects/page.tsx @@ -0,0 +1,4 @@ +import { prisma } from "@/lib/prisma" +import { RedirectManager } from "@/components/admin/redirect-manager" +export const dynamic = "force-dynamic" +export default async function RedirectsPage() { let redirects: Awaited> = []; try { redirects = await prisma.redirect.findMany({ orderBy: { source: "asc" } }) } catch {} return

SEO redirects

Preserve links and search equity when public URLs change.

} diff --git a/app/admin/reviews/page.tsx b/app/admin/reviews/page.tsx new file mode 100644 index 0000000..6ab99b6 --- /dev/null +++ b/app/admin/reviews/page.tsx @@ -0,0 +1,234 @@ +import Link from "next/link" +import type { ModerationStatus } from "@prisma/client" +import { Star, ShieldAlert } from "lucide-react" + +import { prisma } from "@/lib/prisma" +import { cn } from "@/lib/utils" +import { Card, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { ReviewModerationActions } from "@/components/admin/review-moderation-actions" +import { MODERATION_TABS as TABS, normaliseStatus } from "@/lib/reviews/moderation" + +export const dynamic = "force-dynamic" + +interface AdminReviewsPageProps { + searchParams?: Promise<{ status?: string }> +} + +function formatWhen(value: Date): string { + const iso = value.toISOString() + return `${iso.slice(0, 10)} ${iso.slice(11, 16)}` +} + +function Stars({ rating }: { rating: number }) { + const safe = Math.max(0, Math.min(5, rating)) + return ( + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + ) +} + +export default async function AdminReviewsPage({ searchParams }: AdminReviewsPageProps) { + const params = (await searchParams) ?? {} + const status = normaliseStatus(params.status) + + let reviews: Array<{ + id: string + rating: number + comment: string | null + verifiedPurchase: boolean + reportedCount: number + createdAt: Date + moderationStatus: ModerationStatus + displayName: string | null + product: { name: string; slug: string } | null + user: { name: string | null; email: string } | null + }> = [] + let counts: Record = { PENDING: 0, APPROVED: 0, REJECTED: 0 } + let loadError = false + + try { + const [rows, grouped] = await Promise.all([ + prisma.review.findMany({ + where: { moderationStatus: status }, + orderBy: [{ reportedCount: "desc" }, { createdAt: "desc" }], + take: 100, + select: { + id: true, + rating: true, + comment: true, + verifiedPurchase: true, + reportedCount: true, + createdAt: true, + moderationStatus: true, + displayName: true, + product: { select: { name: true, slug: true } }, + user: { select: { name: true, email: true } }, + }, + }), + prisma.review.groupBy({ by: ["moderationStatus"], _count: { _all: true } }), + ]) + reviews = rows + for (const g of grouped) { + counts[g.moderationStatus] = g._count._all + } + } catch { + loadError = true + } + + return ( +
+
+

Reviews

+

+ Moderate customer reviews. Approved reviews appear on the product page; rejected reviews + stay hidden. Every decision is recorded in the audit log. +

+
+ +
+ {TABS.map((tab) => { + const active = tab.value === status + return ( + + {tab.label} + + {counts[tab.value]} + + + ) + })} +
+ + {loadError ? ( + + + Reviews could not be loaded. The reviews table may not exist in this environment yet. + + + ) : ( + + + + + + Review + Product + Reviewer + Submitted + Moderate + + + + {reviews.length === 0 ? ( + + + No {status.toLowerCase()} reviews. + + + ) : ( + reviews.map((review) => ( + + +
+ + {review.verifiedPurchase && ( + + Verified + + )} + {review.reportedCount > 0 && ( + + + {review.reportedCount} reported + + )} +
+ {review.comment ? ( +

+ {review.comment} +

+ ) : ( +

+ (no written comment) +

+ )} +
+ + {review.product ? ( + + {review.product.name} + + ) : ( + unknown + )} + + + + {review.displayName || review.user?.name || "Anonymous"} + + {review.user?.email && ( + + {review.user.email} + + )} + + + {formatWhen(review.createdAt)} + + + + +
+ )) + )} +
+
+
+
+ )} +
+ ) +} diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx new file mode 100644 index 0000000..e54945a --- /dev/null +++ b/app/admin/settings/page.tsx @@ -0,0 +1,27 @@ +import { getSiteSettings } from "@/lib/settings/service" +import { SETTINGS } from "@/lib/settings/schema" +import { SettingsForm } from "@/components/admin/settings-form" + +export const dynamic = "force-dynamic" + +export default async function AdminSettingsPage() { + let values: Awaited> + try { + values = await getSiteSettings() + } catch { + values = {} + } + + return ( +
+
+

Site settings

+

+ Brand, SEO defaults, announcement bar, social links, footer and contact details used + across the storefront. +

+
+ +
+ ) +} diff --git a/app/admin/stories/[id]/edit/page.tsx b/app/admin/stories/[id]/edit/page.tsx new file mode 100644 index 0000000..8cdb18d --- /dev/null +++ b/app/admin/stories/[id]/edit/page.tsx @@ -0,0 +1,76 @@ +import { notFound } from "next/navigation" + +import { prisma } from "@/lib/prisma" +import { getStoryForAdmin, StoryServiceError } from "@/lib/stories/service" +import { + StoryEditor, + type StoryEditorInitial, + type EditorBlock, +} from "@/components/admin/story-editor" + +export const dynamic = "force-dynamic" + +/** Format a Date to the value a expects (local time). */ +function toLocalInput(date: Date | null): string { + if (!date) return "" + const pad = (n: number) => String(n).padStart(2, "0") + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad( + date.getHours() + )}:${pad(date.getMinutes())}` +} + +export default async function EditStoryPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + + let story + try { + story = await getStoryForAdmin(id) + } catch (error) { + if (error instanceof StoryServiceError && error.code === "NOT_FOUND") notFound() + throw error + } + + let products: { id: string; slug: string; name: string }[] = [] + try { + products = await prisma.product.findMany({ + where: { deletedAt: null }, + select: { id: true, slug: true, name: true }, + orderBy: { name: "asc" }, + }) + } catch { + products = [] + } + + const initial: StoryEditorInitial = { + id: story.id, + updatedAt: story.updatedAt.toISOString(), + slug: story.slug, + title: story.title, + subtitle: story.subtitle ?? "", + excerpt: story.excerpt ?? "", + category: story.category ?? "", + readTime: story.readTime ?? "", + author: story.author ?? "", + tags: story.tags ?? "", + coverImageUrl: story.coverImageUrl ?? "", + mobileCoverUrl: story.mobileCoverUrl ?? "", + socialImageUrl: story.socialImageUrl ?? "", + seoTitle: story.seoTitle ?? "", + seoDescription: story.seoDescription ?? "", + featured: story.featured, + position: story.position, + status: story.status as StoryEditorInitial["status"], + scheduledFor: toLocalInput(story.scheduledFor), + unpublishAt: toLocalInput(story.unpublishAt), + blocks: story.blocks.map( + (b): EditorBlock => ({ type: b.type as EditorBlock["type"], data: (b.data ?? {}) as Record }) + ), + relatedProductIds: story.relatedProducts.map((rp) => rp.productId), + } + + return +} diff --git a/app/admin/stories/new/page.tsx b/app/admin/stories/new/page.tsx new file mode 100644 index 0000000..cbd6900 --- /dev/null +++ b/app/admin/stories/new/page.tsx @@ -0,0 +1,42 @@ +import { prisma } from "@/lib/prisma" +import { StoryEditor, type StoryEditorInitial } from "@/components/admin/story-editor" + +export const dynamic = "force-dynamic" + +const EMPTY: StoryEditorInitial = { + slug: "", + title: "", + subtitle: "", + excerpt: "", + category: "", + readTime: "", + author: "", + tags: "", + coverImageUrl: "", + mobileCoverUrl: "", + socialImageUrl: "", + seoTitle: "", + seoDescription: "", + featured: false, + position: 0, + status: "DRAFT", + scheduledFor: "", + unpublishAt: "", + blocks: [], + relatedProductIds: [], +} + +export default async function NewStoryPage() { + let products: { id: string; slug: string; name: string }[] = [] + try { + products = await prisma.product.findMany({ + where: { deletedAt: null }, + select: { id: true, slug: true, name: true }, + orderBy: { name: "asc" }, + }) + } catch { + products = [] + } + + return +} diff --git a/app/admin/stories/page.tsx b/app/admin/stories/page.tsx new file mode 100644 index 0000000..b840173 --- /dev/null +++ b/app/admin/stories/page.tsx @@ -0,0 +1,140 @@ +import Link from "next/link" +import { Plus, Pencil } from "lucide-react" + +import { listStories } from "@/lib/stories/service" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { StoryRowActions } from "@/components/admin/story-row-actions" + +export const dynamic = "force-dynamic" + +function statusVariant(status: string): "default" | "secondary" | "outline" { + if (status === "PUBLISHED") return "default" + if (status === "ARCHIVED") return "outline" + return "secondary" +} + +export default async function AdminStoriesPage() { + let stories: Awaited> = [] + let loadError = false + try { + stories = await listStories({ includeDeleted: true }) + } catch { + loadError = true + } + + const active = stories.filter((s) => !s.deletedAt) + const trashed = stories.filter((s) => s.deletedAt) + + return ( +
+
+
+

Journal

+

+ Create and publish editorial stories shown at /journal. +

+
+ +
+ + {loadError && ( + + + The Story tables could not be reached. If this environment has not yet had the + story_cms migration applied, the public Journal will keep + using the built-in articles until it is. + + + )} + + + + Stories + + + {active.length === 0 ? ( +

+ No stories yet. Create your first story, or run the seed script to import the existing + journal articles. +

+ ) : ( +
+ {active.map((story) => ( +
+
+
+ + {story.title} + + {story.featured && Featured} +
+

+ /{story.slug} · {story._count.blocks} blocks ·{" "} + {story._count.relatedProducts} linked products + {story.scheduledFor && story.status === "DRAFT" + ? ` · scheduled ${new Date(story.scheduledFor).toLocaleString("en-NG")}` + : ""} +

+
+ {story.status} + + +
+ ))} +
+ )} +
+
+ + {trashed.length > 0 && ( + + + Trash + + +
+ {trashed.map((story) => ( +
+
+

+ {story.title} +

+

/{story.slug}

+
+ +
+ ))} +
+
+
+ )} +
+ ) +} diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index 2037287..39102ca 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -1,11 +1,17 @@ import { prisma } from '@/lib/prisma' import { requireAdmin } from '@/lib/admin' import { User, Mail, Calendar } from 'lucide-react' +import { UserRoleSelect } from '@/components/admin/user-role-select' export const dynamic = 'force-dynamic' +function effectiveRole(role: string, adminRole: string | null): string { + if (role !== 'ADMIN') return 'none' + return adminRole ?? 'SUPER_ADMIN' +} + export default async function AdminUsersPage() { - await requireAdmin() + const me = await requireAdmin() const users = await prisma.user.findMany({ orderBy: { createdAt: 'desc' }, @@ -13,6 +19,8 @@ export default async function AdminUsersPage() { id: true, name: true, email: true, + role: true, + adminRole: true, createdAt: true, _count: { select: { @@ -50,6 +58,7 @@ export default async function AdminUsersPage() { Email Orders Joined + Admin role @@ -81,6 +90,13 @@ export default async function AdminUsersPage() { {new Date(user.createdAt).toLocaleDateString()} + + + ))} diff --git a/app/api/admin/campaigns/route.ts b/app/api/admin/campaigns/route.ts new file mode 100644 index 0000000..5374b74 --- /dev/null +++ b/app/api/admin/campaigns/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { prisma } from "@/lib/prisma" +import { saveCampaign, saveCoupon } from "@/lib/campaigns/service" + +export async function GET() { + const authz = await getAuthorizedUser("marketing:manage"); if (!authz.ok) return NextResponse.json({ error: "Forbidden" }, { status: authz.status }) + const [campaigns, coupons, products] = await Promise.all([prisma.campaign.findMany({ include: { products: true }, orderBy: { createdAt: "desc" } }), prisma.coupon.findMany({ orderBy: { createdAt: "desc" } }), prisma.product.findMany({ where: { deletedAt: null }, select: { id: true, name: true } })]) + return NextResponse.json({ campaigns, coupons, products }) +} +export async function POST(request: NextRequest) { + const authz = await getAuthorizedUser("marketing:manage"); if (!authz.ok) return NextResponse.json({ error: "Forbidden" }, { status: authz.status }) + try { const body = await request.json(); const actor = { actorId: authz.user.id, actorRole: authz.user.role }; return NextResponse.json(body.kind === "coupon" ? { coupon: await saveCoupon(body, actor) } : { campaign: await saveCampaign(body, actor) }) } catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "Save failed" }, { status: 400 }) } +} diff --git a/app/api/admin/categories/[id]/route.ts b/app/api/admin/categories/[id]/route.ts index a60ef42..5e98db1 100644 --- a/app/api/admin/categories/[id]/route.ts +++ b/app/api/admin/categories/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { prisma } from "@/lib/prisma" -import { requireAdmin } from "@/lib/admin" +import { getAuthorizedUser } from "@/lib/authz" import { ProductCategory } from "@prisma/client" export const runtime = "nodejs" @@ -11,7 +11,8 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> } ) { try { - await requireAdmin() + const authz = await getAuthorizedUser("products:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) const { id } = await params const { name, slug, description, enumKey } = await request.json() @@ -49,7 +50,8 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> } ) { try { - await requireAdmin() + const authz = await getAuthorizedUser("products:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) const { id } = await params const category = await prisma.category.findUnique({ diff --git a/app/api/admin/categories/route.ts b/app/api/admin/categories/route.ts index 65c75f9..34a1625 100644 --- a/app/api/admin/categories/route.ts +++ b/app/api/admin/categories/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { prisma } from "@/lib/prisma" -import { requireAdmin } from "@/lib/admin" +import { getAuthorizedUser } from "@/lib/authz" import { ProductCategory } from "@prisma/client" export const runtime = "nodejs" @@ -8,7 +8,8 @@ export const runtime = "nodejs" // GET - List all categories export async function GET() { try { - await requireAdmin() + const authz = await getAuthorizedUser("products:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) const categories = await prisma.category.findMany({ orderBy: { name: "asc" }, @@ -36,7 +37,8 @@ export async function GET() { // POST - Create category export async function POST(request: NextRequest) { try { - await requireAdmin() + const authz = await getAuthorizedUser("products:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) const { name, slug, description, enumKey } = await request.json() diff --git a/app/api/admin/customers/[id]/route.ts b/app/api/admin/customers/[id]/route.ts new file mode 100644 index 0000000..35d2d4b --- /dev/null +++ b/app/api/admin/customers/[id]/route.ts @@ -0,0 +1,6 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { prisma } from "@/lib/prisma" +import { sanitizeText } from "@/lib/stories/util" +import { writeAudit } from "@/lib/audit" +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const authz = await getAuthorizedUser("support:manage"); if (!authz.ok) return NextResponse.json({ error: "Forbidden" }, { status: authz.status }); const body = await request.json(); const id = (await params).id; const customer = await prisma.user.update({ where: { id }, data: { customerTags: sanitizeText(body.tags).slice(0, 500) || null, customerNotes: sanitizeText(body.notes).slice(0, 4000) || null, customerSegment: sanitizeText(body.segment).slice(0, 100) || null } }); await writeAudit({ actorId: authz.user.id, actorRole: authz.user.role, action: "customer.update", targetType: "User", targetId: id, metadata: { tagsChanged: true, notesChanged: true } }); return NextResponse.json({ customer: { id: customer.id } }) } diff --git a/app/api/admin/email-templates/route.ts b/app/api/admin/email-templates/route.ts new file mode 100644 index 0000000..f9a2d91 --- /dev/null +++ b/app/api/admin/email-templates/route.ts @@ -0,0 +1,8 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { prisma } from "@/lib/prisma" +import { EMAIL_TEMPLATE_CATALOGUE } from "@/lib/email-templates/service" +import { sanitizeText } from "@/lib/stories/util" +import { writeAudit } from "@/lib/audit" +export async function GET() { const authz = await getAuthorizedUser("settings:manage"); if (!authz.ok) return NextResponse.json({ error: "Forbidden" }, { status: authz.status }); return NextResponse.json({ catalogue: EMAIL_TEMPLATE_CATALOGUE, templates: await prisma.emailTemplate.findMany() }) } +export async function POST(request: NextRequest) { const authz = await getAuthorizedUser("settings:manage"); if (!authz.ok) return NextResponse.json({ error: "Forbidden" }, { status: authz.status }); const body = await request.json(); const def = EMAIL_TEMPLATE_CATALOGUE.find((item) => item.key === body.key); if (!def) return NextResponse.json({ error: "Unknown template" }, { status: 400 }); const subject = sanitizeText(body.subject).slice(0, 200); const bodyText = typeof body.bodyText === "string" ? body.bodyText.replace(/<[^>]*>/g, "").slice(0, 10000).trim() : ""; if (!subject || !bodyText) return NextResponse.json({ error: "Subject and body are required" }, { status: 400 }); const template = await prisma.emailTemplate.upsert({ where: { key: def.key }, create: { key: def.key, name: def.name, subject, bodyText, enabled: Boolean(body.enabled), updatedBy: authz.user.id }, update: { subject, bodyText, enabled: Boolean(body.enabled), updatedBy: authz.user.id } }); await writeAudit({ actorId: authz.user.id, actorRole: authz.user.role, action: "email_template.update", targetType: "EmailTemplate", targetId: template.id, metadata: { key: template.key, enabled: template.enabled } }); return NextResponse.json({ template }) } diff --git a/app/api/admin/enquiries/[id]/route.ts b/app/api/admin/enquiries/[id]/route.ts new file mode 100644 index 0000000..8ad5b75 --- /dev/null +++ b/app/api/admin/enquiries/[id]/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { updateFormSubmission } from "@/lib/forms/submissions" + +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const authz = await getAuthorizedUser("support:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + try { + const submission = await updateFormSubmission((await params).id, await request.json(), { actorId: authz.user.id, actorRole: authz.user.role }) + return NextResponse.json({ submission }) + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to update enquiry" + return NextResponse.json({ error: message }, { status: message === "Submission not found" ? 404 : 400 }) + } +} diff --git a/app/api/admin/enquiries/route.ts b/app/api/admin/enquiries/route.ts new file mode 100644 index 0000000..d27b541 --- /dev/null +++ b/app/api/admin/enquiries/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { isFormStatus, listFormSubmissions } from "@/lib/forms/submissions" + +export async function GET(request: NextRequest) { + const authz = await getAuthorizedUser("support:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const rawStatus = request.nextUrl.searchParams.get("status") + try { + const result = await listFormSubmissions({ query: request.nextUrl.searchParams.get("q") ?? undefined, status: isFormStatus(rawStatus) ? rawStatus : undefined }) + return NextResponse.json(result) + } catch { + return NextResponse.json({ error: "Failed to load enquiries" }, { status: 500 }) + } +} diff --git a/app/api/admin/homepage/route.ts b/app/api/admin/homepage/route.ts new file mode 100644 index 0000000..71c8c73 --- /dev/null +++ b/app/api/admin/homepage/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { getHomepageLayout, saveHomepageLayout, HomepageError } from "@/lib/homepage/service" +import { HOMEPAGE_SECTIONS } from "@/lib/homepage/registry" + +export const runtime = "nodejs" + +// GET - current merged layout + the section field registry +export async function GET() { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + try { + const sections = await getHomepageLayout() + return NextResponse.json({ sections, catalogue: HOMEPAGE_SECTIONS }) + } catch (error) { + console.error("Get homepage layout error:", error) + return NextResponse.json({ error: "Failed to load homepage layout" }, { status: 500 }) + } +} + +// PUT - replace the whole layout +export async function PUT(request: NextRequest) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const admin = authz.user + try { + const body = await request.json() + const sections = Array.isArray(body?.sections) ? body.sections : [] + const saved = await saveHomepageLayout(sections, { actorId: admin.id, actorRole: admin.role }) + return NextResponse.json({ sections: saved }) + } catch (error) { + if (error instanceof HomepageError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + console.error("Save homepage layout error:", error) + return NextResponse.json({ error: "Failed to save homepage layout" }, { status: 500 }) + } +} diff --git a/app/api/admin/media/[id]/route.ts b/app/api/admin/media/[id]/route.ts new file mode 100644 index 0000000..53c0bcc --- /dev/null +++ b/app/api/admin/media/[id]/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { updateMedia, deleteMedia, findUsage, MediaError } from "@/lib/media/service" +import { prisma } from "@/lib/prisma" + +export const runtime = "nodejs" + +function statusFor(code: MediaError["code"]): number { + switch (code) { + case "NOT_FOUND": + return 404 + case "IN_USE": + return 409 + default: + return 400 + } +} + +// GET - usage of a single asset +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const { id } = await params + const asset = await prisma.mediaAsset.findUnique({ where: { id } }) + if (!asset) return NextResponse.json({ error: "Asset not found" }, { status: 404 }) + const usage = await findUsage(asset.url) + return NextResponse.json({ usage }) +} + +// PATCH - update alt/caption +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const admin = authz.user + const { id } = await params + try { + const body = await request.json() + const asset = await updateMedia(id, body, { actorId: admin.id, actorRole: admin.role }) + return NextResponse.json({ asset }) + } catch (error) { + if (error instanceof MediaError) { + return NextResponse.json({ error: error.message }, { status: statusFor(error.code) }) + } + console.error("Update media error:", error) + return NextResponse.json({ error: "Failed to update media" }, { status: 500 }) + } +} + +// DELETE - soft-delete (blocked if referenced unless ?force=1) +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const admin = authz.user + const { id } = await params + const force = request.nextUrl.searchParams.get("force") === "1" + try { + await deleteMedia(id, { actorId: admin.id, actorRole: admin.role }, force) + return NextResponse.json({ ok: true }) + } catch (error) { + if (error instanceof MediaError) { + if (error.code === "IN_USE") { + return NextResponse.json({ error: error.message, code: "IN_USE" }, { status: 409 }) + } + return NextResponse.json({ error: error.message }, { status: statusFor(error.code) }) + } + console.error("Delete media error:", error) + return NextResponse.json({ error: "Failed to delete media" }, { status: 500 }) + } +} diff --git a/app/api/admin/media/route.ts b/app/api/admin/media/route.ts new file mode 100644 index 0000000..fc339d5 --- /dev/null +++ b/app/api/admin/media/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { listMedia, registerUrl, MediaError } from "@/lib/media/service" + +export const runtime = "nodejs" + +// GET - list media (optional ?kind= & ?search=) +export async function GET(request: NextRequest) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const kind = request.nextUrl.searchParams.get("kind") ?? undefined + const search = request.nextUrl.searchParams.get("search") ?? undefined + try { + const assets = await listMedia({ kind, search }) + return NextResponse.json({ assets }) + } catch (error) { + console.error("List media error:", error) + return NextResponse.json({ error: "Failed to load media" }, { status: 500 }) + } +} + +// POST - register an existing media URL +export async function POST(request: NextRequest) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const admin = authz.user + try { + const body = await request.json() + const asset = await registerUrl(body, { actorId: admin.id, actorRole: admin.role }) + return NextResponse.json({ asset }, { status: 201 }) + } catch (error) { + if (error instanceof MediaError) { + const status = error.code === "DUPLICATE" ? 409 : 400 + return NextResponse.json({ error: error.message }, { status }) + } + console.error("Register media error:", error) + return NextResponse.json({ error: "Failed to register media" }, { status: 500 }) + } +} diff --git a/app/api/admin/media/upload/route.ts b/app/api/admin/media/upload/route.ts new file mode 100644 index 0000000..84b7ee8 --- /dev/null +++ b/app/api/admin/media/upload/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { validateUpload, safeBaseName } from "@/lib/media/validate" +import { getStorageAdapter, StorageUnavailableError } from "@/lib/media/storage" +import { recordUpload } from "@/lib/media/service" + +export const runtime = "nodejs" + +// POST - multipart upload. The browser MIME type is ignored; the type is sniffed server-side. +export async function POST(request: NextRequest) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const admin = authz.user + + let form: FormData + try { + form = await request.formData() + } catch { + return NextResponse.json({ error: "Expected multipart form data" }, { status: 400 }) + } + + const file = form.get("file") + if (!(file instanceof File)) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }) + } + + const buffer = new Uint8Array(await file.arrayBuffer()) + const check = validateUpload(buffer, buffer.byteLength) + if (!check.ok) { + return NextResponse.json({ error: check.error }, { status: 400 }) + } + + try { + const adapter = getStorageAdapter() + const stored = await adapter.save( + buffer, + check.detected.ext, + safeBaseName(file.name || "asset"), + check.detected.mime + ) + const asset = await recordUpload( + { + url: stored.url, + filename: stored.filename, + kind: check.detected.kind, + mimeType: check.detected.mime, + sizeBytes: buffer.byteLength, + }, + { actorId: admin.id, actorRole: admin.role } + ) + return NextResponse.json({ asset }, { status: 201 }) + } catch (error) { + if (error instanceof StorageUnavailableError) { + return NextResponse.json({ error: error.message }, { status: 501 }) + } + console.error("Upload media error:", error) + return NextResponse.json({ error: "Failed to store upload" }, { status: 500 }) + } +} diff --git a/app/api/admin/navigation/route.ts b/app/api/admin/navigation/route.ts new file mode 100644 index 0000000..fc669d2 --- /dev/null +++ b/app/api/admin/navigation/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { getNavigationForAdmin, replaceMenu, NavigationError } from "@/lib/navigation/service" +import { NAV_LOCATIONS, isNavLocation } from "@/lib/navigation/defaults" + +export const runtime = "nodejs" + +// GET - all navigation items grouped by location + the location registry +export async function GET() { + const authz = await getAuthorizedUser("settings:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + try { + const menus = await getNavigationForAdmin() + return NextResponse.json({ menus, locations: NAV_LOCATIONS }) + } catch (error) { + console.error("Get navigation error:", error) + return NextResponse.json({ error: "Failed to load navigation" }, { status: 500 }) + } +} + +// PUT - replace all items for a single location +export async function PUT(request: NextRequest) { + const authz = await getAuthorizedUser("settings:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const admin = authz.user + try { + const body = await request.json() + const location = body?.location + if (typeof location !== "string" || !isNavLocation(location)) { + return NextResponse.json({ error: "Invalid menu location" }, { status: 400 }) + } + const items = Array.isArray(body?.items) ? body.items : [] + await replaceMenu(location, items, { actorId: admin.id, actorRole: admin.role }) + const menus = await getNavigationForAdmin() + return NextResponse.json({ menus }) + } catch (error) { + if (error instanceof NavigationError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + console.error("Update navigation error:", error) + return NextResponse.json({ error: "Failed to update navigation" }, { status: 500 }) + } +} diff --git a/app/api/admin/newsletter/campaigns/[id]/route.ts b/app/api/admin/newsletter/campaigns/[id]/route.ts index a0c9c28..b204785 100644 --- a/app/api/admin/newsletter/campaigns/[id]/route.ts +++ b/app/api/admin/newsletter/campaigns/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { prisma } from "@/lib/prisma" -import { requireAdmin } from "@/lib/admin" +import { getAuthorizedUser } from "@/lib/authz" export const runtime = "nodejs" @@ -10,7 +10,8 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> } ) { try { - await requireAdmin() + const authz = await getAuthorizedUser("marketing:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) const { id } = await params await prisma.newsletterCampaign.delete({ @@ -33,7 +34,8 @@ export async function GET( { params }: { params: Promise<{ id: string }> } ) { try { - await requireAdmin() + const authz = await getAuthorizedUser("marketing:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) const { id } = await params const campaign = await prisma.newsletterCampaign.findUnique({ diff --git a/app/api/admin/newsletter/campaigns/route.ts b/app/api/admin/newsletter/campaigns/route.ts index c23c084..d7aa113 100644 --- a/app/api/admin/newsletter/campaigns/route.ts +++ b/app/api/admin/newsletter/campaigns/route.ts @@ -1,13 +1,14 @@ import { NextRequest, NextResponse } from "next/server" import { prisma } from "@/lib/prisma" -import { requireAdmin } from "@/lib/admin" +import { getAuthorizedUser } from "@/lib/authz" export const runtime = "nodejs" // GET - List all campaigns export async function GET(_request: NextRequest) { try { - await requireAdmin() + const authz = await getAuthorizedUser("marketing:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) const campaigns = await prisma.newsletterCampaign.findMany({ orderBy: { createdAt: "desc" }, @@ -32,7 +33,8 @@ export async function GET(_request: NextRequest) { // POST - Create new campaign export async function POST(request: NextRequest) { try { - await requireAdmin() + const authz = await getAuthorizedUser("marketing:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) const { subject, html, text, status = "DRAFT" } = await request.json() @@ -71,7 +73,8 @@ export async function POST(request: NextRequest) { // PATCH - Update campaign export async function PATCH(request: NextRequest) { try { - await requireAdmin() + const authz = await getAuthorizedUser("marketing:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) const { id, subject, html, text, status } = await request.json() diff --git a/app/api/admin/newsletter/subscribers/route.ts b/app/api/admin/newsletter/subscribers/route.ts index 92c9ccd..986032c 100644 --- a/app/api/admin/newsletter/subscribers/route.ts +++ b/app/api/admin/newsletter/subscribers/route.ts @@ -1,13 +1,14 @@ import { NextRequest, NextResponse } from "next/server" import { prisma } from "@/lib/prisma" -import { requireAdmin } from "@/lib/admin" +import { getAuthorizedUser } from "@/lib/authz" export const runtime = "nodejs" // GET - List all subscribers export async function GET(request: NextRequest) { try { - await requireAdmin() + const authz = await getAuthorizedUser("marketing:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) const searchParams = request.nextUrl.searchParams const search = searchParams.get("search") || "" @@ -57,7 +58,8 @@ export async function GET(request: NextRequest) { // DELETE - Unsubscribe (soft delete) export async function DELETE(request: NextRequest) { try { - await requireAdmin() + const authz = await getAuthorizedUser("marketing:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) const { email } = await request.json() diff --git a/app/api/admin/notifications/route.ts b/app/api/admin/notifications/route.ts index a7e10ba..d115035 100644 --- a/app/api/admin/notifications/route.ts +++ b/app/api/admin/notifications/route.ts @@ -1,13 +1,14 @@ import { NextRequest, NextResponse } from "next/server" import { prisma } from "@/lib/prisma" -import { requireAdmin } from "@/lib/admin" +import { getAuthorizedUser } from "@/lib/authz" export const runtime = "nodejs" // GET - List notifications export async function GET(request: NextRequest) { try { - await requireAdmin() + const authz = await getAuthorizedUser("dashboard:view") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) const searchParams = request.nextUrl.searchParams const unreadOnly = searchParams.get("unreadOnly") === "true" @@ -41,7 +42,8 @@ export async function GET(request: NextRequest) { // PATCH - Mark notifications as read export async function PATCH(request: NextRequest) { try { - await requireAdmin() + const authz = await getAuthorizedUser("dashboard:view") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) const { notificationIds, markAllAsRead } = await request.json() diff --git a/app/api/admin/pages/[id]/restore/route.ts b/app/api/admin/pages/[id]/restore/route.ts new file mode 100644 index 0000000..0b67a91 --- /dev/null +++ b/app/api/admin/pages/[id]/restore/route.ts @@ -0,0 +1,13 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { restorePage } from "@/lib/pages/service" + +export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + try { + return NextResponse.json({ page: await restorePage((await params).id, { actorId: authz.user.id, actorRole: authz.user.role }) }) + } catch { + return NextResponse.json({ error: "Failed to restore page" }, { status: 500 }) + } +} diff --git a/app/api/admin/pages/[id]/route.ts b/app/api/admin/pages/[id]/route.ts new file mode 100644 index 0000000..1ada08b --- /dev/null +++ b/app/api/admin/pages/[id]/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { getPageForAdmin, PageServiceError, trashPage, updatePage } from "@/lib/pages/service" + +function status(error: PageServiceError) { + if (error.code === "NOT_FOUND") return 404 + if (error.code === "CONFLICT" || error.code === "SLUG_TAKEN") return 409 + return 400 +} + +export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + try { + return NextResponse.json({ page: await getPageForAdmin((await params).id) }) + } catch (error) { + if (error instanceof PageServiceError) return NextResponse.json({ error: error.message }, { status: status(error) }) + return NextResponse.json({ error: "Failed to load page" }, { status: 500 }) + } +} + +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + try { + const body = await request.json() + const page = await updatePage((await params).id, body, { actorId: authz.user.id, actorRole: authz.user.role }, body.expectedUpdatedAt) + return NextResponse.json({ page }) + } catch (error) { + if (error instanceof PageServiceError) return NextResponse.json({ error: error.message }, { status: status(error) }) + return NextResponse.json({ error: "Failed to update page" }, { status: 500 }) + } +} + +export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + try { + return NextResponse.json({ page: await trashPage((await params).id, { actorId: authz.user.id, actorRole: authz.user.role }) }) + } catch (error) { + if (error instanceof PageServiceError) return NextResponse.json({ error: error.message }, { status: status(error) }) + return NextResponse.json({ error: "Failed to archive page" }, { status: 500 }) + } +} diff --git a/app/api/admin/pages/route.ts b/app/api/admin/pages/route.ts new file mode 100644 index 0000000..f19ee39 --- /dev/null +++ b/app/api/admin/pages/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { createPage, listPages, PageServiceError } from "@/lib/pages/service" + +export const runtime = "nodejs" + +export async function GET(request: NextRequest) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + try { + return NextResponse.json({ pages: await listPages(request.nextUrl.searchParams.get("includeDeleted") === "1") }) + } catch { + return NextResponse.json({ error: "Failed to load pages" }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + try { + const page = await createPage(await request.json(), { actorId: authz.user.id, actorRole: authz.user.role }) + return NextResponse.json({ page }, { status: 201 }) + } catch (error) { + if (error instanceof PageServiceError) return NextResponse.json({ error: error.message }, { status: error.code === "SLUG_TAKEN" ? 409 : 400 }) + return NextResponse.json({ error: "Failed to create page" }, { status: 500 }) + } +} diff --git a/app/api/admin/products/[id]/route.ts b/app/api/admin/products/[id]/route.ts index 793312a..bee70bf 100644 --- a/app/api/admin/products/[id]/route.ts +++ b/app/api/admin/products/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server" -import { requireAdmin } from "@/lib/admin" +import { getAuthorizedUser } from "@/lib/authz" import { deleteProduct } from "@/lib/services/product-service" export async function DELETE( @@ -8,7 +8,8 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { try { - await requireAdmin() + const authz = await getAuthorizedUser("products:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) const { id } = await params await deleteProduct(id) diff --git a/app/api/admin/publishing/run/route.ts b/app/api/admin/publishing/run/route.ts new file mode 100644 index 0000000..84fdb7c --- /dev/null +++ b/app/api/admin/publishing/run/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { runScheduledPublishing } from "@/lib/publishing/service" + +export async function POST() { + const authz = await getAuthorizedUser("settings:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + try { + return NextResponse.json({ ok: true, result: await runScheduledPublishing(new Date(), authz.user.id) }) + } catch { + return NextResponse.json({ error: "Scheduled publishing failed" }, { status: 500 }) + } +} diff --git a/app/api/admin/redirects/[id]/route.ts b/app/api/admin/redirects/[id]/route.ts new file mode 100644 index 0000000..e90549a --- /dev/null +++ b/app/api/admin/redirects/[id]/route.ts @@ -0,0 +1,13 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { prisma } from "@/lib/prisma" +import { writeAudit } from "@/lib/audit" + +export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const authz = await getAuthorizedUser("settings:manage") + if (!authz.ok) return NextResponse.json({ error: "Forbidden" }, { status: authz.status }) + const id = (await params).id + await prisma.redirect.delete({ where: { id } }) + await writeAudit({ actorId: authz.user.id, actorRole: authz.user.role, action: "redirect.delete", targetType: "Redirect", targetId: id }) + return NextResponse.json({ ok: true }) +} diff --git a/app/api/admin/redirects/route.ts b/app/api/admin/redirects/route.ts new file mode 100644 index 0000000..f1707cc --- /dev/null +++ b/app/api/admin/redirects/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { prisma } from "@/lib/prisma" +import { writeAudit } from "@/lib/audit" +import { normalizeRedirectDestination, normalizeRedirectSource } from "@/lib/redirects/util" + +export async function GET() { + const authz = await getAuthorizedUser("settings:manage") + if (!authz.ok) return NextResponse.json({ error: "Forbidden" }, { status: authz.status }) + return NextResponse.json({ redirects: await prisma.redirect.findMany({ orderBy: { source: "asc" } }) }) +} + +export async function POST(request: NextRequest) { + const authz = await getAuthorizedUser("settings:manage") + if (!authz.ok) return NextResponse.json({ error: "Forbidden" }, { status: authz.status }) + const body = await request.json() + const source = normalizeRedirectSource(body.source) + const destination = normalizeRedirectDestination(body.destination) + if (!source || !destination || source === destination) return NextResponse.json({ error: "Enter distinct, safe source and destination paths" }, { status: 400 }) + try { + const item = await prisma.redirect.upsert({ where: { source }, create: { source, destination, permanent: body.permanent !== false, active: body.active !== false, createdBy: authz.user.id }, update: { destination, permanent: body.permanent !== false, active: body.active !== false } }) + await writeAudit({ actorId: authz.user.id, actorRole: authz.user.role, action: "redirect.upsert", targetType: "Redirect", targetId: item.id, metadata: { source, destination } }) + return NextResponse.json({ redirect: item }) + } catch { return NextResponse.json({ error: "Failed to save redirect" }, { status: 500 }) } +} diff --git a/app/api/admin/revisions/[id]/restore/route.ts b/app/api/admin/revisions/[id]/restore/route.ts new file mode 100644 index 0000000..dc0f2a5 --- /dev/null +++ b/app/api/admin/revisions/[id]/restore/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { auditRestore, getRevision } from "@/lib/revisions/service" +import { updateStory } from "@/lib/stories/service" +import { updatePage } from "@/lib/pages/service" + +function date(value: unknown) { return typeof value === "string" ? value : null } + +export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const revision = await getRevision((await params).id) + if (!revision) return NextResponse.json({ error: "Revision not found" }, { status: 404 }) + const snapshot = revision.snapshot as Record + const actor = { actorId: authz.user.id, actorRole: authz.user.role } + try { + if (revision.entityType === "Story") { + await updateStory(revision.entityId, { ...snapshot, title: String(snapshot.title ?? ""), scheduledFor: date(snapshot.scheduledFor), unpublishAt: date(snapshot.unpublishAt), blocks: (snapshot.blocks ?? []).map((block: any) => ({ type: block.type, position: block.position, data: block.data })), relatedProductIds: (snapshot.relatedProducts ?? []).map((item: any) => item.productId) }, actor) + } else if (revision.entityType === "Page") { + await updatePage(revision.entityId, { ...snapshot, title: String(snapshot.title ?? ""), scheduledFor: date(snapshot.scheduledFor), unpublishAt: date(snapshot.unpublishAt), blocks: (snapshot.blocks ?? []).map((block: any) => ({ type: block.type, position: block.position, data: block.data })) }, actor) + } else return NextResponse.json({ error: "Unsupported revision" }, { status: 400 }) + await auditRestore(revision.id, revision.entityType, revision.entityId, actor) + return NextResponse.json({ ok: true }) + } catch (error) { + return NextResponse.json({ error: error instanceof Error ? error.message : "Restore failed" }, { status: 400 }) + } +} diff --git a/app/api/admin/revisions/route.ts b/app/api/admin/revisions/route.ts new file mode 100644 index 0000000..9372005 --- /dev/null +++ b/app/api/admin/revisions/route.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { listRevisions } from "@/lib/revisions/service" + +export async function GET(request: NextRequest) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const entityType = request.nextUrl.searchParams.get("entityType") + const entityId = request.nextUrl.searchParams.get("entityId") + if ((entityType !== "Story" && entityType !== "Page") || !entityId) return NextResponse.json({ error: "Invalid entity" }, { status: 400 }) + return NextResponse.json({ revisions: await listRevisions(entityType, entityId) }) +} diff --git a/app/api/admin/scent-story/route.ts b/app/api/admin/scent-story/route.ts index 740cad8..8db7627 100644 --- a/app/api/admin/scent-story/route.ts +++ b/app/api/admin/scent-story/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server" import { z } from "zod" import { getAdminUser } from "@/lib/admin" +import { hasCapability, resolveRole } from "@/lib/authz-core" import { enrichComposition } from "@/lib/fragrance/enrich" import { matchIngredients } from "@/lib/fragrance/normalize" @@ -55,6 +56,9 @@ export async function POST(request: NextRequest) { if (!admin) { return NextResponse.json({ error: "Admin access required" }, { status: 401 }) } + if (!hasCapability(resolveRole(admin), "products:manage")) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } const parsed = BodySchema.safeParse(await request.json()) if (!parsed.success) { diff --git a/app/api/admin/search/route.ts b/app/api/admin/search/route.ts index b7659e4..f13b80c 100644 --- a/app/api/admin/search/route.ts +++ b/app/api/admin/search/route.ts @@ -1,12 +1,13 @@ import { NextRequest, NextResponse } from "next/server" import { prisma } from "@/lib/prisma" -import { requireAdmin } from "@/lib/admin" +import { getAuthorizedUser } from "@/lib/authz" export const runtime = "nodejs" export async function GET(request: NextRequest) { try { - await requireAdmin() + const authz = await getAuthorizedUser("dashboard:view") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) const searchParams = request.nextUrl.searchParams const q = searchParams.get("q")?.trim() || "" diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts new file mode 100644 index 0000000..f9f0613 --- /dev/null +++ b/app/api/admin/settings/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { getSiteSettings, updateSettings, SettingsError } from "@/lib/settings/service" +import { SETTINGS } from "@/lib/settings/schema" + +export const runtime = "nodejs" + +// GET - current effective settings + field registry (so the admin UI can render the form) +export async function GET() { + const authz = await getAuthorizedUser("settings:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + try { + const values = await getSiteSettings() + return NextResponse.json({ values, fields: SETTINGS }) + } catch (error) { + console.error("Get settings error:", error) + return NextResponse.json({ error: "Failed to load settings" }, { status: 500 }) + } +} + +// PATCH - update a partial set of settings +export async function PATCH(request: NextRequest) { + const authz = await getAuthorizedUser("settings:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const admin = authz.user + try { + const body = await request.json() + const patch = (body && typeof body === "object" && body.values) || body + const values = await updateSettings(patch, { actorId: admin.id, actorRole: admin.role }) + return NextResponse.json({ values }) + } catch (error) { + if (error instanceof SettingsError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + console.error("Update settings error:", error) + return NextResponse.json({ error: "Failed to update settings" }, { status: 500 }) + } +} diff --git a/app/api/admin/stories/[id]/restore/route.ts b/app/api/admin/stories/[id]/restore/route.ts new file mode 100644 index 0000000..ea911f5 --- /dev/null +++ b/app/api/admin/stories/[id]/restore/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { restoreStory, StoryServiceError } from "@/lib/stories/service" + +export const runtime = "nodejs" + +// POST - restore a trashed story +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const admin = authz.user + const { id } = await params + try { + const story = await restoreStory(id, { actorId: admin.id, actorRole: admin.role }) + return NextResponse.json({ story }) + } catch (error) { + if (error instanceof StoryServiceError) { + const status = error.code === "NOT_FOUND" ? 404 : 400 + return NextResponse.json({ error: error.message }, { status }) + } + console.error("Restore story error:", error) + return NextResponse.json({ error: "Failed to restore story" }, { status: 500 }) + } +} diff --git a/app/api/admin/stories/[id]/route.ts b/app/api/admin/stories/[id]/route.ts new file mode 100644 index 0000000..6d3c8ec --- /dev/null +++ b/app/api/admin/stories/[id]/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { + getStoryForAdmin, + updateStory, + archiveStory, + StoryServiceError, +} from "@/lib/stories/service" + +export const runtime = "nodejs" + +function statusFor(code: StoryServiceError["code"]): number { + switch (code) { + case "NOT_FOUND": + return 404 + case "CONFLICT": + return 409 + case "SLUG_TAKEN": + return 409 + default: + return 400 + } +} + +// GET - single story (admin, includes drafts) +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const { id } = await params + try { + const story = await getStoryForAdmin(id) + return NextResponse.json({ story }) + } catch (error) { + if (error instanceof StoryServiceError) { + return NextResponse.json({ error: error.message }, { status: statusFor(error.code) }) + } + console.error("Get story error:", error) + return NextResponse.json({ error: "Failed to load story" }, { status: 500 }) + } +} + +// PATCH - update story +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const admin = authz.user + const { id } = await params + try { + const body = await request.json() + const story = await updateStory( + id, + body, + { actorId: admin.id, actorRole: admin.role }, + body.expectedUpdatedAt + ) + return NextResponse.json({ story }) + } catch (error) { + if (error instanceof StoryServiceError) { + return NextResponse.json({ error: error.message }, { status: statusFor(error.code) }) + } + console.error("Update story error:", error) + return NextResponse.json({ error: "Failed to update story" }, { status: 500 }) + } +} + +// DELETE - trash (soft) by default; ?hard=1 permanently deletes +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const admin = authz.user + const { id } = await params + const hard = request.nextUrl.searchParams.get("hard") === "1" + try { + const result = await archiveStory(id, { actorId: admin.id, actorRole: admin.role }, hard) + return NextResponse.json({ ok: true, result }) + } catch (error) { + if (error instanceof StoryServiceError) { + return NextResponse.json({ error: error.message }, { status: statusFor(error.code) }) + } + console.error("Delete story error:", error) + return NextResponse.json({ error: "Failed to delete story" }, { status: 500 }) + } +} diff --git a/app/api/admin/stories/route.ts b/app/api/admin/stories/route.ts new file mode 100644 index 0000000..f4657f0 --- /dev/null +++ b/app/api/admin/stories/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthorizedUser } from "@/lib/authz" +import { listStories, createStory, StoryServiceError } from "@/lib/stories/service" + +export const runtime = "nodejs" + +// GET - list all stories (admin) +export async function GET(request: NextRequest) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + + const includeDeleted = request.nextUrl.searchParams.get("includeDeleted") === "1" + try { + const stories = await listStories({ includeDeleted }) + return NextResponse.json({ stories }) + } catch (error) { + console.error("List stories error:", error) + return NextResponse.json({ error: "Failed to load stories" }, { status: 500 }) + } +} + +// POST - create story +export async function POST(request: NextRequest) { + const authz = await getAuthorizedUser("content:manage") + if (!authz.ok) return NextResponse.json({ error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, { status: authz.status }) + const admin = authz.user + + try { + const body = await request.json() + const story = await createStory(body, { actorId: admin.id, actorRole: admin.role }) + return NextResponse.json({ story }, { status: 201 }) + } catch (error) { + if (error instanceof StoryServiceError) { + const status = error.code === "VALIDATION" ? 400 : error.code === "SLUG_TAKEN" ? 409 : 400 + return NextResponse.json({ error: error.message }, { status }) + } + console.error("Create story error:", error) + return NextResponse.json({ error: "Failed to create story" }, { status: 500 }) + } +} diff --git a/app/api/admin/users/[id]/role/route.ts b/app/api/admin/users/[id]/role/route.ts new file mode 100644 index 0000000..86019bc --- /dev/null +++ b/app/api/admin/users/[id]/role/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server" +import type { AdminRole } from "@prisma/client" +import { prisma } from "@/lib/prisma" +import { writeAudit } from "@/lib/audit" +import { getAuthorizedUser } from "@/lib/authz" + +export const runtime = "nodejs" + +const VALID_ROLES: AdminRole[] = [ + "SUPER_ADMIN", + "CONTENT_MANAGER", + "PRODUCT_MANAGER", + "ORDER_MANAGER", + "MARKETING_MANAGER", + "ANALYST", +] + +// PATCH - set a user's admin access. Body: { role: "none" | AdminRole } +// "none" revokes admin access (role=USER, adminRole=null); any AdminRole grants ADMIN + that role. +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const authz = await getAuthorizedUser("users:manage") + if (!authz.ok) { + return NextResponse.json( + { error: authz.status === 403 ? "Forbidden" : "Unauthorized" }, + { status: authz.status } + ) + } + const actor = authz.user + const { id } = await params + + if (id === actor.id) { + return NextResponse.json({ error: "You cannot change your own role." }, { status: 400 }) + } + + const body = await request.json().catch(() => ({})) + const requested = body?.role + + let data: { role: "USER" | "ADMIN"; adminRole: AdminRole | null } + if (requested === "none") { + data = { role: "USER", adminRole: null } + } else if (typeof requested === "string" && VALID_ROLES.includes(requested as AdminRole)) { + data = { role: "ADMIN", adminRole: requested as AdminRole } + } else { + return NextResponse.json({ error: "Invalid role" }, { status: 400 }) + } + + const target = await prisma.user.findUnique({ where: { id }, select: { id: true, email: true } }) + if (!target) return NextResponse.json({ error: "User not found" }, { status: 404 }) + + const updated = await prisma.user.update({ + where: { id }, + data, + select: { id: true, email: true, role: true, adminRole: true }, + }) + + await writeAudit({ + actorId: actor.id, + actorRole: actor.role, + action: "user.role_change", + targetType: "User", + targetId: id, + metadata: { email: target.email, role: data.role, adminRole: data.adminRole }, + }) + + return NextResponse.json({ user: updated }) +} diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index 43d3783..cfd8286 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -3,6 +3,7 @@ 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" const contactSchema = z.object({ name: nameSchema, @@ -18,6 +19,11 @@ function getClientId(req: NextRequest): string { 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]) +} + export async function POST(req: NextRequest) { try { if (!rateLimit(getClientId(req), 5, 60 * 1000)) { @@ -31,6 +37,24 @@ export async function POST(req: NextRequest) { } const { name, email, subject, message } = validated.data + // 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. + try { + await createContactSubmission({ name, email, subject, message }) + } catch (error) { + console.error("[CONTACT] Failed to persist submission:", error) + // P2021 means the additive table has not been approved/applied yet; preserve the existing + // email-only service during that rollout window. Other DB failures must not claim success. + if ((error as { code?: string }).code !== "P2021") { + return NextResponse.json({ error: "We could not save your message. Please try again." }, { status: 503 }) + } + } + + 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) @@ -43,10 +67,10 @@ export async function POST(req: NextRequest) { html: `

New Contact Form Submission

-

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

-

Subject: ${subject}

+

From: ${safeName} <${safeEmail}>

+

Subject: ${safeSubject}


-

${message}

+

${safeMessage}

`, }).catch((err) => console.error("[CONTACT] Failed to send store notification:", err)) @@ -62,11 +86,11 @@ export async function POST(req: NextRequest) {

Fádé Essence

-

Thank you for reaching out, ${name}!

+

Thank you for reaching out, ${safeName}!

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

Your message:

-

${message}

+

${safeMessage}

If you need urgent assistance, you can also reach us at ${STORE_EMAIL} or +234 8160591348. diff --git a/app/api/cron/publishing/route.ts b/app/api/cron/publishing/route.ts new file mode 100644 index 0000000..9a396b2 --- /dev/null +++ b/app/api/cron/publishing/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server" +import { runScheduledPublishing } from "@/lib/publishing/service" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function GET(request: NextRequest) { + const secret = process.env.CRON_SECRET + if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + try { + return NextResponse.json({ ok: true, result: await runScheduledPublishing() }) + } catch (error) { + console.error("Scheduled publishing failed:", error) + return NextResponse.json({ error: "Scheduled publishing failed" }, { status: 500 }) + } +} diff --git a/app/api/v1/admin/ai-cost/route.ts b/app/api/v1/admin/ai-cost/route.ts index fe3c877..bd25eb2 100644 --- a/app/api/v1/admin/ai-cost/route.ts +++ b/app/api/v1/admin/ai-cost/route.ts @@ -3,6 +3,7 @@ // note on durable persistence). Owner-only. import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { aiUsageSnapshot, PROMPT_VERSIONS } from '@/integrations/ai/cost' export const runtime = 'nodejs' @@ -11,6 +12,7 @@ export const dynamic = 'force-dynamic' export const GET = route(async () => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'dashboard:view')) raise('FORBIDDEN') const snapshot = aiUsageSnapshot() return { data: { usage: snapshot.records, totals: snapshot.totals, promptVersions: PROMPT_VERSIONS, scope: 'process' } } }) diff --git a/app/api/v1/admin/approvals/[id]/route.ts b/app/api/v1/admin/approvals/[id]/route.ts index 914d225..37aab90 100644 --- a/app/api/v1/admin/approvals/[id]/route.ts +++ b/app/api/v1/admin/approvals/[id]/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { decideApproval, executeApproval } from '@/lib/approvals/service' export const runtime = 'nodejs' @@ -13,6 +14,7 @@ const bodySchema = z.object({ op: z.enum(['approve', 'reject', 'execute']) }) export const POST = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'orders:manage')) raise('FORBIDDEN') const parts = req.nextUrl.pathname.split('/').filter(Boolean) const id = parts[parts.length - 1] diff --git a/app/api/v1/admin/approvals/route.ts b/app/api/v1/admin/approvals/route.ts index 5733dca..2930ead 100644 --- a/app/api/v1/admin/approvals/route.ts +++ b/app/api/v1/admin/approvals/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { prisma } from '@/lib/prisma' import { createApproval } from '@/lib/approvals/service' import type { ApprovalStatus } from '@prisma/client' @@ -16,6 +17,7 @@ const VALID: ApprovalStatus[] = ['PENDING', 'APPROVED', 'REJECTED', 'EXECUTED'] export const GET = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'orders:manage')) raise('FORBIDDEN') const statusParam = (req.nextUrl.searchParams.get('status') || 'PENDING').toUpperCase() const status = (VALID as string[]).includes(statusParam) ? (statusParam as ApprovalStatus) : 'PENDING' const approvals = await prisma.approvalRequest.findMany({ where: { status }, orderBy: { createdAt: 'desc' }, take: 100 }) @@ -34,6 +36,7 @@ const createSchema = z.object({ export const POST = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'orders:manage')) raise('FORBIDDEN') const body = createSchema.parse(await req.json()) const approval = await createApproval({ ...body, createdBy: admin!.id }) return { data: { approval }, status: 201 } diff --git a/app/api/v1/admin/audit/route.ts b/app/api/v1/admin/audit/route.ts index 6ae8f39..787f2a6 100644 --- a/app/api/v1/admin/audit/route.ts +++ b/app/api/v1/admin/audit/route.ts @@ -2,6 +2,7 @@ // GET /api/v1/admin/audit?targetType=&targetId=&action= -> ADMIN-only audit-log search. import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { prisma } from '@/lib/prisma' export const runtime = 'nodejs' @@ -10,6 +11,7 @@ export const dynamic = 'force-dynamic' export const GET = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'audit:view')) raise('FORBIDDEN') const sp = req.nextUrl.searchParams const where: Record = {} diff --git a/app/api/v1/admin/copilot/insights/route.ts b/app/api/v1/admin/copilot/insights/route.ts index 0d8e326..46909e3 100644 --- a/app/api/v1/admin/copilot/insights/route.ts +++ b/app/api/v1/admin/copilot/insights/route.ts @@ -3,6 +3,7 @@ // repeat-purchase and top products. Never exposes individual private conversations. Owner-only. import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { buildInsightsReport } from '@/lib/copilot/insights' export const runtime = 'nodejs' @@ -11,6 +12,7 @@ export const dynamic = 'force-dynamic' export const GET = route(async () => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'dashboard:view')) raise('FORBIDDEN') const report = await buildInsightsReport() return { data: { report } } }) diff --git a/app/api/v1/admin/copilot/inventory/route.ts b/app/api/v1/admin/copilot/inventory/route.ts index f9856d1..3a392e1 100644 --- a/app/api/v1/admin/copilot/inventory/route.ts +++ b/app/api/v1/admin/copilot/inventory/route.ts @@ -3,6 +3,7 @@ // confidence + assumptions), dead stock and back-in-stock demand. Read-only; executes nothing. import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { buildInventoryAssistantReport } from '@/lib/copilot/inventory-assistant' export const runtime = 'nodejs' @@ -11,6 +12,7 @@ export const dynamic = 'force-dynamic' export const GET = route(async () => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'products:manage')) raise('FORBIDDEN') const report = await buildInventoryAssistantReport() return { data: { report } } }) diff --git a/app/api/v1/admin/copilot/margin/route.ts b/app/api/v1/admin/copilot/margin/route.ts index fd0dd93..c197c04 100644 --- a/app/api/v1/admin/copilot/margin/route.ts +++ b/app/api/v1/admin/copilot/margin/route.ts @@ -3,6 +3,7 @@ // is absent rather than fabricating numbers. Owner-only. import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { buildMarginReport } from '@/lib/copilot/margin' export const runtime = 'nodejs' @@ -11,6 +12,7 @@ export const dynamic = 'force-dynamic' export const GET = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'products:manage')) raise('FORBIDDEN') const daysRaw = Number(req.nextUrl.searchParams.get('days') ?? 30) const days = Number.isFinite(daysRaw) ? Math.min(Math.max(daysRaw, 1), 365) : 30 const report = await buildMarginReport(days) diff --git a/app/api/v1/admin/copilot/marketing/route.ts b/app/api/v1/admin/copilot/marketing/route.ts index 634ba89..8e91500 100644 --- a/app/api/v1/admin/copilot/marketing/route.ts +++ b/app/api/v1/admin/copilot/marketing/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { generateMarketingDraft } from '@/lib/copilot/marketing' export const runtime = 'nodejs' @@ -23,6 +24,7 @@ const bodySchema = z.object({ export const POST = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'marketing:manage')) raise('FORBIDDEN') const body = bodySchema.parse(await req.json()) const draft = await generateMarketingDraft(body) return { data: { draft } } diff --git a/app/api/v1/admin/daily-brief/route.ts b/app/api/v1/admin/daily-brief/route.ts index 0f68eee..3579595 100644 --- a/app/api/v1/admin/daily-brief/route.ts +++ b/app/api/v1/admin/daily-brief/route.ts @@ -4,6 +4,7 @@ // The AI summary must derive only from the metrics JSON; it never executes any action. import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { getAi } from '@/integrations/registry' import { buildDailyBrief } from '@/lib/copilot/daily-brief' @@ -13,6 +14,7 @@ export const dynamic = 'force-dynamic' export const GET = route(async () => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'dashboard:view')) raise('FORBIDDEN') const brief = await buildDailyBrief() diff --git a/app/api/v1/admin/feature-flags/route.ts b/app/api/v1/admin/feature-flags/route.ts index 87ebfdd..8d7ca7f 100644 --- a/app/api/v1/admin/feature-flags/route.ts +++ b/app/api/v1/admin/feature-flags/route.ts @@ -4,6 +4,7 @@ // lib/config/feature-flags.ts). Owner/admin-only. import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { prisma } from '@/lib/prisma' import { allFlags } from '@/lib/config/feature-flags' @@ -13,6 +14,7 @@ export const dynamic = 'force-dynamic' export const GET = route(async () => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'settings:manage')) raise('FORBIDDEN') const persisted = await prisma.featureFlag.findMany({ select: { key: true, enabled: true, description: true } }).catch(() => []) return { data: { effective: allFlags(), persisted, source: 'FEATURE_FLAGS env var' } } }) diff --git a/app/api/v1/admin/integration-events/[id]/reprocess/route.ts b/app/api/v1/admin/integration-events/[id]/reprocess/route.ts index 364f0d9..8d63bbe 100644 --- a/app/api/v1/admin/integration-events/[id]/reprocess/route.ts +++ b/app/api/v1/admin/integration-events/[id]/reprocess/route.ts @@ -3,6 +3,7 @@ // Idempotency (WebhookReceipt) still guards duplicate side-effects. import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { reprocessIntegrationEvent } from '@/lib/ops/reprocess' export const runtime = 'nodejs' @@ -10,6 +11,7 @@ export const runtime = 'nodejs' export const POST = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'settings:manage')) raise('FORBIDDEN') const parts = req.nextUrl.pathname.split('/').filter(Boolean) const id = parts[parts.length - 2] // .../integration-events//reprocess if (!id) raise('VALIDATION_ERROR', 'Missing integration event id.') diff --git a/app/api/v1/admin/integration-events/route.ts b/app/api/v1/admin/integration-events/route.ts index 064b8bf..a957e35 100644 --- a/app/api/v1/admin/integration-events/route.ts +++ b/app/api/v1/admin/integration-events/route.ts @@ -2,6 +2,7 @@ // GET /api/v1/admin/integration-events?processed=false&provider= -> ADMIN-only webhook/event log. import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { prisma } from '@/lib/prisma' export const runtime = 'nodejs' @@ -10,6 +11,7 @@ export const dynamic = 'force-dynamic' export const GET = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'settings:manage')) raise('FORBIDDEN') const sp = req.nextUrl.searchParams const where: Record = {} const processed = sp.get('processed') diff --git a/app/api/v1/admin/inventory/route.ts b/app/api/v1/admin/inventory/route.ts index e38b666..afeec77 100644 --- a/app/api/v1/admin/inventory/route.ts +++ b/app/api/v1/admin/inventory/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { inventoryHealth, setStock } from '@/lib/catalogue/inventory' export const runtime = 'nodejs' @@ -12,6 +13,7 @@ export const dynamic = 'force-dynamic' export const GET = route(async () => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'products:manage')) raise('FORBIDDEN') const health = await inventoryHealth() return { data: { health } } }) @@ -21,6 +23,7 @@ const bodySchema = z.object({ productId: z.string().min(1), stock: z.number().in export const POST = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'products:manage')) raise('FORBIDDEN') const { productId, stock } = bodySchema.parse(await req.json()) const result = await setStock(productId, stock, admin!.id) return { data: result } diff --git a/app/api/v1/admin/jobs/[id]/reprocess/route.ts b/app/api/v1/admin/jobs/[id]/reprocess/route.ts index a1fc457..853399a 100644 --- a/app/api/v1/admin/jobs/[id]/reprocess/route.ts +++ b/app/api/v1/admin/jobs/[id]/reprocess/route.ts @@ -2,6 +2,7 @@ // POST /api/v1/admin/jobs/:id/reprocess -> ADMIN-only re-queue of a FAILED job. import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { reprocessJob } from '@/lib/ops/reprocess' export const runtime = 'nodejs' @@ -9,6 +10,7 @@ export const runtime = 'nodejs' export const POST = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'settings:manage')) raise('FORBIDDEN') const parts = req.nextUrl.pathname.split('/').filter(Boolean) // .../jobs//reprocess -> id is second-to-last const id = parts[parts.length - 2] diff --git a/app/api/v1/admin/jobs/route.ts b/app/api/v1/admin/jobs/route.ts index 7bb6d49..ce1f780 100644 --- a/app/api/v1/admin/jobs/route.ts +++ b/app/api/v1/admin/jobs/route.ts @@ -2,6 +2,7 @@ // GET /api/v1/admin/jobs?status=FAILED -> ADMIN-only job-run list (failed-job retention view). import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { prisma } from '@/lib/prisma' import type { JobStatus } from '@prisma/client' @@ -13,6 +14,7 @@ const VALID: JobStatus[] = ['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED'] export const GET = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'settings:manage')) raise('FORBIDDEN') const statusParam = req.nextUrl.searchParams.get('status')?.toUpperCase() const where = statusParam && (VALID as string[]).includes(statusParam) ? { status: statusParam as JobStatus } : {} const jobs = await prisma.jobRun.findMany({ where, orderBy: { createdAt: 'desc' }, take: 100 }) diff --git a/app/api/v1/admin/loyalty/route.ts b/app/api/v1/admin/loyalty/route.ts index b1e0c60..9c33795 100644 --- a/app/api/v1/admin/loyalty/route.ts +++ b/app/api/v1/admin/loyalty/route.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { adjustPoints } from '@/lib/loyalty/service' export const runtime = 'nodejs' @@ -17,6 +18,7 @@ const bodySchema = z.object({ export const POST = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'marketing:manage')) raise('FORBIDDEN') const { userId, delta, reason } = bodySchema.parse(await req.json()) const result = await adjustPoints(userId, delta, reason, admin!.id) return { data: { balanceAfter: result.balanceAfter }, status: 201 } diff --git a/app/api/v1/admin/products/sync/route.ts b/app/api/v1/admin/products/sync/route.ts index 2c0e3be..cf57c6c 100644 --- a/app/api/v1/admin/products/sync/route.ts +++ b/app/api/v1/admin/products/sync/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { syncCatalog } from '@/lib/catalogue/sync' export const runtime = 'nodejs' @@ -14,6 +15,7 @@ const bodySchema = z.object({ apply: z.boolean().optional() }).optional() export const POST = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'products:manage')) raise('FORBIDDEN') let apply = false try { const body = bodySchema.parse(await req.json().catch(() => ({}))) diff --git a/app/api/v1/admin/referrals/route.ts b/app/api/v1/admin/referrals/route.ts index d5e162b..a92700f 100644 --- a/app/api/v1/admin/referrals/route.ts +++ b/app/api/v1/admin/referrals/route.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { prisma } from '@/lib/prisma' import { requestReferralReward, reverseReferral } from '@/lib/referrals/service' @@ -16,6 +17,7 @@ const STATUSES = ['PENDING', 'QUALIFIED', 'REWARDED', 'REVERSED'] export const GET = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'marketing:manage')) raise('FORBIDDEN') const statusParam = req.nextUrl.searchParams.get('status')?.toUpperCase() const where = statusParam && STATUSES.includes(statusParam) ? { status: statusParam } : {} const referrals = await prisma.referral.findMany({ where, orderBy: { createdAt: 'desc' }, take: 100 }) @@ -30,6 +32,7 @@ const bodySchema = z.object({ export const POST = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'marketing:manage')) raise('FORBIDDEN') const { referralId, op } = bodySchema.parse(await req.json()) const data: { approval: unknown; referral: unknown } = { approval: null, referral: null } if (op === 'request_reward') { diff --git a/app/api/v1/admin/reviews/[id]/route.ts b/app/api/v1/admin/reviews/[id]/route.ts index d47356e..3f59239 100644 --- a/app/api/v1/admin/reviews/[id]/route.ts +++ b/app/api/v1/admin/reviews/[id]/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { prisma } from '@/lib/prisma' import { writeAudit } from '@/lib/audit' @@ -14,6 +15,7 @@ const bodySchema = z.object({ decision: z.enum(['approve', 'reject']), reason: z export const POST = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'content:manage')) raise('FORBIDDEN') const parts = req.nextUrl.pathname.split('/').filter(Boolean) const id = parts[parts.length - 1] diff --git a/app/api/v1/admin/reviews/route.ts b/app/api/v1/admin/reviews/route.ts index 5e96c5b..0efa93f 100644 --- a/app/api/v1/admin/reviews/route.ts +++ b/app/api/v1/admin/reviews/route.ts @@ -2,6 +2,7 @@ // GET /api/v1/admin/reviews?status=PENDING -> ADMIN-only review moderation queue. import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { prisma } from '@/lib/prisma' import type { ModerationStatus } from '@prisma/client' @@ -13,6 +14,7 @@ const VALID: ModerationStatus[] = ['PENDING', 'APPROVED', 'REJECTED'] export const GET = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'content:manage')) raise('FORBIDDEN') const statusParam = (req.nextUrl.searchParams.get('status') || 'PENDING').toUpperCase() const status = (VALID as string[]).includes(statusParam) ? (statusParam as ModerationStatus) : 'PENDING' diff --git a/app/api/v1/admin/sample-credits/route.ts b/app/api/v1/admin/sample-credits/route.ts index 2081325..8fcf760 100644 --- a/app/api/v1/admin/sample-credits/route.ts +++ b/app/api/v1/admin/sample-credits/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { grantSampleCredit } from '@/lib/samples/service' export const runtime = 'nodejs' @@ -19,6 +20,7 @@ const bodySchema = z.object({ export const POST = route(async ({ req }) => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'marketing:manage')) raise('FORBIDDEN') const body = bodySchema.parse(await req.json()) const credit = await grantSampleCredit(body, admin!.id) return { data: { credit: { id: credit.id, amountNGN: credit.amountNGN, remainingNGN: credit.remainingNGN, expiresAt: credit.expiresAt } }, status: 201 } diff --git a/app/api/v1/admin/status/route.ts b/app/api/v1/admin/status/route.ts index 35d1066..bb9db12 100644 --- a/app/api/v1/admin/status/route.ts +++ b/app/api/v1/admin/status/route.ts @@ -3,6 +3,7 @@ // Never exposes secret values (only whether each integration is configured). import { route, raise } from '@/lib/http/handler' import { getAdminUser } from '@/lib/admin' +import { hasCapability, resolveRole } from '@/lib/authz-core' import { integrationStatus } from '@/lib/env' import { providerStatus } from '@/integrations/registry' import { allFlags } from '@/lib/config/feature-flags' @@ -13,6 +14,7 @@ export const dynamic = 'force-dynamic' export const GET = route(async () => { const admin = await getAdminUser() if (!admin) raise('FORBIDDEN') + if (!hasCapability(resolveRole(admin), 'dashboard:view')) raise('FORBIDDEN') return { data: { integrations: integrationStatus(), diff --git a/app/drops/page.tsx b/app/drops/page.tsx index 0ad8c1c..177764a 100644 --- a/app/drops/page.tsx +++ b/app/drops/page.tsx @@ -7,6 +7,8 @@ import { ProductCard } from "@/components/ui/product-card" import { ProductGrid } from "@/components/ui/product-grid" import { CountdownTimer } from "@/components/drops/countdown-timer" import { NotifyButton } from "@/components/drops/notify-button" +import { getActiveCampaign } from "@/lib/campaigns/service" +import Link from "next/link" export const metadata = { title: "Limited Drops | Fádé", @@ -18,6 +20,7 @@ export const dynamic = "force-dynamic" export default async function DropsPage() { const now = new Date() + const campaign = await getActiveCampaign(now) const upcomingDrops: Awaited> = [] const liveDrops: Awaited> = [] @@ -46,6 +49,17 @@ export default async function DropsPage() { return ( + {campaign && ( +

+ {campaign.desktopImage && } +
+

Current campaign

+

{campaign.title}

+ {campaign.description &&

{campaign.description}

} + {campaign.ctaLabel && campaign.ctaHref && {campaign.ctaLabel}} +
+
+ )} {/* Night hero */}
diff --git a/app/faq/page.tsx b/app/faq/page.tsx index 39ce435..82e44fa 100644 --- a/app/faq/page.tsx +++ b/app/faq/page.tsx @@ -1,12 +1,18 @@ import type { Metadata } from "next" import { MainLayout } from "@/components/layout/main-layout" import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { ManagedPage } from "@/components/pages/managed-page" +import { getManagedPageMetadata, getPublishedPage } from "@/lib/pages/queries" -export const metadata: Metadata = { +export const dynamic = "force-dynamic" + +const fallbackMetadata: Metadata = { title: "FAQ | Fádé Essence", description: "Frequently asked questions about ordering, delivery, payments, and returns at Fádé Essence.", } +export function generateMetadata() { return getManagedPageMetadata("faq", fallbackMetadata) } + const faqs = [ { question: "Do you deliver nationwide?", @@ -50,7 +56,9 @@ const faqs = [ }, ] -export default function FAQPage() { +export default async function FAQPage() { + const managed = await getPublishedPage("faq") + if (managed) return return (
diff --git a/app/help/faq/page.tsx b/app/help/faq/page.tsx index ddf3f21..e01914c 100644 --- a/app/help/faq/page.tsx +++ b/app/help/faq/page.tsx @@ -8,12 +8,18 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { RETURNS_WINDOW_DAYS } from "@/lib/pdp/policy"; +import { ManagedPage } from "@/components/pages/managed-page"; +import { getManagedPageMetadata, getPublishedPage } from "@/lib/pages/queries"; -export const metadata: Metadata = { +export const dynamic = "force-dynamic"; + +const fallbackMetadata: Metadata = { title: "FAQ | Fádé", description: "Frequently asked questions about shopping at Fádé.", }; +export function generateMetadata() { return getManagedPageMetadata("help/faq", fallbackMetadata); } + const faqs = [ { question: "What payment methods do you accept?", @@ -66,7 +72,9 @@ const faqs = [ }, ]; -export default function FAQPage() { +export default async function FAQPage() { + const managed = await getPublishedPage("help/faq"); + if (managed) return ; return (
diff --git a/app/help/page.tsx b/app/help/page.tsx index 7b9e134..c718418 100644 --- a/app/help/page.tsx +++ b/app/help/page.tsx @@ -3,12 +3,18 @@ import Link from "next/link" import { ArrowUpRight } from "lucide-react" import { MainLayout } from "@/components/layout/main-layout" +import { ManagedPage } from "@/components/pages/managed-page" +import { getManagedPageMetadata, getPublishedPage } from "@/lib/pages/queries" -export const metadata: Metadata = { +export const dynamic = "force-dynamic" + +const fallbackMetadata: Metadata = { title: "Help Center | Fádé", description: "Get help with your orders, shipping, and returns.", } +export function generateMetadata() { return getManagedPageMetadata("help", fallbackMetadata) } + const helpTopics = [ { title: "FAQ", @@ -42,7 +48,9 @@ const helpTopics = [ }, ] -export default function HelpPage() { +export default async function HelpPage() { + const managed = await getPublishedPage("help") + if (managed) return return (
; return (
diff --git a/app/help/shipping/page.tsx b/app/help/shipping/page.tsx index 60d0218..7c79f38 100644 --- a/app/help/shipping/page.tsx +++ b/app/help/shipping/page.tsx @@ -4,13 +4,21 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Truck, Clock, Package, MapPin } from "lucide-react"; import { getCommerceConfig } from "@/lib/config/commerce"; import { formatPrice } from "@/lib/format"; +import { ManagedPage } from "@/components/pages/managed-page"; +import { getManagedPageMetadata, getPublishedPage } from "@/lib/pages/queries"; -export const metadata: Metadata = { +export const dynamic = "force-dynamic"; + +const fallbackMetadata: Metadata = { title: "Shipping Information | Fádé", description: "Learn about our shipping options and delivery times.", }; -export default function ShippingPage() { +export function generateMetadata() { return getManagedPageMetadata("help/shipping", fallbackMetadata); } + +export default async function ShippingPage() { + const managed = await getPublishedPage("help/shipping"); + if (managed) return ; const { shipping } = getCommerceConfig(); return ( diff --git a/app/journal/[slug]/page.tsx b/app/journal/[slug]/page.tsx index 9dcd466..adb4b9b 100644 --- a/app/journal/[slug]/page.tsx +++ b/app/journal/[slug]/page.tsx @@ -1,15 +1,24 @@ -import { journalArticles, articleContent } from "@/lib/journal-articles" import { notFound } from "next/navigation" import Link from "next/link" import { ArrowLeft, ArrowUpRight } from "lucide-react" +import type { Metadata } from "next" + import { prisma } from "@/lib/prisma" +import { + getPublicStory, + getPublishedStoryCards, + getAllStorySlugs, +} from "@/lib/stories/queries" import { MainLayout } from "@/components/layout/main-layout" import { ProductCard } from "@/components/ui/product-card" import { mapPrismaProductToCard } from "@/lib/queries/products" -import type { Metadata } from "next" +import { StoryBlocks } from "@/components/journal/story-blocks" + +export const dynamic = "force-dynamic" -export function generateStaticParams() { - return journalArticles.map((a) => ({ slug: a.slug })) +export async function generateStaticParams() { + const slugs = await getAllStorySlugs() + return slugs.map((slug) => ({ slug })) } export async function generateMetadata({ @@ -18,11 +27,14 @@ export async function generateMetadata({ params: Promise<{ slug: string }> }): Promise { const { slug } = await params - const article = journalArticles.find((a) => a.slug === slug) - if (!article) return {} + const story = await getPublicStory(slug) + if (!story) return {} return { - title: `${article.title} | The Journal | Fádé`, - description: article.excerpt, + title: `${story.seoTitle ?? story.title} | The Journal | Fádé`, + description: story.seoDescription ?? story.excerpt, + openGraph: story.socialImageUrl + ? { images: [{ url: story.socialImageUrl }] } + : undefined, } } @@ -32,23 +44,35 @@ export default async function ArticlePage({ params: Promise<{ slug: string }> }) { const { slug } = await params - const article = journalArticles.find((a) => a.slug === slug) - if (!article) notFound() + const story = await getPublicStory(slug) + if (!story) notFound() - const paragraphs = articleContent[slug] ?? [] - const others = journalArticles.filter((a) => a.slug !== slug).slice(0, 3) + // Related products: prefer explicit relations, then any product blocks. + const productSlugs = Array.from( + new Set([ + ...story.relatedProductSlugs, + ...story.blocks + .filter((b) => b.type === "product") + .map((b) => String((b.data as { productSlug?: string }).productSlug ?? "")) + .filter(Boolean), + ]) + ) let relatedProducts: Awaited> = [] - if (article.relatedProductSlugs?.length) { + if (productSlugs.length) { try { relatedProducts = await prisma.product.findMany({ - where: { slug: { in: article.relatedProductSlugs }, deletedAt: null }, + where: { slug: { in: productSlugs }, deletedAt: null }, }) } catch { // silently degrade } } + const others = (await getPublishedStoryCards()) + .filter((s) => s.slug !== slug) + .slice(0, 3) + return (
- {/* Back link */} - {/* Header */}
- {article.category} - · - {article.readTime} + {story.category} + {story.readTime && ( + <> + · + {story.readTime} + + )} · - {new Date(article.date).toLocaleDateString("en-NG", { + {new Date(story.date).toLocaleDateString("en-NG", { year: "numeric", month: "long", day: "numeric", @@ -81,28 +107,19 @@ export default async function ArticlePage({

- {article.title} + {story.title}

-

- {article.excerpt} -

+ {(story.subtitle || story.excerpt) && ( +

+ {story.subtitle || story.excerpt} +

+ )}
- {/* Body */} -
- {paragraphs.map((para, i) => ( -

- {para} -

- ))} -
+ - {/* Related Products */} {relatedProducts.length > 0 && (

Referenced in this story

@@ -114,33 +131,35 @@ export default async function ArticlePage({
)} - {/* More Articles */} -
-

More from the Journal

-
- {others.map((a) => ( - -
-

- {a.category} · {a.readTime} -

-

- {a.title} -

-
- - - ))} + {others.length > 0 && ( +
+

More from the Journal

+
+ {others.map((a) => ( + +
+

+ {a.category} + {a.readTime ? ` · ${a.readTime}` : ""} +

+

+ {a.title} +

+
+ + + ))} +
-
+ )}
diff --git a/app/journal/page.tsx b/app/journal/page.tsx index b4c1114..4f711df 100644 --- a/app/journal/page.tsx +++ b/app/journal/page.tsx @@ -1,9 +1,11 @@ import Link from "next/link" import { ArrowRight, ArrowUpRight } from "lucide-react" -import { journalArticles } from "@/lib/journal-articles" +import { getPublishedStoryCards } from "@/lib/stories/queries" import { MainLayout } from "@/components/layout/main-layout" +export const dynamic = "force-dynamic" + export const metadata = { title: "The Journal | Fádé", description: @@ -18,8 +20,32 @@ function formatDate(date: string) { }) } -export default function JournalPage() { - const [featured, ...rest] = journalArticles +export default async function JournalPage() { + const cards = await getPublishedStoryCards() + const [featured, ...rest] = cards + + if (!featured) { + return ( + +
+
+
+ The Journal +

+ Notes & stories +

+
+

+ New stories are coming soon. +

+
+
+
+ ) + } return ( diff --git a/app/layout.tsx b/app/layout.tsx index 23c15f3..f428766 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,6 +8,9 @@ import { ThemeProvider } from "@/components/theme-provider"; import { ScrollToTop } from "@/components/scroll-to-top"; import { NavigationProgress } from "@/components/loading/navigation-progress"; import { Suspense } from "react"; +import { SiteChromeProvider } from "@/components/layout/site-chrome-context"; +import { getSiteSettings } from "@/lib/settings/service"; +import { getNavigation } from "@/lib/navigation/service"; const fraunces = Fraunces({ subsets: ["latin"], @@ -48,76 +51,76 @@ function resolveSiteUrl() { const siteUrl = resolveSiteUrl(); -export const metadata: Metadata = { - metadataBase: siteUrl, - title: { - default: "Fádé Essence | Luxury Perfumes", - template: "%s | Fádé Essence", - }, - description: +const str = (v: unknown, fallback: string): string => + typeof v === "string" && v.trim() ? v : fallback; + +// SEO defaults are admin-editable (Site settings). Falls back to the built-in copy if unset or +// if the settings store is unavailable. +export async function generateMetadata(): Promise { + const settings = await getSiteSettings().catch(() => ({}) as Record); + + const siteName = str(settings.siteName, "Fádé Essence"); + const defaultTitle = str(settings.seoDefaultTitle, "Fádé Essence | Luxury Perfumes"); + const description = str( + settings.seoDefaultDescription, "Discover premium luxury perfumes at Fádé Essence. Curated collection of timeless fragrances and sophistication.", - keywords: [ - "luxury perfumes", - "fragrances", - "Fádé", - "premium perfume", - "Nigeria", - ], - manifest: "/manifest.webmanifest", - appleWebApp: { - capable: true, - statusBarStyle: "black-translucent", - title: "Fádé", - }, - formatDetection: { telephone: false }, - authors: [{ name: "Fádé Essence" }], - creator: "Fádé Essence", - publisher: "Fádé Essence", - openGraph: { - type: "website", - locale: "en_NG", - url: siteUrl.toString(), - siteName: "Fádé Essence", - title: "Fádé Essence | Luxury Perfumes", - description: "Discover premium luxury perfumes at Fádé Essence.", - images: [ - { - url: "/og-image.jpg", - width: 1200, - height: 630, - alt: "Fádé Essence", - }, - ], - }, - twitter: { - card: "summary_large_image", - title: "Fádé Essence | Luxury Perfumes", - description: "Discover premium luxury perfumes at Fádé Essence.", - images: ["/og-image.jpg"], - }, - robots: { - index: true, - follow: true, - googleBot: { + ); + const ogImage = str(settings.seoOgImage, "/og-image.jpg"); + + return { + metadataBase: siteUrl, + title: { default: defaultTitle, template: `%s | ${siteName}` }, + description, + keywords: ["luxury perfumes", "fragrances", "Fádé", "premium perfume", "Nigeria"], + manifest: "/manifest.webmanifest", + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: "Fádé", + }, + formatDetection: { telephone: false }, + authors: [{ name: siteName }], + creator: siteName, + publisher: siteName, + openGraph: { + type: "website", + locale: "en_NG", + url: siteUrl.toString(), + siteName, + title: defaultTitle, + description, + images: [{ url: ogImage, width: 1200, height: 630, alt: siteName }], + }, + twitter: { + card: "summary_large_image", + title: defaultTitle, + description, + images: [ogImage], + }, + robots: { index: true, follow: true, - "max-video-preview": -1, - "max-image-preview": "large", - "max-snippet": -1, + googleBot: { + index: true, + follow: true, + "max-video-preview": -1, + "max-image-preview": "large", + "max-snippet": -1, + }, }, - }, - verification: { - // Add your verification codes here when available - // google: "your-google-verification-code", - // yandex: "your-yandex-verification-code", - }, -}; + }; +} -export default function RootLayout({ +export default async function RootLayout({ children, }: { children: React.ReactNode; }) { + const [settings, nav] = await Promise.all([ + getSiteSettings().catch(() => ({})), + getNavigation().catch(() => ({}) as Awaited>), + ]); + return ( - - - - - - {children} - - + + + + + + + {children} + + + diff --git a/app/page.tsx b/app/page.tsx index 98fbe9a..1ca1ac0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -16,6 +16,8 @@ import { getApprovedFusionHeroFragrance } from "@/lib/hero/fusion-config" import { prisma } from "@/lib/prisma" +import { getHomepageLayout } from "@/lib/homepage/service" + export const dynamic = "force-dynamic" export default async function HomePage() { @@ -82,25 +84,73 @@ export default async function HomePage() { } }) - return ( + const layout = await getHomepageLayout() + + const renderSection = (type: string, config: Record) => { + switch (type) { + case "hero": + return ( + + ) + case "featured_products": + return ( + + ) + case "fragrance_families": + return ( + + ) + case "brand_story": + return ( + + ) + case "concierge_invitation": + return ( + + ) + default: + return null + } + } + return ( - - - - - - - - - - - + {layout + .filter((section) => section.visible) + .map((section) => renderSection(section.type, section.config))} - ) - } diff --git a/components/admin/admin-header.tsx b/components/admin/admin-header.tsx index 045a1f4..0e85efb 100644 --- a/components/admin/admin-header.tsx +++ b/components/admin/admin-header.tsx @@ -28,6 +28,7 @@ interface AdminHeaderProps { id: string name: string | null email: string + roleLabel?: string | null } } @@ -471,6 +472,11 @@ export function AdminHeader({ user }: AdminHeaderProps) {

{displayName}

{displayEmail}

+ {user?.roleLabel && ( + + {user.roleLabel} + + )}
diff --git a/components/admin/admin-sidebar.tsx b/components/admin/admin-sidebar.tsx index 74aa62a..8016c44 100644 --- a/components/admin/admin-sidebar.tsx +++ b/components/admin/admin-sidebar.tsx @@ -3,7 +3,7 @@ import * as React from "react" import Link from "next/link" import { usePathname } from "next/navigation" -import { LayoutDashboard, Package, ShoppingCart, Tags, Folder, ChevronLeft, Menu, LogOut, Mail, Warehouse, Bot } from "lucide-react" +import { LayoutDashboard, Package, ShoppingCart, Tags, Folder, ChevronLeft, Menu, LogOut, Mail, Warehouse, Bot, BookOpen, Users, Settings, Navigation as NavigationIcon, Home, ImageIcon, Shield, Flag, ScrollText, MessageSquare, FileText, Inbox, Megaphone, Route } from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -11,46 +11,62 @@ import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" import { signOutAction } from "@/app/auth/signout/actions" const navItems = [ - { name: "Dashboard", href: "/admin", icon: LayoutDashboard }, - { name: "Products", href: "/admin/products", icon: Package }, - { name: "Categories", href: "/admin/categories", icon: Tags }, - { name: "Collections", href: "/admin/collections", icon: Folder }, - { name: "Orders", href: "/admin/orders", icon: ShoppingCart }, - { name: "Inventory", href: "/admin/inventory", icon: Warehouse }, - { name: "Newsletter", href: "/admin/newsletter", icon: Mail }, - { name: "Concierge V2", href: "/admin/concierge", icon: Bot }, + { name: "Dashboard", href: "/admin", icon: LayoutDashboard, capability: "dashboard:view" }, + { name: "Homepage", href: "/admin/homepage", icon: Home, capability: "content:manage" }, + { name: "Products", href: "/admin/products", icon: Package, capability: "products:manage" }, + { name: "Categories", href: "/admin/categories", icon: Tags, capability: "products:manage" }, + { name: "Collections", href: "/admin/collections", icon: Folder, capability: "products:manage" }, + { name: "Journal", href: "/admin/stories", icon: BookOpen, capability: "content:manage" }, + { name: "Pages", href: "/admin/pages", icon: FileText, capability: "content:manage" }, + { name: "Media", href: "/admin/media", icon: ImageIcon, capability: "content:manage" }, + { name: "Reviews", href: "/admin/reviews", icon: MessageSquare, capability: "content:manage" }, + { name: "Orders", href: "/admin/orders", icon: ShoppingCart, capability: "orders:manage" }, + { name: "Inventory", href: "/admin/inventory", icon: Warehouse, capability: "products:manage" }, + { name: "Customers", href: "/admin/customers", icon: Users, capability: "customers:view" }, + { name: "Enquiries", href: "/admin/enquiries", icon: Inbox, capability: "support:manage" }, + { name: "Newsletter", href: "/admin/newsletter", icon: Mail, capability: "marketing:manage" }, + { name: "Campaigns", href: "/admin/campaigns", icon: Megaphone, capability: "marketing:manage" }, + { name: "Concierge V2", href: "/admin/concierge", icon: Bot, capability: "content:manage" }, + { name: "Navigation", href: "/admin/navigation", icon: NavigationIcon, capability: "settings:manage" }, + { name: "Settings", href: "/admin/settings", icon: Settings, capability: "settings:manage" }, + { name: "Redirects", href: "/admin/redirects", icon: Route, capability: "settings:manage" }, + { name: "Email templates", href: "/admin/email-templates", icon: Mail, capability: "settings:manage" }, + { name: "Feature flags", href: "/admin/feature-flags", icon: Flag, capability: "settings:manage" }, + { name: "Audit log", href: "/admin/audit", icon: ScrollText, capability: "audit:view" }, + { name: "Users & roles", href: "/admin/users", icon: Shield, capability: "users:manage" }, ] -function SidebarContent() { +function SidebarContent({ capabilities }: { capabilities: string[] }) { const pathname = usePathname() const [isSigningOut, startSignOutTransition] = React.useTransition() + const visibleItems = navItems.filter((item) => capabilities.includes(item.capability)) return ( -
+
{/* Brand */} -
+
Fádé - Admin + Admin
- {/* Navigation */} -