Compare commits

..

19 Commits

Author SHA1 Message Date
618319dbc2 feat(i18n): wire page translation editor and locale rendering 2026-02-12 20:57:42 +01:00
506e2feb10 test(i18n): add translated page CRUD locale validation coverage 2026-02-12 20:53:06 +01:00
749fb80083 test(admin): cover pages and navigation form components 2026-02-12 20:48:51 +01:00
ec4f85e1d0 docs(handover): add architecture map and takeover playbook 2026-02-12 20:45:09 +01:00
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
994b33e081 feat(commissions): add customer records and kanban workflow baseline 2026-02-12 20:01:49 +01:00
f65a9ea03f feat(web): render cms pages and navigation from db 2026-02-12 19:58:01 +01:00
281b1d7a1b feat(pages): add pages and navigation builder baseline 2026-02-12 19:30:09 +01:00
7d9bc9dca9 feat(media): add admin media CRUD preview and storage cleanup 2026-02-12 19:15:26 +01:00
3e4f0b6c75 refactor(media): use asset-centric storage key layout 2026-02-12 18:41:01 +01:00
86a8af25d8 feat(media): default to s3 with local upload fallback 2026-02-12 18:16:11 +01:00
19738b77d8 feat(media): support local and s3 upload providers 2026-02-12 12:02:31 +01:00
5becba602c feat(media): add mvp1 upload pipeline baseline 2026-02-12 11:57:39 +01:00
89 changed files with 7147 additions and 315 deletions

View File

@@ -10,6 +10,18 @@ CMS_SUPPORT_EMAIL="support@cms.local"
CMS_SUPPORT_PASSWORD="change-me-support-password"
CMS_SUPPORT_NAME="Technical Support"
CMS_SUPPORT_LOGIN_KEY="support-access-change-me"
CMS_MEDIA_STORAGE_PROVIDER="s3"
CMS_MEDIA_STORAGE_TENANT_ID="default"
CMS_MEDIA_UPLOAD_MAX_BYTES="26214400"
# Optional: override local media storage directory for admin upload adapter.
# CMS_MEDIA_LOCAL_STORAGE_DIR="/absolute/path/to/media-storage"
# S3/object-storage config (default provider). If unavailable, upload falls back to local storage.
# CMS_MEDIA_S3_BUCKET="cms-media"
# CMS_MEDIA_S3_REGION="eu-central-1"
# CMS_MEDIA_S3_ACCESS_KEY_ID=""
# CMS_MEDIA_S3_SECRET_ACCESS_KEY=""
# CMS_MEDIA_S3_ENDPOINT="" # optional (e.g. MinIO, R2)
# CMS_MEDIA_S3_FORCE_PATH_STYLE="false"
NEXT_PUBLIC_APP_VERSION="0.1.0-dev"
NEXT_PUBLIC_GIT_SHA="local"
# Optional dev bypass role for admin middleware. Leave empty to require auth login.

4
.gitignore vendored
View File

@@ -32,3 +32,7 @@ packages/db/prisma/generated/
# misc
.DS_Store
# local media storage
.data/
apps/admin/.data/

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

115
TODO.md
View File

