feat(pages): add pages and navigation builder baseline
This commit is contained in:
@@ -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"])
|
||||
|
||||
57
packages/content/src/pages-navigation.ts
Normal file
57
packages/content/src/pages-navigation.ts
Normal 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>
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
92
packages/db/src/pages-navigation.test.ts
Normal file
92
packages/db/src/pages-navigation.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
109
packages/db/src/pages-navigation.ts
Normal file
109
packages/db/src/pages-navigation.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user