Compare commits

...

5 Commits

31 changed files with 1607 additions and 364 deletions

20
TODO.md
View File

@@ -171,7 +171,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P1] Media entity rendering with enrichment data - [ ] [P1] Media entity rendering with enrichment data
- [ ] [P1] Portfolio views (gallery/album/category/tag) for artworks with filter and sort controls - [ ] [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 - [ ] [P1] Rendition-aware media delivery (thumbnail/card/full) per template slot
- [ ] [P1] Translation-ready content model for public entities (pages/news/navigation labels) - [~] [P1] Translation-ready content model for public entities (pages/news/navigation labels)
- [ ] [P2] Artwork views and listing filters - [ ] [P2] Artwork views and listing filters
- [ ] [P1] Commission request submission flow - [ ] [P1] Commission request submission flow
- [x] [P1] Header banner render logic and fallbacks - [x] [P1] Header banner render logic and fallbacks
@@ -187,21 +187,21 @@ This file is the single source of truth for roadmap and delivery progress.
### Testing ### Testing
- [x] [P1] Unit tests for content schemas and service logic - [x] [P1] Unit tests for content schemas and service logic
- [~] [P1] Component tests for admin forms (pages/media/navigation) - [x] [P1] Component tests for admin forms (pages/media/navigation)
- [x] [P1] Integration tests for owner invariant and hidden support-user protection - [x] [P1] Integration tests for owner invariant and hidden support-user protection
- [x] [P1] Integration tests for registration allow/deny behavior - [x] [P1] Integration tests for registration allow/deny behavior
- [ ] [P1] Integration tests for translated content CRUD and locale-specific validation - [x] [P1] Integration tests for translated content CRUD and locale-specific validation
- [~] [P1] E2E happy paths: create page, publish, see on public app - [~] [P1] E2E happy paths: create page, publish, see on public app
- [~] [P1] E2E happy paths: media upload + artwork refinement display - [~] [P1] E2E happy paths: media upload + artwork refinement display
- [~] [P1] E2E happy paths: commissions kanban transitions - [~] [P1] E2E happy paths: commissions kanban transitions
### Code Documentation And Handover ### Code Documentation And Handover
- [ ] [P1] Create architecture map per package/app (`what exists`, `why`, `how to extend`) for `@cms/db`, `@cms/content`, `@cms/crud`, `@cms/ui`, `apps/admin`, `apps/web` - [x] [P1] Create architecture map per package/app (`what exists`, `why`, `how to extend`) for `@cms/db`, `@cms/content`, `@cms/crud`, `@cms/ui`, `apps/admin`, `apps/web`
- [ ] [P1] Add module-level ownership docs for auth, media, pages/navigation, commissions, announcements/news flows - [x] [P1] Add module-level ownership docs for auth, media, pages/navigation, commissions, announcements/news flows
- [ ] [P1] Document critical invariants (single owner rule, protected support user, registration policy gates, media storage key contract) - [x] [P1] Document critical invariants (single owner rule, protected support user, registration policy gates, media storage key contract)
- [ ] [P1] Add “request lifecycle” docs for key flows (auth sign-in/up, media upload, page publish, commission status change) - [x] [P1] Add “request lifecycle” docs for key flows (auth sign-in/up, media upload, page publish, commission status change)
- [ ] [P1] Add coding handover playbook: local setup, migration workflow, test strategy, branch/release process, common failure recovery - [x] [P1] Add coding handover playbook: local setup, migration workflow, test strategy, branch/release process, common failure recovery
- [ ] [P2] Add code-level diagrams (Mermaid) for service boundaries and data relationships - [ ] [P2] Add code-level diagrams (Mermaid) for service boundaries and data relationships
- [ ] [P2] Add route/action inventory for admin and public apps with linked source files - [ ] [P2] Add route/action inventory for admin and public apps with linked source files
@@ -319,6 +319,10 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-12] Admin settings now manage public header banner (enabled/message/CTA), backed by `system_setting` and consumed by public layout rendering. - [2026-02-12] Admin settings now manage public header banner (enabled/message/CTA), backed by `system_setting` and consumed by public layout rendering.
- [2026-02-12] Added owner/support invariant integration tests for auth guards (`apps/admin/src/lib/auth/server.test.ts`), covering protected-user deletion blocking and one-owner repair/promotion rules. - [2026-02-12] Added owner/support invariant integration tests for auth guards (`apps/admin/src/lib/auth/server.test.ts`), covering protected-user deletion blocking and one-owner repair/promotion rules.
- [2026-02-12] Started admin form component tests with media upload behavior coverage (`apps/admin/src/components/media/media-upload-form.test.tsx`). - [2026-02-12] Started admin form component tests with media upload behavior coverage (`apps/admin/src/components/media/media-upload-form.test.tsx`).
- [2026-02-12] Added code handover documentation baseline: architecture map, critical invariants, request lifecycles, and onboarding playbook under `docs/product-engineering/`.
- [2026-02-12] Completed admin form component coverage for pages/navigation/media using isolated form components and tests.
- [2026-02-12] Added page translation CRUD baseline (`PageTranslation`) with locale validation (`de/en/es/fr`) and integration coverage for localized read + fallback behavior.
- [2026-02-12] Page editor now supports locale translations in `/pages/:id`; public page rendering uses locale-aware page lookup with base-content fallback.
## How We Use This File ## How We Use This File

View File

