feat(pages): add pages and navigation builder baseline

This commit is contained in:
2026-02-12 19:30:09 +01:00
parent 7d9bc9dca9
commit 281b1d7a1b
15 changed files with 1372 additions and 16 deletions

View File

@@ -1,6 +1,7 @@
import { z } from "zod"
export * from "./media"
export * from "./pages-navigation"
export * from "./rbac"
export const postStatusSchema = z.enum(["draft", "published"])

View File

@@ -0,0 +1,57 @@
import { z } from "zod"
export const pageStatusSchema = z.enum(["draft", "published"])
export const createPageInputSchema = z.object({
title: z.string().min(1).max(180),
slug: z.string().min(1).max(180),
status: pageStatusSchema.default("draft"),
summary: z.string().max(500).nullable().optional(),
content: z.string().min(1),
seoTitle: z.string().max(180).nullable().optional(),
seoDescription: z.string().max(320).nullable().optional(),
})
export const updatePageInputSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1).max(180).optional(),
slug: z.string().min(1).max(180).optional(),
status: pageStatusSchema.optional(),
summary: z.string().max(500).nullable().optional(),
content: z.string().min(1).optional(),
seoTitle: z.string().max(180).nullable().optional(),
seoDescription: z.string().max(320).nullable().optional(),
})
export const createNavigationMenuInputSchema = z.object({
name: z.string().min(1).max(180),
slug: z.string().min(1).max(180),
location: z.string().min(1).max(80).default("primary"),
isVisible: z.boolean().default(true),
})
export const createNavigationItemInputSchema = z.object({
menuId: z.string().uuid(),
label: z.string().min(1).max(180),
href: z.string().max(500).nullable().optional(),
pageId: z.string().uuid().nullable().optional(),
parentId: z.string().uuid().nullable().optional(),
sortOrder: z.number().int().min(0).default(0),
isVisible: z.boolean().default(true),
})
export const updateNavigationItemInputSchema = z.object({
id: z.string().uuid(),
label: z.string().min(1).max(180).optional(),
href: z.string().max(500).nullable().optional(),
pageId: z.string().uuid().nullable().optional(),
parentId: z.string().uuid().nullable().optional(),
sortOrder: z.number().int().min(0).optional(),
isVisible: z.boolean().optional(),
})
export type CreatePageInput = z.infer<typeof createPageInputSchema>
export type UpdatePageInput = z.infer<typeof updatePageInputSchema>
export type CreateNavigationMenuInput = z.infer<typeof createNavigationMenuInputSchema>
export type CreateNavigationItemInput = z.infer<typeof createNavigationItemInputSchema>
export type UpdateNavigationItemInput = z.infer<typeof updateNavigationItemInputSchema>

View File

@@ -0,0 +1,75 @@
-- CreateTable
CREATE TABLE "Page" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"status" TEXT NOT NULL,
"summary" TEXT,
"content" TEXT NOT NULL,
"seoTitle" TEXT,
"seoDescription" TEXT,
"publishedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Page_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NavigationMenu" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"location" TEXT NOT NULL,
"isVisible" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NavigationMenu_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NavigationItem" (
"id" TEXT NOT NULL,
"menuId" TEXT NOT NULL,
"pageId" TEXT,
"label" TEXT NOT NULL,
"href" TEXT,
"parentId" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isVisible" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NavigationItem_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Page_slug_key" ON "Page"("slug");
-- CreateIndex
CREATE INDEX "Page_status_idx" ON "Page"("status");
-- CreateIndex
CREATE UNIQUE INDEX "NavigationMenu_slug_key" ON "NavigationMenu"("slug");
-- CreateIndex
CREATE INDEX "NavigationItem_menuId_idx" ON "NavigationItem"("menuId");
-- CreateIndex
CREATE INDEX "NavigationItem_pageId_idx" ON "NavigationItem"("pageId");
-- CreateIndex
CREATE INDEX "NavigationItem_parentId_idx" ON "NavigationItem"("parentId");
-- CreateIndex
CREATE UNIQUE INDEX "NavigationItem_menuId_parentId_sortOrder_label_key" ON "NavigationItem"("menuId", "parentId", "sortOrder", "label");
-- AddForeignKey
ALTER TABLE "NavigationItem" ADD CONSTRAINT "NavigationItem_menuId_fkey" FOREIGN KEY ("menuId") REFERENCES "NavigationMenu"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NavigationItem" ADD CONSTRAINT "NavigationItem_pageId_fkey" FOREIGN KEY ("pageId") REFERENCES "Page"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NavigationItem" ADD CONSTRAINT "NavigationItem_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "NavigationItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -252,3 +252,53 @@ model ArtworkTag {
@@unique([artworkId, tagId])
@@index([tagId])
}
model Page {
id String @id @default(uuid())
title String
slug String @unique
status String
summary String?
content String
seoTitle String?
seoDescription String?
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
navItems NavigationItem[]
@@index([status])
}
model NavigationMenu {
id String @id @default(uuid())
name String
slug String @unique
location String
isVisible Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items NavigationItem[]
}
model NavigationItem {
id String @id @default(uuid())
menuId String
pageId String?
label String
href String?
parentId String?
sortOrder Int @default(0)
isVisible Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
menu NavigationMenu @relation(fields: [menuId], references: [id], onDelete: Cascade)
page Page? @relation(fields: [pageId], references: [id], onDelete: SetNull)
parent NavigationItem? @relation("NavigationItemParent", fields: [parentId], references: [id], onDelete: Cascade)
children NavigationItem[] @relation("NavigationItemParent")
@@index([menuId])
@@index([pageId])
@@index([parentId])
@@unique([menuId, parentId, sortOrder, label])
}

View File

