Compare commits

...

2 Commits

19 changed files with 1028 additions and 93 deletions

18
TODO.md
View File

@@ -124,11 +124,11 @@ This file is the single source of truth for roadmap and delivery progress.
S3/local upload adapter, media processing presets, metadata input flows, admin media CRUD UI
- [~] [P1] `todo/mvp1-pages-navigation-builder`:
page CRUD, navigation tree, reusable page blocks (forms/price cards/gallery embeds)
- [ ] [P1] `todo/mvp1-commissions-customers`:
- [~] [P1] `todo/mvp1-commissions-customers`:
commission request intake + admin CRUD + kanban + customer entity/linking
- [ ] [P1] `todo/mvp1-announcements-news`:
announcement management/rendering + news/blog CRUD and public rendering
- [ ] [P1] `todo/mvp1-public-rendering-integration`:
- [~] [P1] `todo/mvp1-public-rendering-integration`:
public rendering for pages/navigation/media/portfolio/announcements and commissioning entrypoints
- [ ] [P1] `todo/mvp1-e2e-happy-paths`:
end-to-end scenarios for page publish, media flow, announcement display, commission flow
@@ -156,18 +156,18 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P1] Users management (invite, roles, status)
- [ ] [P1] Disable/ban user function and enforcement in auth/session checks
- [~] [P1] Owner/support protection rules in user management actions (cannot delete/demote)
- [ ] [P1] Commissions management (request intake, owner, due date, notes, linked customer, linked artworks)
- [ ] [P1] Customer records (contact profile, notes, consent flags, recurrence marker)
- [ ] [P1] Customer-to-commission linkage and reuse workflow (no re-entry for recurring customers)
- [ ] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
- [~] [P1] Commissions management (request intake, owner, due date, notes, linked customer, linked artworks)
- [~] [P1] Customer records (contact profile, notes, consent flags, recurrence marker)
- [~] [P1] Customer-to-commission linkage and reuse workflow (no re-entry for recurring customers)
- [~] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
- [ ] [P1] Header banner management (message, CTA, active window)
- [ ] [P1] Announcements management (prominent site notices with schedule, priority, and audience targeting)
- [ ] [P2] News/blog editorial workflow (draft/review/publish, authoring metadata)
### Public App
- [ ] [P1] Dynamic page rendering from CMS page entities
- [ ] [P1] Navigation rendering from managed menu structure
- [~] [P1] Dynamic page rendering from CMS page entities
- [~] [P1] Navigation rendering from managed menu structure
- [ ] [P1] Media entity rendering with enrichment data
- [ ] [P1] Portfolio views (gallery/album/category/tag) for artworks with filter and sort controls
- [ ] [P1] Rendition-aware media delivery (thumbnail/card/full) per template slot
@@ -275,6 +275,8 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-12] Media storage keys now use asset-centric layout (`tenant/<id>/asset/<assetId>/<fileRole>/<assetId>__<variant>.<ext>`) with DB-managed media taxonomy.
- [2026-02-12] Admin media CRUD now includes list-to-detail flow (`/media/:id`) with metadata edit and delete actions.
- [2026-02-12] MVP1 pages/navigation baseline started: `Page`, `NavigationMenu`, and `NavigationItem` models plus admin CRUD routes (`/pages`, `/pages/:id`, `/navigation`).
- [2026-02-12] Public app now renders CMS-managed navigation (header) and CMS-managed pages by slug (including homepage when `home` page exists).
- [2026-02-12] Commissions/customer baseline added: admin `/commissions` now supports customer creation, commission intake, status transitions, and a basic kanban board.
## How We Use This File

View File