@@ -5,17 +5,23 @@ import {
listNavigationMenus, listNavigationMenus,
listPages, listPages,
updateNavigationItem, updateNavigationItem,
upsertNavigationItemTranslation,
} from "@cms/db" } from "@cms/db"
import { Button } from "@cms/ui/button" import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { AdminShell } from "@/components/admin-shell" import { AdminShell } from "@/components/admin-shell"
import { CreateMenuForm } from "@/components/navigation/create-menu-form"
import { CreateNavigationItemForm } from "@/components/navigation/create-navigation-item-form"
import { requirePermissionForRoute } from "@/lib/route-guards" import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined> type SearchParamsInput = Record<string, string | string[] | undefined>
const SUPPORTED_LOCALES = ["de", "en", "es", "fr"] as const
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]
function readFirstValue(value: string | string[] | undefined): string | null { function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) { if (Array.isArray(value)) {
@@ -51,6 +57,14 @@ function readInt(formData: FormData, field: string, fallback = 0): number {
return parsed return parsed
} }
function normalizeLocale(input: string | null): SupportedLocale {
if (input && SUPPORTED_LOCALES.includes(input as SupportedLocale)) {
return input as SupportedLocale
}
return "en"
}
function redirectWithState(params: { notice?: string; error?: string }) { function redirectWithState(params: { notice?: string; error?: string }) {
const query = new URLSearchParams() const query = new URLSearchParams()
@@ -163,6 +177,31 @@ async function deleteItemAction(formData: FormData) {
redirectWithState({ notice: "Navigation item deleted." }) redirectWithState({ notice: "Navigation item deleted." })
} }
async function upsertItemTranslationAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/navigation",
permission: "navigation:write",
scope: "team",
})
const locale = normalizeLocale(readInputString(formData, "locale"))
try {
await upsertNavigationItemTranslation({
navigationItemId: readInputString(formData, "navigationItemId"),
locale,
label: readInputString(formData, "label"),
})
} catch {
redirectWithState({ error: "Failed to save item translation." })
}
revalidatePath("/navigation")
redirectWithState({ notice: "Navigation item translation saved." })
}
export default async function NavigationManagementPage({ export default async function NavigationManagementPage({
searchParams, searchParams,
}: { }: {
@@ -182,6 +221,7 @@ export default async function NavigationManagementPage({
const notice = readFirstValue(resolvedSearchParams.notice) const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error) const error = readFirstValue(resolvedSearchParams.error)
const selectedLocale = normalizeLocale(readFirstValue(resolvedSearchParams.locale))
return ( return (
<AdminShell <AdminShell
@@ -206,127 +246,32 @@ export default async function NavigationManagementPage({
<section className="grid gap-4 lg:grid-cols-2"> <section className="grid gap-4 lg:grid-cols-2">
<article className="rounded-xl border border-neutral-200 p-6"> <article className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Menu</h2> <h2 className="text-xl font-medium">Create Menu</h2>
<form action={createMenuAction} className="mt-4 space-y-3"> <CreateMenuForm action={createMenuAction} />
<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>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<input
name="slug"
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">Location</span>
<input
name="location"
defaultValue="primary"
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="isVisible"
type="checkbox"
value="true"
defaultChecked
className="size-4"
/>
Visible
</label>
<Button type="submit">Create menu</Button>
</form>
</article> </article>
<article className="rounded-xl border border-neutral-200 p-6"> <article className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Navigation Item</h2> <h2 className="text-xl font-medium">Create Navigation Item</h2>
<form action={createItemAction} className="mt-4 space-y-3"> <CreateNavigationItemForm action={createItemAction} menus={menus} pages={pages} />
<label className="space-y-1">
<span className="text-xs text-neutral-600">Menu</span>
<select
name="menuId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
{menus.map((menu) => (
<option key={menu.id} value={menu.id}>
{menu.name} ({menu.location})
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Label</span>
<input
name="label"
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">Custom href</span>
<input
name="href"
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">Linked page</span>
<select
name="pageId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="">(none)</option>
{pages.map((page) => (
<option key={page.id} value={page.id}>
{page.title} (/{page.slug})
</option>
))}
</select>
</label>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Parent item id</span>
<input
name="parentId"
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">Sort order</span>
<input
name="sortOrder"
defaultValue="0"
type="number"
min={0}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<input
name="isVisible"
type="checkbox"
value="true"
defaultChecked
className="size-4"
/>
Visible
</label>
<Button type="submit">Create item</Button>
</form>
</article> </article>
</section> </section>
<section className="space-y-4"> <section className="space-y-4">
<div className="flex flex-wrap gap-2">
{SUPPORTED_LOCALES.map((locale) => (
<a
key={locale}
href={`/navigation?locale=${locale}`}
className={`inline-flex rounded border px-3 py-1.5 text-xs ${
selectedLocale === locale
? "border-neutral-800 bg-neutral-900 text-white"
: "border-neutral-300 text-neutral-700"
}`}
>
{locale.toUpperCase()}
</a>
))}
</div>
{menus.length === 0 ? ( {menus.length === 0 ? (
<article className="rounded-xl border border-neutral-200 p-6 text-sm text-neutral-600"> <article className="rounded-xl border border-neutral-200 p-6 text-sm text-neutral-600">
No navigation menus yet. No navigation menus yet.
@@ -347,94 +292,126 @@ export default async function NavigationManagementPage({
{menu.items.length === 0 ? ( {menu.items.length === 0 ? (
<p className="text-sm text-neutral-600">No items in this menu.</p> <p className="text-sm text-neutral-600">No items in this menu.</p>
) : ( ) : (
menu.items.map((item) => ( menu.items.map((item) => {
<form const translation = item.translations.find(
key={item.id} (entry) => entry.locale === selectedLocale,
action={updateItemAction} )
className="rounded-lg border border-neutral-200 p-3"
>
<input type="hidden" name="id" value={item.id} />
<div className="grid gap-3 md:grid-cols-5">
<label className="space-y-1 md:col-span-2">
<span className="text-xs text-neutral-600">Label</span>
<input
name="label"
defaultValue={item.label}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1 md:col-span-2">
<span className="text-xs text-neutral-600">Href</span>
<input
name="href"
defaultValue={item.href ?? ""}
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">Sort</span>
<input
name="sortOrder"
type="number"
min={0}
defaultValue={item.sortOrder}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2"> return (
<label className="space-y-1"> <div key={item.id} className="rounded-lg border border-neutral-200 p-3">
<span className="text-xs text-neutral-600">Linked page</span> <form action={updateItemAction}>
<select <input type="hidden" name="id" value={item.id} />
name="pageId" <div className="grid gap-3 md:grid-cols-5">
defaultValue={item.pageId ?? ""} <label className="space-y-1 md:col-span-2">
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" <span className="text-xs text-neutral-600">Label</span>
> <input
<option value="">(none)</option> name="label"
{pages.map((page) => ( defaultValue={item.label}
<option key={page.id} value={page.id}> className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
{page.title} (/{page.slug}) />
</option> </label>
))} <label className="space-y-1 md:col-span-2">
</select> <span className="text-xs text-neutral-600">Href</span>
</label> <input
<label className="space-y-1"> name="href"
<span className="text-xs text-neutral-600">Parent id</span> defaultValue={item.href ?? ""}
<input className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
name="parentId" />
defaultValue={item.parentId ?? ""} </label>
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" <label className="space-y-1">
/> <span className="text-xs text-neutral-600">Sort</span>
</label> <input
</div> name="sortOrder"
type="number"
min={0}
defaultValue={item.sortOrder}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3"> <div className="mt-3 grid gap-3 md:grid-cols-2">
<label className="inline-flex items-center gap-2 text-sm text-neutral-700"> <label className="space-y-1">
<input <span className="text-xs text-neutral-600">Linked page</span>
type="checkbox" <select
name="isVisible" name="pageId"
value="true" defaultValue={item.pageId ?? ""}
defaultChecked={item.isVisible} className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
className="size-4" >
/> <option value="">(none)</option>
Visible {pages.map((page) => (
</label> <option key={page.id} value={page.id}>
<div className="flex items-center gap-2"> {page.title} (/{page.slug})
<Button type="submit" size="sm"> </option>
Save item ))}
</Button> </select>
<button </label>
type="submit" <label className="space-y-1">
formAction={deleteItemAction} <span className="text-xs text-neutral-600">Parent id</span>
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700" <input
> name="parentId"
Delete defaultValue={item.parentId ?? ""}
</button> className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
</div> />
</label>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
name="isVisible"
value="true"
defaultChecked={item.isVisible}
className="size-4"
/>
Visible
</label>
<div className="flex items-center gap-2">
<Button type="submit" size="sm">
Save item
</Button>
<button
type="submit"
formAction={deleteItemAction}
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
>
Delete
</button>
</div>
</div>
</form>
<form
action={upsertItemTranslationAction}
className="mt-3 rounded border border-neutral-200 p-3"
>
<input type="hidden" name="navigationItemId" value={item.id} />
<input type="hidden" name="locale" value={selectedLocale} />
<p className="text-xs text-neutral-600">
Translation ({selectedLocale.toUpperCase()}) - saved locales:{" "}
{item.translations.length > 0
? item.translations
.map((entry) => entry.locale.toUpperCase())
.join(", ")
: "none"}
</p>
<div className="mt-2 flex gap-2">
<input
name="label"
defaultValue={translation?.label ?? item.label}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<Button type="submit" size="sm" variant="secondary">
Save translation
</Button>
</div>
</form>
</div> </div>
</form> )
)) })
)} )}
</div> </div>
</article> </article>

View File

@@ -1,4 +1,10 @@
import { createPost, deletePost, listPosts, updatePost } from "@cms/db" import {
createPost,
deletePost,
listPostsWithTranslations,
updatePost,
upsertPostTranslation,
} from "@cms/db"
import { Button } from "@cms/ui/button" import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
@@ -9,6 +15,9 @@ import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined> type SearchParamsInput = Record<string, string | string[] | undefined>
const SUPPORTED_LOCALES = ["de", "en", "es", "fr"] as const
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]
function readFirstValue(value: string | string[] | undefined): string | null { function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) { if (Array.isArray(value)) {
@@ -28,6 +37,14 @@ function readNullableString(formData: FormData, field: string): string | undefin
return value.length > 0 ? value : undefined return value.length > 0 ? value : undefined
} }
function normalizeLocale(input: string | null): SupportedLocale {
if (input && SUPPORTED_LOCALES.includes(input as SupportedLocale)) {
return input as SupportedLocale
}
return "en"
}
function redirectWithState(params: { notice?: string; error?: string }) { function redirectWithState(params: { notice?: string; error?: string }) {
const query = new URLSearchParams() const query = new URLSearchParams()
@@ -115,6 +132,34 @@ async function deleteNewsAction(formData: FormData) {
redirectWithState({ notice: "Post deleted." }) redirectWithState({ notice: "Post deleted." })
} }
async function upsertNewsTranslationAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/news",
permission: "news:write",
scope: "team",
})
const locale = normalizeLocale(readInputString(formData, "locale"))
try {
await upsertPostTranslation({
postId: readInputString(formData, "postId"),
locale,
title: readInputString(formData, "title"),
excerpt: readNullableString(formData, "excerpt") ?? null,
body: readInputString(formData, "body"),
})
} catch {
redirectWithState({ error: "Failed to save translation." })
}
revalidatePath("/news")
revalidatePath("/")
redirectWithState({ notice: "Post translation saved." })
}
export default async function NewsManagementPage({ export default async function NewsManagementPage({
searchParams, searchParams,
}: { }: {
@@ -126,10 +171,14 @@ export default async function NewsManagementPage({
scope: "team", scope: "team",
}) })
const [resolvedSearchParams, posts] = await Promise.all([searchParams, listPosts()]) const [resolvedSearchParams, posts] = await Promise.all([
searchParams,
listPostsWithTranslations(),
])
const notice = readFirstValue(resolvedSearchParams.notice) const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error) const error = readFirstValue(resolvedSearchParams.error)
const selectedLocale = normalizeLocale(readFirstValue(resolvedSearchParams.locale))
return ( return (
<AdminShell <AdminShell
@@ -204,72 +253,146 @@ export default async function NewsManagementPage({
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
{posts.map((post) => ( <div className="flex flex-wrap gap-2">
<form {SUPPORTED_LOCALES.map((locale) => (
key={post.id} <a
action={updateNewsAction} key={locale}
className="rounded-xl border border-neutral-200 p-6" href={`/news?locale=${locale}`}
> className={`inline-flex rounded border px-3 py-1.5 text-xs ${
<input type="hidden" name="id" value={post.id} /> selectedLocale === locale
<div className="grid gap-3 md:grid-cols-2"> ? "border-neutral-800 bg-neutral-900 text-white"
<label className="space-y-1"> : "border-neutral-300 text-neutral-700"
<span className="text-xs text-neutral-600">Title</span> }`}
<input >
name="title" {locale.toUpperCase()}
defaultValue={post.title} </a>
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" ))}
/> </div>
</label>
<label className="space-y-1"> {posts.map((post) => {
<span className="text-xs text-neutral-600">Slug</span> const translation = post.translations.find((entry) => entry.locale === selectedLocale)
<input
name="slug" return (
defaultValue={post.slug} <div key={post.id} className="rounded-xl border border-neutral-200 p-6">
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" <form action={updateNewsAction}>
/> <input type="hidden" name="id" value={post.id} />
</label> <div className="grid gap-3 md:grid-cols-2">
</div> <label className="space-y-1">
<label className="mt-3 block space-y-1"> <span className="text-xs text-neutral-600">Title</span>
<span className="text-xs text-neutral-600">Excerpt</span> <input
<input name="title"
name="excerpt" defaultValue={post.title}
defaultValue={post.excerpt ?? ""} className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" />
/> </label>
</label> <label className="space-y-1">
<label className="mt-3 block space-y-1"> <span className="text-xs text-neutral-600">Slug</span>
<span className="text-xs text-neutral-600">Body</span> <input
<textarea name="slug"
name="body" defaultValue={post.slug}
rows={4} className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
defaultValue={post.body} />
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" </label>
/> </div>
</label> <label className="mt-3 block space-y-1">
<div className="mt-3 flex flex-wrap items-center justify-between gap-3"> <span className="text-xs text-neutral-600">Excerpt</span>
<select <input
name="status" name="excerpt"
defaultValue={post.status} defaultValue={post.excerpt ?? ""}
className="rounded border border-neutral-300 px-3 py-2 text-sm" className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="mt-3 block space-y-1">
<span className="text-xs text-neutral-600">Body</span>
<textarea
name="body"
rows={4}
defaultValue={post.body}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<select
name="status"
defaultValue={post.status}
className="rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="draft">draft</option>
<option value="published">published</option>
</select>
<div className="flex items-center gap-2">
<Button type="submit" size="sm">
Save
</Button>
<button
type="submit"
formAction={deleteNewsAction}
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
>
Delete
</button>
</div>
</div>
</form>
<form
action={upsertNewsTranslationAction}
className="mt-4 rounded-lg border border-neutral-200 p-4"
> >
<option value="draft">draft</option> <input type="hidden" name="postId" value={post.id} />
<option value="published">published</option> <input type="hidden" name="locale" value={selectedLocale} />
</select>
<div className="flex items-center gap-2"> <h3 className="text-sm font-medium">
<Button type="submit" size="sm"> Translation ({selectedLocale.toUpperCase()})
Save </h3>
</Button> <p className="mt-1 text-xs text-neutral-600">
<button Missing fields fall back to base post content on public pages.
type="submit" </p>
formAction={deleteNewsAction} {post.translations.length > 0 ? (
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700" <p className="mt-2 text-xs text-neutral-600">
> Saved locales:{" "}
Delete {post.translations.map((entry) => entry.locale.toUpperCase()).join(", ")}
</button> </p>
</div> ) : null}
<div className="mt-3 grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
defaultValue={translation?.title ?? post.title}
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">Excerpt</span>
<input
name="excerpt"
defaultValue={translation?.excerpt ?? post.excerpt ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="mt-3 block space-y-1">
<span className="text-xs text-neutral-600">Body</span>
<textarea
name="body"
rows={4}
defaultValue={translation?.body ?? post.body}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="mt-3">
<Button type="submit" size="sm">
Save translation
</Button>
</div>
</form>
</div> </div>
</form> )
))} })}
</section> </section>
</AdminShell> </AdminShell>
) )

View File

@@ -1,4 +1,10 @@
import { deletePage, getPageById, updatePage } from "@cms/db" import {
deletePage,
getPageById,
listPageTranslations,
updatePage,
upsertPageTranslation,
} from "@cms/db"
import { Button } from "@cms/ui/button" import { Button } from "@cms/ui/button"
import Link from "next/link" import Link from "next/link"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
@@ -9,6 +15,8 @@ import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined> type SearchParamsInput = Record<string, string | string[] | undefined>
const SUPPORTED_LOCALES = ["de", "en", "es", "fr"] as const
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]
type PageProps = { type PageProps = {
params: Promise<{ id: string }> params: Promise<{ id: string }>
@@ -48,6 +56,14 @@ function redirectWithState(pageId: string, params: { notice?: string; error?: st
redirect(value ? `/pages/${pageId}?${value}` : `/pages/${pageId}`) redirect(value ? `/pages/${pageId}?${value}` : `/pages/${pageId}`)
} }
function normalizeLocale(input: string | null): SupportedLocale {
if (input && SUPPORTED_LOCALES.includes(input as SupportedLocale)) {
return input as SupportedLocale
}
return "en"
}
export default async function PageEditorPage({ params, searchParams }: PageProps) { export default async function PageEditorPage({ params, searchParams }: PageProps) {
const role = await requirePermissionForRoute({ const role = await requirePermissionForRoute({
nextPath: "/pages", nextPath: "/pages",
@@ -57,7 +73,11 @@ export default async function PageEditorPage({ params, searchParams }: PageProps
const resolvedParams = await params const resolvedParams = await params
const pageId = resolvedParams.id const pageId = resolvedParams.id
const [resolvedSearchParams, pageRecord] = await Promise.all([searchParams, getPageById(pageId)]) const [resolvedSearchParams, pageRecord, translations] = await Promise.all([
searchParams,
getPageById(pageId),
listPageTranslations(pageId),
])
if (!pageRecord) { if (!pageRecord) {
redirect("/pages?error=Page+not+found") redirect("/pages?error=Page+not+found")
@@ -66,6 +86,8 @@ export default async function PageEditorPage({ params, searchParams }: PageProps
const page = pageRecord const page = pageRecord
const notice = readFirstValue(resolvedSearchParams.notice) const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error) const error = readFirstValue(resolvedSearchParams.error)
const selectedLocale = normalizeLocale(readFirstValue(resolvedSearchParams.locale))
const selectedTranslation = translations.find((entry) => entry.locale === selectedLocale)
async function updatePageAction(formData: FormData) { async function updatePageAction(formData: FormData) {
"use server" "use server"
@@ -118,6 +140,34 @@ export default async function PageEditorPage({ params, searchParams }: PageProps
redirect("/pages?notice=Page+deleted") redirect("/pages?notice=Page+deleted")
} }
async function upsertPageTranslationAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/pages",
permission: "pages:write",
scope: "team",
})
const locale = normalizeLocale(readInputString(formData, "locale"))
try {
await upsertPageTranslation({
pageId,
locale,
title: readInputString(formData, "title"),
summary: readNullableString(formData, "summary"),
content: readInputString(formData, "content"),
seoTitle: readNullableString(formData, "seoTitle"),
seoDescription: readNullableString(formData, "seoDescription"),
})
} catch {
redirect(`/pages/${pageId}?error=Failed+to+save+translation.&locale=${locale}`)
}
redirect(`/pages/${pageId}?notice=Translation+saved.&locale=${locale}`)
}
return ( return (
<AdminShell <AdminShell
role={role} role={role}
@@ -226,6 +276,132 @@ export default async function PageEditorPage({ params, searchParams }: PageProps
</form> </form>
</section> </section>
<section className="rounded-xl border border-neutral-200 p-6">
<div className="space-y-1">
<h3 className="text-xl font-medium">Translations</h3>
<p className="text-sm text-neutral-600">
Add locale-specific page content. Missing locales fall back to base page fields.
</p>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{SUPPORTED_LOCALES.map((locale) => {
const isActive = locale === selectedLocale
const hasTranslation = translations.some((entry) => entry.locale === locale)
return (
<Link
key={locale}
href={`/pages/${pageId}?locale=${locale}`}
className={`inline-flex items-center gap-2 rounded border px-3 py-1.5 text-xs ${
isActive
? "border-neutral-800 bg-neutral-900 text-white"
: "border-neutral-300 text-neutral-700"
}`}
>
<span>{locale.toUpperCase()}</span>
<span className={isActive ? "text-neutral-200" : "text-neutral-500"}>
{hasTranslation ? "saved" : "missing"}
</span>
</Link>
)
})}
</div>
{translations.length > 0 ? (
<div className="mt-4 rounded border border-neutral-200">
<table className="min-w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wide text-neutral-500">
<tr>
<th className="px-3 py-2">Locale</th>
<th className="px-3 py-2">Title</th>
<th className="px-3 py-2">Updated</th>
</tr>
</thead>
<tbody>
{translations.map((translation) => (
<tr key={translation.id} className="border-t border-neutral-200">
<td className="px-3 py-2">{translation.locale.toUpperCase()}</td>
<td className="px-3 py-2">{translation.title}</td>
<td className="px-3 py-2 text-neutral-600">
{translation.updatedAt.toLocaleDateString("en-US")}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
<form action={upsertPageTranslationAction} className="mt-6 space-y-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Locale</span>
<select
name="locale"
defaultValue={selectedLocale}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
{SUPPORTED_LOCALES.map((locale) => (
<option key={locale} value={locale}>
{locale.toUpperCase()}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
defaultValue={selectedTranslation?.title ?? page.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">Summary</span>
<input
name="summary"
defaultValue={selectedTranslation?.summary ?? page.summary ?? ""}
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">Content</span>
<textarea
name="content"
rows={8}
defaultValue={selectedTranslation?.content ?? page.content}
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">SEO title</span>
<input
name="seoTitle"
defaultValue={selectedTranslation?.seoTitle ?? page.seoTitle ?? ""}
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">SEO description</span>
<input
name="seoDescription"
defaultValue={selectedTranslation?.seoDescription ?? page.seoDescription ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<Button type="submit">Save translation</Button>
</form>
</section>
<section className="rounded-xl border border-red-300 bg-red-50 p-6"> <section className="rounded-xl border border-red-300 bg-red-50 p-6">
<h3 className="text-lg font-medium text-red-800">Danger Zone</h3> <h3 className="text-lg font-medium text-red-800">Danger Zone</h3>
<p className="mt-1 text-sm text-red-700"> <p className="mt-1 text-sm text-red-700">

View File

@@ -1,10 +1,10 @@
import { createPage, listPages } from "@cms/db" import { createPage, listPages } from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import Link from "next/link" import Link from "next/link"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { AdminShell } from "@/components/admin-shell" import { AdminShell } from "@/components/admin-shell"
import { CreatePageForm } from "@/components/pages/create-page-form"
import { requirePermissionForRoute } from "@/lib/route-guards" import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -110,75 +110,7 @@ export default async function PagesManagementPage({
<section className="rounded-xl border border-neutral-200 p-6"> <section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Page</h2> <h2 className="text-xl font-medium">Create Page</h2>
<form action={createPageAction} className="mt-4 space-y-3"> <CreatePageForm action={createPageAction} />
<div className="grid gap-3 md:grid-cols-3">
<label className="space-y-1 md:col-span-2">
<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">Status</span>
<select
name="status"
defaultValue="draft"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="draft">draft</option>
<option value="published">published</option>
</select>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<input
name="slug"
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">Summary</span>
<input
name="summary"
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">Content</span>
<textarea
name="content"
rows={6}
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">SEO title</span>
<input
name="seoTitle"
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">SEO description</span>
<input
name="seoDescription"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<Button type="submit">Create page</Button>
</form>
</section> </section>
<section className="rounded-xl border border-neutral-200 p-6"> <section className="rounded-xl border border-neutral-200 p-6">

View File

@@ -0,0 +1,20 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { CreateMenuForm } from "./create-menu-form"
describe("CreateMenuForm", () => {
it("renders defaults for location and visibility", () => {
render(<CreateMenuForm action={vi.fn()} />)
const location = screen.getByLabelText("Location") as HTMLInputElement
expect(location.value).toBe("primary")
const visible = screen.getByLabelText("Visible") as HTMLInputElement
expect(visible.checked).toBe(true)
expect(screen.getByRole("button", { name: "Create menu" })).not.toBeNull()
})
})

View File

@@ -0,0 +1,41 @@
import { Button } from "@cms/ui/button"
type CreateMenuFormProps = {
action: (formData: FormData) => void | Promise<void>
}
export function CreateMenuForm({ action }: CreateMenuFormProps) {
return (
<form action={action} 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>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<input
name="slug"
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">Location</span>
<input
name="location"
defaultValue="primary"
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="isVisible" type="checkbox" value="true" defaultChecked className="size-4" />
Visible
</label>
<Button type="submit">Create menu</Button>
</form>
)
}

View File

@@ -0,0 +1,32 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { CreateNavigationItemForm } from "./create-navigation-item-form"
describe("CreateNavigationItemForm", () => {
it("renders menu/page options and defaults", () => {
render(
<CreateNavigationItemForm
action={vi.fn()}
menus={[{ id: "menu-1", name: "Primary", location: "header" }]}
pages={[{ id: "page-1", title: "Home", slug: "home" }]}
/>,
)
const menu = screen.getByLabelText("Menu") as HTMLSelectElement
expect(menu.options.length).toBe(1)
expect(menu.value).toBe("menu-1")
const page = screen.getByLabelText("Linked page") as HTMLSelectElement
expect(page.options.length).toBe(2)
expect(page.options[0]?.value).toBe("")
const sortOrder = screen.getByLabelText("Sort order") as HTMLInputElement
expect(sortOrder.value).toBe("0")
const visible = screen.getByLabelText("Visible") as HTMLInputElement
expect(visible.checked).toBe(true)
})
})

View File

@@ -0,0 +1,94 @@
import { Button } from "@cms/ui/button"
type MenuOption = {
id: string
name: string
location: string
}
type PageOption = {
id: string
title: string
slug: string
}
type CreateNavigationItemFormProps = {
action: (formData: FormData) => void | Promise<void>
menus: MenuOption[]
pages: PageOption[]
}
export function CreateNavigationItemForm({ action, menus, pages }: CreateNavigationItemFormProps) {
return (
<form action={action} className="mt-4 space-y-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Menu</span>
<select
name="menuId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
{menus.map((menu) => (
<option key={menu.id} value={menu.id}>
{menu.name} ({menu.location})
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Label</span>
<input
name="label"
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">Custom href</span>
<input
name="href"
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">Linked page</span>
<select
name="pageId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="">(none)</option>
{pages.map((page) => (
<option key={page.id} value={page.id}>
{page.title} (/{page.slug})
</option>
))}
</select>
</label>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Parent item id</span>
<input
name="parentId"
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">Sort order</span>
<input
name="sortOrder"
defaultValue="0"
type="number"
min={0}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<input name="isVisible" type="checkbox" value="true" defaultChecked className="size-4" />
Visible
</label>
<Button type="submit">Create item</Button>
</form>
)
}

View File

@@ -0,0 +1,21 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { CreatePageForm } from "./create-page-form"
describe("CreatePageForm", () => {
it("renders required fields and draft default status", () => {
render(<CreatePageForm action={vi.fn()} />)
expect((screen.getByLabelText("Title") as HTMLInputElement).name).toBe("title")
expect((screen.getByLabelText("Slug") as HTMLInputElement).name).toBe("slug")
expect((screen.getByLabelText("Content") as HTMLTextAreaElement).name).toBe("content")
const status = screen.getByLabelText("Status") as HTMLSelectElement
expect(status.value).toBe("draft")
expect(screen.getByRole("button", { name: "Create page" })).not.toBeNull()
})
})

View File

@@ -0,0 +1,79 @@
import { Button } from "@cms/ui/button"
type CreatePageFormProps = {
action: (formData: FormData) => void | Promise<void>
}
export function CreatePageForm({ action }: CreatePageFormProps) {
return (
<form action={action} className="mt-4 space-y-3">
<div className="grid gap-3 md:grid-cols-3">
<label className="space-y-1 md:col-span-2">
<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">Status</span>
<select
name="status"
defaultValue="draft"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="draft">draft</option>
<option value="published">published</option>
</select>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<input
name="slug"
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">Summary</span>
<input
name="summary"
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">Content</span>
<textarea
name="content"
rows={6}
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">SEO title</span>
<input
name="seoTitle"
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">SEO description</span>
<input
name="seoDescription"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<Button type="submit">Create page</Button>
</form>
)
}

View File

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

View File

@@ -1,15 +1,15 @@
import { getPostBySlug } from "@cms/db" import { getPostBySlugForLocale } from "@cms/db"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
type PageProps = { type PageProps = {
params: Promise<{ slug: string }> params: Promise<{ locale: string; slug: string }>
} }
export default async function PublicNewsDetailPage({ params }: PageProps) { export default async function PublicNewsDetailPage({ params }: PageProps) {
const { slug } = await params const { locale, slug } = await params
const post = await getPostBySlug(slug) const post = await getPostBySlugForLocale(slug, locale)
if (!post || post.status !== "published") { if (!post || post.status !== "published") {
notFound() notFound()

View File

@@ -1,10 +1,15 @@
import { listPosts } from "@cms/db" import { listPostsForLocale } from "@cms/db"
import Link from "next/link" import Link from "next/link"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default async function PublicNewsIndexPage() { type PublicNewsIndexPageProps = {
const posts = await listPosts() params: Promise<{ locale: string }>
}
export default async function PublicNewsIndexPage({ params }: PublicNewsIndexPageProps) {
const { locale } = await params
const posts = await listPostsForLocale(locale)
return ( return (
<section className="mx-auto w-full max-w-4xl space-y-4 px-6 py-16"> <section className="mx-auto w-full max-w-4xl space-y-4 px-6 py-16">

View File

@@ -1,4 +1,4 @@
import { getPublishedPageBySlug, listPosts } from "@cms/db" import { getPublishedPageBySlugForLocale, listPosts } from "@cms/db"
import { Button } from "@cms/ui/button" import { Button } from "@cms/ui/button"
import { getTranslations } from "next-intl/server" import { getTranslations } from "next-intl/server"
import { PublicAnnouncements } from "@/components/public-announcements" import { PublicAnnouncements } from "@/components/public-announcements"
@@ -6,9 +6,15 @@ import { PublicPageView } from "@/components/public-page-view"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default async function HomePage() { type HomePageProps = {
params: Promise<{ locale: string }>
}
export default async function HomePage({ params }: HomePageProps) {
const { locale } = await params
const [homePage, posts, t] = await Promise.all([ const [homePage, posts, t] = await Promise.all([
getPublishedPageBySlug("home"), getPublishedPageBySlugForLocale("home", locale),
listPosts(), listPosts(),
getTranslations("Home"), getTranslations("Home"),
]) ])

View File

@@ -1,11 +1,13 @@
import { listPublicNavigation } from "@cms/db" import { listPublicNavigation } from "@cms/db"
import { getLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation" import { Link } from "@/i18n/navigation"
import { LanguageSwitcher } from "./language-switcher" import { LanguageSwitcher } from "./language-switcher"
export async function PublicSiteHeader() { export async function PublicSiteHeader() {
const navItems = await listPublicNavigation("header") const locale = await getLocale()
const navItems = await listPublicNavigation("header", locale)
return ( return (
<header className="border-b border-neutral-200 bg-white/80 backdrop-blur"> <header className="border-b border-neutral-200 bg-white/80 backdrop-blur">

View File

@@ -25,6 +25,10 @@ export default defineConfig({
{ text: "i18n Baseline", link: "/product-engineering/i18n-baseline" }, { text: "i18n Baseline", link: "/product-engineering/i18n-baseline" },
{ text: "i18n Conventions", link: "/product-engineering/i18n-conventions" }, { text: "i18n Conventions", link: "/product-engineering/i18n-conventions" },
{ text: "RBAC And Permissions", link: "/product-engineering/rbac-permission-model" }, { text: "RBAC And Permissions", link: "/product-engineering/rbac-permission-model" },
{ text: "Code Architecture Map", link: "/product-engineering/code-architecture-map" },
{ text: "Critical Invariants", link: "/product-engineering/critical-invariants" },
{ text: "Request Lifecycle Flows", link: "/product-engineering/request-lifecycle-flows" },
{ text: "Code Handover Playbook", link: "/product-engineering/code-handover-playbook" },
{ text: "Domain Glossary", link: "/product-engineering/domain-glossary" }, { text: "Domain Glossary", link: "/product-engineering/domain-glossary" },
{ text: "Environment Runbook", link: "/product-engineering/environment-runbook" }, { text: "Environment Runbook", link: "/product-engineering/environment-runbook" },
{ text: "Delivery Pipeline", link: "/product-engineering/delivery-pipeline" }, { text: "Delivery Pipeline", link: "/product-engineering/delivery-pipeline" },

View File

@@ -0,0 +1,53 @@
# Code Architecture Map
This page is the fast handover map for engineers taking over the codebase.
## Repository Structure
- `apps/admin`:
Next.js admin panel. Owns auth UI, CMS management screens, and protected workflows.
- `apps/web`:
Next.js public site. Renders CMS-managed content and public-facing routes.
- `packages/db`:
Prisma schema, generated client usage, and data access services.
- `packages/content`:
Domain-level Zod schemas and shared contracts.
- `packages/crud`:
Shared CRUD service pattern (validation, not-found behavior, audit hook contracts).
- `packages/ui`:
Shared UI primitives used by admin/public apps.
- `packages/i18n`:
Shared locale helpers.
## Runtime Boundaries
- Admin app:
writes content and settings, enforces RBAC, runs Better Auth route handlers.
- Public app:
reads published content and settings; no public auth coupling.
- DB package:
only data access and business-persistence rules.
- Content package:
only validation and domain typing; no DB or framework runtime coupling.
## Core Feature Modules
- Auth and user guards:
`apps/admin/src/lib/auth/server.ts`, `apps/admin/src/app/api/auth/[...all]/route.ts`
- Access and route permissions:
`apps/admin/src/lib/access.ts`, `apps/admin/src/lib/route-guards.ts`
- Media domain + storage:
`packages/db/src/media-foundation.ts`, `apps/admin/src/lib/media/storage.ts`
- Pages and navigation:
`packages/db/src/pages-navigation.ts`, `apps/admin/src/app/pages/*`, `apps/admin/src/app/navigation/*`
- Commissions and customers:
`packages/db/src/commissions.ts`, `apps/admin/src/app/commissions/page.tsx`
- Announcements and news:
`packages/db/src/announcements.ts`, `apps/admin/src/app/announcements/page.tsx`, `apps/admin/src/app/news/page.tsx`
## Extension Rules
- Add/adjust schema first in `packages/content`.
- Implement persistence in `packages/db`.
- Wire usage in app route/actions after schema/service are in place.
- Add tests at service and app-boundary levels before marking TODO items done.

View File

@@ -0,0 +1,62 @@
# Code Handover Playbook
This is the minimum runbook for a new engineer to continue delivery safely.
## Local Setup
1. Install Bun matching repo policy.
2. Copy `.env.example` to `.env` and fill required values.
3. Generate Prisma client:
`bun run db:generate`
4. Apply migrations:
`bun run db:migrate:deploy` (or local named migration flow)
5. Seed data:
`bun run db:seed`
6. Start apps:
`bun run dev`
## Daily Development Loop
1. Create branch by task type:
`todo/*`, `refactor/*`, `code/*`.
2. Implement smallest vertical slice for one TODO item.
3. Run quality gates:
`bun run check`
`bun run typecheck`
`bun run test`
4. Update `TODO.md` status and discovery log.
5. Commit with Conventional Commit message and GPG signing.
## Database Workflow
- Schema source is:
`packages/db/prisma/schema.prisma`
- Use named dev migrations for schema changes.
- Avoid manual SQL unless migration tooling is blocked.
- Always regenerate client after schema change.
## Testing Strategy
- Unit/service tests:
`packages/*` and logic helpers.
- App-boundary integration tests:
auth flow and route-level behavior.
- E2E tests:
full admin/public happy paths through Playwright.
## Common Failure Recovery
- `DATABASE_URL not set`:
ensure root `.env` is loaded for Bun/Prisma scripts.
- Prisma client import errors:
run `bun run db:generate`.
- Migration drift:
run deploy/reset flow in dev and reseed.
- Playwright host deps missing:
install browser dependencies on host before running e2e.
## Ownership Expectations
- Keep invariants explicit and tested before changing auth/media pipelines.
- Treat `TODO.md` as delivery source of truth.
- If changing branch/release workflow, update docs in same branch.

View File

@@ -0,0 +1,57 @@
# Critical Invariants
These rules must stay true across refactors and feature work.
## Auth and User Invariants
- Exactly one owner user must exist.
- The canonical owner must remain protected and not banned.
- Support user is system-owned and protected.
- Protected users cannot be deleted through auth endpoints.
- First owner bootstrap closes open owner-registration window.
Primary implementation:
- `apps/admin/src/lib/auth/server.ts`
- `apps/admin/src/app/api/auth/[...all]/route.ts`
Primary tests:
- `apps/admin/src/lib/auth/server.test.ts`
- `apps/admin/src/app/register/page.test.tsx`
- `apps/admin/src/app/welcome/page.test.tsx`
- `apps/admin/src/app/login/page.test.tsx`
## Registration Policy Invariants
- If no owner exists:
`welcome` flow is open for first owner bootstrap.
- If owner exists:
self-registration depends on persisted policy in `system_setting`.
- Register route must never silently create users when policy is disabled.
Primary implementation:
- `packages/db/src/settings.ts`
- `apps/admin/src/app/settings/page.tsx`
- `apps/admin/src/app/register/page.tsx`
## Media Storage Contract
- Storage provider is selected by `CMS_MEDIA_STORAGE_PROVIDER`.
- S3 is primary; local is explicit fallback.
- Each media asset stores a stable `storageKey`.
- Deleting a media asset must also attempt storage object deletion.
Primary implementation:
- `apps/admin/src/lib/media/storage.ts`
- `apps/admin/src/lib/media/storage-key.ts`
- `apps/admin/src/app/media/[id]/page.tsx`
## Public Rendering Contract
- Public pages must render only published CMS pages.
- Public navigation must be built from managed menu items.
- Header banner and announcements must be optional and fail-safe.
Primary implementation:
- `apps/web/src/app/[locale]/layout.tsx`
- `apps/web/src/app/[locale]/page.tsx`
- `apps/web/src/app/[locale]/[slug]/page.tsx`

View File

@@ -11,6 +11,10 @@ This section covers platform and implementation documentation for engineers and
- [i18n Conventions](/product-engineering/i18n-conventions) - [i18n Conventions](/product-engineering/i18n-conventions)
- [CRUD Examples](/product-engineering/crud-examples) - [CRUD Examples](/product-engineering/crud-examples)
- [Package Catalog And Decision Notes](/product-engineering/package-catalog) - [Package Catalog And Decision Notes](/product-engineering/package-catalog)
- [Code Architecture Map](/product-engineering/code-architecture-map)
- [Critical Invariants](/product-engineering/critical-invariants)
- [Request Lifecycle Flows](/product-engineering/request-lifecycle-flows)
- [Code Handover Playbook](/product-engineering/code-handover-playbook)
- [User Personas And Use-Case Topics](/product-engineering/user-personas-and-use-cases) - [User Personas And Use-Case Topics](/product-engineering/user-personas-and-use-cases)
- [CMS Feature Topics (Domain-Centric)](/product-engineering/cms-feature-topics) - [CMS Feature Topics (Domain-Centric)](/product-engineering/cms-feature-topics)
- [Domain Glossary](/product-engineering/domain-glossary) - [Domain Glossary](/product-engineering/domain-glossary)

View File

@@ -0,0 +1,61 @@
# Request Lifecycle Flows
## 1. Auth Sign-In (Admin)
1. Browser posts to `/api/auth/sign-in/email`.
2. Route resolves `identifier` (email or username) to canonical email.
3. Better Auth credential sign-in executes.
4. Session cookie is set and user is redirected.
Key files:
- `apps/admin/src/app/login/login-form.tsx`
- `apps/admin/src/app/api/auth/[...all]/route.ts`
- `apps/admin/src/lib/auth/server.ts`
## 2. Initial Owner Registration
1. If no owner exists, `/welcome` renders owner sign-up mode.
2. Sign-up request goes through auth route handler.
3. New user is promoted to owner in transactional guard.
4. Owner invariant is re-validated to enforce single owner.
Key files:
- `apps/admin/src/app/welcome/page.tsx`
- `apps/admin/src/app/api/auth/[...all]/route.ts`
- `apps/admin/src/lib/auth/server.ts`
## 3. Media Upload
1. Admin form posts multipart data to `/api/media/upload`.
2. Metadata is validated and file is stored through selected provider.
3. Media asset record is persisted with storage metadata.
4. UI redirects back to media list with flash status query.
Key files:
- `apps/admin/src/components/media/media-upload-form.tsx`
- `apps/admin/src/app/api/media/upload/route.ts`
- `apps/admin/src/lib/media/storage.ts`
- `packages/db/src/media-foundation.ts`
## 4. Page Publish
1. Admin submit on `/pages` calls server action.
2. Page schema validates payload and persists.
3. `published` status sets publication fields.
4. Public app resolves slug and renders page if published.
Key files:
- `apps/admin/src/app/pages/page.tsx`
- `packages/db/src/pages-navigation.ts`
- `apps/web/src/app/[locale]/[slug]/page.tsx`
## 5. Commission Status Transition
1. Admin updates status from commission card form.
2. Server action validates transition payload.
3. DB update persists new status.
4. Kanban view re-renders with updated column placement.
Key files:
- `apps/admin/src/app/commissions/page.tsx`
- `packages/db/src/commissions.ts`

View File

@@ -1,6 +1,7 @@
import { z } from "zod" import { z } from "zod"
export const pageStatusSchema = z.enum(["draft", "published"]) export const pageStatusSchema = z.enum(["draft", "published"])
export const pageLocaleSchema = z.enum(["de", "en", "es", "fr"])
export const createPageInputSchema = z.object({ export const createPageInputSchema = z.object({
title: z.string().min(1).max(180), title: z.string().min(1).max(180),
@@ -23,6 +24,16 @@ export const updatePageInputSchema = z.object({
seoDescription: z.string().max(320).nullable().optional(), seoDescription: z.string().max(320).nullable().optional(),
}) })
export const upsertPageTranslationInputSchema = z.object({
pageId: z.string().uuid(),
locale: pageLocaleSchema,
title: z.string().min(1).max(180),
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 createNavigationMenuInputSchema = z.object({ export const createNavigationMenuInputSchema = z.object({
name: z.string().min(1).max(180), name: z.string().min(1).max(180),
slug: z.string().min(1).max(180), slug: z.string().min(1).max(180),
@@ -52,6 +63,7 @@ export const updateNavigationItemInputSchema = z.object({
export type CreatePageInput = z.infer<typeof createPageInputSchema> export type CreatePageInput = z.infer<typeof createPageInputSchema>
export type UpdatePageInput = z.infer<typeof updatePageInputSchema> export type UpdatePageInput = z.infer<typeof updatePageInputSchema>
export type UpsertPageTranslationInput = z.infer<typeof upsertPageTranslationInputSchema>
export type CreateNavigationMenuInput = z.infer<typeof createNavigationMenuInputSchema> export type CreateNavigationMenuInput = z.infer<typeof createNavigationMenuInputSchema>
export type CreateNavigationItemInput = z.infer<typeof createNavigationItemInputSchema> export type CreateNavigationItemInput = z.infer<typeof createNavigationItemInputSchema>
export type UpdateNavigationItemInput = z.infer<typeof updateNavigationItemInputSchema> export type UpdateNavigationItemInput = z.infer<typeof updateNavigationItemInputSchema>

View File

@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "PageTranslation" (
"id" TEXT NOT NULL,
"pageId" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"title" TEXT NOT NULL,
"summary" TEXT,
"content" TEXT NOT NULL,
"seoTitle" TEXT,
"seoDescription" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PageTranslation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PageTranslation_locale_idx" ON "PageTranslation"("locale");
-- CreateIndex
CREATE UNIQUE INDEX "PageTranslation_pageId_locale_key" ON "PageTranslation"("pageId", "locale");
-- AddForeignKey
ALTER TABLE "PageTranslation" ADD CONSTRAINT "PageTranslation_pageId_fkey" FOREIGN KEY ("pageId") REFERENCES "Page"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,43 @@
-- CreateTable
CREATE TABLE "PostTranslation" (
"id" TEXT NOT NULL,
"postId" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"title" TEXT NOT NULL,
"excerpt" TEXT,
"body" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PostTranslation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NavigationItemTranslation" (
"id" TEXT NOT NULL,
"navigationItemId" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"label" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NavigationItemTranslation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PostTranslation_locale_idx" ON "PostTranslation"("locale");
-- CreateIndex
CREATE UNIQUE INDEX "PostTranslation_postId_locale_key" ON "PostTranslation"("postId", "locale");
-- CreateIndex
CREATE INDEX "NavigationItemTranslation_locale_idx" ON "NavigationItemTranslation"("locale");
-- CreateIndex
CREATE UNIQUE INDEX "NavigationItemTranslation_navigationItemId_locale_key" ON "NavigationItemTranslation"("navigationItemId", "locale");
-- AddForeignKey
ALTER TABLE "PostTranslation" ADD CONSTRAINT "PostTranslation_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NavigationItemTranslation" ADD CONSTRAINT "NavigationItemTranslation_navigationItemId_fkey" FOREIGN KEY ("navigationItemId") REFERENCES "NavigationItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -16,6 +16,22 @@ model Post {
status String status String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
translations PostTranslation[]
}
model PostTranslation {
id String @id @default(uuid())
postId String
locale String
title String
excerpt String?
body String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
@@unique([postId, locale])
@@index([locale])
} }
model User { model User {
@@ -267,10 +283,28 @@ model Page {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
navItems NavigationItem[] navItems NavigationItem[]
translations PageTranslation[]
@@index([status]) @@index([status])
} }
model PageTranslation {
id String @id @default(uuid())
pageId String
locale String
title String
summary String?
content String
seoTitle String?
seoDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
@@unique([pageId, locale])
@@index([locale])
}
model NavigationMenu { model NavigationMenu {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
@@ -297,6 +331,7 @@ model NavigationItem {
page Page? @relation(fields: [pageId], references: [id], onDelete: SetNull) page Page? @relation(fields: [pageId], references: [id], onDelete: SetNull)
parent NavigationItem? @relation("NavigationItemParent", fields: [parentId], references: [id], onDelete: Cascade) parent NavigationItem? @relation("NavigationItemParent", fields: [parentId], references: [id], onDelete: Cascade)
children NavigationItem[] @relation("NavigationItemParent") children NavigationItem[] @relation("NavigationItemParent")
translations NavigationItemTranslation[]
@@index([menuId]) @@index([menuId])
@@index([pageId]) @@index([pageId])
@@ -304,6 +339,19 @@ model NavigationItem {
@@unique([menuId, parentId, sortOrder, label]) @@unique([menuId, parentId, sortOrder, label])
} }
model NavigationItemTranslation {
id String @id @default(uuid())
navigationItemId String
locale String
label String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
navigationItem NavigationItem @relation(fields: [navigationItemId], references: [id], onDelete: Cascade)
@@unique([navigationItemId, locale])
@@index([locale])
}
model Customer { model Customer {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String

View File

@@ -41,21 +41,29 @@ export {
deletePage, deletePage,
getPageById, getPageById,
getPublishedPageBySlug, getPublishedPageBySlug,
getPublishedPageBySlugForLocale,
listNavigationMenus, listNavigationMenus,
listPages, listPages,
listPageTranslations,
listPublicNavigation, listPublicNavigation,
listPublishedPageSlugs, listPublishedPageSlugs,
updateNavigationItem, updateNavigationItem,
updatePage, updatePage,
upsertNavigationItemTranslation,
upsertPageTranslation,
} from "./pages-navigation" } from "./pages-navigation"
export { export {
createPost, createPost,
deletePost, deletePost,
getPostById, getPostById,
getPostBySlug, getPostBySlug,
getPostBySlugForLocale,
listPosts, listPosts,
listPostsForLocale,
listPostsWithTranslations,
registerPostCrudAuditHook, registerPostCrudAuditHook,
updatePost, updatePost,
upsertPostTranslation,
} from "./posts" } from "./posts"
export type { PublicHeaderBanner, PublicHeaderBannerConfig } from "./settings" export type { PublicHeaderBanner, PublicHeaderBannerConfig } from "./settings"
export { export {

View File

@@ -7,6 +7,11 @@ const { mockDb } = vi.hoisted(() => ({
update: vi.fn(), update: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
findUnique: vi.fn(), findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
},
pageTranslation: {
upsert: vi.fn(),
findMany: vi.fn(), findMany: vi.fn(),
}, },
navigationMenu: { navigationMenu: {
@@ -19,6 +24,9 @@ const { mockDb } = vi.hoisted(() => ({
update: vi.fn(), update: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
}, },
navigationItemTranslation: {
upsert: vi.fn(),
},
}, },
})) }))
@@ -30,8 +38,11 @@ import {
createNavigationItem, createNavigationItem,
createNavigationMenu, createNavigationMenu,
createPage, createPage,
getPublishedPageBySlugForLocale,
listPublicNavigation, listPublicNavigation,
updatePage, updatePage,
upsertNavigationItemTranslation,
upsertPageTranslation,
} from "./pages-navigation" } from "./pages-navigation"
describe("pages-navigation service", () => { describe("pages-navigation service", () => {
@@ -105,19 +116,89 @@ describe("pages-navigation service", () => {
slug: "home", slug: "home",
status: "published", status: "published",
}, },
translations: [{ locale: "de", label: "Startseite" }],
}, },
], ],
}) })
const navigation = await listPublicNavigation("header") const navigation = await listPublicNavigation("header", "de")
expect(navigation).toEqual([ expect(navigation).toEqual([
{ {
id: "item-1", id: "item-1",
label: "Home", label: "Startseite",
href: "/", href: "/",
children: [], children: [],
}, },
]) ])
}) })
it("validates locale when upserting navigation item translation", async () => {
await expect(() =>
upsertNavigationItemTranslation({
navigationItemId: "550e8400-e29b-41d4-a716-446655440001",
locale: "it",
label: "Home",
}),
).rejects.toThrow()
})
it("validates locale when upserting page translation", async () => {
await expect(() =>
upsertPageTranslation({
pageId: "550e8400-e29b-41d4-a716-446655440000",
locale: "it",
title: "Titolo",
content: "Contenuto",
}),
).rejects.toThrow()
})
it("upserts page translation and reads localized page with fallback", async () => {
mockDb.pageTranslation.upsert.mockResolvedValue({ id: "pt-1" })
mockDb.page.findFirst
.mockResolvedValueOnce({
id: "page-1",
title: "About",
summary: "Base summary",
content: "Base content",
seoTitle: "Base SEO",
seoDescription: "Base description",
translations: [
{
locale: "de",
title: "Uber Uns",
summary: "Zusammenfassung",
content: "Inhalt",
seoTitle: "SEO DE",
seoDescription: "Beschreibung",
},
],
})
.mockResolvedValueOnce({
id: "page-1",
title: "About",
summary: "Base summary",
content: "Base content",
seoTitle: "Base SEO",
seoDescription: "Base description",
translations: [],
})
await upsertPageTranslation({
pageId: "550e8400-e29b-41d4-a716-446655440000",
locale: "de",
title: "Uber Uns",
content: "Inhalt",
})
const translated = await getPublishedPageBySlugForLocale("about", "de")
const fallback = await getPublishedPageBySlugForLocale("about", "fr")
expect(mockDb.pageTranslation.upsert).toHaveBeenCalledTimes(1)
expect(translated?.title).toBe("Uber Uns")
expect(translated?.content).toBe("Inhalt")
expect(fallback?.title).toBe("About")
expect(fallback?.content).toBe("Base content")
})
}) })

View File

@@ -4,7 +4,9 @@ import {
createPageInputSchema, createPageInputSchema,
updateNavigationItemInputSchema, updateNavigationItemInputSchema,
updatePageInputSchema, updatePageInputSchema,
upsertPageTranslationInputSchema,
} from "@cms/content" } from "@cms/content"
import { z } from "zod"
import { db } from "./client" import { db } from "./client"
@@ -15,6 +17,13 @@ export type PublicNavigationItem = {
children: PublicNavigationItem[] children: PublicNavigationItem[]
} }
const supportedLocaleSchema = z.enum(["de", "en", "es", "fr"])
const upsertNavigationItemTranslationInputSchema = z.object({
navigationItemId: z.string().uuid(),
locale: supportedLocaleSchema,
label: z.string().min(1).max(180),
})
function resolvePublishedAt(status: string): Date | null { function resolvePublishedAt(status: string): Date | null {
return status === "published" ? new Date() : null return status === "published" ? new Date() : null
} }
@@ -54,6 +63,38 @@ export async function getPublishedPageBySlug(slug: string) {
}) })
} }
export async function getPublishedPageBySlugForLocale(slug: string, locale: string) {
const page = await db.page.findFirst({
where: {
slug,
status: "published",
},
include: {
translations: {
where: {
locale,
},
take: 1,
},
},
})
if (!page) {
return null
}
const translation = page.translations[0]
return {
...page,
title: translation?.title ?? page.title,
summary: translation?.summary ?? page.summary,
content: translation?.content ?? page.content,
seoTitle: translation?.seoTitle ?? page.seoTitle,
seoDescription: translation?.seoDescription ?? page.seoDescription,
}
}
export async function createPage(input: unknown) { export async function createPage(input: unknown) {
const payload = createPageInputSchema.parse(input) const payload = createPageInputSchema.parse(input)
@@ -85,6 +126,33 @@ export async function deletePage(id: string) {
}) })
} }
export async function upsertPageTranslation(input: unknown) {
const payload = upsertPageTranslationInputSchema.parse(input)
const { pageId, locale, ...data } = payload
return db.pageTranslation.upsert({
where: {
pageId_locale: {
pageId,
locale,
},
},
create: {
pageId,
locale,
...data,
},
update: data,
})
}
export async function listPageTranslations(pageId: string) {
return db.pageTranslation.findMany({
where: { pageId },
orderBy: [{ locale: "asc" }],
})
}
export async function listNavigationMenus() { export async function listNavigationMenus() {
return db.navigationMenu.findMany({ return db.navigationMenu.findMany({
orderBy: [{ location: "asc" }, { name: "asc" }], orderBy: [{ location: "asc" }, { name: "asc" }],
@@ -99,6 +167,9 @@ export async function listNavigationMenus() {
slug: true, slug: true,
}, },
}, },
translations: {
orderBy: [{ locale: "asc" }],
},
}, },
}, },
}, },
@@ -123,7 +194,12 @@ function resolveNavigationHref(item: {
return null return null
} }
export async function listPublicNavigation(location = "header"): Promise<PublicNavigationItem[]> { export async function listPublicNavigation(
location = "header",
locale?: string,
): Promise<PublicNavigationItem[]> {
const normalizedLocale = locale ? supportedLocaleSchema.safeParse(locale).data : undefined
const menu = await db.navigationMenu.findFirst({ const menu = await db.navigationMenu.findFirst({
where: { where: {
location, location,
@@ -143,6 +219,12 @@ export async function listPublicNavigation(location = "header"): Promise<PublicN
status: true, status: true,
}, },
}, },
translations: normalizedLocale
? {
where: { locale: normalizedLocale },
take: 1,
}
: false,
}, },
}, },
}, },
@@ -172,7 +254,7 @@ export async function listPublicNavigation(location = "header"): Promise<PublicN
itemMap.set(item.id, { itemMap.set(item.id, {
id: item.id, id: item.id,
label: item.label, label: item.translations?.[0]?.label ?? item.label,
href, href,
parentId: item.parentId, parentId: item.parentId,
children: [], children: [],
@@ -238,3 +320,20 @@ export async function deleteNavigationItem(id: string) {
where: { id }, where: { id },
}) })
} }
export async function upsertNavigationItemTranslation(input: unknown) {
const payload = upsertNavigationItemTranslationInputSchema.parse(input)
return db.navigationItemTranslation.upsert({
where: {
navigationItemId_locale: {
navigationItemId: payload.navigationItemId,
locale: payload.locale,
},
},
create: payload,
update: {
label: payload.label,
},
})
}

View File

@@ -9,6 +9,9 @@ const { mockDb } = vi.hoisted(() => ({
update: vi.fn(), update: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
}, },
postTranslation: {
upsert: vi.fn(),
},
}, },
})) }))
@@ -16,7 +19,15 @@ vi.mock("./client", () => ({
db: mockDb, db: mockDb,
})) }))
import { createPost, getPostBySlug, listPosts, updatePost } from "./posts" import {
createPost,
getPostBySlug,
getPostBySlugForLocale,
listPosts,
listPostsForLocale,
updatePost,
upsertPostTranslation,
} from "./posts"
describe("posts service", () => { describe("posts service", () => {
beforeEach(() => { beforeEach(() => {
@@ -25,6 +36,7 @@ describe("posts service", () => {
fn.mockReset() fn.mockReset()
} }
} }
mockDb.postTranslation.upsert.mockReset()
}) })
it("lists posts ordered by update date desc", async () => { it("lists posts ordered by update date desc", async () => {
@@ -72,4 +84,63 @@ describe("posts service", () => {
}, },
}) })
}) })
it("upserts post translation and reads localized/fallback post views", async () => {
mockDb.postTranslation.upsert.mockResolvedValue({ id: "pt-1" })
mockDb.post.findUnique
.mockResolvedValueOnce({
id: "post-1",
slug: "hello",
title: "Base title",
excerpt: "Base excerpt",
body: "Base body",
translations: [{ locale: "de", title: "Titel", excerpt: "Auszug", body: "Text" }],
})
.mockResolvedValueOnce({
id: "post-1",
slug: "hello",
title: "Base title",
excerpt: "Base excerpt",
body: "Base body",
translations: [],
})
mockDb.post.findMany.mockResolvedValue([
{
id: "post-1",
slug: "hello",
title: "Base title",
excerpt: "Base excerpt",
body: "Base body",
status: "published",
translations: [{ locale: "de", title: "Titel", excerpt: "Auszug", body: "Text" }],
},
])
await upsertPostTranslation({
postId: "550e8400-e29b-41d4-a716-446655440000",
locale: "de",
title: "Titel",
body: "Text",
})
const localized = await getPostBySlugForLocale("hello", "de")
const fallback = await getPostBySlugForLocale("hello", "fr")
const localizedList = await listPostsForLocale("de")
expect(mockDb.postTranslation.upsert).toHaveBeenCalledTimes(1)
expect(localized?.title).toBe("Titel")
expect(fallback?.title).toBe("Base title")
expect(localizedList[0]?.title).toBe("Titel")
})
it("validates locale for post translations", async () => {
await expect(() =>
upsertPostTranslation({
postId: "550e8400-e29b-41d4-a716-446655440000",
locale: "it",
title: "Titolo",
body: "Testo",
}),
).rejects.toThrow()
})
}) })

View File

@@ -5,6 +5,7 @@ import {
updatePostInputSchema, updatePostInputSchema,
} from "@cms/content" } from "@cms/content"
import { type CrudAuditHook, type CrudMutationContext, createCrudService } from "@cms/crud" import { type CrudAuditHook, type CrudMutationContext, createCrudService } from "@cms/crud"
import { z } from "zod"
import type { Post } from "../prisma/generated/client/client" import type { Post } from "../prisma/generated/client/client"
import { db } from "./client" import { db } from "./client"
@@ -35,6 +36,15 @@ const postRepository = {
}), }),
} }
const supportedLocaleSchema = z.enum(["de", "en", "es", "fr"])
const upsertPostTranslationInputSchema = z.object({
postId: z.string().uuid(),
locale: supportedLocaleSchema,
title: z.string().min(3).max(180),
excerpt: z.string().max(320).nullable().optional(),
body: z.string().min(1),
})
const postAuditHooks: Array<CrudAuditHook<Post>> = [] const postAuditHooks: Array<CrudAuditHook<Post>> = []
const postCrudService = createCrudService({ const postCrudService = createCrudService({
@@ -73,6 +83,100 @@ export async function getPostBySlug(slug: string) {
}) })
} }
export async function getPostBySlugForLocale(slug: string, locale: string) {
const normalizedLocale = supportedLocaleSchema.safeParse(locale).data
const post = await db.post.findUnique({
where: { slug },
include: {
translations: normalizedLocale
? {
where: {
locale: normalizedLocale,
},
take: 1,
}
: false,
},
})
if (!post) {
return null
}
const translation = post.translations?.[0]
return {
...post,
title: translation?.title ?? post.title,
excerpt: translation?.excerpt ?? post.excerpt,
body: translation?.body ?? post.body,
}
}
export async function listPostsForLocale(locale: string) {
const normalizedLocale = supportedLocaleSchema.safeParse(locale).data
const posts = await db.post.findMany({
where: {
status: "published",
},
orderBy: {
updatedAt: "desc",
},
include: {
translations: normalizedLocale
? {
where: { locale: normalizedLocale },
take: 1,
}
: false,
},
})
return posts.map((post) => {
const translation = post.translations?.[0]
return {
...post,
title: translation?.title ?? post.title,
excerpt: translation?.excerpt ?? post.excerpt,
body: translation?.body ?? post.body,
}
})
}
export async function listPostsWithTranslations() {
return db.post.findMany({
orderBy: {
updatedAt: "desc",
},
include: {
translations: {
orderBy: [{ locale: "asc" }],
},
},
})
}
export async function upsertPostTranslation(input: unknown) {
const payload = upsertPostTranslationInputSchema.parse(input)
const { postId, locale, ...data } = payload
return db.postTranslation.upsert({
where: {
postId_locale: {
postId,
locale,
},
},
create: {
postId,
locale,
...data,
},
update: data,
})
}
export async function createPost(input: unknown, context?: CrudMutationContext) { export async function createPost(input: unknown, context?: CrudMutationContext) {
return postCrudService.create(input, context) return postCrudService.create(input, context)
} }