@@ -120,17 +120,17 @@ This file is the single source of truth for roadmap and delivery progress.
- [x] [P1] `todo/mvp1-media-foundation`:
media model, artwork entity, grouping primitives (gallery/album/category/tag), rendition slots
- [ ] [P1] `todo/mvp1-media-upload-pipeline`:
- [~] [P1] `todo/mvp1-media-upload-pipeline`:
S3/local upload adapter, media processing presets, metadata input flows, admin media CRUD UI
- [ ] [P1] `todo/mvp1-pages-navigation-builder`:
- [~] [P1] `todo/mvp1-pages-navigation-builder`:
page CRUD, navigation tree, reusable page blocks (forms/price cards/gallery embeds)
- [ ] [P1] `todo/mvp1-commissions-customers`:
- [~] [P1] `todo/mvp1-commissions-customers`:
commission request intake + admin CRUD + kanban + customer entity/linking
- [ ] [P1] `todo/mvp1-announcements-news`:
- [~] [P1] `todo/mvp1-announcements-news`:
announcement management/rendering + news/blog CRUD and public rendering
- [ ] [P1] `todo/mvp1-public-rendering-integration`:
- [~] [P1] `todo/mvp1-public-rendering-integration`:
public rendering for pages/navigation/media/portfolio/announcements and commissioning entrypoints
- [ ] [P1] `todo/mvp1-e2e-happy-paths`:
- [~] [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)
@@ -144,10 +144,10 @@ This file is the single source of truth for roadmap and delivery progress.
### Admin App (Primary Focus)
- [ ] [P1] Page management (create/edit/publish/unpublish/schedule)
- [~] [P1] Page management (create/edit/publish/unpublish/schedule)
- [ ] [P1] Page builder with reusable content blocks (hero, rich text, gallery, CTA, forms, price cards)
- [ ] [P1] Navigation management (menus, nested items, order, visibility)
- [ ] [P1] Media library (upload, browse, replace, delete) with media-type classification (artwork, banner, promo, generic, video/gif)
- [~] [P1] Navigation management (menus, nested items, order, visibility)
- [~] [P1] Media library (upload, browse, replace, delete) with media-type classification (artwork, banner, promo, generic, video/gif)
- [ ] [P1] Media enrichment metadata (alt text, copyright, author, source, tags, licensing, usage context)
- [ ] [P1] Portfolio grouping primitives (galleries, albums, categories, tags) with ordering/visibility controls
- [ ] [P1] Artwork refinement fields (medium, dimensions, year, framing, availability, price visibility)
@@ -156,44 +156,78 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P1] Users management (invite, roles, status)
- [ ] [P1] Disable/ban user function and enforcement in auth/session checks
- [~] [P1] Owner/support protection rules in user management actions (cannot delete/demote)
- [ ] [P1] Commissions management (request intake, owner, due date, notes, linked customer, linked artworks)
- [ ] [P1] Customer records (contact profile, notes, consent flags, recurrence marker)
- [ ] [P1] Customer-to-commission linkage and reuse workflow (no re-entry for recurring customers)
- [ ] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
- [ ] [P1] 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)
- [~] [P1] Commissions management (request intake, owner, due date, notes, linked customer, linked artworks)
- [~] [P1] Customer records (contact profile, notes, consent flags, recurrence marker)
- [~] [P1] Customer-to-commission linkage and reuse workflow (no re-entry for recurring customers)
- [~] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
- [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
- [ ] [P1] Dynamic page rendering from CMS page entities
- [ ] [P1] Navigation rendering from managed menu structure
- [~] [P1] Dynamic page rendering from CMS page entities
- [~] [P1] Navigation rendering from managed menu structure
- [ ] [P1] Media entity rendering with enrichment data
- [ ] [P1] Portfolio views (gallery/album/category/tag) for artworks with filter and sort controls
- [ ] [P1] Rendition-aware media delivery (thumbnail/card/full) per template slot
- [ ] [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
- [ ] [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
- [ ] [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
- [x] [P1] Unit tests for content schemas and service logic
- [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 registration allow/deny behavior
- [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: media upload + artwork refinement display
- [~] [P1] E2E happy paths: commissions kanban transitions
### Code Documentation And Handover
- [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`
- [x] [P1] Add module-level ownership docs for auth, media, pages/navigation, commissions, announcements/news flows
- [x] [P1] Document critical invariants (single owner rule, protected support user, registration policy gates, media storage key contract)
- [x] [P1] Add “request lifecycle” docs for key flows (auth sign-in/up, media upload, page publish, commission status change)
- [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 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
@@ -270,6 +304,25 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-11] MVP1 media foundation started: portfolio domain models (`MediaAsset`, `Artwork`, galleries/albums/categories/tags, rendition links) plus initial admin `/media` and `/portfolio` data views.
- [2026-02-11] `prisma migrate dev --name media_foundation` can fail when DB endpoint is unreachable; apply this named migration once `DATABASE_URL` host is reachable again.
- [2026-02-11] MVP1 media foundation now includes baseline create/link workflows in admin (`/media`, `/portfolio`), seeded sample portfolio entities, and schema/service test coverage.
- [2026-02-12] MVP1 media upload pipeline started: admin `/api/media/upload` accepts metadata + file upload with permission checks, stores files via local adapter (`.data/media`), and persists upload metadata to `MediaAsset`.
- [2026-02-12] Upload storage is now provider-based (`local` + `s3`) via `CMS_MEDIA_STORAGE_PROVIDER`; admin-side GUI toggle remains a later MVP item.
- [2026-02-12] Media storage keys now use asset-centric layout (`tenant/<id>/asset/<assetId>/<fileRole>/<assetId>__<variant>.<ext>`) with DB-managed media taxonomy.
- [2026-02-12] Admin media CRUD now includes list-to-detail flow (`/media/:id`) with metadata edit and delete actions.
- [2026-02-12] MVP1 pages/navigation baseline started: `Page`, `NavigationMenu`, and `NavigationItem` models plus admin CRUD routes (`/pages`, `/pages/:id`, `/navigation`).
- [2026-02-12] Public app now renders CMS-managed navigation (header) and CMS-managed pages by slug (including homepage when `home` page exists).
- [2026-02-12] Commissions/customer baseline added: admin `/commissions` now supports customer creation, commission intake, status transitions, and a basic kanban board.
- [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`).
- [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

View File

@@ -12,6 +12,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "3.988.0",
"@cms/content": "workspace:*",
"@cms/db": "workspace:*",
"@cms/i18n": "workspace:*",

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,120 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
import { GetObjectCommand } from "@aws-sdk/client-s3"
import { hasPermission } from "@cms/content/rbac"
import { getMediaAssetById } from "@cms/db"
import { auth, resolveRoleFromAuthSession } from "@/lib/auth/server"
import { resolveLocalMediaBaseDirectory } from "@/lib/media/local-storage"
import { createS3Client, resolveS3Config } from "@/lib/media/s3-storage"
import { resolveMediaStorageProvider } from "@/lib/media/storage"
export const runtime = "nodejs"
type RouteContext = {
params: Promise<{ id: string }>
}
async function readFromLocalStorage(storageKey: string): Promise<Uint8Array> {
const baseDirectory = resolveLocalMediaBaseDirectory()
const outputPath = path.join(baseDirectory, storageKey)
return readFile(outputPath)
}
async function readFromS3Storage(storageKey: string): Promise<Uint8Array> {
const config = resolveS3Config()
const client = createS3Client(config)
const response = await client.send(
new GetObjectCommand({
Bucket: config.bucket,
Key: storageKey,
}),
)
if (!response.Body) {
throw new Error("S3 object body is empty")
}
return response.Body.transformToByteArray()
}
function toBody(data: Uint8Array): BodyInit {
const bytes = new Uint8Array(data.byteLength)
bytes.set(data)
return bytes
}
export async function GET(request: Request, context: RouteContext): Promise<Response> {
const session = await auth.api
.getSession({
headers: request.headers,
})
.catch(() => null)
const role = resolveRoleFromAuthSession(session)
if (!role) {
return Response.json(
{
message: "Unauthorized",
},
{ status: 401 },
)
}
if (!hasPermission(role, "media:read", "team")) {
return Response.json(
{
message: "Missing permission: media:read",
},
{ status: 403 },
)
}
const { id } = await context.params
const asset = await getMediaAssetById(id)
if (!asset || !asset.storageKey) {
return Response.json(
{
message: "Media file not found",
},
{ status: 404 },
)
}
const preferred = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER)
const reads =
preferred === "s3"
? [
() => readFromS3Storage(asset.storageKey as string),
() => readFromLocalStorage(asset.storageKey as string),
]
: [
() => readFromLocalStorage(asset.storageKey as string),
() => readFromS3Storage(asset.storageKey as string),
]
for (const read of reads) {
try {
const data = await read()
return new Response(toBody(data), {
status: 200,
headers: {
"content-type": asset.mimeType || "application/octet-stream",
"cache-control": "private, max-age=0, no-store",
},
})
} catch {
// Try next backend.
}
}
return Response.json(
{
message: "Unable to read media file from configured storage backends",
},
{ status: 404 },
)
}

View File

@@ -0,0 +1,207 @@
import { randomUUID } from "node:crypto"
import { hasPermission } from "@cms/content/rbac"
import { createMediaAsset } from "@cms/db"
import { auth, resolveRoleFromAuthSession } from "@/lib/auth/server"
import { storeUpload } from "@/lib/media/storage"
export const runtime = "nodejs"
const MAX_UPLOAD_BYTES = Number(process.env.CMS_MEDIA_UPLOAD_MAX_BYTES ?? 25 * 1024 * 1024)
type AllowedRule = {
mimePrefix?: string
mimeExact?: string[]
}
const ALLOWED_MIME_BY_TYPE: Record<string, AllowedRule> = {
artwork: {
mimePrefix: "image/",
},
banner: {
mimePrefix: "image/",
},
promotion: {
mimePrefix: "image/",
},
video: {
mimePrefix: "video/",
},
gif: {
mimeExact: ["image/gif"],
},
generic: {
mimePrefix: "",
},
}
function parseTextField(formData: FormData, field: string): string {
const value = formData.get(field)
return typeof value === "string" ? value.trim() : ""
}
function parseOptionalField(formData: FormData, field: string): string | undefined {
const value = parseTextField(formData, field)
return value.length > 0 ? value : undefined
}
function parseTags(formData: FormData): string[] {
const value = parseTextField(formData, "tags")
if (!value) {
return []
}
return value
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0)
}
function deriveTitleFromFilename(fileName: string): string {
const trimmed = fileName.trim()
if (!trimmed) {
return "Untitled media"
}
const dotIndex = trimmed.lastIndexOf(".")
const base = dotIndex > 0 ? trimmed.slice(0, dotIndex) : trimmed
const normalized = base.trim()
return normalized.length > 0 ? normalized : "Untitled media"
}
function isMimeAllowed(mediaType: string, mimeType: string): boolean {
const rule = ALLOWED_MIME_BY_TYPE[mediaType]
if (!rule) {
return false
}
if (rule.mimeExact?.includes(mimeType)) {
return true
}
if (rule.mimePrefix === "") {
return true
}
return rule.mimePrefix ? mimeType.startsWith(rule.mimePrefix) : false
}
function badRequest(message: string): Response {
return Response.json(
{
message,
},
{ status: 400 },
)
}
export async function POST(request: Request): Promise<Response> {
const session = await auth.api
.getSession({
headers: request.headers,
})
.catch(() => null)
const role = resolveRoleFromAuthSession(session)
if (!role) {
return Response.json(
{
message: "Unauthorized",
},
{ status: 401 },
)
}
if (!hasPermission(role, "media:write", "team")) {
return Response.json(
{
message: "Missing permission: media:write",
},
{ status: 403 },
)
}
const formData = await request.formData().catch(() => null)
if (!formData) {
return badRequest("Invalid form payload.")
}
const type = parseTextField(formData, "type")
const fileEntry = formData.get("file")
if (!type) {
return badRequest("Type is required.")
}
if (!(fileEntry instanceof File)) {
return badRequest("File is required.")
}
if (fileEntry.size === 0) {
return badRequest("File is empty.")
}
if (fileEntry.size > MAX_UPLOAD_BYTES) {
return badRequest(
`File is too large. Maximum upload is ${Math.floor(MAX_UPLOAD_BYTES / 1024 / 1024)} MB.`,
)
}
if (!isMimeAllowed(type, fileEntry.type)) {
return badRequest(`File type ${fileEntry.type || "unknown"} is not allowed for ${type}.`)
}
const title = parseTextField(formData, "title") || deriveTitleFromFilename(fileEntry.name)
const mediaAssetId = randomUUID()
const variant = "original"
const fileRole = "original"
try {
const stored = await storeUpload({
file: fileEntry,
assetId: mediaAssetId,
variant,
fileRole,
})
const created = await createMediaAsset({
id: mediaAssetId,
title,
type,
description: parseOptionalField(formData, "description"),
altText: parseOptionalField(formData, "altText"),
source: parseOptionalField(formData, "source"),
copyright: parseOptionalField(formData, "copyright"),
author: parseOptionalField(formData, "author"),
tags: parseTags(formData),
storageKey: stored.storageKey,
mimeType: fileEntry.type || undefined,
sizeBytes: fileEntry.size,
isPublished: parseTextField(formData, "isPublished") === "true",
})
return Response.json(
{
id: created.id,
provider: stored.provider,
warning: stored.fallbackReason,
notice: "Media uploaded successfully.",
},
{ status: 201 },
)
} catch (error) {
const message = error instanceof Error ? error.message : "Upload failed. Please try again."
return Response.json(
{
message,
},
{ status: 500 },
)
}
}

View File

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

View File

@@ -0,0 +1,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,423 @@
import { deleteMediaAsset, getMediaAssetById, updateMediaAsset } from "@cms/db"
import { Button } from "@cms/ui/button"
import Link from "next/link"
import { redirect } from "next/navigation"
import { AdminShell } from "@/components/admin-shell"
import { deleteStoredMediaObject } from "@/lib/media/storage"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined>
type PageProps = {
params: Promise<{ id: string }>
searchParams: Promise<SearchParamsInput>
}
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 readNullableInt(formData: FormData, field: string): number | null {
const value = readInputString(formData, field)
if (!value) {
return null
}
const parsed = Number.parseInt(value, 10)
if (!Number.isFinite(parsed)) {
return null
}
return parsed
}
function readTags(formData: FormData): string[] {
const raw = readInputString(formData, "tags")
if (!raw) {
return []
}
return raw
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0)
}
function redirectWithState(mediaAssetId: string, 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 ? `/media/${mediaAssetId}?${value}` : `/media/${mediaAssetId}`)
}
function toLocalDateTimeInputValue(date: Date): string {
const offset = date.getTimezoneOffset() * 60_000
return new Date(date.getTime() - offset).toISOString().slice(0, 16)
}
export default async function MediaAssetEditorPage({ params, searchParams }: PageProps) {
const role = await requirePermissionForRoute({
nextPath: "/media",
permission: "media:read",
scope: "team",
})
const resolvedParams = await params
const mediaAssetId = resolvedParams.id
const [resolvedSearchParams, asset] = await Promise.all([
searchParams,
getMediaAssetById(mediaAssetId),
])
if (!asset) {
redirect("/media?error=Media+asset+not+found")
}
const mediaAsset = asset
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
const previewUrl = mediaAsset.storageKey ? `/api/media/file/${mediaAsset.id}` : null
const isImage = Boolean(mediaAsset.mimeType?.startsWith("image/"))
const isVideo = Boolean(mediaAsset.mimeType?.startsWith("video/"))
async function updateMediaAssetAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/media",
permission: "media:write",
scope: "team",
})
try {
await updateMediaAsset({
id: mediaAssetId,
title: readInputString(formData, "title"),
type: readInputString(formData, "type"),
description: readNullableString(formData, "description"),
altText: readNullableString(formData, "altText"),
source: readNullableString(formData, "source"),
copyright: readNullableString(formData, "copyright"),
author: readNullableString(formData, "author"),
tags: readTags(formData),
mimeType: readNullableString(formData, "mimeType"),
width: readNullableInt(formData, "width"),
height: readNullableInt(formData, "height"),
sizeBytes: readNullableInt(formData, "sizeBytes"),
isPublished: readInputString(formData, "isPublished") === "true",
})
} catch {
redirectWithState(mediaAssetId, {
error: "Failed to update media asset. Validate values and try again.",
})
}
redirectWithState(mediaAssetId, { notice: "Media asset updated." })
}
async function deleteMediaAssetAction() {
"use server"
await requirePermissionForRoute({
nextPath: "/media",
permission: "media:write",
scope: "team",
})
try {
if (mediaAsset.storageKey) {
await deleteStoredMediaObject(mediaAsset.storageKey)
}
await deleteMediaAsset(mediaAssetId)
} catch {
redirectWithState(mediaAssetId, {
error:
"Failed to delete media asset and file from storage. Check storage config and links.",
})
}
redirect("/media?notice=Media+asset+deleted")
}
return (
<AdminShell
role={role}
activePath="/media"
badge="Admin App"
title="Media Asset"
description="View, edit, and delete uploaded media metadata."
>
{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">
<h3 className="text-lg font-medium">Preview</h3>
<p className="mt-1 text-sm text-neutral-600">
{mediaAsset.mimeType ? `MIME: ${mediaAsset.mimeType}` : "MIME: unknown"}
</p>
<div className="mt-4 rounded-lg border border-neutral-200 bg-neutral-50 p-3">
{!previewUrl ? (
<p className="text-sm text-neutral-600">
No stored file is linked for this media asset.
</p>
) : isImage ? (
// biome-ignore lint/performance/noImgElement: Auth-protected media preview requires direct browser request with session cookies.
<img
src={previewUrl}
alt={mediaAsset.altText || mediaAsset.title}
className="max-h-[26rem] w-auto rounded border border-neutral-200 bg-white"
/>
) : isVideo ? (
// biome-ignore lint/a11y/useMediaCaption: Preview uses source assets without guaranteed caption tracks.
<video
src={previewUrl}
controls
preload="metadata"
className="max-h-[26rem] w-full rounded border border-neutral-200 bg-black"
/>
) : (
<p className="text-sm text-neutral-700">
Inline preview is not available for this media type.
</p>
)}
</div>
{previewUrl ? (
<a
href={previewUrl}
target="_blank"
rel="noreferrer"
className="mt-3 inline-block text-sm text-neutral-700 underline underline-offset-2"
>
Open raw media file
</a>
) : null}
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-medium">{mediaAsset.title}</h2>
<p className="mt-1 text-xs text-neutral-600">ID: {mediaAsset.id}</p>
</div>
<Link href="/media" className="text-sm text-neutral-700 underline underline-offset-2">
Back to media list
</Link>
</div>
<form action={updateMediaAssetAction} className="mt-6 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"
defaultValue={mediaAsset.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">Type</span>
<select
name="type"
defaultValue={mediaAsset.type}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="artwork">artwork</option>
<option value="banner">banner</option>
<option value="promotion">promotion</option>
<option value="video">video</option>
<option value="gif">gif</option>
<option value="generic">generic</option>
</select>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Description</span>
<textarea
name="description"
rows={3}
defaultValue={mediaAsset.description ?? ""}
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">Alt text</span>
<input
name="altText"
defaultValue={mediaAsset.altText ?? ""}
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">Author</span>
<input
name="author"
defaultValue={mediaAsset.author ?? ""}
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">Source</span>
<input
name="source"
defaultValue={mediaAsset.source ?? ""}
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">Copyright</span>
<input
name="copyright"
defaultValue={mediaAsset.copyright ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<div className="grid gap-3 md:grid-cols-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">MIME type</span>
<input
name="mimeType"
defaultValue={mediaAsset.mimeType ?? ""}
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">Width</span>
<input
name="width"
type="number"
min={1}
defaultValue={mediaAsset.width ?? ""}
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">Height</span>
<input
name="height"
type="number"
min={1}
defaultValue={mediaAsset.height ?? ""}
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">Size (bytes)</span>
<input
name="sizeBytes"
type="number"
min={0}
defaultValue={mediaAsset.sizeBytes ?? ""}
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">Tags (comma-separated)</span>
<input
name="tags"
defaultValue={mediaAsset.tags.join(", ")}
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
type="checkbox"
name="isPublished"
value="true"
defaultChecked={mediaAsset.isPublished}
className="size-4"
/>
Published
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Storage key</span>
<input
value={mediaAsset.storageKey ?? "-"}
readOnly
className="w-full rounded border border-neutral-200 bg-neutral-50 px-3 py-2 text-xs text-neutral-600"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Last updated</span>
<input
value={toLocalDateTimeInputValue(mediaAsset.updatedAt)}
readOnly
className="w-full rounded border border-neutral-200 bg-neutral-50 px-3 py-2 text-xs text-neutral-600"
/>
</label>
</div>
<Button type="submit">Save changes</Button>
</form>
</section>
<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>
<p className="mt-1 text-sm text-red-700">
Deleting this media asset is permanent. Any linked artwork rendition references will also
be removed.
</p>
<form action={deleteMediaAssetAction} className="mt-4">
<Button type="submit" variant="secondary" className="border border-red-300 text-red-800">
Delete media asset
</Button>
</form>
</section>
</AdminShell>
)
}

View File

@@ -1,9 +1,10 @@
import { createMediaAsset, getMediaFoundationSummary, listMediaAssets } from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { getMediaFoundationSummary, listMediaAssets } from "@cms/db"
import Link from "next/link"
import { AdminShell } from "@/components/admin-shell"
import { FlashQueryCleanup } from "@/components/media/flash-query-cleanup"
import { MediaUploadForm } from "@/components/media/media-upload-form"
import { resolveMediaStorageProvider } from "@/lib/media/storage"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
@@ -18,75 +19,6 @@ function readFirstValue(value: string | string[] | undefined): string | null {
return value ?? null
}
function readField(formData: FormData, field: string): string {
const value = formData.get(field)
return typeof value === "string" ? value.trim() : ""
}
function readOptionalField(formData: FormData, field: string): string | undefined {
const value = readField(formData, field)
return value.length > 0 ? value : undefined
}
function readTags(formData: FormData, field: string): string[] {
const raw = readField(formData, field)
if (!raw) {
return []
}
return raw
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0)
}
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 ? `/media?${value}` : "/media")
}
async function createMediaAssetAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/media",
permission: "media:write",
scope: "team",
})
try {
await createMediaAsset({
title: readField(formData, "title"),
type: readField(formData, "type"),
description: readOptionalField(formData, "description"),
altText: readOptionalField(formData, "altText"),
source: readOptionalField(formData, "source"),
copyright: readOptionalField(formData, "copyright"),
author: readOptionalField(formData, "author"),
tags: readTags(formData, "tags"),
})
} catch {
redirectWithState({
error: "Failed to create media asset. Validate required fields and try again.",
})
}
revalidatePath("/media")
revalidatePath("/portfolio")
redirectWithState({ notice: "Media asset created." })
}
export default async function MediaManagementPage({
searchParams,
}: {
@@ -104,6 +36,10 @@ export default async function MediaManagementPage({
])
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
const uploadedVia = readFirstValue(resolvedSearchParams.uploadedVia)
const warning = readFirstValue(resolvedSearchParams.warning)
const activeStorageProvider = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER)
const hasFlashQuery = Boolean(notice || error || warning || uploadedVia)
return (
<AdminShell
@@ -113,9 +49,18 @@ export default async function MediaManagementPage({
title="Media"
description="Media foundation baseline for assets, artwork renditions, and grouping metadata."
>
<FlashQueryCleanup enabled={hasFlashQuery} />
{notice ? (
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{notice}
<div className="flex flex-wrap items-center gap-2">
<span>{notice}</span>
{uploadedVia ? (
<span className="rounded border border-emerald-300 bg-white px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-emerald-700">
Stored via: {uploadedVia}
</span>
) : null}
</div>
</section>
) : null}
@@ -125,6 +70,12 @@ export default async function MediaManagementPage({
</section>
) : null}
{warning ? (
<section className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
{warning}
</section>
) : null}
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<article className="rounded-xl border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Media Assets</p>
@@ -141,97 +92,26 @@ export default async function MediaManagementPage({
</p>
<p className="mt-1 text-xs text-neutral-500">
{summary.galleries} galleries · {summary.albums} albums · {summary.categories}{" "}
categories{" · "}
{summary.tags} tags
categories · {summary.tags} tags
</p>
</article>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Media Asset</h2>
<form action={createMediaAssetAction} 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
minLength={1}
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">Type</span>
<select
name="type"
defaultValue="artwork"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="artwork">artwork</option>
<option value="banner">banner</option>
<option value="promotion">promotion</option>
<option value="video">video</option>
<option value="gif">gif</option>
<option value="generic">generic</option>
</select>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Description</span>
<textarea
name="description"
rows={3}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Alt text</span>
<input
name="altText"
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">Author</span>
<input
name="author"
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">Source</span>
<input
name="source"
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">Copyright</span>
<input
name="copyright"
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">Tags (comma-separated)</span>
<input
name="tags"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<Button type="submit">Create media asset</Button>
</form>
<h2 className="text-xl font-medium">Upload Media Asset</h2>
<p className="mt-1 text-sm text-neutral-600">
Upload storage provider: <strong>{activeStorageProvider}</strong>. You can switch via
`CMS_MEDIA_STORAGE_PROVIDER` (`s3` default, `local` fallback) until the admin settings
toggle lands.
</p>
<MediaUploadForm />
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<div className="flex items-center justify-between gap-2">
<h2 className="text-xl font-medium">Recent Media Assets</h2>
<span className="text-xs uppercase tracking-[0.2em] text-neutral-500">
MVP1 Foundation
MVP1 Upload Pipeline
</span>
</div>
<div className="mt-4 overflow-x-auto">
@@ -240,15 +120,18 @@ export default async function MediaManagementPage({
<tr>
<th className="py-2 pr-4">Title</th>
<th className="py-2 pr-4">Type</th>
<th className="py-2 pr-4">MIME</th>
<th className="py-2 pr-4">Size</th>
<th className="py-2 pr-4">Published</th>
<th className="py-2 pr-4">Updated</th>
<th className="py-2 pr-4">Actions</th>
</tr>
</thead>
<tbody>
{assets.length === 0 ? (
<tr>
<td className="py-3 text-neutral-500" colSpan={4}>
No media assets yet. Upload workflows land in `todo/mvp1-media-upload-pipeline`.
<td className="py-3 text-neutral-500" colSpan={7}>
No media assets yet. Upload your first asset above.
</td>
</tr>
) : (
@@ -256,10 +139,24 @@ export default async function MediaManagementPage({
<tr key={asset.id} className="border-t border-neutral-200">
<td className="py-3 pr-4">{asset.title}</td>
<td className="py-3 pr-4">{asset.type}</td>
<td className="py-3 pr-4 text-neutral-600">{asset.mimeType ?? "-"}</td>
<td className="py-3 pr-4 text-neutral-600">
{typeof asset.sizeBytes === "number"
? `${Math.max(1, Math.round(asset.sizeBytes / 1024))} KB`
: "-"}
</td>
<td className="py-3 pr-4">{asset.isPublished ? "yes" : "no"}</td>
<td className="py-3 pr-4 text-neutral-600">
{asset.updatedAt.toLocaleDateString("en-US")}
</td>
<td className="py-3 pr-4">
<Link
href={`/media/${asset.id}`}
className="text-xs font-medium text-neutral-700 underline underline-offset-2"
>
Open
</Link>
</td>
</tr>
))
)}

View File

@@ -0,0 +1,337 @@
import {
createNavigationItem,
createNavigationMenu,
deleteNavigationItem,
listNavigationMenus,
listPages,
updateNavigationItem,
} 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 { CreateMenuForm } from "@/components/navigation/create-menu-form"
import { CreateNavigationItemForm } from "@/components/navigation/create-navigation-item-form"
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 readInt(formData: FormData, field: string, fallback = 0): 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 ? `/navigation?${value}` : "/navigation")
}
async function createMenuAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/navigation",
permission: "navigation:write",
scope: "team",
})
try {
await createNavigationMenu({
name: readInputString(formData, "name"),
slug: readInputString(formData, "slug"),
location: readInputString(formData, "location"),
isVisible: readInputString(formData, "isVisible") === "true",
})
} catch {
redirectWithState({ error: "Failed to create navigation menu." })
}
revalidatePath("/navigation")
redirectWithState({ notice: "Navigation menu created." })
}
async function createItemAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/navigation",
permission: "navigation:write",
scope: "team",
})
try {
await createNavigationItem({
menuId: readInputString(formData, "menuId"),
label: readInputString(formData, "label"),
href: readNullableString(formData, "href"),
pageId: readNullableString(formData, "pageId"),
parentId: readNullableString(formData, "parentId"),
sortOrder: readInt(formData, "sortOrder", 0),
isVisible: readInputString(formData, "isVisible") === "true",
})
} catch {
redirectWithState({ error: "Failed to create navigation item." })
}
revalidatePath("/navigation")
redirectWithState({ notice: "Navigation item created." })
}
async function updateItemAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/navigation",
permission: "navigation:write",
scope: "team",
})
try {
await updateNavigationItem({
id: readInputString(formData, "id"),
label: readInputString(formData, "label"),
href: readNullableString(formData, "href"),
pageId: readNullableString(formData, "pageId"),
parentId: readNullableString(formData, "parentId"),
sortOrder: readInt(formData, "sortOrder", 0),
isVisible: readInputString(formData, "isVisible") === "true",
})
} catch {
redirectWithState({ error: "Failed to update navigation item." })
}
revalidatePath("/navigation")
redirectWithState({ notice: "Navigation item updated." })
}
async function deleteItemAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/navigation",
permission: "navigation:write",
scope: "team",
})
try {
await deleteNavigationItem(readInputString(formData, "id"))
} catch {
redirectWithState({ error: "Failed to delete navigation item." })
}
revalidatePath("/navigation")
redirectWithState({ notice: "Navigation item deleted." })
}
export default async function NavigationManagementPage({
searchParams,
}: {
searchParams: Promise<SearchParamsInput>
}) {
const role = await requirePermissionForRoute({
nextPath: "/navigation",
permission: "navigation:read",
scope: "team",
})
const [resolvedSearchParams, menus, pages] = await Promise.all([
searchParams,
listNavigationMenus(),
listPages(200),
])
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
return (
<AdminShell
role={role}
activePath="/navigation"
badge="Admin App"
title="Navigation"
description="Manage menus and navigation entries linked to pages or custom routes."
>
{notice ? (
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{notice}
</section>
) : null}
{error ? (
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</section>
) : null}
<section className="grid gap-4 lg:grid-cols-2">
<article className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Menu</h2>
<CreateMenuForm action={createMenuAction} />
</article>
<article className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Navigation Item</h2>
<CreateNavigationItemForm action={createItemAction} menus={menus} pages={pages} />
</article>
</section>
<section className="space-y-4">
{menus.length === 0 ? (
<article className="rounded-xl border border-neutral-200 p-6 text-sm text-neutral-600">
No navigation menus yet.
</article>
) : (
menus.map((menu) => (
<article key={menu.id} className="rounded-xl border border-neutral-200 p-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-lg font-medium">
{menu.name} <span className="text-sm text-neutral-500">({menu.location})</span>
</h3>
<span className="text-xs text-neutral-500">
{menu.isVisible ? "visible" : "hidden"}
</span>
</div>
<div className="mt-4 space-y-3">
{menu.items.length === 0 ? (
<p className="text-sm text-neutral-600">No items in this menu.</p>
) : (
menu.items.map((item) => (
<form
key={item.id}
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">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Linked page</span>
<select
name="pageId"
defaultValue={item.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>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Parent id</span>
<input
name="parentId"
defaultValue={item.parentId ?? ""}
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
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>
))
)}
</div>
</article>
))
)}
</section>
</AdminShell>
)
}

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,418 @@
import {
deletePage,
getPageById,
listPageTranslations,
updatePage,
upsertPageTranslation,
} from "@cms/db"
import { Button } from "@cms/ui/button"
import Link from "next/link"
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>
const SUPPORTED_LOCALES = ["de", "en", "es", "fr"] as const
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]
type PageProps = {
params: Promise<{ id: string }>
searchParams: Promise<SearchParamsInput>
}
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 redirectWithState(pageId: string, 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 ? `/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) {
const role = await requirePermissionForRoute({
nextPath: "/pages",
permission: "pages:read",
scope: "team",
})
const resolvedParams = await params
const pageId = resolvedParams.id
const [resolvedSearchParams, pageRecord, translations] = await Promise.all([
searchParams,
getPageById(pageId),
listPageTranslations(pageId),
])
if (!pageRecord) {
redirect("/pages?error=Page+not+found")
}
const page = pageRecord
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
const selectedLocale = normalizeLocale(readFirstValue(resolvedSearchParams.locale))
const selectedTranslation = translations.find((entry) => entry.locale === selectedLocale)
async function updatePageAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/pages",
permission: "pages:write",
scope: "team",
})
try {
await updatePage({
id: pageId,
title: readInputString(formData, "title"),
slug: readInputString(formData, "slug"),
status: readInputString(formData, "status"),
summary: readNullableString(formData, "summary"),
content: readInputString(formData, "content"),
seoTitle: readNullableString(formData, "seoTitle"),
seoDescription: readNullableString(formData, "seoDescription"),
})
} catch {
redirectWithState(pageId, {
error: "Failed to update page. Validate values and try again.",
})
}
redirectWithState(pageId, {
notice: "Page updated.",
})
}
async function deletePageAction() {
"use server"
await requirePermissionForRoute({
nextPath: "/pages",
permission: "pages:write",
scope: "team",
})
try {
await deletePage(pageId)
} catch {
redirectWithState(pageId, {
error: "Failed to delete page. Remove linked navigation references first.",
})
}
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 (
<AdminShell
role={role}
activePath="/pages"
badge="Admin App"
title="Page Editor"
description="Edit page metadata, content, and publication status."
>
{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">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-medium">{page.title}</h2>
<p className="mt-1 text-xs text-neutral-600">ID: {page.id}</p>
</div>
<Link href="/pages" className="text-sm text-neutral-700 underline underline-offset-2">
Back to pages
</Link>
</div>
<form action={updatePageAction} className="mt-6 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"
defaultValue={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">Status</span>
<select
name="status"
defaultValue={page.status}
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"
defaultValue={page.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"
defaultValue={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={10}
defaultValue={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={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={page.seoDescription ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<Button type="submit">Save page</Button>
</form>
</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">
<h3 className="text-lg font-medium text-red-800">Danger Zone</h3>
<p className="mt-1 text-sm text-red-700">
Deleting this page is permanent and may break linked navigation items.
</p>
<form action={deletePageAction} className="mt-4">
<Button type="submit" variant="secondary" className="border border-red-300 text-red-800">
Delete page
</Button>
</form>
</section>
</AdminShell>
)
}

View File

@@ -1,15 +1,92 @@
import { AdminSectionPlaceholder } from "@/components/admin-section-placeholder"
import { createPage, listPages } from "@cms/db"
import { revalidatePath } from "next/cache"
import Link from "next/link"
import { redirect } from "next/navigation"
import { AdminShell } from "@/components/admin-shell"
import { CreatePageForm } from "@/components/pages/create-page-form"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
export default async function PagesManagementPage() {
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 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 ? `/pages?${value}` : "/pages")
}
async function createPageAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/pages",
permission: "pages:write",
scope: "team",
})
try {
await createPage({
title: readInputString(formData, "title"),
slug: readInputString(formData, "slug"),
status: readInputString(formData, "status"),
summary: readNullableString(formData, "summary"),
content: readInputString(formData, "content"),
seoTitle: readNullableString(formData, "seoTitle"),
seoDescription: readNullableString(formData, "seoDescription"),
})
} catch {
redirectWithState({
error: "Failed to create page. Validate slug/title/content and try again.",
})
}
revalidatePath("/pages")
revalidatePath("/navigation")
redirectWithState({ notice: "Page created." })
}
export default async function PagesManagementPage({
searchParams,
}: {
searchParams: Promise<SearchParamsInput>
}) {
const role = await requirePermissionForRoute({
nextPath: "/pages",
permission: "pages:read",
scope: "team",
})
const [resolvedSearchParams, pages] = await Promise.all([searchParams, listPages(100)])
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
return (
<AdminShell
@@ -17,18 +94,69 @@ export default async function PagesManagementPage() {
activePath="/pages"
badge="Admin App"
title="Pages"
description="Manage page entities and publication workflows."
description="Create, update, and manage published page entities."
>
<AdminSectionPlaceholder
feature="Page Management"
summary="This MVP0 scaffold defines information architecture and access boundaries for future page CRUD."
requiredPermission="pages:read (team)"
nextSteps={[
"Add page entity list and search.",
"Add create/edit draft flows with validation.",
"Add publish/unpublish scheduling 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 Page</h2>
<CreatePageForm action={createPageAction} />
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Pages</h2>
<div className="mt-4 overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wide text-neutral-500">
<tr>
<th className="py-2 pr-4">Title</th>
<th className="py-2 pr-4">Slug</th>
<th className="py-2 pr-4">Status</th>
<th className="py-2 pr-4">Updated</th>
<th className="py-2 pr-4">Action</th>
</tr>
</thead>
<tbody>
{pages.length === 0 ? (
<tr>
<td className="py-3 text-neutral-500" colSpan={5}>
No pages yet.
</td>
</tr>
) : (
pages.map((page) => (
<tr key={page.id} className="border-t border-neutral-200">
<td className="py-3 pr-4">{page.title}</td>
<td className="py-3 pr-4 text-neutral-600">/{page.slug}</td>
<td className="py-3 pr-4">{page.status}</td>
<td className="py-3 pr-4 text-neutral-600">
{page.updatedAt.toLocaleDateString("en-US")}
</td>
<td className="py-3 pr-4">
<Link
href={`/pages/${page.id}`}
className="text-xs font-medium text-neutral-700 underline underline-offset-2"
>
Open
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</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

@@ -26,10 +26,13 @@ type NavItem = {
const navItems: NavItem[] = [
{ href: "/", label: "Dashboard", permission: "dashboard:read", scope: "global" },
{ href: "/pages", label: "Pages", permission: "pages:read", scope: "team" },
{ href: "/navigation", label: "Navigation", permission: "navigation:read", scope: "team" },
{ href: "/media", label: "Media", permission: "media:read", scope: "team" },
{ 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,19 @@
"use client"
import { useEffect } from "react"
type FlashQueryCleanupProps = {
enabled: boolean
}
export function FlashQueryCleanup({ enabled }: FlashQueryCleanupProps) {
useEffect(() => {
if (!enabled) {
return
}
window.history.replaceState(window.history.state, "", "/media")
}, [enabled])
return null
}

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

@@ -0,0 +1,167 @@
"use client"
import { Button } from "@cms/ui/button"
import { type FormEvent, useState } from "react"
type MediaType = "artwork" | "banner" | "promotion" | "video" | "gif" | "generic"
const ACCEPT_BY_TYPE: Record<MediaType, string> = {
artwork: "image/jpeg,image/png,image/webp,image/avif,image/gif",
banner: "image/jpeg,image/png,image/webp,image/avif",
promotion: "image/jpeg,image/png,image/webp,image/avif,image/gif,video/mp4,video/webm",
video: "video/mp4,video/webm,video/quicktime",
gif: "image/gif",
generic: "image/*,video/*",
}
export function MediaUploadForm() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [mediaType, setMediaType] = useState<MediaType>("artwork")
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
setError(null)
setIsSubmitting(true)
try {
const response = await fetch("/api/media/upload", {
method: "POST",
body: formData,
})
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as {
message?: string
} | null
setError(payload?.message ?? "Upload failed. Please verify file and metadata.")
return
}
const payload = (await response.json().catch(() => null)) as {
notice?: string
provider?: "s3" | "local"
warning?: string
} | null
const notice = payload?.notice ?? "Media uploaded."
const provider = payload?.provider ?? "local"
const warning = payload?.warning
const warningQuery = warning ? `&warning=${encodeURIComponent(warning)}` : ""
window.location.href = `/media?notice=${encodeURIComponent(notice)}&uploadedVia=${encodeURIComponent(provider)}${warningQuery}`
} catch {
setError("Upload request failed. Please retry.")
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
{error ? (
<p className="rounded border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
) : null}
<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"
placeholder="Optional (defaults to file name)"
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">Type</span>
<select
name="type"
value={mediaType}
onChange={(event) => setMediaType(event.target.value as MediaType)}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="artwork">artwork</option>
<option value="banner">banner</option>
<option value="promotion">promotion</option>
<option value="video">video</option>
<option value="gif">gif</option>
<option value="generic">generic</option>
</select>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">File</span>
<input
name="file"
type="file"
required
accept={ACCEPT_BY_TYPE[mediaType]}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Description</span>
<textarea
name="description"
rows={3}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Alt text</span>
<input
name="altText"
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">Author</span>
<input
name="author"
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">Source</span>
<input
name="source"
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">Copyright</span>
<input
name="copyright"
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">Tags (comma-separated)</span>
<input name="tags" 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="isPublished" type="checkbox" value="true" className="size-4" />
Publish immediately
</label>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Uploading..." : "Upload media"}
</Button>
</form>
)
}

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

@@ -27,6 +27,10 @@ describe("admin route access rules", () => {
permission: "pages:read",
scope: "team",
})
expect(getRequiredPermission("/navigation")).toEqual({
permission: "navigation:read",
scope: "team",
})
expect(getRequiredPermission("/media")).toEqual({
permission: "media:read",
scope: "team",
@@ -43,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

@@ -50,6 +50,13 @@ const guardRules: GuardRule[] = [
scope: "team",
},
},
{
route: /^\/navigation(?:\/|$)/,
requirement: {
permission: "navigation:read",
scope: "team",
},
},
{
route: /^\/media(?:\/|$)/,
requirement: {
@@ -78,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

@@ -0,0 +1,66 @@
import { mkdir, rm, writeFile } from "node:fs/promises"
import path from "node:path"
import { buildMediaStorageKey } from "@/lib/media/storage-key"
type StoreLocalUploadParams = {
file: File
tenantId: string
assetId: string
fileRole: string
variant: string
}
type StoredUpload = {
storageKey: string
}
export function resolveLocalMediaBaseDirectory(): string {
const configured = process.env.CMS_MEDIA_LOCAL_STORAGE_DIR?.trim()
if (configured) {
return path.resolve(configured)
}
return path.resolve(process.cwd(), ".data", "media")
}
export async function storeUploadLocally(params: StoreLocalUploadParams): Promise<StoredUpload> {
const storageKey = buildMediaStorageKey({
tenantId: params.tenantId,
assetId: params.assetId,
fileRole: params.fileRole,
variant: params.variant,
fileName: params.file.name,
})
const baseDirectory = resolveLocalMediaBaseDirectory()
const outputPath = path.join(baseDirectory, storageKey)
await mkdir(path.dirname(outputPath), { recursive: true })
const bytes = new Uint8Array(await params.file.arrayBuffer())
await writeFile(outputPath, bytes)
return { storageKey }
}
export async function deleteLocalStorageObject(storageKey: string): Promise<boolean> {
const baseDirectory = resolveLocalMediaBaseDirectory()
const outputPath = path.join(baseDirectory, storageKey)
try {
await rm(outputPath)
return true
} catch (error) {
const code =
typeof error === "object" && error !== null && "code" in error
? String((error as { code?: unknown }).code)
: ""
if (code === "ENOENT") {
return false
}
throw error
}
}

View File

@@ -0,0 +1,103 @@
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
import { buildMediaStorageKey } from "@/lib/media/storage-key"
type StoreS3UploadParams = {
file: File
tenantId: string
assetId: string
fileRole: string
variant: string
}
type StoredUpload = {
storageKey: string
}
type S3Config = {
bucket: string
region: string
endpoint?: string
accessKeyId: string
secretAccessKey: string
forcePathStyle?: boolean
}
function parseBoolean(value: string | undefined): boolean {
return value?.toLowerCase() === "true"
}
export function resolveS3Config(): S3Config {
const bucket = process.env.CMS_MEDIA_S3_BUCKET?.trim()
const region = process.env.CMS_MEDIA_S3_REGION?.trim()
const accessKeyId = process.env.CMS_MEDIA_S3_ACCESS_KEY_ID?.trim()
const secretAccessKey = process.env.CMS_MEDIA_S3_SECRET_ACCESS_KEY?.trim()
const endpoint = process.env.CMS_MEDIA_S3_ENDPOINT?.trim() || undefined
if (!bucket || !region || !accessKeyId || !secretAccessKey) {
throw new Error(
"S3 storage selected but required env vars are missing: CMS_MEDIA_S3_BUCKET, CMS_MEDIA_S3_REGION, CMS_MEDIA_S3_ACCESS_KEY_ID, CMS_MEDIA_S3_SECRET_ACCESS_KEY",
)
}
return {
bucket,
region,
endpoint,
accessKeyId,
secretAccessKey,
forcePathStyle: parseBoolean(process.env.CMS_MEDIA_S3_FORCE_PATH_STYLE),
}
}
export function createS3Client(config: S3Config): S3Client {
return new S3Client({
region: config.region,
endpoint: config.endpoint,
forcePathStyle: config.forcePathStyle,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
})
}
export async function storeUploadToS3(params: StoreS3UploadParams): Promise<StoredUpload> {
const config = resolveS3Config()
const client = createS3Client(config)
const storageKey = buildMediaStorageKey({
tenantId: params.tenantId,
assetId: params.assetId,
fileRole: params.fileRole,
variant: params.variant,
fileName: params.file.name,
})
const payload = new Uint8Array(await params.file.arrayBuffer())
await client.send(
new PutObjectCommand({
Bucket: config.bucket,
Key: storageKey,
Body: payload,
ContentType: params.file.type || undefined,
ContentLength: params.file.size,
CacheControl: "public, max-age=31536000, immutable",
}),
)
return { storageKey }
}
export async function deleteS3Object(storageKey: string): Promise<boolean> {
const config = resolveS3Config()
const client = createS3Client(config)
await client.send(
new DeleteObjectCommand({
Bucket: config.bucket,
Key: storageKey,
}),
)
return true
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest"
import { buildMediaStorageKey } from "@/lib/media/storage-key"
describe("buildMediaStorageKey", () => {
it("builds asset-centric key with fileRole and variant", () => {
const key = buildMediaStorageKey({
tenantId: "default",
assetId: "550e8400-e29b-41d4-a716-446655440000",
fileRole: "original",
variant: "thumb",
fileName: "My File.PNG",
})
expect(key).toBe(
"tenant/default/asset/550e8400-e29b-41d4-a716-446655440000/original/550e8400-e29b-41d4-a716-446655440000__thumb.png",
)
})
})

View File

@@ -0,0 +1,49 @@
import path from "node:path"
const FALLBACK_EXTENSION = "bin"
const DEFAULT_VARIANT = "original"
type BuildMediaStorageKeyParams = {
tenantId: string
assetId: string
fileRole: string
variant?: string
fileName: string
}
function normalizeSegment(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
}
function extensionFromFilename(fileName: string): string {
const extension = path.extname(fileName).slice(1)
if (!extension) {
return FALLBACK_EXTENSION
}
const normalized = normalizeSegment(extension)
return normalized.length > 0 ? normalized : FALLBACK_EXTENSION
}
export function buildMediaStorageKey(params: BuildMediaStorageKeyParams): string {
const normalizedTenantId = normalizeSegment(params.tenantId) || "default"
const normalizedAssetId = normalizeSegment(params.assetId)
const normalizedFileRole = normalizeSegment(params.fileRole) || "original"
const normalizedVariant = normalizeSegment(params.variant ?? DEFAULT_VARIANT) || DEFAULT_VARIANT
const extension = extensionFromFilename(params.fileName)
const fileName = `${normalizedAssetId}__${normalizedVariant}.${extension}`
return [
"tenant",
normalizedTenantId,
"asset",
normalizedAssetId,
normalizedFileRole,
fileName,
].join("/")
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest"
import { resolveMediaStorageProvider } from "@/lib/media/storage"
describe("resolveMediaStorageProvider", () => {
it("defaults to s3 when unset", () => {
expect(resolveMediaStorageProvider(undefined)).toBe("s3")
})
it("resolves s3", () => {
expect(resolveMediaStorageProvider("s3")).toBe("s3")
expect(resolveMediaStorageProvider("S3")).toBe("s3")
})
it("resolves local explicitly", () => {
expect(resolveMediaStorageProvider("local")).toBe("local")
expect(resolveMediaStorageProvider("LOCAL")).toBe("local")
})
it("falls back to s3 for unknown values", () => {
expect(resolveMediaStorageProvider("foo")).toBe("s3")
})
})

View File

@@ -0,0 +1,149 @@
import { deleteLocalStorageObject, storeUploadLocally } from "@/lib/media/local-storage"
import { deleteS3Object, storeUploadToS3 } from "@/lib/media/s3-storage"
export type MediaStorageProvider = "local" | "s3"
type StoreUploadParams = {
file: File
assetId: string
variant: string
fileRole: string
}
type StoredUpload = {
storageKey: string
provider: MediaStorageProvider
fallbackReason?: string
}
type S3LikeError = {
name?: unknown
message?: unknown
Code?: unknown
code?: unknown
$metadata?: {
httpStatusCode?: unknown
requestId?: unknown
}
}
function resolveTenantId(): string {
return process.env.CMS_MEDIA_STORAGE_TENANT_ID?.trim() || "default"
}
function describeS3Error(error: unknown): string {
if (!error || typeof error !== "object") {
return "Unknown S3 error"
}
const err = error as S3LikeError
const details: string[] = []
if (typeof err.name === "string" && err.name.length > 0) {
details.push(`name=${err.name}`)
}
if (typeof err.message === "string" && err.message.length > 0) {
details.push(`message=${err.message}`)
}
if (typeof err.Code === "string" && err.Code.length > 0) {
details.push(`code=${err.Code}`)
} else if (typeof err.code === "string" && err.code.length > 0) {
details.push(`code=${err.code}`)
}
const status = err.$metadata?.httpStatusCode
if (typeof status === "number") {
details.push(`status=${status}`)
}
const requestId = err.$metadata?.requestId
if (typeof requestId === "string" && requestId.length > 0) {
details.push(`requestId=${requestId}`)
}
return details.length > 0 ? details.join(", ") : "Unknown S3 error"
}
export function resolveMediaStorageProvider(raw: string | undefined): MediaStorageProvider {
if (raw?.toLowerCase() === "local") {
return "local"
}
return "s3"
}
export async function storeUpload(params: StoreUploadParams): Promise<StoredUpload> {
const provider = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER)
const tenantId = resolveTenantId()
if (provider === "s3") {
try {
const stored = await storeUploadToS3({
file: params.file,
tenantId,
assetId: params.assetId,
fileRole: params.fileRole,
variant: params.variant,
})
return {
...stored,
provider,
}
} catch (error) {
const detail = describeS3Error(error)
const fallbackStored = await storeUploadLocally({
file: params.file,
tenantId,
assetId: params.assetId,
fileRole: params.fileRole,
variant: params.variant,
})
return {
...fallbackStored,
provider: "local",
fallbackReason: `S3 upload failed; file stored locally instead. ${detail}`,
}
}
}
const stored = await storeUploadLocally({
file: params.file,
tenantId,
assetId: params.assetId,
fileRole: params.fileRole,
variant: params.variant,
})
return {
...stored,
provider,
}
}
export async function deleteStoredMediaObject(storageKey: string): Promise<void> {
const preferred = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER)
const deleteOperations =
preferred === "s3"
? [() => deleteS3Object(storageKey), () => deleteLocalStorageObject(storageKey)]
: [() => deleteLocalStorageObject(storageKey), () => deleteS3Object(storageKey)]
const errors: string[] = []
for (const performDelete of deleteOperations) {
try {
const deleted = await performDelete()
if (deleted) {
return
}
} catch (error) {
const detail = describeS3Error(error)
errors.push(detail)
}
}
if (errors.length > 0) {
throw new Error(`Storage object deletion failed for key "${storageKey}": ${errors.join(" | ")}`)
}
}

View File

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

View File

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

View File

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

View File

@@ -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,35 +1,52 @@
import { listPosts } from "@cms/db"
import { getPublishedPageBySlugForLocale, 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"
export default async function HomePage() {
const [posts, t] = await Promise.all([listPosts(), getTranslations("Home")])
type HomePageProps = {
params: Promise<{ locale: string }>
}
export default async function HomePage({ params }: HomePageProps) {
const { locale } = await params
const [homePage, posts, t] = await Promise.all([
getPublishedPageBySlugForLocale("home", locale),
listPosts(),
getTranslations("Home"),
])
return (
<section className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-16">
<header className="space-y-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-neutral-600">{t("description")}</p>
</header>
<section>
{homePage ? <PublicPageView page={homePage} /> : null}
<PublicAnnouncements placement="homepage" />
<section className="space-y-4 rounded-xl border border-neutral-200 p-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-medium">{t("latestPosts")}</h2>
<Button variant="secondary">{t("explore")}</Button>
</div>
<section className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-6 pb-16">
<header className="space-y-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<h2 className="text-3xl font-semibold tracking-tight">{t("latestPosts")}</h2>
<p className="text-neutral-600">{t("description")}</p>
</header>
<ul className="space-y-3">
{posts.map((post) => (
<li key={post.id} className="rounded-lg border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-wide text-neutral-500">{post.status}</p>
<h3 className="mt-1 text-lg font-medium">{post.title}</h3>
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? t("noExcerpt")}</p>
</li>
))}
</ul>
<section className="space-y-4 rounded-xl border border-neutral-200 p-6">
<div className="flex items-center justify-between">
<h3 className="text-xl font-medium">{t("latestPosts")}</h3>
<Button variant="secondary">{t("explore")}</Button>
</div>
<ul className="space-y-3">
{posts.map((post) => (
<li key={post.id} className="rounded-lg border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-wide text-neutral-500">{post.status}</p>
<h4 className="mt-1 text-lg font-medium">{post.title}</h4>
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? t("noExcerpt")}</p>
</li>
))}
</ul>
</section>
</section>
</section>
)

View File

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

View File

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

View File

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

209
bun.lock
View File

@@ -28,6 +28,7 @@
"name": "@cms/admin",
"version": "0.0.1",
"dependencies": {
"@aws-sdk/client-s3": "^3.988.0",
"@cms/content": "workspace:*",
"@cms/db": "workspace:*",
"@cms/i18n": "workspace:*",
@@ -207,6 +208,88 @@
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.988.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.8", "@aws-sdk/credential-provider-node": "^3.972.7", "@aws-sdk/middleware-bucket-endpoint": "^3.972.3", "@aws-sdk/middleware-expect-continue": "^3.972.3", "@aws-sdk/middleware-flexible-checksums": "^3.972.6", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-location-constraint": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-sdk-s3": "^3.972.8", "@aws-sdk/middleware-ssec": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.8", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/signature-v4-multi-region": "3.988.0", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.988.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.6", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.23.0", "@smithy/eventstream-serde-browser": "^4.2.8", "@smithy/eventstream-serde-config-resolver": "^4.3.8", "@smithy/eventstream-serde-node": "^4.2.8", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-blob-browser": "^4.2.9", "@smithy/hash-node": "^4.2.8", "@smithy/hash-stream-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/md5-js": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.14", "@smithy/middleware-retry": "^4.4.31", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.10", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.30", "@smithy/util-defaults-mode-node": "^4.2.33", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-mt7AdkieJJ5hEKeCxH4sdTTd679shUjo/cUvNY0fUHgQIPZa1jRuekTXnRytRrEwdrZWJDx56n1S8ism2uX7jg=="],
"@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.988.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.8", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.8", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.988.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.6", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.23.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.14", "@smithy/middleware-retry": "^4.4.31", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.10", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.30", "@smithy/util-defaults-mode-node": "^4.2.33", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-ThqQ7aF1k0Zz4yJRwegHw+T1rM3a7ZPvvEUSEdvn5Z8zTeWgJAbtqW/6ejPsMLmFOlHgNcwDQN/e69OvtEOoIQ=="],
"@aws-sdk/core": ["@aws-sdk/core@3.973.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/xml-builder": "^3.972.4", "@smithy/core": "^3.23.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-WeYJ2sfvRLbbUIrjGMUXcEHGu5SJk53jz3K9F8vFP42zWyROzPJ2NB6lMu9vWl5hnMwzwabX7pJc9Euh3JyMGw=="],
"@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.0", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.6", "", { "dependencies": { "@aws-sdk/core": "^3.973.8", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-+dYEBWgTqkQQHFUllvBL8SLyXyLKWdxLMD1LmKJRvmb0NMJuaJFG/qg78C+LE67eeGbipYcE+gJ48VlLBGHlMw=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.8", "", { "dependencies": { "@aws-sdk/core": "^3.973.8", "@aws-sdk/types": "^3.973.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.10", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.12", "tslib": "^2.6.2" } }, "sha512-z3QkozMV8kOFisN2pgRag/f0zPDrw96mY+ejAM0xssV/+YQ2kklbylRNI/TcTQUDnGg0yPxNjyV6F2EM2zPTwg=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.6", "", { "dependencies": { "@aws-sdk/core": "^3.973.8", "@aws-sdk/credential-provider-env": "^3.972.6", "@aws-sdk/credential-provider-http": "^3.972.8", "@aws-sdk/credential-provider-login": "^3.972.6", "@aws-sdk/credential-provider-process": "^3.972.6", "@aws-sdk/credential-provider-sso": "^3.972.6", "@aws-sdk/credential-provider-web-identity": "^3.972.6", "@aws-sdk/nested-clients": "3.988.0", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-6tkIYFv3sZH1XsjQq+veOmx8XWRnyqTZ5zx/sMtdu/xFRIzrJM1Y2wAXeCJL1rhYSB7uJSZ1PgALI2WVTj78ow=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.6", "", { "dependencies": { "@aws-sdk/core": "^3.973.8", "@aws-sdk/nested-clients": "3.988.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-LXsoBoaTSGHdRCQXlWSA0CHHh05KWncb592h9ElklnPus++8kYn1Ic6acBR4LKFQ0RjjMVgwe5ypUpmTSUOjPA=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.7", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.6", "@aws-sdk/credential-provider-http": "^3.972.8", "@aws-sdk/credential-provider-ini": "^3.972.6", "@aws-sdk/credential-provider-process": "^3.972.6", "@aws-sdk/credential-provider-sso": "^3.972.6", "@aws-sdk/credential-provider-web-identity": "^3.972.6", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-PuJ1IkISG7ZDpBFYpGotaay6dYtmriBYuHJ/Oko4VHxh8YN5vfoWnMNYFEWuzOfyLmP7o9kDVW0BlYIpb3skvw=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.6", "", { "dependencies": { "@aws-sdk/core": "^3.973.8", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-Yf34cjIZJHVnD92jnVYy3tNjM+Q4WJtffLK2Ehn0nKpZfqd1m7SI0ra22Lym4C53ED76oZENVSS2wimoXJtChQ=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.6", "", { "dependencies": { "@aws-sdk/client-sso": "3.988.0", "@aws-sdk/core": "^3.973.8", "@aws-sdk/token-providers": "3.988.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-2+5UVwUYdD4BBOkLpKJ11MQ8wQeyJGDVMDRH5eWOULAh9d6HJq07R69M/mNNMC9NTjr3mB1T0KGDn4qyQh5jzg=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.6", "", { "dependencies": { "@aws-sdk/core": "^3.973.8", "@aws-sdk/nested-clients": "3.988.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-pdJzwKtlDxBnvZ04pWMqttijmkUIlwOsS0GcxCjzEVyUMpARysl0S0ks74+gs2Pdev3Ujz+BTAjOc1tQgAxGqA=="],
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-arn-parser": "^3.972.2", "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-config-provider": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg=="],
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg=="],
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.972.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.8", "@aws-sdk/crc64-nvme": "3.972.0", "@aws-sdk/types": "^3.973.1", "@smithy/is-array-buffer": "^4.2.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-g5DadWO58IgQKuq+uLL3pLohOwLiA67gB49xj8694BW+LpHLNu/tjCqwLfIaWvZyABbv0LXeNiiTuTnjdgkZWw=="],
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA=="],
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g=="],
"@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA=="],
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q=="],
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.8", "", { "dependencies": { "@aws-sdk/core": "^3.973.8", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-arn-parser": "^3.972.2", "@smithy/core": "^3.23.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-/yJdahpN/q3Dc88qXBTQVZfnXryLnxfCoP4hGClbKjuF0VCMxrz3il7sj0GhIkEQt5OM5+lA88XrvbjjuwSxIg=="],
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg=="],
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.8", "", { "dependencies": { "@aws-sdk/core": "^3.973.8", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.988.0", "@smithy/core": "^3.23.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-3PGL+Kvh1PhB0EeJeqNqOWQgipdqFheO4OUKc6aYiFwEpM5t9AyE5hjjxZ5X6iSj8JiduWFZLPwASzF6wQRgFg=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.988.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.8", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.8", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.988.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.6", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.23.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.14", "@smithy/middleware-retry": "^4.4.31", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.10", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.30", "@smithy/util-defaults-mode-node": "^4.2.33", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-OgYV9k1oBCQ6dOM+wWAMNNehXA8L4iwr7ydFV+JDHyuuu0Ko7tDXnLEtEmeQGYRcAFU3MGasmlBkMB8vf4POrg=="],
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/config-resolver": "^4.4.6", "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.988.0", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.8", "@aws-sdk/types": "^3.973.1", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-SXwhbe2v0Jno7QLIBmZWAL2eVzGmXkfLLy0WkM6ZJVhE0SFUcnymDwMUA1oMDUvyArzvKBiU8khQ2ImheCKOHQ=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.988.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.8", "@aws-sdk/nested-clients": "3.988.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-xvXVlRVKHnF2h6fgWBm64aPP5J+58aJyGfRrQa/uFh8a9mcK68mLfJOYq+ZSxQy/UN3McafJ2ILAy7IWzT9kRw=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
"@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg=="],
"@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.988.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-HuXu4boeUWU0DQiLslbgdvuQ4ZMCo4Lsk97w8BIUokql2o9MvjE5dwqI5pzGt0K7afO1FybjidUQVTMLuZNTOA=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.4", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog=="],
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw=="],
"@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.972.6", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.8", "@aws-sdk/types": "^3.973.1", "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-966xH8TPqkqOXP7EwnEThcKKz0SNP9kVJBKd9M8bNXE4GSqVouMKKnFBwYnzbWVKuLXubzX5seokcX4a0JLJIA=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.4", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.3.4", "tslib": "^2.6.2" } }, "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
@@ -633,6 +716,108 @@
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@smithy/abort-controller": ["@smithy/abort-controller@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw=="],
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA=="],
"@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.1", "", { "dependencies": { "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ=="],
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.6", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ=="],
"@smithy/core": ["@smithy/core@3.23.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw=="],
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
"@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.8", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw=="],
"@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ=="],
"@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.8", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A=="],
"@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.8", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ=="],
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.9", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA=="],
"@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.9", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.0", "@smithy/chunked-blob-reader-native": "^4.2.1", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg=="],
"@smithy/hash-node": ["@smithy/hash-node@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA=="],
"@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w=="],
"@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ=="],
"@smithy/md5-js": ["@smithy/md5-js@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ=="],
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.8", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A=="],
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.14", "", { "dependencies": { "@smithy/core": "^3.23.0", "@smithy/middleware-serde": "^4.2.9", "@smithy/node-config-provider": "^4.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag=="],
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.31", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/service-error-classification": "^4.2.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg=="],
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.9", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ=="],
"@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA=="],
"@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.8", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg=="],
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.10", "", { "dependencies": { "@smithy/abort-controller": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA=="],
"@smithy/property-provider": ["@smithy/property-provider@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w=="],
"@smithy/protocol-http": ["@smithy/protocol-http@5.3.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ=="],
"@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw=="],
"@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA=="],
"@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0" } }, "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ=="],
"@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.3", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg=="],
"@smithy/signature-v4": ["@smithy/signature-v4@5.3.8", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg=="],
"@smithy/smithy-client": ["@smithy/smithy-client@4.11.3", "", { "dependencies": { "@smithy/core": "^3.23.0", "@smithy/middleware-endpoint": "^4.4.14", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.12", "tslib": "^2.6.2" } }, "sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg=="],
"@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
"@smithy/url-parser": ["@smithy/url-parser@4.2.8", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA=="],
"@smithy/util-base64": ["@smithy/util-base64@4.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ=="],
"@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg=="],
"@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA=="],
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew=="],
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q=="],
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.30", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng=="],
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.33", "", { "dependencies": { "@smithy/config-resolver": "^4.4.6", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA=="],
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw=="],
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw=="],
"@smithy/util-middleware": ["@smithy/util-middleware@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A=="],
"@smithy/util-retry": ["@smithy/util-retry@4.2.8", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg=="],
"@smithy/util-stream": ["@smithy/util-stream@4.5.12", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.10", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg=="],
"@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA=="],
"@smithy/util-utf8": ["@smithy/util-utf8@4.2.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="],
"@smithy/util-waiter": ["@smithy/util-waiter@4.2.8", "", { "dependencies": { "@smithy/abort-controller": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg=="],
"@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
@@ -829,6 +1014,8 @@
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
@@ -989,6 +1176,8 @@
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-xml-parser": ["fast-xml-parser@5.3.4", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"find-up-simple": ["find-up-simple@1.0.1", "", {}, "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ=="],
@@ -1407,6 +1596,8 @@
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
@@ -1553,6 +1744,12 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@commitlint/is-ignored/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -1637,6 +1834,12 @@
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@vitejs/plugin-vue/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
@@ -1647,6 +1850,12 @@
"vitepress/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@vitejs/plugin-vue/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],

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

@@ -25,6 +25,10 @@ export default defineConfig({
{ text: "i18n Baseline", link: "/product-engineering/i18n-baseline" },
{ text: "i18n Conventions", link: "/product-engineering/i18n-conventions" },
{ 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: "Environment Runbook", link: "/product-engineering/environment-runbook" },
{ 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)
- [CRUD Examples](/product-engineering/crud-examples)
- [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)
- [CMS Feature Topics (Domain-Centric)](/product-engineering/cms-feature-topics)
- [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

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

View File

@@ -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,6 +1,9 @@
import { z } from "zod"
export * from "./announcements"
export * from "./commissions"
export * from "./media"
export * from "./pages-navigation"
export * from "./rbac"
export const postStatusSchema = z.enum(["draft", "published"])

View File

@@ -12,6 +12,7 @@ export const mediaAssetTypeSchema = z.enum([
export const artworkRenditionSlotSchema = z.enum(["thumbnail", "card", "full", "custom"])
export const createMediaAssetInputSchema = z.object({
id: z.string().uuid().optional(),
type: mediaAssetTypeSchema,
title: z.string().min(1).max(180),
description: z.string().max(5000).optional(),
@@ -20,6 +21,29 @@ export const createMediaAssetInputSchema = z.object({
copyright: z.string().max(500).optional(),
author: z.string().max(180).optional(),
tags: z.array(z.string().min(1).max(100)).default([]),
storageKey: z.string().max(500).optional(),
mimeType: z.string().max(180).optional(),
sizeBytes: z.number().int().min(0).optional(),
width: z.number().int().positive().optional(),
height: z.number().int().positive().optional(),
isPublished: z.boolean().optional(),
})
export const updateMediaAssetInputSchema = z.object({
id: z.string().uuid(),
type: mediaAssetTypeSchema.optional(),
title: z.string().min(1).max(180).optional(),
description: z.string().max(5000).nullable().optional(),
altText: z.string().max(1000).nullable().optional(),
source: z.string().max(500).nullable().optional(),
copyright: z.string().max(500).nullable().optional(),
author: z.string().max(180).nullable().optional(),
tags: z.array(z.string().min(1).max(100)).optional(),
mimeType: z.string().max(180).nullable().optional(),
width: z.number().int().positive().nullable().optional(),
height: z.number().int().positive().nullable().optional(),
sizeBytes: z.number().int().min(0).nullable().optional(),
isPublished: z.boolean().optional(),
})
export const createArtworkInputSchema = z.object({
@@ -59,6 +83,7 @@ export const attachArtworkRenditionInputSchema = z.object({
export type MediaAssetType = z.infer<typeof mediaAssetTypeSchema>
export type ArtworkRenditionSlot = z.infer<typeof artworkRenditionSlotSchema>
export type CreateMediaAssetInput = z.infer<typeof createMediaAssetInputSchema>
export type UpdateMediaAssetInput = z.infer<typeof updateMediaAssetInputSchema>
export type CreateArtworkInput = z.infer<typeof createArtworkInputSchema>
export type CreateGroupingInput = z.infer<typeof createGroupingInputSchema>
export type LinkArtworkGroupingInput = z.infer<typeof linkArtworkGroupingInputSchema>

View File

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

View File

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

View File

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

View File

@@ -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

@@ -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

@@ -34,6 +34,7 @@ model User {
isProtected Boolean @default(false)
sessions Session[]
accounts Account[]
commissions Commission[] @relation("CommissionAssignee")
@@unique([email])
@@index([role])
@@ -252,3 +253,125 @@ model ArtworkTag {
@@unique([artworkId, tagId])
@@index([tagId])
}
model Page {
id String @id @default(uuid())
title String
slug String @unique
status String
summary String?
content String
seoTitle String?
seoDescription String?
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
navItems NavigationItem[]
translations PageTranslation[]
@@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 {
id String @id @default(uuid())
name String
slug String @unique
location String
isVisible Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items NavigationItem[]
}
model NavigationItem {
id String @id @default(uuid())
menuId String
pageId String?
label String
href String?
parentId String?
sortOrder Int @default(0)
isVisible Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
menu NavigationMenu @relation(fields: [menuId], references: [id], onDelete: Cascade)
page Page? @relation(fields: [pageId], references: [id], onDelete: SetNull)
parent NavigationItem? @relation("NavigationItemParent", fields: [parentId], references: [id], onDelete: Cascade)
children NavigationItem[] @relation("NavigationItemParent")
@@index([menuId])
@@index([pageId])
@@index([parentId])
@@unique([menuId, parentId, sortOrder, label])
}
model Customer {
id String @id @default(uuid())
name String
email String?
phone String?
instagram String?
notes String?
isRecurring Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
commissions Commission[]
@@index([email])
@@index([isRecurring])
}
model Commission {
id String @id @default(uuid())
title String
description String?
status String
customerId String?
assignedUserId String?
budgetMin Float?
budgetMax Float?
dueAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
assignedUser User? @relation("CommissionAssignee", fields: [assignedUserId], references: [id], onDelete: SetNull)
@@index([status])
@@index([customerId])
@@index([assignedUserId])
}
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

@@ -95,6 +95,134 @@ async function main() {
}),
},
})
const homePage = await db.page.upsert({
where: { slug: "home" },
update: {},
create: {
title: "Home",
slug: "home",
status: "published",
summary: "Default homepage seeded for pages/navigation baseline.",
content: "Welcome to your new artist CMS homepage.",
seoTitle: "Home",
seoDescription: "Seeded homepage",
publishedAt: new Date(),
},
})
const primaryMenu = await db.navigationMenu.upsert({
where: { slug: "primary" },
update: {},
create: {
name: "Primary",
slug: "primary",
location: "header",
isVisible: true,
},
})
const existingHomeItem = await db.navigationItem.findFirst({
where: {
menuId: primaryMenu.id,
parentId: null,
sortOrder: 0,
label: "Home",
},
select: {
id: true,
},
})
if (existingHomeItem) {
await db.navigationItem.update({
where: {
id: existingHomeItem.id,
},
data: {
pageId: homePage.id,
href: "/",
isVisible: true,
},
})
} else {
await db.navigationItem.create({
data: {
menuId: primaryMenu.id,
label: "Home",
href: "/",
pageId: homePage.id,
parentId: null,
sortOrder: 0,
isVisible: true,
},
})
}
const existingCustomer = await db.customer.findFirst({
where: {
email: "collector@example.com",
},
select: {
id: true,
},
})
const seededCustomer = existingCustomer
? await db.customer.update({
where: {
id: existingCustomer.id,
},
data: {
name: "Collector One",
phone: "+1-555-0101",
isRecurring: true,
notes: "Interested in recurring portrait commissions.",
},
})
: await db.customer.create({
data: {
name: "Collector One",
email: "collector@example.com",
phone: "+1-555-0101",
isRecurring: true,
notes: "Interested in recurring portrait commissions.",
},
})
await db.commission.upsert({
where: {
id: "11111111-1111-1111-1111-111111111111",
},
update: {},
create: {
id: "11111111-1111-1111-1111-111111111111",
title: "Portrait Commission Baseline",
description: "Initial seeded commission request for MVP1 board validation.",
status: "new",
customerId: seededCustomer.id,
budgetMin: 400,
budgetMax: 900,
dueAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
},
})
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

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

View File

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

View File

@@ -1,4 +1,20 @@
export type { PublicAnnouncement } from "./announcements"
export {
createAnnouncement,
deleteAnnouncement,
listActiveAnnouncements,
listAnnouncements,
updateAnnouncement,
} from "./announcements"
export { db } from "./client"
export {
commissionKanbanOrder,
createCommission,
createCustomer,
listCommissions,
listCustomers,
updateCommissionStatus,
} from "./commissions"
export {
attachArtworkRendition,
createAlbum,
@@ -7,23 +23,48 @@ export {
createGallery,
createMediaAsset,
createTag,
deleteMediaAsset,
getMediaAssetById,
getMediaFoundationSummary,
linkArtworkToGrouping,
listArtworks,
listMediaAssets,
listMediaFoundationGroups,
updateMediaAsset,
} from "./media-foundation"
export type { PublicNavigationItem } from "./pages-navigation"
export {
createNavigationItem,
createNavigationMenu,
createPage,
deleteNavigationItem,
deletePage,
getPageById,
getPublishedPageBySlug,
getPublishedPageBySlugForLocale,
listNavigationMenus,
listPages,
listPageTranslations,
listPublicNavigation,
listPublishedPageSlugs,
updateNavigationItem,
updatePage,
upsertPageTranslation,
} from "./pages-navigation"
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

@@ -7,7 +7,7 @@ const { mockDb } = vi.hoisted(() => ({
artworkCategory: { upsert: vi.fn() },
artworkTag: { upsert: vi.fn() },
artworkRendition: { upsert: vi.fn() },
mediaAsset: { create: vi.fn() },
mediaAsset: { create: vi.fn(), findUnique: vi.fn(), update: vi.fn(), delete: vi.fn() },
artwork: { create: vi.fn() },
gallery: { create: vi.fn() },
album: { create: vi.fn() },
@@ -24,7 +24,10 @@ import {
attachArtworkRendition,
createArtwork,
createMediaAsset,
deleteMediaAsset,
getMediaAssetById,
linkArtworkToGrouping,
updateMediaAsset,
} from "./media-foundation"
describe("media foundation service", () => {
@@ -36,6 +39,15 @@ describe("media foundation service", () => {
if ("create" in value) {
value.create.mockReset()
}
if ("findUnique" in value) {
value.findUnique.mockReset()
}
if ("update" in value) {
value.update.mockReset()
}
if ("delete" in value) {
value.delete.mockReset()
}
}
})
@@ -90,4 +102,22 @@ describe("media foundation service", () => {
expect(mockDb.mediaAsset.create).toHaveBeenCalledTimes(1)
expect(mockDb.artwork.create).toHaveBeenCalledTimes(1)
})
it("handles media asset read/update/delete operations", async () => {
mockDb.mediaAsset.findUnique.mockResolvedValue({ id: "asset-1" })
mockDb.mediaAsset.update.mockResolvedValue({ id: "asset-1", title: "Updated" })
mockDb.mediaAsset.delete.mockResolvedValue({ id: "asset-1" })
await getMediaAssetById("asset-1")
await updateMediaAsset({
id: "c58f3aca-f958-4079-b2df-c9edf3a5fb0a",
title: "Updated",
tags: ["a", "b"],
})
await deleteMediaAsset("asset-1")
expect(mockDb.mediaAsset.findUnique).toHaveBeenCalledTimes(1)
expect(mockDb.mediaAsset.update).toHaveBeenCalledTimes(1)
expect(mockDb.mediaAsset.delete).toHaveBeenCalledTimes(1)
})
})

View File

@@ -4,6 +4,7 @@ import {
createGroupingInputSchema,
createMediaAssetInputSchema,
linkArtworkGroupingInputSchema,
updateMediaAssetInputSchema,
} from "@cms/content"
import { db } from "./client"
@@ -107,6 +108,28 @@ export async function createMediaAsset(input: unknown) {
})
}
export async function getMediaAssetById(id: string) {
return db.mediaAsset.findUnique({
where: { id },
})
}
export async function updateMediaAsset(input: unknown) {
const payload = updateMediaAssetInputSchema.parse(input)
const { id, ...data } = payload
return db.mediaAsset.update({
where: { id },
data,
})
}
export async function deleteMediaAsset(id: string) {
return db.mediaAsset.delete({
where: { id },
})
}
export async function createArtwork(input: unknown) {
const payload = createArtworkInputSchema.parse(input)

View File

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

@@ -0,0 +1,300 @@
import {
createNavigationItemInputSchema,
createNavigationMenuInputSchema,
createPageInputSchema,
updateNavigationItemInputSchema,
updatePageInputSchema,
upsertPageTranslationInputSchema,
} from "@cms/content"
import { db } from "./client"
export type PublicNavigationItem = {
id: string
label: string
href: string
children: PublicNavigationItem[]
}
function resolvePublishedAt(status: string): Date | null {
return status === "published" ? new Date() : null
}
export async function listPages(limit = 50) {
return db.page.findMany({
orderBy: [{ updatedAt: "desc" }],
take: limit,
})
}
export async function listPublishedPageSlugs() {
const pages = await db.page.findMany({
where: { status: "published" },
orderBy: { updatedAt: "desc" },
select: {
slug: true,
updatedAt: true,
},
})
return pages
}
export async function getPageById(id: string) {
return db.page.findUnique({
where: { id },
})
}
export async function getPublishedPageBySlug(slug: string) {
return db.page.findFirst({
where: {
slug,
status: "published",
},
})
}
export async function 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) {
const payload = createPageInputSchema.parse(input)
return db.page.create({
data: {
...payload,
publishedAt: resolvePublishedAt(payload.status),
},
})
}
export async function updatePage(input: unknown) {
const payload = updatePageInputSchema.parse(input)
const { id, ...data } = payload
return db.page.update({
where: { id },
data: {
...data,
publishedAt:
data.status === undefined ? undefined : data.status === "published" ? new Date() : null,
},
})
}
export async function deletePage(id: string) {
return db.page.delete({
where: { id },
})
}
export async function 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() {
return db.navigationMenu.findMany({
orderBy: [{ location: "asc" }, { name: "asc" }],
include: {
items: {
orderBy: [{ sortOrder: "asc" }, { label: "asc" }],
include: {
page: {
select: {
id: true,
title: true,
slug: true,
},
},
},
},
},
})
}
function resolveNavigationHref(item: {
href: string | null
page: {
slug: string
status: string
} | null
}): string | null {
if (item.href) {
return item.href
}
if (item.page?.status === "published") {
return item.page.slug === "home" ? "/" : `/${item.page.slug}`
}
return null
}
export async function listPublicNavigation(location = "header"): Promise<PublicNavigationItem[]> {
const menu = await db.navigationMenu.findFirst({
where: {
location,
isVisible: true,
},
orderBy: { updatedAt: "desc" },
include: {
items: {
where: {
isVisible: true,
},
orderBy: [{ sortOrder: "asc" }, { label: "asc" }],
include: {
page: {
select: {
slug: true,
status: true,
},
},
},
},
},
})
if (!menu) {
return []
}
const itemMap = new Map<
string,
{
id: string
label: string
href: string
parentId: string | null
children: PublicNavigationItem[]
}
>()
for (const item of menu.items) {
const href = resolveNavigationHref(item)
if (!href) {
continue
}
itemMap.set(item.id, {
id: item.id,
label: item.label,
href,
parentId: item.parentId,
children: [],
})
}
const roots: PublicNavigationItem[] = []
for (const entry of itemMap.values()) {
if (entry.parentId) {
const parent = itemMap.get(entry.parentId)
if (parent) {
parent.children.push({
id: entry.id,
label: entry.label,
href: entry.href,
children: entry.children,
})
continue
}
}
roots.push({
id: entry.id,
label: entry.label,
href: entry.href,
children: entry.children,
})
}
return roots
}
export async function createNavigationMenu(input: unknown) {
const payload = createNavigationMenuInputSchema.parse(input)
return db.navigationMenu.create({
data: payload,
})
}
export async function createNavigationItem(input: unknown) {
const payload = createNavigationItemInputSchema.parse(input)
return db.navigationItem.create({
data: payload,
})
}
export async function updateNavigationItem(input: unknown) {
const payload = updateNavigationItemInputSchema.parse(input)
const { id, ...data } = payload
return db.navigationItem.update({
where: { id },
data,
})
}
export async function deleteNavigationItem(id: string) {
return db.navigationItem.delete({
where: { id },
})
}

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,
}),
},
})
}