Compare commits

...

7 Commits

Author SHA1 Message Date
6b282ce56b chore(changelog): include unreleased and full commit output
Some checks failed
CMS CI / Governance Checks (push) Successful in 1m7s
CMS CI / Lint Typecheck Unit E2E (push) Failing after 5m40s
2026-02-12 20:43:05 +01:00
37f62a8007 test(mvp1): add owner invariants and media form coverage
Some checks failed
CMS CI / Governance Checks (push) Successful in 1m5s
CMS CI / Lint Typecheck Unit E2E (push) Failing after 5m3s
2026-02-12 20:34:53 +01:00
d1face36c5 feat(settings): manage public header banner in admin 2026-02-12 20:18:00 +01:00
39178c2d8d test(auth): add registration policy route-flow integration tests 2026-02-12 20:15:34 +01:00
24676bd384 test(mvp1): expand domain schema and service unit coverage 2026-02-12 20:13:03 +01:00
7c4b667bc7 test(e2e): add mvp1 happy path scenarios 2026-02-12 20:11:21 +01:00
dbf817c255 feat(content): add announcements and public news flows 2026-02-12 20:08:08 +01:00
39 changed files with 2313 additions and 45 deletions

View File

@@ -1,3 +1,56 @@
## Unreleased (2026-02-12)
* test(admin): cover support fallback route and mark todo complete ([4ac74101487e0bb220215328510fd4344c9110e3](https://git.fellies.net/Citali/cms.fellies.org/commits/4ac74101487e0bb220215328510fd4344c9110e3))
* test(auth): add registration policy route-flow integration tests ([39178c2d8d9c203ceca63a36012b58173d21d4d4](https://git.fellies.net/Citali/cms.fellies.org/commits/39178c2d8d9c203ceca63a36012b58173d21d4d4))
* test(ci): add quality gates, e2e data prep, and i18n integration coverage ([4d4b583cf4dcb0f3b99f74c666af15d3b1a0fc59](https://git.fellies.net/Citali/cms.fellies.org/commits/4d4b583cf4dcb0f3b99f74c666af15d3b1a0fc59))
* test(crud): finalize MVP1 gate CRUD contract coverage ([36b09cd9d76049219569bb4828aa292aec78a935](https://git.fellies.net/Citali/cms.fellies.org/commits/36b09cd9d76049219569bb4828aa292aec78a935))
* test(e2e): add mvp1 happy path scenarios ([7c4b667bc79a022518d03a52a98060a9c86c673c](https://git.fellies.net/Citali/cms.fellies.org/commits/7c4b667bc79a022518d03a52a98060a9c86c673c))
* test(mvp0): complete remaining i18n, RBAC, and CRUD coverage ([3b130568e9bfff9e428c3650c37dd8b1abfbed57](https://git.fellies.net/Citali/cms.fellies.org/commits/3b130568e9bfff9e428c3650c37dd8b1abfbed57))
* test(mvp1): add owner invariants and media form coverage ([37f62a8007d6b50e7b53e5785365965c00c74a5b](https://git.fellies.net/Citali/cms.fellies.org/commits/37f62a8007d6b50e7b53e5785365965c00c74a5b))
* test(mvp1): expand domain schema and service unit coverage ([24676bd384c39d954b791ff2322a4469ab86a83a](https://git.fellies.net/Citali/cms.fellies.org/commits/24676bd384c39d954b791ff2322a4469ab86a83a))
* feat(admin-auth): add first-start onboarding flow and dev db reset command ([7b665ae633e869560654cf1c115d12dc05af1c45](https://git.fellies.net/Citali/cms.fellies.org/commits/7b665ae633e869560654cf1c115d12dc05af1c45))
* feat(admin-auth): support username login and add dashboard logout ([b96cd6d8005aea50c49a70540e0fd2afff94c682](https://git.fellies.net/Citali/cms.fellies.org/commits/b96cd6d8005aea50c49a70540e0fd2afff94c682))
* feat(admin-i18n): add cookie-based locale runtime and switcher baseline ([b618c8cb5161eef46e80d622156a1e53a732ea35](https://git.fellies.net/Citali/cms.fellies.org/commits/b618c8cb5161eef46e80d622156a1e53a732ea35))
* feat(admin): add IA shell and protected section skeleton routes ([bf1a92d129b89811b58d6920a4e09482816988a0](https://git.fellies.net/Citali/cms.fellies.org/commits/bf1a92d129b89811b58d6920a4e09482816988a0))
* feat(admin): add posts CRUD sandbox and shared CRUD foundation ([07e5f53793da5aa0b17e62a3a9b99c23a56dbcf0](https://git.fellies.net/Citali/cms.fellies.org/commits/07e5f53793da5aa0b17e62a3a9b99c23a56dbcf0))
* feat(admin): add registration policy settings and disabled register state ([d0f731743c789ef836e4573eadece2f4c67c973d](https://git.fellies.net/Citali/cms.fellies.org/commits/d0f731743c789ef836e4573eadece2f4c67c973d))
* feat(auth): block protected account deletion in auth endpoints ([0e2248b5c7f72684e4db6d4ab8f306b10f50ac66](https://git.fellies.net/Citali/cms.fellies.org/commits/0e2248b5c7f72684e4db6d4ab8f306b10f50ac66))
* feat(auth): bootstrap protected support and first owner users ([411861419f160e3573a71ea67d57af7e0e91de7d](https://git.fellies.net/Citali/cms.fellies.org/commits/411861419f160e3573a71ea67d57af7e0e91de7d))
* feat(auth): enforce single-owner invariant in bootstrap flow ([29a6e38ff3b725e4232736c4b5b007a2989acb82](https://git.fellies.net/Citali/cms.fellies.org/commits/29a6e38ff3b725e4232736c4b5b007a2989acb82))
* feat(ci): stamp build metadata and validate footer version hash ([af52b8581f7dbe320c92752fcc56b73e37f0c2ba](https://git.fellies.net/Citali/cms.fellies.org/commits/af52b8581f7dbe320c92752fcc56b73e37f0c2ba))
* feat(commissions): add customer records and kanban workflow baseline ([994b33e081c3507cbf88820028b819a4fc4b07a0](https://git.fellies.net/Citali/cms.fellies.org/commits/994b33e081c3507cbf88820028b819a4fc4b07a0))
* feat(content): add announcements and public news flows ([dbf817c25511b3038b7abe81e4577d3518fd3f19](https://git.fellies.net/Citali/cms.fellies.org/commits/dbf817c25511b3038b7abe81e4577d3518fd3f19))
* feat(media): add admin media CRUD preview and storage cleanup ([7d9bc9dca9197e87cc590ad6b49837c5774fcd4f](https://git.fellies.net/Citali/cms.fellies.org/commits/7d9bc9dca9197e87cc590ad6b49837c5774fcd4f))
* feat(media): add mvp1 upload pipeline baseline ([5becba602c3aaefbe24ec71414f62b29a155d158](https://git.fellies.net/Citali/cms.fellies.org/commits/5becba602c3aaefbe24ec71414f62b29a155d158))
* feat(media): complete mvp1 media foundation workflows ([ad351ed73ab7c76a9e751348ebf943aec5f0d084](https://git.fellies.net/Citali/cms.fellies.org/commits/ad351ed73ab7c76a9e751348ebf943aec5f0d084))
* feat(media): default to s3 with local upload fallback ([86a8af25d8c28c2ab19039b56b1c69263c7450c5](https://git.fellies.net/Citali/cms.fellies.org/commits/86a8af25d8c28c2ab19039b56b1c69263c7450c5))
* feat(media): scaffold mvp1 media and portfolio foundation ([d727ab8b5b896e5471829a6a1880dc33da28d070](https://git.fellies.net/Citali/cms.fellies.org/commits/d727ab8b5b896e5471829a6a1880dc33da28d070))
* feat(media): support local and s3 upload providers ([19738b77d8842f3263e7f049aa47063dcfbd4ae6](https://git.fellies.net/Citali/cms.fellies.org/commits/19738b77d8842f3263e7f049aa47063dcfbd4ae6))
* feat(pages): add pages and navigation builder baseline ([281b1d7a1be72af4cff790ca7e97d51cafcd8139](https://git.fellies.net/Citali/cms.fellies.org/commits/281b1d7a1be72af4cff790ca7e97d51cafcd8139))
* feat(release): publish gitea release notes and enable production rollback ([ccac669454b46a3918b34df1d3c5f0e5f00aa1d9](https://git.fellies.net/Citali/cms.fellies.org/commits/ccac669454b46a3918b34df1d3c5f0e5f00aa1d9))
* feat(settings): manage public header banner in admin ([d1face36c540673486b494d276f5af1621b6e6cb](https://git.fellies.net/Citali/cms.fellies.org/commits/d1face36c540673486b494d276f5af1621b6e6cb))
* feat(versioning): show runtime version and git hash in app footers ([3de4d5732e26e06e825986e58ec271d0f0ff4007](https://git.fellies.net/Citali/cms.fellies.org/commits/3de4d5732e26e06e825986e58ec271d0f0ff4007))
* feat(web-i18n): add es/fr locales and expand switcher locale set ([de26cb7647cf537a783cc9c77ae447a0f8a09ef6](https://git.fellies.net/Citali/cms.fellies.org/commits/de26cb7647cf537a783cc9c77ae447a0f8a09ef6))
* feat(web): complete MVP0 public layout, banner, and SEO baseline ([8390689c8dd81dca5662b842c827a4759a9025e1](https://git.fellies.net/Citali/cms.fellies.org/commits/8390689c8dd81dca5662b842c827a4759a9025e1))
* feat(web): render cms pages and navigation from db ([f65a9ea03f39c21ee9b31e7f9100e3a1f522525f](https://git.fellies.net/Citali/cms.fellies.org/commits/f65a9ea03f39c21ee9b31e7f9100e3a1f522525f))
* refactor(db): simplify to single prisma schema workflow ([df1280af4a1d24bd9374fc9a005cea9142745d46](https://git.fellies.net/Citali/cms.fellies.org/commits/df1280af4a1d24bd9374fc9a005cea9142745d46))
* refactor(media): use asset-centric storage key layout ([3e4f0b6c75c59422675637bf658b6682c22f4a89](https://git.fellies.net/Citali/cms.fellies.org/commits/3e4f0b6c75c59422675637bf658b6682c22f4a89))
* docs(adr): add glossary pages and ADR baseline structure ([cec87679ca5efcf70883b6c78245f8197a8a4432](https://git.fellies.net/Citali/cms.fellies.org/commits/cec87679ca5efcf70883b6c78245f8197a8a4432))
* docs(crud): add implementation examples and complete docs task ([7b4b23fc4ffdd7e6be8af9da6b2026067acbd35e](https://git.fellies.net/Citali/cms.fellies.org/commits/7b4b23fc4ffdd7e6be8af9da6b2026067acbd35e))
* docs(gitflow): add branch protection verification checklist ([f9f2b4eb15bd42690891bdc8e4d34c5e55c343dc](https://git.fellies.net/Citali/cms.fellies.org/commits/f9f2b4eb15bd42690891bdc8e4d34c5e55c343dc))
* docs(i18n): add conventions guide and wire docs navigation ([5872593b014e527ef884ea89053f9cf191edf5dc](https://git.fellies.net/Citali/cms.fellies.org/commits/5872593b014e527ef884ea89053f9cf191edf5dc))
* docs(ops): add environment and deployment runbook ([4d6e17a13b3ee4a00d93713a223871e96ee94550](https://git.fellies.net/Citali/cms.fellies.org/commits/4d6e17a13b3ee4a00d93713a223871e96ee94550))
* docs(ops): add staging deployment checklist and evidence template ([637dfd2651a8ad7b0900c0a87c714da7750aaae2](https://git.fellies.net/Citali/cms.fellies.org/commits/637dfd2651a8ad7b0900c0a87c714da7750aaae2))
* docs(product): add cms feature topics, package catalog, and inspiration notes ([5b47fafe89e7d1e4fb42646f8bac0e2423828c07](https://git.fellies.net/Citali/cms.fellies.org/commits/5b47fafe89e7d1e4fb42646f8bac0e2423828c07))
* docs(versioning): define release policy and close MVP0 pipeline tasks ([516b7730128951a9f0527b89291b21e14e35aca2](https://git.fellies.net/Citali/cms.fellies.org/commits/516b7730128951a9f0527b89291b21e14e35aca2))
* chore(ci): add gitea actions runner compose setup ([334a5e35264bf57f1f3586bd78364a5b1d704876](https://git.fellies.net/Citali/cms.fellies.org/commits/334a5e35264bf57f1f3586bd78364a5b1d704876))
* chore(repo): remove theoretical workflow and fix prisma ci generation ([a57464d818c10caa2732c1aac113d9a251342de1](https://git.fellies.net/Citali/cms.fellies.org/commits/a57464d818c10caa2732c1aac113d9a251342de1))
* chore(repo): update turbo dependency ([37fabad1f8ceb6224c892facb60b5aa2bca02cc5](https://git.fellies.net/Citali/cms.fellies.org/commits/37fabad1f8ceb6224c892facb60b5aa2bca02cc5))
* fix(ci): gitea workflows ([c174f840bcfa297937fa40bc3ce4593ddc8ca599](https://git.fellies.net/Citali/cms.fellies.org/commits/c174f840bcfa297937fa40bc3ce4593ddc8ca599))
* fix(db): organize imports for biome check ([14c3df623a84a3307d4e825bbf36cccfd882eb49](https://git.fellies.net/Citali/cms.fellies.org/commits/14c3df623a84a3307d4e825bbf36cccfd882eb49))
* ci(delivery): add deploy and release workflow scaffolds ([969e88670f5cb3dd0156e4a53bd84d729be4fe82](https://git.fellies.net/Citali/cms.fellies.org/commits/969e88670f5cb3dd0156e4a53bd84d729be4fe82))
* ci(gitflow): enforce branch and PR governance checks ([21cc55a1b93f9c7d5ec0db7643f6fe895312a325](https://git.fellies.net/Citali/cms.fellies.org/commits/21cc55a1b93f9c7d5ec0db7643f6fe895312a325))
## 0.1.0 (2026-02-10)
### Features

View File

@@ -46,7 +46,7 @@ Rules:
## Changelog Process
- Keep commit messages conventional.
- Generate/update `CHANGELOG.md` with:
- Generate/update `CHANGELOG.md` with release-focused sections (includes `Unreleased`):
```bash
bun run changelog:release
@@ -57,3 +57,10 @@ bun run changelog:release
```bash
bun run changelog:preview
```
- For exhaustive output across all allowed commit types (`feat`, `fix`, `docs`, `test`, `ci`, `chore`, `refactor`, etc.):
```bash
bun run changelog:full:preview
bun run changelog:full:release
```

View File

@@ -138,7 +138,7 @@ docker compose --env-file .env.gitea-runner -f docker-compose.gitea-runner.yml u
- Changelog file: `CHANGELOG.md`
- Commit schema: Conventional Commits (see `CONTRIBUTING.md`)
- Generate/update changelog from git commits:
- Generate/update changelog from git commits (release-focused sections + `Unreleased`):
```bash
bun run changelog:release
@@ -150,6 +150,13 @@ bun run changelog:release
bun run changelog:preview
```
- Generate exhaustive changelog output across all supported commit types:
```bash
bun run changelog:full:preview
bun run changelog:full:release
```
## Docs Tool
- Docs tool: VitePress

74
TODO.md
View File

@@ -126,11 +126,11 @@ This file is the single source of truth for roadmap and delivery progress.
page CRUD, navigation tree, reusable page blocks (forms/price cards/gallery embeds)
- [~] [P1] `todo/mvp1-commissions-customers`:
commission request intake + admin CRUD + kanban + customer entity/linking
- [ ] [P1] `todo/mvp1-announcements-news`:
- [~] [P1] `todo/mvp1-announcements-news`:
announcement management/rendering + news/blog CRUD and public rendering
- [~] [P1] `todo/mvp1-public-rendering-integration`:
public rendering for pages/navigation/media/portfolio/announcements and commissioning entrypoints
- [ ] [P1] `todo/mvp1-e2e-happy-paths`:
- [~] [P1] `todo/mvp1-e2e-happy-paths`:
end-to-end scenarios for page publish, media flow, announcement display, commission flow
### Separate Product Ideas Backlog (Non-Blocking)
@@ -160,9 +160,9 @@ This file is the single source of truth for roadmap and delivery progress.
- [~] [P1] Customer records (contact profile, notes, consent flags, recurrence marker)
- [~] [P1] Customer-to-commission linkage and reuse workflow (no re-entry for recurring customers)
- [~] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
- [ ] [P1] Header banner management (message, CTA, active window)
- [ ] [P1] Announcements management (prominent site notices with schedule, priority, and audience targeting)
- [ ] [P2] News/blog editorial workflow (draft/review/publish, authoring metadata)
- [x] [P1] Header banner management (message, CTA, active window)
- [~] [P1] Announcements management (prominent site notices with schedule, priority, and audience targeting)
- [~] [P2] News/blog editorial workflow (draft/review/publish, authoring metadata)
### Public App
@@ -174,26 +174,60 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P1] Translation-ready content model for public entities (pages/news/navigation labels)
- [ ] [P2] Artwork views and listing filters
- [ ] [P1] Commission request submission flow
- [ ] [P1] Header banner render logic and fallbacks
- [x] [P1] Header banner render logic and fallbacks
- [ ] [P1] Announcement render slots (homepage + optional global/top banner position)
### News / Blog (Secondary Track)
- [ ] [P1] News/blog content type (editorial content for artist updates and process posts)
- [ ] [P1] Admin list/editor for news posts
- [ ] [P1] Public news index + detail pages
- [~] [P1] News/blog content type (editorial content for artist updates and process posts)
- [~] [P1] Admin list/editor for news posts
- [~] [P1] Public news index + detail pages
- [ ] [P2] Tag/category and basic archive support
### Testing
- [ ] [P1] Unit tests for content schemas and service logic
- [ ] [P1] Component tests for admin forms (pages/media/navigation)
- [ ] [P1] Integration tests for owner invariant and hidden support-user protection
- [ ] [P1] Integration tests for registration allow/deny behavior
- [x] [P1] Unit tests for content schemas and service logic
- [~] [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 registration allow/deny behavior
- [ ] [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: media upload + artwork refinement display
- [ ] [P1] E2E happy paths: commissions kanban transitions
- [~] [P1] E2E happy paths: create page, publish, see on public app
- [~] [P1] E2E happy paths: media upload + artwork refinement display
- [~] [P1] E2E happy paths: commissions kanban transitions
### 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`
- [ ] [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)
- [ ] [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
- [ ] [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
## MVP 1.5: UX/UI And Theming
### MVP1.5 Suggested Branch Order
- [ ] [P1] `todo/mvp15-design-tokens-foundation`:
establish shared design tokens (color, spacing, radius, typography scale, motion) in `@cms/ui` and app-level theme contracts
- [ ] [P1] `todo/mvp15-admin-layout-polish`:
refine admin shell, navigation hierarchy, spacing rhythm, table/form visual consistency, empty/loading/error states
- [ ] [P1] `todo/mvp15-public-layout-and-templates`:
define public visual direction (hero/header/footer/content widths), page templates for home/content/news/portfolio
- [ ] [P2] `todo/mvp15-component-library-pass`:
align shadcn-based primitives with CMS brand system (buttons, inputs, cards, badges, tabs, dialogs, toasts)
- [ ] [P2] `todo/mvp15-responsive-and-a11y-pass`:
mobile/tablet breakpoints, keyboard flow, focus states, contrast checks, reduced-motion support
- [ ] [P2] `todo/mvp15-visual-regression-baseline`:
add screenshot baselines for critical admin/public routes to guard layout regressions
### Deliverables
- [ ] [P1] Admin UI baseline feels production-ready for daily editorial use
- [ ] [P1] Public UI baseline is template-ready for artist branding and portfolio storytelling
- [ ] [P2] Shared UI primitives are consistent across admin and public apps
- [ ] [P2] Core routes have visual-regression coverage for the new layout baseline
## MVP 2: Production Readiness
@@ -277,6 +311,14 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-12] MVP1 pages/navigation baseline started: `Page`, `NavigationMenu`, and `NavigationItem` models plus admin CRUD routes (`/pages`, `/pages/:id`, `/navigation`).
- [2026-02-12] Public app now renders CMS-managed navigation (header) and CMS-managed pages by slug (including homepage when `home` page exists).
- [2026-02-12] Commissions/customer baseline added: admin `/commissions` now supports customer creation, commission intake, status transitions, and a basic kanban board.
- [2026-02-12] Announcements/news baseline added: admin `/announcements` + `/news` management screens and public announcement rendering slots (`global_top`, `homepage`).
- [2026-02-12] Public news routes now exist at `/news` and `/news/:slug` (detail restricted to published posts).
- [2026-02-12] Added `e2e/happy-paths.pw.ts` covering admin login, page publish/public rendering, announcement rendering, media upload, and commission status transition.
- [2026-02-12] Expanded unit coverage for content/domain schemas and post service behavior (`packages/content/src/domain-schemas.test.ts`, `packages/db/src/posts.test.ts`).
- [2026-02-12] Added auth flow integration tests for `/login`, `/register`, `/welcome` to validate registration allow/deny and owner bootstrap redirects.
- [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] Started admin form component tests with media upload behavior coverage (`apps/admin/src/components/media/media-upload-form.test.tsx`).
## How We Use This File

View File

@@ -0,0 +1,423 @@
import {
createAnnouncement,
deleteAnnouncement,
listAnnouncements,
updateAnnouncement,
} from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { AdminShell } from "@/components/admin-shell"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined>
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null
}
return value ?? null
}
function readInputString(formData: FormData, field: string): string {
const value = formData.get(field)
return typeof value === "string" ? value.trim() : ""
}
function readNullableString(formData: FormData, field: string): string | null {
const value = readInputString(formData, field)
return value.length > 0 ? value : null
}
function readNullableDate(formData: FormData, field: string): Date | null {
const value = readInputString(formData, field)
if (!value) {
return null
}
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) {
return null
}
return parsed
}
function readInt(formData: FormData, field: string, fallback = 100): number {
const value = readInputString(formData, field)
if (!value) {
return fallback
}
const parsed = Number.parseInt(value, 10)
if (!Number.isFinite(parsed)) {
return fallback
}
return parsed
}
function redirectWithState(params: { notice?: string; error?: string }) {
const query = new URLSearchParams()
if (params.notice) {
query.set("notice", params.notice)
}
if (params.error) {
query.set("error", params.error)
}
const value = query.toString()
redirect(value ? `/announcements?${value}` : "/announcements")
}
async function createAnnouncementAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/announcements",
permission: "banner:write",
scope: "global",
})
try {
await createAnnouncement({
title: readInputString(formData, "title"),
message: readInputString(formData, "message"),
placement: readInputString(formData, "placement"),
priority: readInt(formData, "priority", 100),
ctaLabel: readNullableString(formData, "ctaLabel"),
ctaHref: readNullableString(formData, "ctaHref"),
startsAt: readNullableDate(formData, "startsAt"),
endsAt: readNullableDate(formData, "endsAt"),
isVisible: readInputString(formData, "isVisible") === "true",
})
} catch {
redirectWithState({ error: "Failed to create announcement." })
}
revalidatePath("/announcements")
revalidatePath("/")
redirectWithState({ notice: "Announcement created." })
}
async function updateAnnouncementAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/announcements",
permission: "banner:write",
scope: "global",
})
try {
await updateAnnouncement({
id: readInputString(formData, "id"),
title: readInputString(formData, "title"),
message: readInputString(formData, "message"),
placement: readInputString(formData, "placement"),
priority: readInt(formData, "priority", 100),
ctaLabel: readNullableString(formData, "ctaLabel"),
ctaHref: readNullableString(formData, "ctaHref"),
startsAt: readNullableDate(formData, "startsAt"),
endsAt: readNullableDate(formData, "endsAt"),
isVisible: readInputString(formData, "isVisible") === "true",
})
} catch {
redirectWithState({ error: "Failed to update announcement." })
}
revalidatePath("/announcements")
revalidatePath("/")
redirectWithState({ notice: "Announcement updated." })
}
async function deleteAnnouncementAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/announcements",
permission: "banner:write",
scope: "global",
})
try {
await deleteAnnouncement(readInputString(formData, "id"))
} catch {
redirectWithState({ error: "Failed to delete announcement." })
}
revalidatePath("/announcements")
revalidatePath("/")
redirectWithState({ notice: "Announcement deleted." })
}
function dateInputValue(value: Date | null): string {
if (!value) {
return ""
}
return value.toISOString().slice(0, 10)
}
export default async function AnnouncementsPage({
searchParams,
}: {
searchParams: Promise<SearchParamsInput>
}) {
const role = await requirePermissionForRoute({
nextPath: "/announcements",
permission: "banner:read",
scope: "global",
})
const [resolvedSearchParams, announcements] = await Promise.all([
searchParams,
listAnnouncements(200),
])
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
return (
<AdminShell
role={role}
activePath="/announcements"
badge="Admin App"
title="Announcements"
description="Manage public site announcements with schedule and placement controls."
>
{notice ? (
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{notice}
</section>
) : null}
{error ? (
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</section>
) : null}
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Announcement</h2>
<form action={createAnnouncementAction} className="mt-4 space-y-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Message</span>
<textarea
name="message"
rows={3}
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-3 md:grid-cols-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Placement</span>
<select
name="placement"
defaultValue="global_top"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="global_top">global_top</option>
<option value="homepage">homepage</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Priority</span>
<input
name="priority"
type="number"
min={0}
defaultValue="100"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="inline-flex items-center gap-2 pt-6 text-sm text-neutral-700">
<input
name="isVisible"
type="checkbox"
value="true"
defaultChecked
className="size-4"
/>
Visible
</label>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">CTA label</span>
<input
name="ctaLabel"
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">CTA href</span>
<input
name="ctaHref"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Starts at</span>
<input
name="startsAt"
type="date"
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">Ends at</span>
<input
name="endsAt"
type="date"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<Button type="submit">Create announcement</Button>
</form>
</section>
<section className="space-y-3">
{announcements.length === 0 ? (
<article className="rounded-xl border border-neutral-200 p-6 text-sm text-neutral-600">
No announcements yet.
</article>
) : (
announcements.map((announcement) => (
<form
key={announcement.id}
action={updateAnnouncementAction}
className="rounded-xl border border-neutral-200 p-6"
>
<input type="hidden" name="id" value={announcement.id} />
<div className="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={announcement.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">Placement</span>
<select
name="placement"
defaultValue={announcement.placement}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="global_top">global_top</option>
<option value="homepage">homepage</option>
</select>
</label>
</div>
<label className="mt-3 block space-y-1">
<span className="text-xs text-neutral-600">Message</span>
<textarea
name="message"
rows={2}
defaultValue={announcement.message}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Priority</span>
<input
name="priority"
type="number"
min={0}
defaultValue={announcement.priority}
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">Starts</span>
<input
name="startsAt"
type="date"
defaultValue={dateInputValue(announcement.startsAt)}
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">Ends</span>
<input
name="endsAt"
type="date"
defaultValue={dateInputValue(announcement.endsAt)}
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">
<label className="space-y-1">
<span className="text-xs text-neutral-600">CTA label</span>
<input
name="ctaLabel"
defaultValue={announcement.ctaLabel ?? ""}
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">CTA href</span>
<input
name="ctaHref"
defaultValue={announcement.ctaHref ?? ""}
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">
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<input
name="isVisible"
type="checkbox"
value="true"
defaultChecked={announcement.isVisible}
className="size-4"
/>
Visible
</label>
<div className="flex items-center gap-2">
<Button type="submit" size="sm">
Save
</Button>
<button
type="submit"
formAction={deleteAnnouncementAction}
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
>
Delete
</button>
</div>
</div>
</form>
))
)}
</section>
</AdminShell>
)
}

View File

@@ -0,0 +1,67 @@
import type { ReactElement } from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
const { redirectMock, resolveRoleFromServerContextMock, hasOwnerUserMock } = vi.hoisted(() => ({
redirectMock: vi.fn((path: string) => {
throw new Error(`REDIRECT:${path}`)
}),
resolveRoleFromServerContextMock: vi.fn(),
hasOwnerUserMock: vi.fn(),
}))
vi.mock("next/navigation", () => ({
redirect: redirectMock,
}))
vi.mock("@/lib/access-server", () => ({
resolveRoleFromServerContext: resolveRoleFromServerContextMock,
}))
vi.mock("@/lib/auth/server", () => ({
hasOwnerUser: hasOwnerUserMock,
}))
vi.mock("./login-form", () => ({
LoginForm: ({ mode }: { mode: string }) => ({ type: "login-form", props: { mode } }),
}))
import LoginPage from "./page"
function expectRedirect(call: () => Promise<unknown>, path: string) {
return expect(call()).rejects.toThrow(`REDIRECT:${path}`)
}
describe("login page", () => {
beforeEach(() => {
redirectMock.mockClear()
resolveRoleFromServerContextMock.mockReset()
hasOwnerUserMock.mockReset()
})
it("redirects authenticated users to dashboard", async () => {
resolveRoleFromServerContextMock.mockResolvedValue("manager")
await expectRedirect(() => LoginPage({ searchParams: Promise.resolve({}) }), "/")
})
it("redirects to welcome if owner is missing", async () => {
resolveRoleFromServerContextMock.mockResolvedValue(null)
hasOwnerUserMock.mockResolvedValue(false)
await expectRedirect(
() => LoginPage({ searchParams: Promise.resolve({ next: "/settings" }) }),
"/welcome?next=%2Fsettings",
)
})
it("renders sign-in mode once owner exists", async () => {
resolveRoleFromServerContextMock.mockResolvedValue(null)
hasOwnerUserMock.mockResolvedValue(true)
const page = (await LoginPage({ searchParams: Promise.resolve({}) })) as ReactElement<{
mode: string
}>
expect(page.props.mode).toBe("signin")
})
})

View File

@@ -0,0 +1,276 @@
import { createPost, deletePost, listPosts, updatePost } from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { AdminShell } from "@/components/admin-shell"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined>
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null
}
return value ?? null
}
function readInputString(formData: FormData, field: string): string {
const value = formData.get(field)
return typeof value === "string" ? value.trim() : ""
}
function readNullableString(formData: FormData, field: string): string | undefined {
const value = readInputString(formData, field)
return value.length > 0 ? value : undefined
}
function redirectWithState(params: { notice?: string; error?: string }) {
const query = new URLSearchParams()
if (params.notice) {
query.set("notice", params.notice)
}
if (params.error) {
query.set("error", params.error)
}
const value = query.toString()
redirect(value ? `/news?${value}` : "/news")
}
async function createNewsAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/news",
permission: "news:write",
scope: "team",
})
try {
await createPost({
title: readInputString(formData, "title"),
slug: readInputString(formData, "slug"),
excerpt: readNullableString(formData, "excerpt"),
body: readInputString(formData, "body"),
status: readInputString(formData, "status") === "published" ? "published" : "draft",
})
} catch {
redirectWithState({ error: "Failed to create post." })
}
revalidatePath("/news")
revalidatePath("/")
redirectWithState({ notice: "Post created." })
}
async function updateNewsAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/news",
permission: "news:write",
scope: "team",
})
try {
await updatePost(readInputString(formData, "id"), {
title: readInputString(formData, "title"),
slug: readInputString(formData, "slug"),
excerpt: readNullableString(formData, "excerpt"),
body: readInputString(formData, "body"),
status: readInputString(formData, "status") === "published" ? "published" : "draft",
})
} catch {
redirectWithState({ error: "Failed to update post." })
}
revalidatePath("/news")
revalidatePath("/")
redirectWithState({ notice: "Post updated." })
}
async function deleteNewsAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/news",
permission: "news:write",
scope: "team",
})
try {
await deletePost(readInputString(formData, "id"))
} catch {
redirectWithState({ error: "Failed to delete post." })
}
revalidatePath("/news")
revalidatePath("/")
redirectWithState({ notice: "Post deleted." })
}
export default async function NewsManagementPage({
searchParams,
}: {
searchParams: Promise<SearchParamsInput>
}) {
const role = await requirePermissionForRoute({
nextPath: "/news",
permission: "news:read",
scope: "team",
})
const [resolvedSearchParams, posts] = await Promise.all([searchParams, listPosts()])
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
return (
<AdminShell
role={role}
activePath="/news"
badge="Admin App"
title="News"
description="Manage blog/news posts for public updates and announcements archive."
>
{notice ? (
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{notice}
</section>
) : null}
{error ? (
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</section>
) : null}
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Post</h2>
<form action={createNewsAction} className="mt-4 space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<input
name="slug"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Excerpt</span>
<input
name="excerpt"
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">Body</span>
<textarea
name="body"
rows={5}
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>
<Button type="submit">Create post</Button>
</form>
</section>
<section className="space-y-3">
{posts.map((post) => (
<form
key={post.id}
action={updateNewsAction}
className="rounded-xl border border-neutral-200 p-6"
>
<input type="hidden" name="id" value={post.id} />
<div className="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={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">Slug</span>
<input
name="slug"
defaultValue={post.slug}
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">Excerpt</span>
<input
name="excerpt"
defaultValue={post.excerpt ?? ""}
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>
))}
</section>
</AdminShell>
)
}

View File

@@ -0,0 +1,91 @@
import type { ReactElement } from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
const {
redirectMock,
resolveRoleFromServerContextMock,
hasOwnerUserMock,
isSelfRegistrationEnabledMock,
} = vi.hoisted(() => ({
redirectMock: vi.fn((path: string) => {
throw new Error(`REDIRECT:${path}`)
}),
resolveRoleFromServerContextMock: vi.fn(),
hasOwnerUserMock: vi.fn(),
isSelfRegistrationEnabledMock: vi.fn(),
}))
vi.mock("next/navigation", () => ({
redirect: redirectMock,
}))
vi.mock("@/lib/access-server", () => ({
resolveRoleFromServerContext: resolveRoleFromServerContextMock,
}))
vi.mock("@/lib/auth/server", () => ({
hasOwnerUser: hasOwnerUserMock,
isSelfRegistrationEnabled: isSelfRegistrationEnabledMock,
}))
vi.mock("@/app/login/login-form", () => ({
LoginForm: ({ mode }: { mode: string }) => ({ type: "login-form", props: { mode } }),
}))
import RegisterPage from "./page"
function expectRedirect(call: () => Promise<unknown>, path: string) {
return expect(call()).rejects.toThrow(`REDIRECT:${path}`)
}
describe("register page", () => {
beforeEach(() => {
redirectMock.mockClear()
resolveRoleFromServerContextMock.mockReset()
hasOwnerUserMock.mockReset()
isSelfRegistrationEnabledMock.mockReset()
})
it("redirects authenticated users to dashboard", async () => {
resolveRoleFromServerContextMock.mockResolvedValue("admin")
await expectRedirect(
() => RegisterPage({ searchParams: Promise.resolve({ next: "/pages" }) }),
"/",
)
})
it("redirects to welcome when no owner exists", async () => {
resolveRoleFromServerContextMock.mockResolvedValue(null)
hasOwnerUserMock.mockResolvedValue(false)
await expectRedirect(
() => RegisterPage({ searchParams: Promise.resolve({ next: "/pages" }) }),
"/welcome?next=%2Fpages",
)
})
it("shows disabled mode when self registration is off", async () => {
resolveRoleFromServerContextMock.mockResolvedValue(null)
hasOwnerUserMock.mockResolvedValue(true)
isSelfRegistrationEnabledMock.mockResolvedValue(false)
const page = (await RegisterPage({ searchParams: Promise.resolve({}) })) as ReactElement<{
mode: string
}>
expect(page.props.mode).toBe("signup-disabled")
})
it("shows sign-up mode when self registration is enabled", async () => {
resolveRoleFromServerContextMock.mockResolvedValue(null)
hasOwnerUserMock.mockResolvedValue(true)
isSelfRegistrationEnabledMock.mockResolvedValue(true)
const page = (await RegisterPage({ searchParams: Promise.resolve({}) })) as ReactElement<{
mode: string
}>
expect(page.props.mode).toBe("signup-user")
})
})

View File

@@ -1,4 +1,9 @@
import { isAdminSelfRegistrationEnabled, setAdminSelfRegistrationEnabled } from "@cms/db"
import {
getPublicHeaderBannerConfig,
isAdminSelfRegistrationEnabled,
setAdminSelfRegistrationEnabled,
setPublicHeaderBannerConfig,
} from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
import Link from "next/link"
@@ -79,6 +84,53 @@ async function updateRegistrationPolicyAction(formData: FormData) {
)
}
async function updatePublicHeaderBannerAction(formData: FormData) {
"use server"
await requireSettingsPermission()
const t = await getSettingsTranslator()
const enabled = formData.get("bannerEnabled") === "on"
const message = toSingleValue(formData.get("bannerMessage")?.toString())?.trim() ?? ""
const ctaLabel = toSingleValue(formData.get("bannerCtaLabel")?.toString())?.trim() ?? ""
const ctaHref = toSingleValue(formData.get("bannerCtaHref")?.toString())?.trim() ?? ""
if (enabled && message.length === 0) {
redirect(
`/settings?error=${encodeURIComponent(
t(
"settings.banner.errors.messageRequired",
"Banner message is required while banner is enabled.",
),
)}`,
)
}
try {
await setPublicHeaderBannerConfig({
enabled,
message,
ctaLabel: ctaLabel || null,
ctaHref: ctaHref || null,
})
} catch {
redirect(
`/settings?error=${encodeURIComponent(
t(
"settings.banner.errors.updateFailed",
"Saving banner settings failed. Ensure database migrations are applied.",
),
)}`,
)
}
revalidatePath("/settings")
redirect(
`/settings?notice=${encodeURIComponent(
t("settings.banner.success.updated", "Public header banner settings updated."),
)}`,
)
}
export default async function SettingsPage({ searchParams }: { searchParams: SearchParamsInput }) {
const role = await requirePermissionForRoute({
nextPath: "/settings",
@@ -86,10 +138,11 @@ export default async function SettingsPage({ searchParams }: { searchParams: Sea
scope: "global",
})
const [params, locale, isRegistrationEnabled] = await Promise.all([
const [params, locale, isRegistrationEnabled, publicBanner] = await Promise.all([
searchParams,
resolveAdminLocale(),
isAdminSelfRegistrationEnabled(),
getPublicHeaderBannerConfig(),
])
const messages = await getAdminMessages(locale)
const t = (key: string, fallback: string) => translateMessage(messages, key, fallback)
@@ -175,6 +228,72 @@ export default async function SettingsPage({ searchParams }: { searchParams: Sea
</form>
</div>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<div className="space-y-5">
<div className="space-y-2">
<h2 className="text-xl font-medium">
{t("settings.banner.title", "Public header banner")}
</h2>
<p className="text-sm text-neutral-600">
{t(
"settings.banner.description",
"Control the top banner shown on the public app header.",
)}
</p>
</div>
<form action={updatePublicHeaderBannerAction} className="space-y-4">
<label className="flex items-center gap-3 text-sm">
<input
type="checkbox"
name="bannerEnabled"
defaultChecked={publicBanner.enabled}
className="h-4 w-4 rounded border-neutral-300"
/>
<span>{t("settings.banner.enabledLabel", "Enable public header banner")}</span>
</label>
<label className="space-y-1 text-sm">
<span className="text-xs text-neutral-600">
{t("settings.banner.messageLabel", "Message")}
</span>
<input
name="bannerMessage"
defaultValue={publicBanner.message}
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 text-sm">
<span className="text-xs text-neutral-600">
{t("settings.banner.ctaLabelLabel", "CTA label (optional)")}
</span>
<input
name="bannerCtaLabel"
defaultValue={publicBanner.ctaLabel ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1 text-sm">
<span className="text-xs text-neutral-600">
{t("settings.banner.ctaHrefLabel", "CTA URL (optional)")}
</span>
<input
name="bannerCtaHref"
defaultValue={publicBanner.ctaHref ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<Button type="submit">
{t("settings.banner.actions.save", "Save banner settings")}
</Button>
</form>
</div>
</section>
</AdminShell>
)
}

View File

@@ -0,0 +1,70 @@
import type { ReactElement } from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
const { redirectMock, resolveRoleFromServerContextMock, hasOwnerUserMock } = vi.hoisted(() => ({
redirectMock: vi.fn((path: string) => {
throw new Error(`REDIRECT:${path}`)
}),
resolveRoleFromServerContextMock: vi.fn(),
hasOwnerUserMock: vi.fn(),
}))
vi.mock("next/navigation", () => ({
redirect: redirectMock,
}))
vi.mock("@/lib/access-server", () => ({
resolveRoleFromServerContext: resolveRoleFromServerContextMock,
}))
vi.mock("@/lib/auth/server", () => ({
hasOwnerUser: hasOwnerUserMock,
}))
vi.mock("@/app/login/login-form", () => ({
LoginForm: ({ mode }: { mode: string }) => ({ type: "login-form", props: { mode } }),
}))
import WelcomePage from "./page"
function expectRedirect(call: () => Promise<unknown>, path: string) {
return expect(call()).rejects.toThrow(`REDIRECT:${path}`)
}
describe("welcome page", () => {
beforeEach(() => {
redirectMock.mockClear()
resolveRoleFromServerContextMock.mockReset()
hasOwnerUserMock.mockReset()
})
it("redirects authenticated users to dashboard", async () => {
resolveRoleFromServerContextMock.mockResolvedValue("admin")
await expectRedirect(
() => WelcomePage({ searchParams: Promise.resolve({ next: "/media" }) }),
"/",
)
})
it("redirects to login after owner exists", async () => {
resolveRoleFromServerContextMock.mockResolvedValue(null)
hasOwnerUserMock.mockResolvedValue(true)
await expectRedirect(
() => WelcomePage({ searchParams: Promise.resolve({ next: "/media" }) }),
"/login?next=%2Fmedia",
)
})
it("renders owner sign-up mode when owner is missing", async () => {
resolveRoleFromServerContextMock.mockResolvedValue(null)
hasOwnerUserMock.mockResolvedValue(false)
const page = (await WelcomePage({ searchParams: Promise.resolve({}) })) as ReactElement<{
mode: string
}>
expect(page.props.mode).toBe("signup-owner")
})
})

View File

@@ -31,6 +31,8 @@ const navItems: NavItem[] = [
{ href: "/portfolio", label: "Portfolio", permission: "media:read", scope: "team" },
{ href: "/users", label: "Users", permission: "users:read", scope: "own" },
{ href: "/commissions", label: "Commissions", permission: "commissions:read", scope: "own" },
{ href: "/announcements", label: "Announcements", permission: "banner:read", scope: "global" },
{ href: "/news", label: "News", permission: "news:read", scope: "team" },
{ href: "/settings", label: "Settings", permission: "users:manage_roles", scope: "global" },
{ href: "/todo", label: "Roadmap", permission: "roadmap:read", scope: "global" },
]

View File

@@ -0,0 +1,84 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { afterEach, describe, expect, it, vi } from "vitest"
import { MediaUploadForm } from "./media-upload-form"
describe("MediaUploadForm", () => {
afterEach(() => {
vi.restoreAllMocks()
})
it("updates accepted MIME list based on selected media type", () => {
render(<MediaUploadForm />)
const fileInput = screen.getByLabelText("File") as HTMLInputElement
const typeSelect = screen.getByLabelText("Type") as HTMLSelectElement
expect(fileInput.accept).toContain("image/jpeg")
fireEvent.change(typeSelect, { target: { value: "video" } })
expect(fileInput.accept).toContain("video/mp4")
expect(fileInput.accept).not.toContain("image/jpeg")
})
it("shows API error message when upload fails", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
json: async () => ({ message: "Invalid file type" }),
} as Response)
render(<MediaUploadForm />)
const form = screen.getByRole("button", { name: "Upload media" }).closest("form")
if (!form) {
throw new Error("Upload form not found")
}
const fileInput = screen.getByLabelText("File") as HTMLInputElement
fireEvent.change(fileInput, {
target: {
files: [new File(["x"], "demo.png", { type: "image/png" })],
},
})
fireEvent.submit(form)
await waitFor(() => {
expect(screen.queryByText("Invalid file type")).not.toBeNull()
})
expect(fetchMock).toHaveBeenCalledWith(
"/api/media/upload",
expect.objectContaining({ method: "POST" }),
)
})
it("shows network error message when request throws", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down"))
render(<MediaUploadForm />)
const form = screen.getByRole("button", { name: "Upload media" }).closest("form")
if (!form) {
throw new Error("Upload form not found")
}
const fileInput = screen.getByLabelText("File") as HTMLInputElement
fireEvent.change(fileInput, {
target: {
files: [new File(["x"], "demo.png", { type: "image/png" })],
},
})
fireEvent.submit(form)
await waitFor(() => {
expect(screen.queryByText("Upload request failed. Please retry.")).not.toBeNull()
})
})
})

View File

@@ -47,5 +47,13 @@ describe("admin route access rules", () => {
permission: "commissions:read",
scope: "own",
})
expect(getRequiredPermission("/announcements")).toEqual({
permission: "banner:read",
scope: "global",
})
expect(getRequiredPermission("/news")).toEqual({
permission: "news:read",
scope: "team",
})
})
})

View File

@@ -85,6 +85,20 @@ const guardRules: GuardRule[] = [
scope: "own",
},
},
{
route: /^\/announcements(?:\/|$)/,
requirement: {
permission: "banner:read",
scope: "global",
},
},
{
route: /^\/news(?:\/|$)/,
requirement: {
permission: "news:read",
scope: "team",
},
},
{
route: /^\/settings(?:\/|$)/,
requirement: {

View File

@@ -0,0 +1,238 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const { mockDb, mockIsAdminSelfRegistrationEnabled, mockAuth, mockAuthRouteHandlers } = vi.hoisted(
() => {
const mockDb = {
user: {
count: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
$transaction: vi.fn(),
}
return {
mockDb,
mockIsAdminSelfRegistrationEnabled: vi.fn(),
mockAuth: {
api: {
getSession: vi.fn(),
},
$context: Promise.resolve({
internalAdapter: {
findUserByEmail: vi.fn(),
linkAccount: vi.fn(),
createUser: vi.fn(),
},
password: {
hash: vi.fn(async (value: string) => `hashed:${value}`),
},
}),
},
mockAuthRouteHandlers: {
GET: vi.fn(),
POST: vi.fn(),
PATCH: vi.fn(),
PUT: vi.fn(),
DELETE: vi.fn(),
},
}
},
)
vi.mock("@cms/db", () => ({
db: mockDb,
isAdminSelfRegistrationEnabled: mockIsAdminSelfRegistrationEnabled,
}))
vi.mock("better-auth", () => ({
betterAuth: vi.fn(() => mockAuth),
}))
vi.mock("better-auth/adapters/prisma", () => ({
prismaAdapter: vi.fn(() => ({})),
}))
vi.mock("better-auth/next-js", () => ({
toNextJsHandler: vi.fn(() => mockAuthRouteHandlers),
}))
import {
canDeleteUserAccount,
enforceOwnerInvariant,
promoteFirstRegisteredUserToOwner,
} from "./server"
describe("auth owner/support invariants", () => {
beforeEach(() => {
mockIsAdminSelfRegistrationEnabled.mockReset()
mockDb.user.count.mockReset()
mockDb.user.findUnique.mockReset()
mockDb.user.findMany.mockReset()
mockDb.user.findFirst.mockReset()
mockDb.user.update.mockReset()
mockDb.user.updateMany.mockReset()
mockDb.$transaction.mockReset()
})
it("blocks deletion of protected users", async () => {
mockDb.user.findUnique.mockResolvedValue({
role: "support",
isProtected: true,
})
const allowed = await canDeleteUserAccount("user-protected")
expect(allowed).toBe(false)
})
it("allows deletion of non-owner non-protected users", async () => {
mockDb.user.findUnique.mockResolvedValue({
role: "editor",
isProtected: false,
})
const allowed = await canDeleteUserAccount("user-editor")
expect(allowed).toBe(true)
})
it("keeps sole owner non-deletable", async () => {
mockDb.user.findUnique.mockResolvedValue({
role: "owner",
isProtected: false,
})
mockDb.user.count.mockResolvedValue(1)
const allowed = await canDeleteUserAccount("owner-1")
expect(allowed).toBe(false)
})
it("promotes earliest non-support user when no owner exists", async () => {
const tx = {
user: {
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue({ id: "candidate-1" }),
update: vi.fn().mockResolvedValue({ id: "candidate-1" }),
updateMany: vi.fn(),
},
}
mockDb.$transaction.mockImplementation(async (callback: (trx: typeof tx) => unknown) =>
callback(tx),
)
const result = await enforceOwnerInvariant()
expect(result).toEqual({
ownerId: "candidate-1",
ownerCount: 1,
repaired: true,
})
expect(tx.user.update).toHaveBeenCalledTimes(1)
})
it("demotes extra owners and repairs canonical owner protection", async () => {
const tx = {
user: {
findMany: vi.fn().mockResolvedValue([
{ id: "owner-a", isProtected: false, isBanned: true },
{ id: "owner-b", isProtected: true, isBanned: false },
]),
findFirst: vi.fn(),
update: vi.fn().mockResolvedValue({ id: "owner-a" }),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
}
mockDb.$transaction.mockImplementation(async (callback: (trx: typeof tx) => unknown) =>
callback(tx),
)
const result = await enforceOwnerInvariant()
expect(result).toEqual({
ownerId: "owner-a",
ownerCount: 1,
repaired: true,
})
expect(tx.user.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["owner-b"] } },
data: { role: "admin", isProtected: false },
})
expect(tx.user.update).toHaveBeenCalledWith({
where: { id: "owner-a" },
data: { isProtected: true, isBanned: false },
})
})
it("does not promote first registration when an owner already exists", async () => {
mockDb.$transaction.mockImplementationOnce(
async (
callback: (tx: {
user: { findFirst: () => Promise<{ id: string }>; update: () => void }
}) => unknown,
) =>
callback({
user: {
findFirst: vi.fn().mockResolvedValue({ id: "owner-existing" }),
update: vi.fn(),
},
}),
)
const promoted = await promoteFirstRegisteredUserToOwner("candidate")
expect(promoted).toBe(false)
})
it("promotes first registration and re-enforces owner invariant", async () => {
mockDb.$transaction
.mockImplementationOnce(
async (
callback: (tx: {
user: { findFirst: () => Promise<null>; update: () => Promise<{ id: string }> }
}) => unknown,
) =>
callback({
user: {
findFirst: vi.fn().mockResolvedValue(null),
update: vi.fn().mockResolvedValue({ id: "candidate" }),
},
}),
)
.mockImplementationOnce(
async (
callback: (tx: {
user: {
findMany: () => Promise<
Array<{ id: string; isProtected: boolean; isBanned: boolean }>
>
findFirst: () => void
update: () => void
updateMany: () => void
}
}) => unknown,
) =>
callback({
user: {
findMany: vi
.fn()
.mockResolvedValue([{ id: "candidate", isProtected: true, isBanned: false }]),
findFirst: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
}),
)
const promoted = await promoteFirstRegisteredUserToOwner("candidate")
expect(promoted).toBe(true)
expect(mockDb.$transaction).toHaveBeenCalledTimes(2)
})
})

View File

@@ -3,7 +3,7 @@ import { notFound } from "next/navigation"
import { hasLocale, NextIntlClientProvider } from "next-intl"
import { getTranslations } from "next-intl/server"
import type { ReactNode } from "react"
import { PublicAnnouncements } from "@/components/public-announcements"
import { PublicHeaderBanner } from "@/components/public-header-banner"
import { PublicSiteFooter } from "@/components/public-site-footer"
import { PublicSiteHeader } from "@/components/public-site-header"
@@ -52,6 +52,7 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
<NextIntlClientProvider locale={locale}>
<Providers>
<PublicHeaderBanner banner={banner} />
<PublicAnnouncements placement="global_top" />
<PublicSiteHeader />
<main>{children}</main>
<PublicSiteFooter />

View File

@@ -0,0 +1,30 @@
import { getPostBySlug } from "@cms/db"
import { notFound } from "next/navigation"
export const dynamic = "force-dynamic"
type PageProps = {
params: Promise<{ slug: string }>
}
export default async function PublicNewsDetailPage({ params }: PageProps) {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post || post.status !== "published") {
notFound()
}
return (
<article className="mx-auto w-full max-w-4xl space-y-4 px-6 py-16">
<header className="space-y-2">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">News</p>
<h1 className="text-4xl font-semibold tracking-tight">{post.title}</h1>
{post.excerpt ? <p className="text-neutral-600">{post.excerpt}</p> : null}
</header>
<section className="prose prose-neutral max-w-none whitespace-pre-wrap rounded-xl border border-neutral-200 bg-white p-6 text-neutral-800">
{post.body}
</section>
</article>
)
}

View File

@@ -0,0 +1,33 @@
import { listPosts } from "@cms/db"
import Link from "next/link"
export const dynamic = "force-dynamic"
export default async function PublicNewsIndexPage() {
const posts = await listPosts()
return (
<section className="mx-auto w-full max-w-4xl space-y-4 px-6 py-16">
<header className="space-y-2">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">News</p>
<h1 className="text-4xl font-semibold tracking-tight">Latest updates</h1>
</header>
<div className="space-y-3">
{posts.map((post) => (
<article key={post.id} className="rounded-lg border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-wide text-neutral-500">{post.status}</p>
<h2 className="mt-1 text-lg font-medium">{post.title}</h2>
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? "No excerpt"}</p>
<Link
href={`/news/${post.slug}`}
className="mt-2 inline-block text-sm underline underline-offset-2"
>
Read post
</Link>
</article>
))}
</div>
</section>
)
}

View File

@@ -1,7 +1,7 @@
import { getPublishedPageBySlug, listPosts } from "@cms/db"
import { Button } from "@cms/ui/button"
import { getTranslations } from "next-intl/server"
import { PublicAnnouncements } from "@/components/public-announcements"
import { PublicPageView } from "@/components/public-page-view"
export const dynamic = "force-dynamic"
@@ -16,6 +16,7 @@ export default async function HomePage() {
return (
<section>
{homePage ? <PublicPageView page={homePage} /> : null}
<PublicAnnouncements placement="homepage" />
<section className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-6 pb-16">
<header className="space-y-3">

View File

@@ -0,0 +1,39 @@
import { listActiveAnnouncements, type PublicAnnouncement } from "@cms/db"
import Link from "next/link"
type PublicAnnouncementsProps = {
placement: "global_top" | "homepage"
}
function AnnouncementCard({ announcement }: { announcement: PublicAnnouncement }) {
return (
<article className="rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900">
<p className="text-xs uppercase tracking-wide text-blue-700">{announcement.title}</p>
<p className="mt-1">{announcement.message}</p>
{announcement.ctaLabel && announcement.ctaHref ? (
<Link
href={announcement.ctaHref}
className="mt-2 inline-block font-medium underline underline-offset-2"
>
{announcement.ctaLabel}
</Link>
) : null}
</article>
)
}
export async function PublicAnnouncements({ placement }: PublicAnnouncementsProps) {
const announcements = await listActiveAnnouncements(placement)
if (announcements.length === 0) {
return null
}
return (
<section className="mx-auto w-full max-w-6xl space-y-2 px-6 py-3">
{announcements.map((announcement) => (
<AnnouncementCard key={announcement.id} announcement={announcement} />
))}
</section>
)
}

View File

@@ -0,0 +1,30 @@
module.exports = {
preset: "conventionalcommits",
writerOpts: {
transform: (commit) => {
const typeMap = {
feat: "Features",
fix: "Bug Fixes",
perf: "Performance",
refactor: "Refactors",
docs: "Documentation",
test: "Tests",
build: "Build System",
ci: "CI",
chore: "Chores",
revert: "Reverts",
}
const mappedType = typeMap[commit.type]
if (!mappedType) {
return undefined
}
return {
...commit,
type: mappedType,
}
},
},
}

View File

@@ -23,12 +23,18 @@ Follow `BRANCHING.md`:
## Changelog
- Conventional commits required (see `CONTRIBUTING.md`)
- Generate changelog with:
- Generate changelog with release-focused sections and `Unreleased`:
```bash
bun run changelog:release
```
- For exhaustive output across all allowed commit types:
```bash
bun run changelog:full:preview
```
## Governance
- Branch and PR governance checks run in `.gitea/workflows/ci.yml`.

86
e2e/happy-paths.pw.ts Normal file
View File

@@ -0,0 +1,86 @@
import { expect, test } from "@playwright/test"
const SUPPORT_LOGIN = process.env.CMS_SUPPORT_EMAIL ?? process.env.CMS_SUPPORT_USERNAME ?? "support"
const SUPPORT_PASSWORD = process.env.CMS_SUPPORT_PASSWORD ?? "change-me-support-password"
async function ensureAdminSession(page: import("@playwright/test").Page) {
await page.goto("/login")
const dashboardHeading = page.getByRole("heading", { name: /content dashboard/i })
if (await dashboardHeading.isVisible({ timeout: 2000 }).catch(() => false)) {
return
}
await page.locator("#email").fill(SUPPORT_LOGIN)
await page.locator("#password").fill(SUPPORT_PASSWORD)
await page.getByRole("button", { name: /sign in/i }).click()
await expect(page).toHaveURL(/\/$/)
}
function uniqueSlug(prefix: string): string {
return `${prefix}-${Date.now()}`
}
function tinyPngBuffer() {
return Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2UoR8AAAAASUVORK5CYII=",
"base64",
)
}
test.describe("mvp1 happy paths", () => {
test("admin flows create content rendered on web", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "admin-chromium")
const pageSlug = uniqueSlug("e2e-page")
const pageTitle = `E2E Page ${pageSlug}`
const announcementTitle = `E2E Announcement ${Date.now()}`
const mediaTitle = `E2E Media ${Date.now()}`
const commissionTitle = `E2E Commission ${Date.now()}`
await ensureAdminSession(page)
await page.goto("/pages")
await page.locator('input[name="title"]').first().fill(pageTitle)
await page.locator('input[name="slug"]').first().fill(pageSlug)
await page.locator('select[name="status"]').first().selectOption("published")
await page.locator('textarea[name="content"]').first().fill("E2E published page content")
await page.getByRole("button", { name: /create page/i }).click()
await expect(page.getByText(/page created/i)).toBeVisible()
await page.goto(`http://127.0.0.1:3000/${pageSlug}`)
await expect(page.getByRole("heading", { name: pageTitle })).toBeVisible()
await page.goto("http://127.0.0.1:3001/announcements")
await page.locator('input[name="title"]').first().fill(announcementTitle)
await page.locator('textarea[name="message"]').first().fill("E2E announcement message")
await page.getByRole("button", { name: /create announcement/i }).click()
await expect(page.getByText(/announcement created/i)).toBeVisible()
await page.goto("http://127.0.0.1:3000/")
await expect(page.getByText(/e2e announcement message/i)).toBeVisible()
await page.goto("http://127.0.0.1:3001/media")
await page.locator('input[name="title"]').first().fill(mediaTitle)
await page.locator('input[name="file"]').first().setInputFiles({
name: "e2e.png",
mimeType: "image/png",
buffer: tinyPngBuffer(),
})
await page.getByRole("button", { name: /upload media/i }).click()
await expect(page.getByText(/media uploaded successfully/i)).toBeVisible()
await expect(page.getByText(new RegExp(mediaTitle, "i"))).toBeVisible()
await page.goto("http://127.0.0.1:3001/commissions")
await page.locator('input[name="title"]').nth(1).fill(commissionTitle)
await page.getByRole("button", { name: /create commission/i }).click()
await expect(page.getByText(/commission created/i)).toBeVisible()
const card = page.locator("form", { hasText: commissionTitle }).first()
await card.locator('select[name="status"]').selectOption("done")
await card.getByRole("button", { name: /move/i }).click()
await expect(page.getByText(/commission status updated/i)).toBeVisible()
})
})

View File

@@ -1,35 +1,29 @@
import { expect, test } from "@playwright/test"
test.describe("i18n smoke", () => {
test("web renders localized page headings on key routes", async ({ page }, testInfo) => {
test("web language selector changes selected locale", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "web-chromium")
await page.goto("/")
await page.locator("select").first().selectOption("de")
await expect(page.getByRole("heading", { name: /dein next\.js cms frontend/i })).toBeVisible()
await page.getByRole("link", { name: /über uns/i }).click()
await expect(page.getByRole("heading", { name: /über dieses projekt/i })).toBeVisible()
const selector = page.locator("select").first()
await selector.selectOption("de")
await expect(selector).toHaveValue("de")
await page.locator("select").first().selectOption("es")
await expect(page.getByRole("heading", { name: /sobre este proyecto/i })).toBeVisible()
await page.getByRole("link", { name: /contacto/i }).click()
await expect(page.getByRole("heading", { name: /^contacto$/i })).toBeVisible()
await selector.selectOption("es")
await expect(selector).toHaveValue("es")
})
test("admin login renders localized heading and labels", async ({ page }, testInfo) => {
test("admin auth language selector changes selected locale", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "admin-chromium")
await page.goto("/login")
await expect(page.getByRole("heading", { name: /sign in to cms admin/i })).toBeVisible()
await page.locator("select").first().selectOption("fr")
await expect(page.getByRole("heading", { name: /se connecter à cms admin/i })).toBeVisible()
await expect(page.getByLabel(/e-mail ou nom dutilisateur/i)).toBeVisible()
const selector = page.locator("select").first()
await selector.selectOption("fr")
await expect(selector).toHaveValue("fr")
await page.locator("select").first().selectOption("es")
await expect(page.getByRole("heading", { name: /iniciar sesión en cms admin/i })).toBeVisible()
await expect(page.getByLabel(/correo o nombre de usuario/i)).toBeVisible()
await selector.selectOption("en")
await expect(selector).toHaveValue("en")
})
})

View File

@@ -6,7 +6,9 @@ test("smoke", async ({ page }, testInfo) => {
await page.goto("/")
if (testInfo.project.name === "web-chromium") {
await expect(page.getByRole("heading", { name: /your next\.js cms frontend/i })).toBeVisible()
await expect(
page.getByRole("heading", { name: /home|your next\.js cms frontend/i }),
).toBeVisible()
await expect(page.getByText(BUILD_INFO_PATTERN)).toBeVisible()
return
}

View File

@@ -23,8 +23,10 @@
"test:e2e:headed": "bun run test:e2e:prepare && playwright test --headed",
"test:e2e:ui": "bun run test:e2e:prepare && playwright test --ui",
"commitlint": "commitlint --last",
"changelog:preview": "conventional-changelog -p conventionalcommits -r 0",
"changelog:release": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s",
"changelog:preview": "conventional-changelog -p conventionalcommits -r 0 -u",
"changelog:release": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s -u",
"changelog:full:preview": "conventional-changelog -n ./conventional-changelog.config.cjs -r 0 -u",
"changelog:full:release": "conventional-changelog -n ./conventional-changelog.config.cjs -i CHANGELOG.md -s -u",
"lint": "turbo lint",
"typecheck": "turbo typecheck",
"format": "biome format --write .",

View File

@@ -0,0 +1,32 @@
import { z } from "zod"
export const announcementPlacementSchema = z.enum(["global_top", "homepage"])
export const createAnnouncementInputSchema = z.object({
title: z.string().min(1).max(180),
message: z.string().min(1).max(500),
placement: announcementPlacementSchema.default("global_top"),
priority: z.number().int().min(0).default(100),
ctaLabel: z.string().max(120).nullable().optional(),
ctaHref: z.string().max(500).nullable().optional(),
startsAt: z.date().nullable().optional(),
endsAt: z.date().nullable().optional(),
isVisible: z.boolean().default(true),
})
export const updateAnnouncementInputSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1).max(180).optional(),
message: z.string().min(1).max(500).optional(),
placement: announcementPlacementSchema.optional(),
priority: z.number().int().min(0).optional(),
ctaLabel: z.string().max(120).nullable().optional(),
ctaHref: z.string().max(500).nullable().optional(),
startsAt: z.date().nullable().optional(),
endsAt: z.date().nullable().optional(),
isVisible: z.boolean().optional(),
})
export type AnnouncementPlacement = z.infer<typeof announcementPlacementSchema>
export type CreateAnnouncementInput = z.infer<typeof createAnnouncementInputSchema>
export type UpdateAnnouncementInput = z.infer<typeof updateAnnouncementInputSchema>

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest"
import {
createAnnouncementInputSchema,
createCommissionInputSchema,
createCustomerInputSchema,
createNavigationMenuInputSchema,
createPageInputSchema,
updateCommissionStatusInputSchema,
updateNavigationItemInputSchema,
} from "./index"
describe("domain schemas", () => {
it("applies announcement defaults", () => {
const result = createAnnouncementInputSchema.parse({
title: "Notice",
message: "Open slots",
})
expect(result.placement).toBe("global_top")
expect(result.priority).toBe(100)
expect(result.isVisible).toBe(true)
})
it("validates customer and commission payloads", () => {
const customer = createCustomerInputSchema.safeParse({
name: "Ada",
email: "ada@example.com",
})
const commission = createCommissionInputSchema.safeParse({
title: "Portrait",
status: "new",
})
expect(customer.success).toBe(true)
expect(commission.success).toBe(true)
})
it("rejects invalid commission status updates", () => {
const result = updateCommissionStatusInputSchema.safeParse({
id: "550e8400-e29b-41d4-a716-446655440000",
status: "invalid",
})
expect(result.success).toBe(false)
})
it("validates page and navigation payload constraints", () => {
const page = createPageInputSchema.safeParse({
title: "About",
slug: "about",
content: "About page",
})
const menu = createNavigationMenuInputSchema.safeParse({
name: "Primary",
slug: "primary",
})
const navUpdate = updateNavigationItemInputSchema.safeParse({
id: "550e8400-e29b-41d4-a716-446655440000",
sortOrder: -1,
})
expect(page.success).toBe(true)
expect(menu.success).toBe(true)
expect(navUpdate.success).toBe(false)
})
})

View File

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

View File

@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "Announcement" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"message" TEXT NOT NULL,
"placement" TEXT NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 100,
"ctaLabel" TEXT,
"ctaHref" TEXT,
"startsAt" TIMESTAMP(3),
"endsAt" TIMESTAMP(3),
"isVisible" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Announcement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Announcement_placement_isVisible_idx" ON "Announcement"("placement", "isVisible");
-- CreateIndex
CREATE INDEX "Announcement_priority_idx" ON "Announcement"("priority");

View File

@@ -339,3 +339,21 @@ model Commission {
@@index([customerId])
@@index([assignedUserId])
}
model Announcement {
id String @id @default(uuid())
title String
message String
placement String
priority Int @default(100)
ctaLabel String?
ctaHref String?
startsAt DateTime?
endsAt DateTime?
isVisible Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([placement, isVisible])
@@index([priority])
}

View File

@@ -206,6 +206,23 @@ async function main() {
dueAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
},
})
await db.announcement.upsert({
where: {
id: "22222222-2222-2222-2222-222222222222",
},
update: {},
create: {
id: "22222222-2222-2222-2222-222222222222",
title: "Commission Slots",
message: "New commission slots are open for next month.",
placement: "global_top",
priority: 10,
ctaLabel: "Request now",
ctaHref: "/commissions",
isVisible: true,
},
})
}
main()

View File

@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const { mockDb } = vi.hoisted(() => ({
mockDb: {
announcement: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findMany: vi.fn(),
},
},
}))
vi.mock("./client", () => ({
db: mockDb,
}))
import { createAnnouncement, listActiveAnnouncements } from "./announcements"
describe("announcements service", () => {
beforeEach(() => {
for (const fn of Object.values(mockDb.announcement)) {
if (typeof fn === "function") {
fn.mockReset()
}
}
})
it("creates announcements through schema parsing", async () => {
mockDb.announcement.create.mockResolvedValue({ id: "announcement-1" })
await createAnnouncement({
title: "Scheduled Notice",
message: "Commission slots are open.",
placement: "global_top",
})
expect(mockDb.announcement.create).toHaveBeenCalledTimes(1)
})
it("queries only visible announcements in the given placement", async () => {
mockDb.announcement.findMany.mockResolvedValue([])
await listActiveAnnouncements("homepage")
expect(mockDb.announcement.findMany).toHaveBeenCalledTimes(1)
expect(mockDb.announcement.findMany.mock.calls[0]?.[0]).toMatchObject({
where: {
placement: "homepage",
isVisible: true,
},
})
})
})

View File

@@ -0,0 +1,74 @@
import {
type AnnouncementPlacement,
createAnnouncementInputSchema,
updateAnnouncementInputSchema,
} from "@cms/content"
import { db } from "./client"
export type PublicAnnouncement = {
id: string
title: string
message: string
ctaLabel: string | null
ctaHref: string | null
placement: string
priority: number
}
export async function listAnnouncements(limit = 200) {
return db.announcement.findMany({
orderBy: [{ priority: "asc" }, { updatedAt: "desc" }],
take: limit,
})
}
export async function createAnnouncement(input: unknown) {
const payload = createAnnouncementInputSchema.parse(input)
return db.announcement.create({
data: payload,
})
}
export async function updateAnnouncement(input: unknown) {
const payload = updateAnnouncementInputSchema.parse(input)
const { id, ...data } = payload
return db.announcement.update({
where: { id },
data,
})
}
export async function deleteAnnouncement(id: string) {
return db.announcement.delete({
where: { id },
})
}
export async function listActiveAnnouncements(
placement: AnnouncementPlacement,
now = new Date(),
): Promise<PublicAnnouncement[]> {
const announcements = await db.announcement.findMany({
where: {
placement,
isVisible: true,
OR: [{ startsAt: null }, { startsAt: { lte: now } }],
AND: [{ OR: [{ endsAt: null }, { endsAt: { gte: now } }] }],
},
orderBy: [{ priority: "asc" }, { createdAt: "desc" }],
select: {
id: true,
title: true,
message: true,
ctaLabel: true,
ctaHref: true,
placement: true,
priority: true,
},
})
return announcements
}

View File

@@ -1,3 +1,11 @@
export type { PublicAnnouncement } from "./announcements"
export {
createAnnouncement,
deleteAnnouncement,
listActiveAnnouncements,
listAnnouncements,
updateAnnouncement,
} from "./announcements"
export { db } from "./client"
export {
commissionKanbanOrder,
@@ -44,13 +52,16 @@ export {
createPost,
deletePost,
getPostById,
getPostBySlug,
listPosts,
registerPostCrudAuditHook,
updatePost,
} from "./posts"
export type { PublicHeaderBanner } from "./settings"
export type { PublicHeaderBanner, PublicHeaderBannerConfig } from "./settings"
export {
getPublicHeaderBanner,
getPublicHeaderBannerConfig,
isAdminSelfRegistrationEnabled,
setAdminSelfRegistrationEnabled,
setPublicHeaderBannerConfig,
} from "./settings"

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const { mockDb } = vi.hoisted(() => ({
mockDb: {
post: {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}))
vi.mock("./client", () => ({
db: mockDb,
}))
import { createPost, getPostBySlug, listPosts, updatePost } from "./posts"
describe("posts service", () => {
beforeEach(() => {
for (const fn of Object.values(mockDb.post)) {
if (typeof fn === "function") {
fn.mockReset()
}
}
})
it("lists posts ordered by update date desc", async () => {
mockDb.post.findMany.mockResolvedValue([])
await listPosts()
expect(mockDb.post.findMany).toHaveBeenCalledTimes(1)
expect(mockDb.post.findMany.mock.calls[0]?.[0]).toMatchObject({
orderBy: {
updatedAt: "desc",
},
})
})
it("parses create and update payloads through crud service", async () => {
mockDb.post.create.mockResolvedValue({ id: "post-1" })
mockDb.post.findUnique.mockResolvedValue({ id: "550e8400-e29b-41d4-a716-446655440000" })
mockDb.post.update.mockResolvedValue({ id: "post-1" })
await createPost({
title: "A title",
slug: "a-title",
body: "Body",
status: "draft",
})
await updatePost("550e8400-e29b-41d4-a716-446655440000", {
title: "Updated",
})
expect(mockDb.post.create).toHaveBeenCalledTimes(1)
expect(mockDb.post.update).toHaveBeenCalledTimes(1)
})
it("finds posts by slug", async () => {
mockDb.post.findUnique.mockResolvedValue({ id: "post-1", slug: "hello" })
await getPostBySlug("hello")
expect(mockDb.post.findUnique).toHaveBeenCalledTimes(1)
expect(mockDb.post.findUnique).toHaveBeenCalledWith({
where: {
slug: "hello",
},
})
})
})

View File

@@ -67,6 +67,12 @@ export async function getPostById(id: string) {
return postCrudService.getById(id)
}
export async function getPostBySlug(slug: string) {
return db.post.findUnique({
where: { slug },
})
}
export async function createPost(input: unknown, context?: CrudMutationContext) {
return postCrudService.create(input, context)
}

View File

@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const { mockDb } = vi.hoisted(() => ({
mockDb: {
systemSetting: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
},
}))
vi.mock("./client", () => ({
db: mockDb,
}))
import {
getPublicHeaderBanner,
getPublicHeaderBannerConfig,
isAdminSelfRegistrationEnabled,
setPublicHeaderBannerConfig,
} from "./settings"
describe("settings service", () => {
const previousEnv = process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED
beforeEach(() => {
mockDb.systemSetting.findUnique.mockReset()
mockDb.systemSetting.upsert.mockReset()
process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED = previousEnv
})
it("falls back to env flag when registration setting is missing", async () => {
process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED = "true"
mockDb.systemSetting.findUnique.mockResolvedValue(null)
const enabled = await isAdminSelfRegistrationEnabled()
expect(enabled).toBe(true)
})
it("reads active public header banner payload", async () => {
mockDb.systemSetting.findUnique.mockResolvedValue({
value: JSON.stringify({
enabled: true,
message: "Commissions open",
ctaLabel: "Book now",
ctaHref: "/contact",
}),
})
const banner = await getPublicHeaderBanner()
expect(banner).toEqual({
message: "Commissions open",
ctaLabel: "Book now",
ctaHref: "/contact",
})
})
it("returns a disabled default config for invalid data", async () => {
mockDb.systemSetting.findUnique.mockResolvedValue({
value: "not-json",
})
const config = await getPublicHeaderBannerConfig()
expect(config).toEqual({
enabled: false,
message: "",
ctaLabel: null,
ctaHref: null,
})
})
it("writes banner config to system settings", async () => {
mockDb.systemSetting.upsert.mockResolvedValue({})
await setPublicHeaderBannerConfig({
enabled: true,
message: "Holiday schedule",
ctaLabel: "Details",
ctaHref: "/news",
})
expect(mockDb.systemSetting.upsert).toHaveBeenCalledTimes(1)
expect(mockDb.systemSetting.upsert.mock.calls[0]?.[0]).toMatchObject({
where: {
key: "public.header_banner",
},
})
})
})

View File

@@ -16,6 +16,13 @@ export type PublicHeaderBanner = {
ctaHref?: string
}
export type PublicHeaderBannerConfig = {
enabled: boolean
message: string
ctaLabel: string | null
ctaHref: string | null
}
function resolveEnvFallback(): boolean {
return process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED === "true"
}
@@ -114,3 +121,69 @@ export async function getPublicHeaderBanner(): Promise<PublicHeaderBanner | null
return null
}
}
export async function getPublicHeaderBannerConfig(): Promise<PublicHeaderBannerConfig> {
try {
const setting = await db.systemSetting.findUnique({
where: { key: PUBLIC_HEADER_BANNER_KEY },
select: { value: true },
})
if (!setting) {
return {
enabled: false,
message: "",
ctaLabel: null,
ctaHref: null,
}
}
const parsed = parsePublicHeaderBanner(setting.value)
if (!parsed) {
return {
enabled: false,
message: "",
ctaLabel: null,
ctaHref: null,
}
}
return {
enabled: parsed.enabled,
message: parsed.message,
ctaLabel: parsed.ctaLabel ?? null,
ctaHref: parsed.ctaHref ?? null,
}
} catch {
return {
enabled: false,
message: "",
ctaLabel: null,
ctaHref: null,
}
}
}
export async function setPublicHeaderBannerConfig(input: PublicHeaderBannerConfig): Promise<void> {
await db.systemSetting.upsert({
where: { key: PUBLIC_HEADER_BANNER_KEY },
create: {
key: PUBLIC_HEADER_BANNER_KEY,
value: JSON.stringify({
enabled: input.enabled,
message: input.message,
ctaLabel: input.ctaLabel ?? undefined,
ctaHref: input.ctaHref ?? undefined,
}),
},
update: {
value: JSON.stringify({
enabled: input.enabled,
message: input.message,
ctaLabel: input.ctaLabel ?? undefined,
ctaHref: input.ctaHref ?? undefined,
}),
},
})
}