@@ -95,6 +95,69 @@ async function main() {
}),
},
})
const homePage = await db.page.upsert({
where: { slug: "home" },
update: {},
create: {
title: "Home",
slug: "home",
status: "published",
summary: "Default homepage seeded for pages/navigation baseline.",
content: "Welcome to your new artist CMS homepage.",
seoTitle: "Home",
seoDescription: "Seeded homepage",
publishedAt: new Date(),
},
})
const primaryMenu = await db.navigationMenu.upsert({
where: { slug: "primary" },
update: {},
create: {
name: "Primary",
slug: "primary",
location: "header",
isVisible: true,
},
})
const existingHomeItem = await db.navigationItem.findFirst({
where: {
menuId: primaryMenu.id,
parentId: null,
sortOrder: 0,
label: "Home",
},
select: {
id: true,
},
})
if (existingHomeItem) {
await db.navigationItem.update({
where: {
id: existingHomeItem.id,
},
data: {
pageId: homePage.id,
href: "/",
isVisible: true,
},
})
} else {
await db.navigationItem.create({
data: {
menuId: primaryMenu.id,
label: "Home",
href: "/",
pageId: homePage.id,
parentId: null,
sortOrder: 0,
isVisible: true,
},
})
}
}
main()

View File

@@ -16,6 +16,18 @@ export {
listMediaFoundationGroups,
updateMediaAsset,
} from "./media-foundation"
export {
createNavigationItem,
createNavigationMenu,
createPage,
deleteNavigationItem,
deletePage,
getPageById,
listNavigationMenus,
listPages,
updateNavigationItem,
updatePage,
} from "./pages-navigation"
export {
createPost,
deletePost,

View File

@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const { mockDb } = vi.hoisted(() => ({
mockDb: {
page: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
},
navigationMenu: {
create: vi.fn(),
findMany: vi.fn(),
},
navigationItem: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}))
vi.mock("./client", () => ({
db: mockDb,
}))
import {
createNavigationItem,
createNavigationMenu,
createPage,
updatePage,
} from "./pages-navigation"
describe("pages-navigation service", () => {
beforeEach(() => {
for (const value of Object.values(mockDb)) {
for (const fn of Object.values(value)) {
if (typeof fn === "function") {
fn.mockReset()
}
}
}
})
it("creates published pages with publishedAt", async () => {
mockDb.page.create.mockResolvedValue({ id: "page-1" })
await createPage({
title: "About",
slug: "about",
status: "published",
content: "hello",
})
expect(mockDb.page.create).toHaveBeenCalledTimes(1)
expect(mockDb.page.create.mock.calls[0]?.[0].data.publishedAt).toBeInstanceOf(Date)
})
it("updates page status publication timestamp", async () => {
mockDb.page.update.mockResolvedValue({ id: "page-1" })
await updatePage({
id: "550e8400-e29b-41d4-a716-446655440000",
status: "draft",
})
expect(mockDb.page.update).toHaveBeenCalledTimes(1)
expect(mockDb.page.update.mock.calls[0]?.[0].data.publishedAt).toBeNull()
})
it("creates menus and items with schema parsing", async () => {
mockDb.navigationMenu.create.mockResolvedValue({ id: "menu-1" })
mockDb.navigationItem.create.mockResolvedValue({ id: "item-1" })
await createNavigationMenu({
name: "Primary",
slug: "primary",
location: "header",
})
await createNavigationItem({
menuId: "550e8400-e29b-41d4-a716-446655440001",
label: "Home",
href: "/",
sortOrder: 0,
})
expect(mockDb.navigationMenu.create).toHaveBeenCalledTimes(1)
expect(mockDb.navigationItem.create).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,109 @@
import {
createNavigationItemInputSchema,
createNavigationMenuInputSchema,
createPageInputSchema,
updateNavigationItemInputSchema,
updatePageInputSchema,
} from "@cms/content"
import { db } from "./client"
function resolvePublishedAt(status: string): Date | null {
return status === "published" ? new Date() : null
}
export async function listPages(limit = 50) {
return db.page.findMany({
orderBy: [{ updatedAt: "desc" }],
take: limit,
})
}
export async function getPageById(id: string) {
return db.page.findUnique({
where: { id },
})
}
export async function createPage(input: unknown) {
const payload = createPageInputSchema.parse(input)
return db.page.create({
data: {
...payload,
publishedAt: resolvePublishedAt(payload.status),
},
})
}
export async function updatePage(input: unknown) {
const payload = updatePageInputSchema.parse(input)
const { id, ...data } = payload
return db.page.update({
where: { id },
data: {
...data,
publishedAt:
data.status === undefined ? undefined : data.status === "published" ? new Date() : null,
},
})
}
export async function deletePage(id: string) {
return db.page.delete({
where: { id },
})
}
export async function listNavigationMenus() {
return db.navigationMenu.findMany({
orderBy: [{ location: "asc" }, { name: "asc" }],
include: {
items: {
orderBy: [{ sortOrder: "asc" }, { label: "asc" }],
include: {
page: {
select: {
id: true,
title: true,
slug: true,
},
},
},
},
},
})
}
export async function createNavigationMenu(input: unknown) {
const payload = createNavigationMenuInputSchema.parse(input)
return db.navigationMenu.create({
data: payload,
})
}
export async function createNavigationItem(input: unknown) {
const payload = createNavigationItemInputSchema.parse(input)
return db.navigationItem.create({
data: payload,
})
}
export async function updateNavigationItem(input: unknown) {
const payload = updateNavigationItemInputSchema.parse(input)
const { id, ...data } = payload
return db.navigationItem.update({
where: { id },
data,
})
}
export async function deleteNavigationItem(id: string) {
return db.navigationItem.delete({
where: { id },
})
}