@@ -1,34 +1,454 @@
import { AdminSectionPlaceholder } from "@/components/admin-section-placeholder"
import {
commissionKanbanOrder,
createCommission,
createCustomer,
listCommissions,
listCustomers,
updateCommissionStatus,
} from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { AdminShell } from "@/components/admin-shell"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
export default async function CommissionsManagementPage() {
type SearchParamsInput = Record<string, string | string[] | undefined>
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null
}
return value ?? null
}
function readInputString(formData: FormData, field: string): string {
const value = formData.get(field)
return typeof value === "string" ? value.trim() : ""
}
function readNullableString(formData: FormData, field: string): string | null {
const value = readInputString(formData, field)
return value.length > 0 ? value : null
}
function readNullableNumber(formData: FormData, field: string): number | null {
const value = readInputString(formData, field)
if (!value) {
return null
}
const parsed = Number.parseFloat(value)
if (!Number.isFinite(parsed)) {
return null
}
return parsed
}
function readNullableDate(formData: FormData, field: string): Date | null {
const value = readInputString(formData, field)
if (!value) {
return null
}
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) {
return null
}
return parsed
}
function redirectWithState(params: { notice?: string; error?: string }) {
const query = new URLSearchParams()
if (params.notice) {
query.set("notice", params.notice)
}
if (params.error) {
query.set("error", params.error)
}
const value = query.toString()
redirect(value ? `/commissions?${value}` : "/commissions")
}
async function createCustomerAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/commissions",
permission: "commissions:write",
scope: "own",
})
try {
await createCustomer({
name: readInputString(formData, "name"),
email: readNullableString(formData, "email"),
phone: readNullableString(formData, "phone"),
instagram: readNullableString(formData, "instagram"),
notes: readNullableString(formData, "notes"),
isRecurring: readInputString(formData, "isRecurring") === "true",
})
} catch {
redirectWithState({ error: "Failed to create customer." })
}
revalidatePath("/commissions")
redirectWithState({ notice: "Customer created." })
}
async function createCommissionAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/commissions",
permission: "commissions:write",
scope: "own",
})
try {
await createCommission({
title: readInputString(formData, "title"),
description: readNullableString(formData, "description"),
status: readInputString(formData, "status"),
customerId: readNullableString(formData, "customerId"),
assignedUserId: readNullableString(formData, "assignedUserId"),
budgetMin: readNullableNumber(formData, "budgetMin"),
budgetMax: readNullableNumber(formData, "budgetMax"),
dueAt: readNullableDate(formData, "dueAt"),
})
} catch {
redirectWithState({ error: "Failed to create commission." })
}
revalidatePath("/commissions")
redirectWithState({ notice: "Commission created." })
}
async function updateCommissionStatusAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/commissions",
permission: "commissions:transition",
scope: "own",
})
try {
await updateCommissionStatus({
id: readInputString(formData, "id"),
status: readInputString(formData, "status"),
})
} catch {
redirectWithState({ error: "Failed to transition commission." })
}
revalidatePath("/commissions")
redirectWithState({ notice: "Commission status updated." })
}
function formatDate(value: Date | null) {
if (!value) {
return "-"
}
return value.toLocaleDateString("en-US")
}
export default async function CommissionsManagementPage({
searchParams,
}: {
searchParams: Promise<SearchParamsInput>
}) {
const role = await requirePermissionForRoute({
nextPath: "/commissions",
permission: "commissions:read",
scope: "own",
})
const [resolvedSearchParams, customers, commissions] = await Promise.all([
searchParams,
listCustomers(200),
listCommissions(300),
])
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
return (
<AdminShell
role={role}
activePath="/commissions"
badge="Admin App"
title="Commissions"
description="Prepare commissions intake and kanban workflow tooling."
description="Manage customers and commission requests with kanban-style status transitions."
>
<AdminSectionPlaceholder
feature="Commissions Workflow"
summary="This route is reserved for request intake, ownership assignment, and kanban transitions."
requiredPermission="commissions:read (own)"
nextSteps={[
"Add commissions board with status columns.",
"Add assignment, due-date, and notes editing.",
"Add transition rules and audit history.",
]}
{notice ? (
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{notice}
</section>
) : null}
{error ? (
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</section>
) : null}
<section className="grid gap-4 xl:grid-cols-2">
<article className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Customer</h2>
<form action={createCustomerAction} className="mt-4 space-y-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Name</span>
<input
name="name"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Email</span>
<input
name="email"
type="email"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Phone</span>
<input
name="phone"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Instagram</span>
<input
name="instagram"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Notes</span>
<textarea
name="notes"
rows={3}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<input name="isRecurring" type="checkbox" value="true" className="size-4" />
Recurring customer
</label>
<Button type="submit">Create customer</Button>
</form>
</article>
<article className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Commission</h2>
<form action={createCommissionAction} className="mt-4 space-y-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Description</span>
<textarea
name="description"
rows={3}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Status</span>
<select
name="status"
defaultValue="new"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
{commissionKanbanOrder.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Customer</span>
<select
name="customerId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="">(none)</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
</label>
</div>
<div className="grid gap-3 md:grid-cols-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Assigned user id</span>
<input
name="assignedUserId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Budget min</span>
<input
name="budgetMin"
type="number"
min={0}
step="0.01"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Budget max</span>
<input
name="budgetMax"
type="number"
min={0}
step="0.01"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Due date</span>
<input
name="dueAt"
type="date"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<Button type="submit">Create commission</Button>
</form>
</article>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Kanban Board</h2>
<div className="grid gap-3 xl:grid-cols-6">
{commissionKanbanOrder.map((status) => {
const items = commissions.filter((commission) => commission.status === status)
return (
<article
key={status}
className="rounded-xl border border-neutral-200 bg-neutral-50 p-3"
>
<header className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold uppercase tracking-wide text-neutral-700">
{status}
</h3>
<span className="text-xs text-neutral-500">{items.length}</span>
</header>
<div className="space-y-2">
{items.length === 0 ? (
<p className="text-xs text-neutral-500">No commissions</p>
) : (
items.map((commission) => (
<form
key={commission.id}
action={updateCommissionStatusAction}
className="rounded border border-neutral-200 bg-white p-2"
>
<input type="hidden" name="id" value={commission.id} />
<div className="space-y-1">
<p className="text-sm font-medium">{commission.title}</p>
<p className="text-xs text-neutral-600">
{commission.customer?.name ?? "No customer"}
</p>
<p className="text-xs text-neutral-500">
Due: {formatDate(commission.dueAt)}
</p>
</div>
<div className="mt-2 flex items-center gap-2">
<select
name="status"
defaultValue={commission.status}
className="w-full rounded border border-neutral-300 px-2 py-1 text-xs"
>
{commissionKanbanOrder.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<button
type="submit"
className="rounded border border-neutral-300 px-2 py-1 text-xs"
>
Move
</button>
</div>
</form>
))
)}
</div>
</article>
)
})}
</div>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Customers</h2>
<div className="mt-4 overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wide text-neutral-500">
<tr>
<th className="py-2 pr-4">Name</th>
<th className="py-2 pr-4">Email</th>
<th className="py-2 pr-4">Phone</th>
<th className="py-2 pr-4">Recurring</th>
</tr>
</thead>
<tbody>
{customers.length === 0 ? (
<tr>
<td className="py-3 text-neutral-500" colSpan={4}>
No customers yet.
</td>
</tr>
) : (
customers.map((customer) => (
<tr key={customer.id} className="border-t border-neutral-200">
<td className="py-3 pr-4">{customer.name}</td>
<td className="py-3 pr-4 text-neutral-600">{customer.email ?? "-"}</td>
<td className="py-3 pr-4 text-neutral-600">{customer.phone ?? "-"}</td>
<td className="py-3 pr-4">{customer.isRecurring ? "yes" : "no"}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</AdminShell>
)
}

View File

@@ -0,0 +1,21 @@
import { getPublishedPageBySlug } from "@cms/db"
import { notFound } from "next/navigation"
import { PublicPageView } from "@/components/public-page-view"
export const dynamic = "force-dynamic"
type PageProps = {
params: Promise<{ slug: string }>
}
export default async function CmsPageRoute({ params }: PageProps) {
const { slug } = await params
const page = await getPublishedPageBySlug(slug)
if (!page) {
notFound()
}
return <PublicPageView page={page} />
}

View File

@@ -1,13 +0,0 @@
import { getTranslations } from "next-intl/server"
export default async function AboutPage() {
const t = await getTranslations("About")
return (
<section className="mx-auto w-full max-w-6xl space-y-4 px-6 py-16">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
<p className="max-w-3xl text-neutral-600">{t("description")}</p>
</section>
)
}

View File

@@ -1,13 +0,0 @@
import { getTranslations } from "next-intl/server"
export default async function ContactPage() {
const t = await getTranslations("Contact")
return (
<section className="mx-auto w-full max-w-6xl space-y-4 px-6 py-16">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
<p className="max-w-3xl text-neutral-600">{t("description")}</p>
</section>
)
}

View File

@@ -1,23 +1,32 @@
import { listPosts } from "@cms/db"
import { getPublishedPageBySlug, listPosts } from "@cms/db"
import { Button } from "@cms/ui/button"
import { getTranslations } from "next-intl/server"
import { PublicPageView } from "@/components/public-page-view"
export const dynamic = "force-dynamic"
export default async function HomePage() {
const [posts, t] = await Promise.all([listPosts(), getTranslations("Home")])
const [homePage, posts, t] = await Promise.all([
getPublishedPageBySlug("home"),
listPosts(),
getTranslations("Home"),
])
return (
<section className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-16">
<section>
{homePage ? <PublicPageView page={homePage} /> : null}
<section className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-6 pb-16">
<header className="space-y-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
<h2 className="text-3xl font-semibold tracking-tight">{t("latestPosts")}</h2>
<p className="text-neutral-600">{t("description")}</p>
</header>
<section className="space-y-4 rounded-xl border border-neutral-200 p-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-medium">{t("latestPosts")}</h2>
<h3 className="text-xl font-medium">{t("latestPosts")}</h3>
<Button variant="secondary">{t("explore")}</Button>
</div>
@@ -25,12 +34,13 @@ export default async function HomePage() {
{posts.map((post) => (
<li key={post.id} className="rounded-lg border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-wide text-neutral-500">{post.status}</p>
<h3 className="mt-1 text-lg font-medium">{post.title}</h3>
<h4 className="mt-1 text-lg font-medium">{post.title}</h4>
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? t("noExcerpt")}</p>
</li>
))}
</ul>
</section>
</section>
</section>
)
}

View File

@@ -1,14 +1,13 @@
import { listPublishedPageSlugs } from "@cms/db"
import type { MetadataRoute } from "next"
const baseUrl = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000"
const publicRoutes = ["/", "/about", "/contact"]
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const pages = await listPublishedPageSlugs()
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date()
return publicRoutes.map((route) => ({
url: `${baseUrl}${route}`,
lastModified: now,
return pages.map((page) => ({
url: page.slug === "home" ? `${baseUrl}/` : `${baseUrl}/${page.slug}`,
lastModified: page.updatedAt,
}))
}

View File

@@ -0,0 +1,26 @@
type PageEntity = {
title: string
status: string
summary: string | null
content: string
}
type PublicPageViewProps = {
page: PageEntity
}
export function PublicPageView({ page }: PublicPageViewProps) {
return (
<article className="mx-auto flex w-full max-w-4xl flex-col gap-6 px-6 py-16">
<header className="space-y-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{page.status}</p>
<h1 className="text-4xl font-semibold tracking-tight">{page.title}</h1>
{page.summary ? <p className="text-neutral-600">{page.summary}</p> : null}
</header>
<section className="prose prose-neutral max-w-none whitespace-pre-wrap rounded-xl border border-neutral-200 bg-white p-6 text-neutral-800">
{page.content}
</section>
</article>
)
}

View File

@@ -1,19 +1,11 @@
"use client"
import { useTranslations } from "next-intl"
import { listPublicNavigation } from "@cms/db"
import { Link } from "@/i18n/navigation"
import { LanguageSwitcher } from "./language-switcher"
export function PublicSiteHeader() {
const t = useTranslations("Layout")
const navItems = [
{ href: "/", label: t("nav.home") },
{ href: "/about", label: t("nav.about") },
{ href: "/contact", label: t("nav.contact") },
]
export async function PublicSiteHeader() {
const navItems = await listPublicNavigation("header")
return (
<header className="border-b border-neutral-200 bg-white/80 backdrop-blur">
@@ -22,19 +14,28 @@ export function PublicSiteHeader() {
href="/"
className="text-sm font-semibold uppercase tracking-[0.2em] text-neutral-700"
>
{t("brand")}
CMS Web
</Link>
<nav className="flex flex-wrap items-center gap-2">
{navItems.map((item) => (
{navItems.length === 0 ? (
<Link
key={item.href}
href="/"
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-100"
>
Home
</Link>
) : (
navItems.map((item) => (
<Link
key={item.id}
href={item.href}
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-100"
>
{item.label}
</Link>
))}
))
)}
</nav>
<LanguageSwitcher />

View File

@@ -0,0 +1,40 @@
import { z } from "zod"
export const commissionStatusSchema = z.enum([
"new",
"scoped",
"in_progress",
"review",
"done",
"canceled",
])
export const createCustomerInputSchema = z.object({
name: z.string().min(1).max(180),
email: z.string().email().max(320).nullable().optional(),
phone: z.string().max(80).nullable().optional(),
instagram: z.string().max(120).nullable().optional(),
notes: z.string().max(4000).nullable().optional(),
isRecurring: z.boolean().default(false),
})
export const createCommissionInputSchema = z.object({
title: z.string().min(1).max(180),
description: z.string().max(4000).nullable().optional(),
status: commissionStatusSchema.default("new"),
customerId: z.string().uuid().nullable().optional(),
assignedUserId: z.string().max(120).nullable().optional(),
budgetMin: z.number().nonnegative().nullable().optional(),
budgetMax: z.number().nonnegative().nullable().optional(),
dueAt: z.date().nullable().optional(),
})
export const updateCommissionStatusInputSchema = z.object({
id: z.string().uuid(),
status: commissionStatusSchema,
})
export type CommissionStatus = z.infer<typeof commissionStatusSchema>
export type CreateCustomerInput = z.infer<typeof createCustomerInputSchema>
export type CreateCommissionInput = z.infer<typeof createCommissionInputSchema>
export type UpdateCommissionStatusInput = z.infer<typeof updateCommissionStatusInputSchema>

View File

@@ -1,5 +1,6 @@
import { z } from "zod"
export * from "./commissions"
export * from "./media"
export * from "./pages-navigation"
export * from "./rbac"

View File

@@ -0,0 +1,52 @@
-- CreateTable
CREATE TABLE "Customer" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"instagram" TEXT,
"notes" TEXT,
"isRecurring" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Customer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Commission" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"status" TEXT NOT NULL,
"customerId" TEXT,
"assignedUserId" TEXT,
"budgetMin" DOUBLE PRECISION,
"budgetMax" DOUBLE PRECISION,
"dueAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Commission_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Customer_email_idx" ON "Customer"("email");
-- CreateIndex
CREATE INDEX "Customer_isRecurring_idx" ON "Customer"("isRecurring");
-- CreateIndex
CREATE INDEX "Commission_status_idx" ON "Commission"("status");
-- CreateIndex
CREATE INDEX "Commission_customerId_idx" ON "Commission"("customerId");
-- CreateIndex
CREATE INDEX "Commission_assignedUserId_idx" ON "Commission"("assignedUserId");
-- AddForeignKey
ALTER TABLE "Commission" ADD CONSTRAINT "Commission_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Commission" ADD CONSTRAINT "Commission_assignedUserId_fkey" FOREIGN KEY ("assignedUserId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -34,6 +34,7 @@ model User {
isProtected Boolean @default(false)
sessions Session[]
accounts Account[]
commissions Commission[] @relation("CommissionAssignee")
@@unique([email])
@@index([role])
@@ -302,3 +303,39 @@ model NavigationItem {
@@index([parentId])
@@unique([menuId, parentId, sortOrder, label])
}
model Customer {
id String @id @default(uuid())
name String
email String?
phone String?
instagram String?
notes String?
isRecurring Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
commissions Commission[]
@@index([email])
@@index([isRecurring])
}
model Commission {
id String @id @default(uuid())
title String
description String?
status String
customerId String?
assignedUserId String?
budgetMin Float?
budgetMax Float?
dueAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
assignedUser User? @relation("CommissionAssignee", fields: [assignedUserId], references: [id], onDelete: SetNull)
@@index([status])
@@index([customerId])
@@index([assignedUserId])
}

View File

@@ -158,6 +158,54 @@ async function main() {
},
})
}
const existingCustomer = await db.customer.findFirst({
where: {
email: "collector@example.com",
},
select: {
id: true,
},
})
const seededCustomer = existingCustomer
? await db.customer.update({
where: {
id: existingCustomer.id,
},
data: {
name: "Collector One",
phone: "+1-555-0101",
isRecurring: true,
notes: "Interested in recurring portrait commissions.",
},
})
: await db.customer.create({
data: {
name: "Collector One",
email: "collector@example.com",
phone: "+1-555-0101",
isRecurring: true,
notes: "Interested in recurring portrait commissions.",
},
})
await db.commission.upsert({
where: {
id: "11111111-1111-1111-1111-111111111111",
},
update: {},
create: {
id: "11111111-1111-1111-1111-111111111111",
title: "Portrait Commission Baseline",
description: "Initial seeded commission request for MVP1 board validation.",
status: "new",
customerId: seededCustomer.id,
budgetMin: 400,
budgetMax: 900,
dueAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
},
})
}
main()

View File

@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const { mockDb } = vi.hoisted(() => ({
mockDb: {
customer: {
create: vi.fn(),
findMany: vi.fn(),
},
commission: {
create: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
},
}))
vi.mock("./client", () => ({
db: mockDb,
}))
import { createCommission, createCustomer, updateCommissionStatus } from "./commissions"
describe("commissions service", () => {
beforeEach(() => {
for (const value of Object.values(mockDb)) {
for (const fn of Object.values(value)) {
if (typeof fn === "function") {
fn.mockReset()
}
}
}
})
it("creates customer and commission payloads", async () => {
mockDb.customer.create.mockResolvedValue({ id: "customer-1" })
mockDb.commission.create.mockResolvedValue({ id: "commission-1" })
await createCustomer({
name: "Ada Lovelace",
email: "ada@example.com",
isRecurring: true,
})
await createCommission({
title: "Portrait Request",
status: "new",
customerId: "550e8400-e29b-41d4-a716-446655440000",
})
expect(mockDb.customer.create).toHaveBeenCalledTimes(1)
expect(mockDb.commission.create).toHaveBeenCalledTimes(1)
})
it("updates commission status", async () => {
mockDb.commission.update.mockResolvedValue({ id: "commission-1", status: "done" })
await updateCommissionStatus({
id: "550e8400-e29b-41d4-a716-446655440001",
status: "done",
})
expect(mockDb.commission.update).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,66 @@
import {
commissionStatusSchema,
createCommissionInputSchema,
createCustomerInputSchema,
updateCommissionStatusInputSchema,
} from "@cms/content"
import { db } from "./client"
export const commissionKanbanOrder = commissionStatusSchema.options
export async function listCustomers(limit = 200) {
return db.customer.findMany({
orderBy: [{ updatedAt: "desc" }],
take: limit,
})
}
export async function createCustomer(input: unknown) {
const payload = createCustomerInputSchema.parse(input)
return db.customer.create({
data: payload,
})
}
export async function listCommissions(limit = 300) {
return db.commission.findMany({
orderBy: [{ updatedAt: "desc" }],
take: limit,
include: {
customer: {
select: {
id: true,
name: true,
email: true,
isRecurring: true,
},
},
assignedUser: {
select: {
id: true,
name: true,
username: true,
},
},
},
})
}
export async function createCommission(input: unknown) {
const payload = createCommissionInputSchema.parse(input)
return db.commission.create({
data: payload,
})
}
export async function updateCommissionStatus(input: unknown) {
const payload = updateCommissionStatusInputSchema.parse(input)
return db.commission.update({
where: { id: payload.id },
data: { status: payload.status },
})
}

View File

@@ -1,4 +1,12 @@
export { db } from "./client"
export {
commissionKanbanOrder,
createCommission,
createCustomer,
listCommissions,
listCustomers,
updateCommissionStatus,
} from "./commissions"
export {
attachArtworkRendition,
createAlbum,
@@ -16,6 +24,7 @@ export {
listMediaFoundationGroups,
updateMediaAsset,
} from "./media-foundation"
export type { PublicNavigationItem } from "./pages-navigation"
export {
createNavigationItem,
createNavigationMenu,
@@ -23,8 +32,11 @@ export {
deleteNavigationItem,
deletePage,
getPageById,
getPublishedPageBySlug,
listNavigationMenus,
listPages,
listPublicNavigation,
listPublishedPageSlugs,
updateNavigationItem,
updatePage,
} from "./pages-navigation"

View File

@@ -12,6 +12,7 @@ const { mockDb } = vi.hoisted(() => ({
navigationMenu: {
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
},
navigationItem: {
create: vi.fn(),
@@ -29,6 +30,7 @@ import {
createNavigationItem,
createNavigationMenu,
createPage,
listPublicNavigation,
updatePage,
} from "./pages-navigation"
@@ -89,4 +91,33 @@ describe("pages-navigation service", () => {
expect(mockDb.navigationMenu.create).toHaveBeenCalledTimes(1)
expect(mockDb.navigationItem.create).toHaveBeenCalledTimes(1)
})
it("maps public navigation href from linked pages", async () => {
mockDb.navigationMenu.findFirst.mockResolvedValue({
id: "menu-1",
items: [
{
id: "item-1",
label: "Home",
href: null,
parentId: null,
page: {
slug: "home",
status: "published",
},
},
],
})
const navigation = await listPublicNavigation("header")
expect(navigation).toEqual([
{
id: "item-1",
label: "Home",
href: "/",
children: [],
},
])
})
})

View File

@@ -8,6 +8,13 @@ import {
import { db } from "./client"
export type PublicNavigationItem = {
id: string
label: string
href: string
children: PublicNavigationItem[]
}
function resolvePublishedAt(status: string): Date | null {
return status === "published" ? new Date() : null
}
@@ -19,12 +26,34 @@ export async function listPages(limit = 50) {
})
}
export async function listPublishedPageSlugs() {
const pages = await db.page.findMany({
where: { status: "published" },
orderBy: { updatedAt: "desc" },
select: {
slug: true,
updatedAt: true,
},
})
return pages
}
export async function getPageById(id: string) {
return db.page.findUnique({
where: { id },
})
}
export async function getPublishedPageBySlug(slug: string) {
return db.page.findFirst({
where: {
slug,
status: "published",
},
})
}
export async function createPage(input: unknown) {
const payload = createPageInputSchema.parse(input)
@@ -76,6 +105,108 @@ export async function listNavigationMenus() {
})
}
function resolveNavigationHref(item: {
href: string | null
page: {
slug: string
status: string
} | null
}): string | null {
if (item.href) {
return item.href
}
if (item.page?.status === "published") {
return item.page.slug === "home" ? "/" : `/${item.page.slug}`
}
return null
}
export async function listPublicNavigation(location = "header"): Promise<PublicNavigationItem[]> {
const menu = await db.navigationMenu.findFirst({
where: {
location,
isVisible: true,
},
orderBy: { updatedAt: "desc" },
include: {
items: {
where: {
isVisible: true,
},
orderBy: [{ sortOrder: "asc" }, { label: "asc" }],
include: {
page: {
select: {
slug: true,
status: true,
},
},
},
},
},
})
if (!menu) {
return []
}
const itemMap = new Map<
string,
{
id: string
label: string
href: string
parentId: string | null
children: PublicNavigationItem[]
}
>()
for (const item of menu.items) {
const href = resolveNavigationHref(item)
if (!href) {
continue
}
itemMap.set(item.id, {
id: item.id,
label: item.label,
href,
parentId: item.parentId,
children: [],
})
}
const roots: PublicNavigationItem[] = []
for (const entry of itemMap.values()) {
if (entry.parentId) {
const parent = itemMap.get(entry.parentId)
if (parent) {
parent.children.push({
id: entry.id,
label: entry.label,
href: entry.href,
children: entry.children,
})
continue
}
}
roots.push({
id: entry.id,
label: entry.label,
href: entry.href,
children: entry.children,
})
}
return roots
}
export async function createNavigationMenu(input: unknown) {
const payload = createNavigationMenuInputSchema.parse(input)