Compare commits

..

20 Commits

Author SHA1 Message Date
ad351ed73a feat(media): complete mvp1 media foundation workflows 2026-02-11 22:56:01 +01:00
d727ab8b5b feat(media): scaffold mvp1 media and portfolio foundation 2026-02-11 22:46:24 +01:00
5b47fafe89 docs(product): add cms feature topics, package catalog, and inspiration notes
Some checks failed
CMS CI / Governance Checks (push) Successful in 1m2s
CMS CI / Lint Typecheck Unit E2E (push) Failing after 3m24s
2026-02-11 22:35:46 +01:00
37fabad1f8 chore(repo): update turbo dependency
Some checks failed
CMS CI / Governance Checks (push) Successful in 1m5s
CMS CI / Lint Typecheck Unit E2E (push) Failing after 3m40s
2026-02-11 22:08:01 +01:00
637dfd2651 docs(ops): add staging deployment checklist and evidence template 2026-02-11 19:11:45 +01:00
f9f2b4eb15 docs(gitflow): add branch protection verification checklist 2026-02-11 19:09:57 +01:00
ccac669454 feat(release): publish gitea release notes and enable production rollback 2026-02-11 19:09:22 +01:00
af52b8581f feat(ci): stamp build metadata and validate footer version hash 2026-02-11 19:06:55 +01:00
3de4d5732e feat(versioning): show runtime version and git hash in app footers 2026-02-11 19:01:53 +01:00
14c3df623a fix(db): organize imports for biome check
Some checks failed
CMS CI / Governance Checks (push) Successful in 1m2s
CMS CI / Lint Typecheck Unit E2E (push) Failing after 3m38s
2026-02-11 18:45:30 +01:00
a57464d818 chore(repo): remove theoretical workflow and fix prisma ci generation
Some checks failed
CMS CI / Governance Checks (push) Successful in 1m6s
CMS CI / Lint Typecheck Unit E2E (push) Failing after 1m8s
2026-02-11 18:26:42 +01:00
c174f840bc fix(ci): gitea workflows
Some checks failed
CMS CI/CD (Theoretical) / Lint Typecheck Tests (push) Failing after 35s
CMS CI / Governance Checks (push) Successful in 1m1s
CMS CI/CD (Theoretical) / Build Staging Images (push) Has been skipped
CMS CI/CD (Theoretical) / Build Production Images (push) Has been skipped
CMS CI/CD (Theoretical) / Deploy Staging (Placeholder) (push) Has been skipped
CMS CI / Lint Typecheck Unit E2E (push) Failing after 1m25s
CMS CI/CD (Theoretical) / Deploy Production (Placeholder) (push) Has been skipped
2026-02-11 13:12:12 +01:00
334a5e3526 chore(ci): add gitea actions runner compose setup 2026-02-11 12:25:57 +01:00
516b773012 docs(versioning): define release policy and close MVP0 pipeline tasks
Some checks failed
CMS CI/CD (Theoretical) / Lint Typecheck Tests (push) Failing after 5m34s
CMS CI / Governance Checks (push) Failing after 4m47s
CMS CI/CD (Theoretical) / Build Staging Images (push) Has been skipped
CMS CI / Lint Typecheck Unit E2E (push) Has been skipped
CMS CI/CD (Theoretical) / Build Production Images (push) Has been skipped
CMS CI/CD (Theoretical) / Deploy Staging (Placeholder) (push) Has been skipped
CMS CI/CD (Theoretical) / Deploy Production (Placeholder) (push) Has been skipped
2026-02-11 12:19:50 +01:00
21cc55a1b9 ci(gitflow): enforce branch and PR governance checks 2026-02-11 12:19:39 +01:00
969e88670f ci(delivery): add deploy and release workflow scaffolds 2026-02-11 12:19:31 +01:00
cec87679ca docs(adr): add glossary pages and ADR baseline structure 2026-02-11 12:12:34 +01:00
4d6e17a13b docs(ops): add environment and deployment runbook 2026-02-11 12:11:08 +01:00
7b4b23fc4f docs(crud): add implementation examples and complete docs task 2026-02-11 12:10:28 +01:00
5872593b01 docs(i18n): add conventions guide and wire docs navigation 2026-02-11 12:09:43 +01:00
64 changed files with 3733 additions and 177 deletions

View File

@@ -10,5 +10,7 @@ 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"
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.
# CMS_DEV_ROLE="admin"

View File

@@ -0,0 +1,4 @@
GITEA_INSTANCE_URL="https://git.example.com"
GITEA_RUNNER_REGISTRATION_TOKEN="replace-with-runner-registration-token"
GITEA_RUNNER_NAME="cms-runner"
GITEA_RUNNER_LABELS="ubuntu-latest:docker://node:20-bookworm"

View File

@@ -0,0 +1,17 @@
## Summary
- TODO item reference (exact text): `...`
- Scope (single primary TODO item): `...`
## Checklist
- [ ] Linked TODO item is in `TODO.md`
- [ ] Branch name follows `todo/*`, `refactor/*`, or `code/*`
- [ ] `bun run check`
- [ ] `bun run typecheck`
- [ ] `bun run test`
- [ ] E2E validation plan included (`bun run test:e2e` or reason if deferred)
## Notes
- Risks / migrations / rollout notes:

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env sh
set -eu
branch="${1:-}"
if [ -z "$branch" ]; then
echo "Missing branch name."
exit 1
fi
case "$branch" in
dev|staging|main)
echo "Long-lived branch detected: $branch"
exit 0
;;
esac
if printf "%s" "$branch" | grep -Eq '^(todo|refactor|code)\/[a-z0-9]+([._-][a-z0-9]+)*$'; then
echo "Branch naming valid: $branch"
exit 0
fi
echo "Invalid branch name: $branch"
echo "Expected: todo/<slug> | refactor/<slug> | code/<slug>"
exit 1

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env sh
set -eu
body="${1:-}"
if [ -z "$body" ]; then
echo "PR body is empty."
exit 1
fi
if printf "%s" "$body" | grep -Eq 'TODO|todo|\[P[1-3]\]'; then
echo "PR body includes TODO reference."
exit 0
fi
echo "PR body must reference the related TODO item."
exit 1

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env sh
set -eu
if [ "${#}" -ne 4 ]; then
echo "Usage: $0 <base-url> <owner> <repo> <token>"
exit 1
fi
base_url="$1"
owner="$2"
repo="$3"
token="$4"
protect_branch() {
branch="$1"
curl -sS -X POST \
"${base_url}/api/v1/repos/${owner}/${repo}/branch_protections" \
-H "Authorization: token ${token}" \
-H "Content-Type: application/json" \
-d "{
\"branch_name\": \"${branch}\",
\"enable_push\": false,
\"enable_push_whitelist\": false,
\"enable_merge_whitelist\": false,
\"enable_status_check\": true,
\"status_check_contexts\": [\"Governance Checks\", \"Lint Typecheck Unit E2E\"]
}" >/dev/null
}
protect_branch "main"
protect_branch "staging"
echo "Branch protection applied for main and staging."

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env sh
set -eu
tag="${1:-}"
if [ -z "$tag" ]; then
echo "Missing release tag argument (expected vX.Y.Z)."
exit 1
fi
if [ ! -f CHANGELOG.md ]; then
echo "CHANGELOG.md not found."
exit 1
fi
version="${tag#v}"
awk -v version="$version" '
BEGIN {
in_section = 0
started = 0
}
/^## / {
if (in_section == 1) {
exit
}
if (index($0, version) > 0) {
in_section = 1
started = 1
print $0
next
}
}
{
if (in_section == 1) {
print $0
}
}
END {
if (started == 0) {
exit 2
}
}
' CHANGELOG.md

View File

@@ -0,0 +1,80 @@
import { readFileSync } from "node:fs"
const tag = process.env.RELEASE_TAG?.trim()
const releaseName = process.env.RELEASE_NAME?.trim() || tag
const bodyFile = process.env.RELEASE_BODY_FILE?.trim() || ".gitea-release-notes.md"
const serverUrl = process.env.GITHUB_SERVER_URL?.trim()
const repository = process.env.GITHUB_REPOSITORY?.trim()
const token = process.env.GITEA_RELEASE_TOKEN?.trim()
if (!tag) {
throw new Error("RELEASE_TAG is required")
}
if (!serverUrl || !repository) {
throw new Error("GITHUB_SERVER_URL and GITHUB_REPOSITORY are required")
}
if (!token) {
throw new Error("GITEA_RELEASE_TOKEN is required")
}
const body = readFileSync(bodyFile, "utf8")
const baseApi = `${serverUrl.replace(/\/$/, "")}/api/v1/repos/${repository}`
async function request(path, options = {}) {
const response = await fetch(`${baseApi}${path}`, {
...options,
headers: {
"content-type": "application/json",
authorization: `token ${token}`,
...(options.headers ?? {}),
},
})
return response
}
const payload = {
tag_name: tag,
target_commitish: "main",
name: releaseName,
body,
draft: false,
prerelease: false,
}
const existingResponse = await request(`/releases/tags/${encodeURIComponent(tag)}`)
if (existingResponse.ok) {
const existing = await existingResponse.json()
const updateResponse = await request(`/releases/${existing.id}`, {
method: "PATCH",
body: JSON.stringify({
...payload,
target_commitish: existing.target_commitish ?? payload.target_commitish,
}),
})
if (!updateResponse.ok) {
const message = await updateResponse.text()
throw new Error(`Failed to update release: ${updateResponse.status} ${message}`)
}
console.log(`Updated release for tag ${tag}`)
} else if (existingResponse.status === 404) {
const createResponse = await request("/releases", {
method: "POST",
body: JSON.stringify(payload),
})
if (!createResponse.ok) {
const message = await createResponse.text()
throw new Error(`Failed to create release: ${createResponse.status} ${message}`)
}
console.log(`Created release for tag ${tag}`)
} else {
const message = await existingResponse.text()
throw new Error(`Failed to query existing release: ${existingResponse.status} ${message}`)
}

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env sh
set -eu
tag="${1:-}"
if [ -z "$tag" ]; then
echo "Missing tag ref name (expected vX.Y.Z)."
exit 1
fi
version="$(node -p "require('./package.json').version")"
if [ "$tag" != "v$version" ]; then
echo "Tag/version mismatch: tag=$tag package.json=$version"
exit 1
fi
echo "Tag matches package.json version: $tag"

View File

@@ -1,113 +0,0 @@
name: CMS CI/CD (Theoretical)
on:
pull_request:
push:
branches:
- dev
- main
- staging
tags:
- "v*"
workflow_dispatch:
env:
BUN_VERSION: "1.3.5"
jobs:
quality:
name: Lint Typecheck Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Generate Prisma client
run: bun run db:generate
- name: Lint
run: bun run lint
- name: Typecheck
run: bun run typecheck
- name: Unit and component tests
run: bun run test
- name: E2E suite discovery check
run: bun run test:e2e --list
- name: Conventional commit check (latest commit)
run: bun run commitlint
build_staging_images:
name: Build Staging Images
runs-on: ubuntu-latest
needs: quality
if: github.ref == 'refs/heads/staging'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build web image (staging)
run: docker build -f apps/web/Dockerfile -t cms-web:staging .
- name: Build admin image (staging)
run: docker build -f apps/admin/Dockerfile -t cms-admin:staging .
build_production_images:
name: Build Production Images
runs-on: ubuntu-latest
needs: quality
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build web image (production)
run: docker build -f apps/web/Dockerfile -t cms-web:${{ github.ref_name }} .
- name: Build admin image (production)
run: docker build -f apps/admin/Dockerfile -t cms-admin:${{ github.ref_name }} .
- name: Generate changelog
run: |
bun install --frozen-lockfile
bun run changelog:release
- name: Push images (placeholder)
run: |
echo "TODO: docker login to registry"
echo "TODO: docker push cms-web:${{ github.ref_name }}"
echo "TODO: docker push cms-admin:${{ github.ref_name }}"
echo "TODO: publish CHANGELOG.md content as release notes"
deploy_staging:
name: Deploy Staging (Placeholder)
runs-on: ubuntu-latest
needs: build_staging_images
if: github.ref == 'refs/heads/staging'
steps:
- name: Deploy placeholder
run: |
echo "TODO: Pull and restart staging compose on target host"
echo "docker compose -f docker-compose.staging.yml up -d"
deploy_production:
name: Deploy Production (Placeholder)
runs-on: ubuntu-latest
needs: build_production_images
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Deploy placeholder
run: |
echo "TODO: Pull and restart production compose on target host"
echo "docker compose -f docker-compose.production.yml up -d"

View File

@@ -25,9 +25,39 @@ env:
CMS_SUPPORT_LOGIN_KEY: "support-access"
jobs:
governance:
name: Governance Checks
runs-on: node22-bun
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate branch naming
run: |
branch="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
sh .gitea/scripts/check-branch-name.sh "$branch"
- name: Validate PR TODO reference
if: github.event_name == 'pull_request'
run: |
body='${{ github.event.pull_request.body }}'
sh .gitea/scripts/check-pr-todo-reference.sh "$body"
- name: Commit schema check (latest commit)
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install dependencies for commitlint
run: bun install --frozen-lockfile
- name: Commitlint
run: bun run commitlint
quality:
name: Lint Typecheck Unit E2E
runs-on: ubuntu-latest
needs: governance
runs-on: node22-bun
services:
postgres:
image: postgres:16-alpine
@@ -54,12 +84,21 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Resolve build metadata
run: |
version=$(bun -e 'const pkg = JSON.parse(await Bun.file("package.json").text()); console.log(pkg.version)')
echo "NEXT_PUBLIC_APP_VERSION=$version" >> "$GITHUB_ENV"
echo "NEXT_PUBLIC_GIT_SHA=${GITHUB_SHA}" >> "$GITHUB_ENV"
- name: Install Playwright browser deps
run: bunx playwright install --with-deps chromium
- name: Lint and format checks
run: bun run check
- name: Generate Prisma client
run: bun run db:generate
- name: Typecheck
run: bun run typecheck

View File

@@ -0,0 +1,54 @@
name: CMS Deploy
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options:
- staging
- production
image_tag:
description: "Image tag to deploy (e.g. v0.1.0)"
required: true
rollback_tag:
description: "Optional rollback tag"
required: false
jobs:
deploy:
name: Deploy Compose Stack
runs-on: node22-bun
steps:
- name: Resolve deployment target
id: target
run: |
if [ "${{ github.event.inputs.environment }}" = "staging" ]; then
echo "host=${{ secrets.CMS_STAGING_HOST }}" >> "$GITHUB_OUTPUT"
echo "user=${{ secrets.CMS_STAGING_USER }}" >> "$GITHUB_OUTPUT"
echo "compose=docker-compose.staging.yml" >> "$GITHUB_OUTPUT"
else
echo "host=${{ secrets.CMS_PRODUCTION_HOST }}" >> "$GITHUB_OUTPUT"
echo "user=${{ secrets.CMS_PRODUCTION_USER }}" >> "$GITHUB_OUTPUT"
echo "compose=docker-compose.production.yml" >> "$GITHUB_OUTPUT"
fi
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.CMS_DEPLOY_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H "${{ steps.target.outputs.host }}" >> ~/.ssh/known_hosts
- name: Deploy image tag
run: |
ssh "${{ steps.target.outputs.user }}@${{ steps.target.outputs.host }}" \
"cd ${{ secrets.CMS_REMOTE_DEPLOY_PATH }} && CMS_IMAGE_TAG=${{ github.event.inputs.image_tag }} docker compose -f ${{ steps.target.outputs.compose }} up -d"
- name: Optional rollback
if: github.event.inputs.rollback_tag != ''
run: |
ssh "${{ steps.target.outputs.user }}@${{ steps.target.outputs.host }}" \
"cd ${{ secrets.CMS_REMOTE_DEPLOY_PATH }} && CMS_IMAGE_TAG=${{ github.event.inputs.rollback_tag }} docker compose -f ${{ steps.target.outputs.compose }} up -d"

View File

@@ -0,0 +1,103 @@
name: CMS Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: "Release tag in vX.Y.Z format"
required: false
rollback_image_tag:
description: "Optional rollback image tag"
required: false
env:
BUN_VERSION: "1.3.5"
REGISTRY: ${{ secrets.CMS_IMAGE_REGISTRY }}
IMAGE_NAMESPACE: ${{ secrets.CMS_IMAGE_NAMESPACE }}
jobs:
release:
name: Build Push Changelog
if: github.event_name == 'push' || github.event.inputs.rollback_image_tag == ''
runs-on: node22-bun
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Resolve release tag
id: tag
run: |
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
if [ -z "${{ github.event.inputs.release_tag }}" ]; then
echo "release_tag input is required when publishing a release manually."
exit 1
fi
echo "value=${{ github.event.inputs.release_tag }}" >> "$GITHUB_OUTPUT"
else
echo "value=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
fi
- name: Validate tag against package version
run: sh .gitea/scripts/validate-tag-version.sh "${{ steps.tag.outputs.value }}"
- name: Generate changelog
run: bun run changelog:release
- name: Build release notes payload
run: |
if ! sh .gitea/scripts/extract-release-notes.sh "${{ steps.tag.outputs.value }}" > .gitea-release-notes.md; then
echo "Could not isolate section for tag ${{ steps.tag.outputs.value }}. Falling back to full CHANGELOG.md."
cp CHANGELOG.md .gitea-release-notes.md
fi
- name: Login to image registry
run: |
echo "${{ secrets.CMS_IMAGE_REGISTRY_PASSWORD }}" | docker login "${{ env.REGISTRY }}" -u "${{ secrets.CMS_IMAGE_REGISTRY_USER }}" --password-stdin
- name: Build and push web image
run: |
image="${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/cms-web:${{ steps.tag.outputs.value }}"
docker build -f apps/web/Dockerfile -t "$image" .
docker push "$image"
- name: Build and push admin image
run: |
image="${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/cms-admin:${{ steps.tag.outputs.value }}"
docker build -f apps/admin/Dockerfile -t "$image" .
docker push "$image"
- name: Publish release notes to Gitea
env:
RELEASE_TAG: ${{ steps.tag.outputs.value }}
RELEASE_NAME: ${{ steps.tag.outputs.value }}
RELEASE_BODY_FILE: ".gitea-release-notes.md"
GITEA_RELEASE_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }}
run: bun .gitea/scripts/publish-gitea-release.mjs
rollback:
name: Rollback Production (Manual)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_image_tag != ''
runs-on: node22-bun
steps:
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.CMS_DEPLOY_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H "${{ secrets.CMS_PRODUCTION_HOST }}" >> ~/.ssh/known_hosts
- name: Apply rollback image tag on production
run: |
ssh "${{ secrets.CMS_PRODUCTION_USER }}@${{ secrets.CMS_PRODUCTION_HOST }}" \
"cd ${{ secrets.CMS_REMOTE_DEPLOY_PATH }} && CMS_IMAGE_TAG=${{ github.event.inputs.rollback_image_tag }} docker compose -f docker-compose.production.yml pull && CMS_IMAGE_TAG=${{ github.event.inputs.rollback_image_tag }} docker compose -f docker-compose.production.yml up -d"

1
.gitignore vendored
View File

@@ -24,6 +24,7 @@ test-results
!.env.example
!.env.staging.example
!.env.production.example
!.env.gitea-runner.example
# prisma
packages/db/prisma/dev.db*

View File

@@ -96,6 +96,13 @@ Apply in repository settings:
Optional:
- Protect `dev` from direct push if team size/process requires stricter control.
- Automate protection via `.gitea/scripts/configure-branch-protection.sh`.
## Governance Automation
- Branch naming check: `.gitea/scripts/check-branch-name.sh`
- PR TODO reference check: `.gitea/scripts/check-pr-todo-reference.sh`
- PR template: `.gitea/PULL_REQUEST_TEMPLATE.md`
## Commit Signing Notes

View File

@@ -4,6 +4,8 @@
Follow `BRANCHING.md` for long-lived and task branch rules.
Pull requests should use `.gitea/PULL_REQUEST_TEMPLATE.md` and link the exact TODO item.
## Commit Message Schema
This repository uses Conventional Commits.

View File

@@ -3,6 +3,7 @@
Roadmap and progress are tracked in `TODO.md` (also visible in admin at `/todo`).
Branch model and promotion flow are documented in `BRANCHING.md`.
Commit schema and changelog workflow are documented in `CONTRIBUTING.md`.
Versioning and release policy are documented in `VERSIONING.md`.
A baseline monorepo with:
@@ -96,10 +97,11 @@ bunx playwright install
## Delivery Scaffolding
The repo includes a theoretical CI/CD and deployment baseline:
The repo includes a CI/CD and deployment baseline:
- Gitea workflow: `.gitea/workflows/ci-cd-theoretical.yml`
- Real quality gate workflow: `.gitea/workflows/ci.yml`
- Quality gate workflow: `.gitea/workflows/ci.yml`
- Deployment workflow: `.gitea/workflows/deploy.yml`
- Release workflow: `.gitea/workflows/release.yml`
- App images:
- `apps/web/Dockerfile`
- `apps/admin/Dockerfile`
@@ -118,12 +120,20 @@ Environment examples:
- `.env.staging.example`
- `.env.production.example`
- `.env.gitea-runner.example`
Notes:
- `dev` remains your local non-docker Bun workflow.
- Staging and production compose files are templates and still require real secrets, registry strategy, and deployment host wiring.
Gitea Actions runner compose (self-hosted):
```bash
cp .env.gitea-runner.example .env.gitea-runner
docker compose --env-file .env.gitea-runner -f docker-compose.gitea-runner.yml up -d
```
## Changelog
- Changelog file: `CHANGELOG.md`

122
TODO.md
View File

@@ -73,70 +73,116 @@ This file is the single source of truth for roadmap and delivery progress.
- [x] [P1] Docs tool baseline added (`docs/` via VitePress)
- [x] [P1] RBAC and permission model documentation in docs site
- [ ] [P2] i18n conventions docs (keys, namespaces, fallback, translation workflow)
- [~] [P1] CRUD base patterns documentation and examples
- [ ] [P1] Environment and deployment runbook docs (dev/staging/production)
- [ ] [P2] API and domain glossary pages
- [ ] [P2] Architecture Decision Records (ADR) structure and first ADRs
- [x] [P2] i18n conventions docs (keys, namespaces, fallback, translation workflow)
- [x] [P1] CRUD base patterns documentation and examples
- [x] [P1] Environment and deployment runbook docs (dev/staging/production)
- [x] [P2] API and domain glossary pages
- [x] [P2] Architecture Decision Records (ADR) structure and first ADRs
### Delivery Pipeline And Runtime
- [x] [P2] Theoretical Gitea Actions workflow scaffold (`.gitea/workflows/ci-cd-theoretical.yml`)
- [x] [P2] Gitea workflow baseline (`.gitea/workflows/ci.yml`, `.gitea/workflows/deploy.yml`, `.gitea/workflows/release.yml`)
- [x] [P2] Bun-based Dockerfiles for public and admin apps
- [x] [P2] Staging and production docker-compose templates
- [ ] [P1] Registry credentials and image push strategy
- [ ] [P1] Staging deployment automation against real host
- [ ] [P1] Production promotion and rollback procedure
- [x] [P1] Registry credentials and image push strategy
- [~] [P1] Staging deployment automation against real host
- [~] [P1] Production promotion and rollback procedure
### Git Flow And Branching
- [ ] [P1] Protect `main` and `staging` branches in Gitea
- [ ] [P1] Define PR gates: lint + typecheck + unit + e2e list minimum
- [ ] [P1] Enforce one todo item per branch naming convention
- [ ] [P2] Add PR template requiring linked TODO step
- [ ] [P2] Define branch lifecycle for `todo/*`, `refactor/*`, and `code/*`
- [~] [P1] Protect `main` and `staging` branches in Gitea
- [x] [P1] Define PR gates: lint + typecheck + unit + e2e list minimum
- [x] [P1] Enforce one todo item per branch naming convention
- [x] [P2] Add PR template requiring linked TODO step
- [x] [P2] Define branch lifecycle for `todo/*`, `refactor/*`, and `code/*`
- [x] [P2] Conventional commit schema documentation (`CONTRIBUTING.md`)
- [x] [P2] Changelog scaffold and generation scripts (`CHANGELOG.md`, `bun run changelog:*`)
- [ ] [P1] Versioning policy definition (SemVer strategy + when to bump major/minor/patch)
- [ ] [P1] Source of truth for version (`package.json` root) and release tagging rules (`vX.Y.Z`)
- [ ] [P1] Build metadata policy for git hash (`+sha.<short>`) in app runtime footer
- [ ] [P1] App footer implementation plan for version + commit hash (admin + web)
- [ ] [P2] Automated version injection in CI (stamping build from tag + commit hash)
- [ ] [P2] Validation tests for displayed version/hash consistency per deployment
- [ ] [P1] Release tagging and changelog publication policy in CI
- [x] [P1] Versioning policy definition (SemVer strategy + when to bump major/minor/patch)
- [x] [P1] Source of truth for version (`package.json` root) and release tagging rules (`vX.Y.Z`)
- [x] [P1] Build metadata policy for git hash (`+sha.<short>`) in app runtime footer
- [x] [P1] App footer implementation plan for version + commit hash (admin + web)
- [x] [P2] Automated version injection in CI (stamping build from tag + commit hash)
- [x] [P2] Validation tests for displayed version/hash consistency per deployment
- [x] [P1] Release tagging and changelog publication policy in CI
### MVP0 Close-Out Checklist
- [~] [P1] Verify and document protected branch rules in Gitea (`main`, `staging`)
- [~] [P1] Run first staging deployment against a real host with deploy workflow and document result
- [x] [P1] Replace release workflow placeholders with real release-notes and rollback execution steps
- [x] [P1] Expose runtime version + short git hash in admin and public app footer
- [x] [P2] Add CI build stamping for version/hash values consumed by app footers
- [x] [P2] Add automated tests validating displayed version/hash format and consistency
## MVP 1: Core CMS Business Features
### MVP1 Suggested Branch Order
- [x] [P1] `todo/mvp1-media-foundation`:
media model, artwork entity, grouping primitives (gallery/album/category/tag), rendition slots
- [ ] [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`:
page CRUD, navigation tree, reusable page blocks (forms/price cards/gallery embeds)
- [ ] [P1] `todo/mvp1-commissions-customers`:
commission request intake + admin CRUD + kanban + customer entity/linking
- [ ] [P1] `todo/mvp1-announcements-news`:
announcement management/rendering + news/blog CRUD and public rendering
- [ ] [P1] `todo/mvp1-public-rendering-integration`:
public rendering for pages/navigation/media/portfolio/announcements and commissioning entrypoints
- [ ] [P1] `todo/mvp1-e2e-happy-paths`:
end-to-end scenarios for page publish, media flow, announcement display, commission flow
### Separate Product Ideas Backlog (Non-Blocking)
- [ ] [P2] Smart homepage section presets for artists (featured artwork, latest news, open commissions)
- [ ] [P2] Portfolio narrative mode (series story + process notes + ordered media sequence)
- [ ] [P2] Reusable CTA/form snippets with per-page override tokens
- [ ] [P2] Lightweight CRM timeline per customer (requests, replies, outcomes)
- [ ] [P3] AI-assisted alt text and metadata suggestion workflow (human approval required)
- [ ] [P3] Auto-generated social crops/promo packs from selected artworks
### Admin App (Primary Focus)
- [ ] [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)
- [ ] [P1] Media enrichment metadata (alt text, copyright, author, source, tags)
- [ ] [P1] Media refinement for artworks (medium, dimensions, year, framing, availability)
- [ ] [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)
- [ ] [P1] Artwork rendition management (thumbnail, card, full, retina/custom sizes)
- [ ] [P1] Type-specific processing presets (artwork/banner/promo/video/gif) with validation rules
- [ ] [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)
- [ ] [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)
### Public App
- [ ] [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)
- [ ] [P2] Artwork views and listing filters
- [ ] [P1] Commission request submission flow
- [ ] [P1] Header banner render logic and fallbacks
- [ ] [P1] Announcement render slots (homepage + optional global/top banner position)
### News / Blog (Secondary Track)
- [ ] [P2] News/blog content type (not primary CMS domain)
- [ ] [P2] Admin list/editor for news posts
- [ ] [P2] Public news index + detail pages
- [ ] [P3] Tag/category and basic archive support
- [ ] [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
@@ -156,6 +202,12 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P1] Audit log for key content operations
- [ ] [P2] Revision history for pages/navigation/media metadata
- [ ] [P1] Permission matrix refinement with granular scopes
- [ ] [P2] Media processing orchestration UI (queue status, retries, processing diagnostics)
- [ ] [P2] Automatic color palette extraction from artworks (stored for theming/filtering)
- [ ] [P2] Watermark pipeline for artwork renditions with configurable watermark asset/position/opacity
- [ ] [P2] Advanced media transforms by type (video transcode profiles, gif optimization, banner safe-area presets)
- [ ] [P2] Announcement targeting refinement (locale/segment targeting rules)
- [ ] [P2] Customer lifecycle tooling (status stages, communication history, export)
- [ ] [P1] Verify email pipeline and operational templates (welcome/verify/resend)
- [ ] [P1] Forgot password/reset password pipeline and support tooling
- [ ] [P2] GUI page to edit role-permission mappings with safety guardrails
@@ -170,6 +222,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P2] Performance budget checks (Core Web Vitals)
- [ ] [P1] 404/500 content-aware error pages
- [ ] [P1] Accessibility review and fixes
- [ ] [P2] Theme assistance from extracted artwork palettes (opt-in per page/section)
### Platform
@@ -206,6 +259,17 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-10] Admin app now uses a shared shell with permission-aware navigation and dedicated IA routes (`/pages`, `/media`, `/users`, `/commissions`).
- [2026-02-10] Public app now has a shared site layout (`banner/header/footer`), DB-backed header banner config, and SEO defaults (`metadata`, `robots`, `sitemap`).
- [2026-02-10] Testing baseline now includes explicit RBAC regression checks, locale-resolution unit tests (admin/web), CRUD service contract tests, and i18n smoke e2e routes.
- [2026-02-10] i18n conventions are now documented as an engineering standard (`docs/product-engineering/i18n-conventions.md`).
- [2026-02-10] Docs now include a domain glossary, public API glossary, and ADR baseline with initial accepted decision (`ADR 0001`).
- [2026-02-10] Delivery and release governance now include branch/PR policy checks, deploy/release workflows, and explicit versioning policy (`VERSIONING.md`).
- [2026-02-11] Release workflow now publishes changelog-derived notes to Gitea releases and supports executable production rollback via SSH + compose tag switch.
- [2026-02-11] Branch protection verification checklist is now documented; final UI-level verification remains environment-specific.
- [2026-02-11] Added a staging deployment execution checklist and deployment-record template to capture first real-host rollout evidence.
- [2026-02-11] Artist-focused feature map refined: MVP1 covers portfolio media/domain CRUD + announcements + customer/commission linking; MVP2 covers advanced automation (watermark, palette extraction, media transform pipelines).
- [2026-02-11] `gaertan` inspiration to reuse: S3 object strategy with signed delivery, commission type/options/extras/custom-input modeling, request-status kanban mapping, and gallery rendition/color extraction patterns.
- [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.
## How We Use This File

71
VERSIONING.md Normal file
View File

@@ -0,0 +1,71 @@
# Versioning Policy
## Source Of Truth
- Canonical version: root `package.json` field `version`
- Tag format: `vX.Y.Z`
Tag validation is enforced in CI:
- `.gitea/scripts/validate-tag-version.sh`
## SemVer Strategy
- `major`: breaking API/behavior changes
- `minor`: backward-compatible features
- `patch`: backward-compatible fixes
## Build Metadata Policy
Use git metadata in runtime display format:
- `<version>+sha.<short>`
Example:
- `0.1.0+sha.a1b2c3d`
## Footer Display Plan (Admin + Web)
Planned runtime footer fields:
- app name
- version from root `package.json`
- commit hash (short)
- environment (`dev|staging|production`)
Implementation note:
- inject values at build/deploy time through env vars
- render in shared footer components
## CI Version Injection
Release/deploy workflows pass release tag and commit metadata:
- `.gitea/workflows/release.yml`
- `.gitea/workflows/deploy.yml`
Required inputs:
- release tag (`vX.Y.Z`)
- image tag for deployment
## Validation Strategy
CI validations:
- tag equals `v${package.json.version}`
- required checks pass before release builds
Runtime validations (planned):
- smoke tests assert footer version/hash format
- environment-specific deployment checks assert expected image tag
## Changelog and Release Publication
- changelog generation command:
- `bun run changelog:release`
- release workflow generates changelog on tag pipeline
- release notes publication remains a dedicated step in CI workflow.

View File

@@ -1,15 +1,109 @@
import { AdminSectionPlaceholder } from "@/components/admin-section-placeholder"
import { createMediaAsset, getMediaFoundationSummary, listMediaAssets } 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 MediaManagementPage() {
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 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,
}: {
searchParams: Promise<SearchParamsInput>
}) {
const role = await requirePermissionForRoute({
nextPath: "/media",
permission: "media:read",
scope: "team",
})
const [resolvedSearchParams, summary, assets] = await Promise.all([
searchParams,
getMediaFoundationSummary(),
listMediaAssets(20),
])
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
return (
<AdminShell
@@ -17,18 +111,162 @@ export default async function MediaManagementPage() {
activePath="/media"
badge="Admin App"
title="Media"
description="Prepare media library and enrichment workflows."
description="Media foundation baseline for assets, artwork renditions, and grouping metadata."
>
<AdminSectionPlaceholder
feature="Media Library"
summary="This route is ready for media browsing, upload, and metadata refinement features."
requiredPermission="media:read (team)"
nextSteps={[
"Add media upload and asset listing.",
"Add enrichment fields (alt text, source, tags).",
"Add artwork-specific refinement fields.",
]}
{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-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>
<p className="mt-2 text-3xl font-semibold">{summary.mediaAssets}</p>
</article>
<article className="rounded-xl border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Artworks</p>
<p className="mt-2 text-3xl font-semibold">{summary.artworks}</p>
</article>
<article className="rounded-xl border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Groups</p>
<p className="mt-2 text-3xl font-semibold">
{summary.galleries + summary.albums + summary.categories + summary.tags}
</p>
<p className="mt-1 text-xs text-neutral-500">
{summary.galleries} galleries · {summary.albums} albums · {summary.categories}{" "}
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>
</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
</span>
</div>
<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">Type</th>
<th className="py-2 pr-4">Published</th>
<th className="py-2 pr-4">Updated</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>
</tr>
) : (
assets.map((asset) => (
<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">{asset.isPublished ? "yes" : "no"}</td>
<td className="py-3 pr-4 text-neutral-600">
{asset.updatedAt.toLocaleDateString("en-US")}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</AdminShell>
)
}

View File

@@ -0,0 +1,481 @@
import {
attachArtworkRendition,
createAlbum,
createArtwork,
createCategory,
createGallery,
createTag,
linkArtworkToGrouping,
listArtworks,
listMediaAssets,
listMediaFoundationGroups,
} 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>
type GroupType = "gallery" | "album" | "category" | "tag"
function readField(formData: FormData, key: string): string {
const value = formData.get(key)
return typeof value === "string" ? value.trim() : ""
}
function readOptionalField(formData: FormData, key: string): string | undefined {
const value = readField(formData, key)
return value.length > 0 ? value : undefined
}
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null
}
return value ?? null
}
function slugify(input: string): string {
return input
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 180)
}
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 ? `/portfolio?${value}` : "/portfolio")
}
async function requireWritePermission() {
await requirePermissionForRoute({
nextPath: "/portfolio",
permission: "media:write",
scope: "team",
})
}
async function createArtworkAction(formData: FormData) {
"use server"
await requireWritePermission()
const title = readField(formData, "title")
const slug = slugify(readField(formData, "slug") || title)
try {
await createArtwork({
title,
slug,
description: readOptionalField(formData, "description"),
medium: readOptionalField(formData, "medium"),
dimensions: readOptionalField(formData, "dimensions"),
framing: readOptionalField(formData, "framing"),
availability: readOptionalField(formData, "availability"),
year: (() => {
const raw = readField(formData, "year")
return raw ? Number(raw) : undefined
})(),
})
} catch {
redirectWithState({ error: "Failed to create artwork." })
}
revalidatePath("/portfolio")
redirectWithState({ notice: "Artwork created." })
}
async function createGroupAction(formData: FormData) {
"use server"
await requireWritePermission()
const type = readField(formData, "groupType") as GroupType
const name = readField(formData, "name")
const slug = slugify(readField(formData, "slug") || name)
try {
if (type === "gallery") {
await createGallery({
name,
slug,
description: readOptionalField(formData, "description"),
})
} else if (type === "album") {
await createAlbum({
name,
slug,
description: readOptionalField(formData, "description"),
})
} else if (type === "category") {
await createCategory({
name,
slug,
description: readOptionalField(formData, "description"),
})
} else {
await createTag({
name,
slug,
})
}
} catch {
redirectWithState({ error: "Failed to create grouping entity." })
}
revalidatePath("/portfolio")
redirectWithState({ notice: `${type} created.` })
}
async function linkArtworkGroupAction(formData: FormData) {
"use server"
await requireWritePermission()
const artworkId = readField(formData, "artworkId")
const groupType = readField(formData, "groupType") as GroupType
const groupId = readField(formData, "groupId")
try {
await linkArtworkToGrouping({
artworkId,
groupType,
groupId,
})
} catch {
redirectWithState({ error: "Failed to link artwork to grouping." })
}
revalidatePath("/portfolio")
redirectWithState({ notice: "Artwork linked to grouping." })
}
async function attachRenditionAction(formData: FormData) {
"use server"
await requireWritePermission()
try {
await attachArtworkRendition({
artworkId: readField(formData, "artworkId"),
mediaAssetId: readField(formData, "mediaAssetId"),
slot: readField(formData, "slot"),
width: (() => {
const raw = readField(formData, "width")
return raw ? Number(raw) : undefined
})(),
height: (() => {
const raw = readField(formData, "height")
return raw ? Number(raw) : undefined
})(),
isPrimary: readField(formData, "isPrimary") === "true",
})
} catch {
redirectWithState({ error: "Failed to attach artwork rendition." })
}
revalidatePath("/portfolio")
redirectWithState({ notice: "Rendition attached." })
}
export default async function PortfolioPage({
searchParams,
}: {
searchParams: Promise<SearchParamsInput>
}) {
const role = await requirePermissionForRoute({
nextPath: "/portfolio",
permission: "media:read",
scope: "team",
})
const [resolvedSearchParams, artworks, mediaAssets, groups] = await Promise.all([
searchParams,
listArtworks(30),
listMediaAssets(200),
listMediaFoundationGroups(),
])
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
return (
<AdminShell
role={role}
activePath="/portfolio"
badge="Admin App"
title="Portfolio"
description="Artwork foundation with rendition slots and grouping relations."
>
{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 Artwork</h2>
<form action={createArtworkAction} 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 (optional)</span>
<input
name="slug"
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">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 xl:grid-cols-4">
<input
name="medium"
placeholder="Medium"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<input
name="dimensions"
placeholder="Dimensions"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<input
name="year"
type="number"
placeholder="Year"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<input
name="framing"
placeholder="Framing"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<input
name="availability"
placeholder="Availability"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<Button type="submit">Create artwork</Button>
</form>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Group Entity</h2>
<form action={createGroupAction} className="mt-4 space-y-3">
<div className="grid gap-3 md:grid-cols-3">
<select
name="groupType"
defaultValue="gallery"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="gallery">gallery</option>
<option value="album">album</option>
<option value="category">category</option>
<option value="tag">tag</option>
</select>
<input
name="name"
required
placeholder="Name"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<input
name="slug"
placeholder="Slug (optional)"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<textarea
name="description"
rows={2}
placeholder="Description (optional)"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<Button type="submit">Create group</Button>
</form>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Link Artwork To Group</h2>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
{(
[
{ type: "gallery" as const, label: "Gallery", items: groups.galleries },
{ type: "album" as const, label: "Album", items: groups.albums },
{ type: "category" as const, label: "Category", items: groups.categories },
{ type: "tag" as const, label: "Tag", items: groups.tags },
] as const
).map((groupConfig) => (
<form
key={groupConfig.type}
action={linkArtworkGroupAction}
className="space-y-3 rounded border border-neutral-200 p-4"
>
<h3 className="text-sm font-semibold">{groupConfig.label} Link</h3>
<input type="hidden" name="groupType" value={groupConfig.type} />
<select
name="artworkId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
{artworks.map((artwork) => (
<option key={`${groupConfig.type}-${artwork.id}`} value={artwork.id}>
{artwork.title}
</option>
))}
</select>
<select
name="groupId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
{groupConfig.items.map((group) => (
<option key={`${groupConfig.type}-${group.id}`} value={group.id}>
{group.name}
</option>
))}
</select>
<Button type="submit">Link artwork</Button>
</form>
))}
</div>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Attach Artwork Rendition Slot</h2>
<form
action={attachRenditionAction}
className="mt-4 grid gap-3 md:grid-cols-3 xl:grid-cols-6"
>
<select name="artworkId" className="rounded border border-neutral-300 px-3 py-2 text-sm">
{artworks.map((artwork) => (
<option key={artwork.id} value={artwork.id}>
{artwork.title}
</option>
))}
</select>
<select
name="mediaAssetId"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
>
{mediaAssets.map((asset) => (
<option key={asset.id} value={asset.id}>
{asset.title}
</option>
))}
</select>
<select
name="slot"
defaultValue="thumbnail"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="thumbnail">thumbnail</option>
<option value="card">card</option>
<option value="full">full</option>
<option value="custom">custom</option>
</select>
<input
name="width"
type="number"
placeholder="width"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<input
name="height"
type="number"
placeholder="height"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<select
name="isPrimary"
defaultValue="false"
className="rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="false">not primary</option>
<option value="true">primary</option>
</select>
<div className="md:col-span-3 xl:col-span-6">
<Button type="submit">Attach rendition</Button>
</div>
</form>
</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">Artworks</h2>
<span className="text-xs uppercase tracking-[0.2em] text-neutral-500">
MVP1 Foundation
</span>
</div>
<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">Published</th>
<th className="py-2 pr-4">Renditions</th>
<th className="py-2 pr-4">Groups</th>
</tr>
</thead>
<tbody>
{artworks.length === 0 ? (
<tr>
<td className="py-3 text-neutral-500" colSpan={5}>
No artworks yet. Add creation flows after media upload pipeline lands.
</td>
</tr>
) : (
artworks.map((artwork) => (
<tr key={artwork.id} className="border-t border-neutral-200">
<td className="py-3 pr-4">{artwork.title}</td>
<td className="py-3 pr-4 font-mono text-xs">{artwork.slug}</td>
<td className="py-3 pr-4">{artwork.isPublished ? "yes" : "no"}</td>
<td className="py-3 pr-4">{artwork.renditions.length}</td>
<td className="py-3 pr-4 text-neutral-600">
g:{artwork.galleryLinks.length} a:{artwork.albumLinks.length} c:
{artwork.categoryLinks.length} t:{artwork.tagLinks.length}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</AdminShell>
)
}

View File

@@ -4,6 +4,7 @@ import type { ReactNode } from "react"
import { LogoutButton } from "@/app/logout-button"
import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher"
import { getBuildInfo } from "@/lib/build-info"
type AdminShellProps = {
role: Role
@@ -26,6 +27,7 @@ const navItems: NavItem[] = [
{ href: "/", label: "Dashboard", permission: "dashboard:read", scope: "global" },
{ href: "/pages", label: "Pages", permission: "pages: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: "/settings", label: "Settings", permission: "users:manage_roles", scope: "global" },
@@ -57,6 +59,8 @@ export function AdminShell({
actions,
children,
}: AdminShellProps) {
const buildInfo = getBuildInfo()
return (
<div className="mx-auto flex min-h-screen w-full max-w-7xl gap-8 px-6 py-10">
<aside className="sticky top-0 hidden h-fit w-64 shrink-0 space-y-4 lg:block">
@@ -111,6 +115,10 @@ export function AdminShell({
</header>
{children}
<footer className="border-t border-neutral-200 pt-4 text-xs text-neutral-500">
Build v{buildInfo.version} +sha.{buildInfo.sha}
</footer>
</div>
</div>
)

View File

@@ -31,6 +31,10 @@ describe("admin route access rules", () => {
permission: "media:read",
scope: "team",
})
expect(getRequiredPermission("/portfolio")).toEqual({
permission: "media:read",
scope: "team",
})
expect(getRequiredPermission("/users")).toEqual({
permission: "users:read",
scope: "own",

View File

@@ -57,6 +57,13 @@ const guardRules: GuardRule[] = [
scope: "team",
},
},
{
route: /^\/portfolio(?:\/|$)/,
requirement: {
permission: "media:read",
scope: "team",
},
},
{
route: /^\/users(?:\/|$)/,
requirement: {

View File

@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { getBuildInfo } from "./build-info"
afterEach(() => {
vi.unstubAllEnvs()
})
describe("getBuildInfo (admin)", () => {
it("returns fallback values when env is missing", () => {
vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "")
vi.stubEnv("NEXT_PUBLIC_GIT_SHA", "")
expect(getBuildInfo()).toEqual({
version: "0.0.1-dev",
sha: "local",
})
})
it("uses env values and truncates git sha", () => {
vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "0.2.0")
vi.stubEnv("NEXT_PUBLIC_GIT_SHA", "abcdef123456")
expect(getBuildInfo()).toEqual({
version: "0.2.0",
sha: "abcdef1",
})
})
})

View File

@@ -0,0 +1,21 @@
const FALLBACK_VERSION = "0.0.1-dev"
const FALLBACK_SHA = "local"
function shortenSha(input: string): string {
const value = input.trim()
if (!value) {
return FALLBACK_SHA
}
return value.slice(0, 7)
}
export function getBuildInfo() {
const version = process.env.NEXT_PUBLIC_APP_VERSION?.trim() || FALLBACK_VERSION
const sha = shortenSha(process.env.NEXT_PUBLIC_GIT_SHA ?? "")
return {
version,
sha,
}
}

View File

@@ -2,9 +2,12 @@
import { useTranslations } from "next-intl"
import { getBuildInfo } from "@/lib/build-info"
export function PublicSiteFooter() {
const t = useTranslations("Layout")
const year = new Date().getFullYear()
const buildInfo = getBuildInfo()
return (
<footer className="border-t border-neutral-200 bg-neutral-50">
@@ -15,6 +18,9 @@ export function PublicSiteFooter() {
})}
</p>
<p>{t("footer.tagline")}</p>
<p className="font-mono text-xs text-neutral-500">
Build v{buildInfo.version} +sha.{buildInfo.sha}
</p>
</div>
</footer>
)

View File

@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { getBuildInfo } from "./build-info"
afterEach(() => {
vi.unstubAllEnvs()
})
describe("getBuildInfo (web)", () => {
it("returns fallback values when env is missing", () => {
vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "")
vi.stubEnv("NEXT_PUBLIC_GIT_SHA", "")
expect(getBuildInfo()).toEqual({
version: "0.0.1-dev",
sha: "local",
})
})
it("uses env values and truncates git sha", () => {
vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "0.2.0")
vi.stubEnv("NEXT_PUBLIC_GIT_SHA", "123456789abc")
expect(getBuildInfo()).toEqual({
version: "0.2.0",
sha: "1234567",
})
})
})

View File

@@ -0,0 +1,21 @@
const FALLBACK_VERSION = "0.0.1-dev"
const FALLBACK_SHA = "local"
function shortenSha(input: string): string {
const value = input.trim()
if (!value) {
return FALLBACK_SHA
}
return value.slice(0, 7)
}
export function getBuildInfo() {
const version = process.env.NEXT_PUBLIC_APP_VERSION?.trim() || FALLBACK_VERSION
const sha = shortenSha(process.env.NEXT_PUBLIC_GIT_SHA ?? "")
return {
version,
sha,
}
}

View File

@@ -17,7 +17,7 @@
"conventional-changelog-cli": "5.0.0",
"jsdom": "28.0.0",
"msw": "2.12.9",
"turbo": "2.8.3",
"turbo": "^2.8.6",
"typescript": "5.9.3",
"vite-tsconfig-paths": "6.1.0",
"vitepress": "1.6.4",
@@ -1451,19 +1451,19 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"turbo": ["turbo@2.8.3", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.3", "turbo-darwin-arm64": "2.8.3", "turbo-linux-64": "2.8.3", "turbo-linux-arm64": "2.8.3", "turbo-windows-64": "2.8.3", "turbo-windows-arm64": "2.8.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-8Osxz5Tu/Dw2kb31EAY+nhq/YZ3wzmQSmYa1nIArqxgCAldxv9TPlrAiaBUDVnKA4aiPn0OFBD1ACcpc5VFOAQ=="],
"turbo": ["turbo@2.8.6", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.6", "turbo-darwin-arm64": "2.8.6", "turbo-linux-64": "2.8.6", "turbo-linux-arm64": "2.8.6", "turbo-windows-64": "2.8.6", "turbo-windows-arm64": "2.8.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-QMj1SQwUYehc+xJ9SxXn56UO43hfKN64/NFetVW1BwzysRqn+q0FSgrmk+IbJ+djfd8j8zXGKGeqsnUcXwQSUQ=="],
"turbo-darwin-64": ["turbo-darwin-64@2.8.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kXRLfcygLOeNcP6JquqRLmGB/ATjjfehiojL2dJkL7GFm3SPSXbq7oNj8UbD8XriYQ5hPaSuz59iF1ijPHkTw=="],
"turbo-darwin-64": ["turbo-darwin-64@2.8.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-6QeZ/aLZizekiI6tKZN0IGP1a1WYZ9c/qDKPa0rSmj2X0O0Iw/ES4rKZV40S5n8SUJdiU01EFLygHJ2oWaYKXg=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xF7uCeC0UY0Hrv/tqax0BMbFlVP1J/aRyeGQPZT4NjvIPj8gSPDgFhfkfz06DhUwDg5NgMo04uiSkAWE8WB/QQ=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RS4Z902vB93cQD3PJS/1IMmS0HefrB5ZXuw4ECOrxhOGz5jJVmYFJ6weDzedjoTDeYHHXGo1NoiCSHg69ngWKA=="],
"turbo-linux-64": ["turbo-linux-64@2.8.3", "", { "os": "linux", "cpu": "x64" }, "sha512-vxMDXwaOjweW/4etY7BxrXCSkvtwh0PbwVafyfT1Ww659SedUxd5rM3V2ZCmbwG8NiCfY7d6VtxyHx3Wh1GoZA=="],
"turbo-linux-64": ["turbo-linux-64@2.8.6", "", { "os": "linux", "cpu": "x64" }, "sha512-hCWDnDepYbrSJdByuryKFoHAGFkvgBYXr6qdaGsYhX1Wgq8isqXCQBKOo99Y/9tXDwKGEeQ7xnkdFvSL7AQ4iQ=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-mQX7uYBZFkuPLLlKaNe9IjR1JIef4YvY8f21xFocvttXvdPebnq3PK1Zjzl9A1zun2BEuWNUwQIL8lgvN9Pm3Q=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oS15aCYEpynG/l69xs/ZnQ0dnz0pHhfHg70Zf5J+j5Cam0/RA0MpcryjneN/9G0PmP8a/6ZxnL5nZahX+wOBPA=="],
"turbo-windows-64": ["turbo-windows-64@2.8.3", "", { "os": "win32", "cpu": "x64" }, "sha512-YLGEfppGxZj3VWcNOVa08h6ISsVKiG85aCAWosOKNUjb6yErWEuydv6/qImRJUI+tDLvDvW7BxopAkujRnWCrw=="],
"turbo-windows-64": ["turbo-windows-64@2.8.6", "", { "os": "win32", "cpu": "x64" }, "sha512-eqBxqJD7H/uk9V0QO10VgwY9J2BUXejsGuzChln72Yl+o8GZwsvhOekndRxccR90J8ZO+LKO24+3VzHFh4Cu/g=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-afTUGKBRmOJU1smQSBnFGcbq0iabAPwh1uXu2BVk7BREg30/1gMnJh9DFEQTah+UD3n3ru8V55J83RQNFfqoyw=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-I3VEQyxIlNZ6XTg4fLKAkuhcwzIs/GD7Vs1yhelH2aUTjf08wprjBWknDqP7mjAHMpsosRrq4DtfSZEQm83Hxg=="],
"type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="],

View File

@@ -6,7 +6,7 @@ module.exports = {
"always",
["feat", "fix", "refactor", "perf", "test", "docs", "build", "ci", "chore", "revert"],
],
"scope-empty": [2, "never"],
"scope-empty": [0],
"subject-empty": [2, "never"],
},
}

View File

@@ -0,0 +1,13 @@
services:
gitea-runner:
image: gitea/act_runner:latest
container_name: cms-gitea-runner
restart: unless-stopped
environment:
GITEA_INSTANCE_URL: "${GITEA_INSTANCE_URL}"
GITEA_RUNNER_REGISTRATION_TOKEN: "${GITEA_RUNNER_REGISTRATION_TOKEN}"
GITEA_RUNNER_NAME: "${GITEA_RUNNER_NAME:-cms-runner}"
GITEA_RUNNER_LABELS: "${GITEA_RUNNER_LABELS:-ubuntu-latest:docker://node:20-bookworm}"
volumes:
- ./runner-data:/data
- /var/run/docker.sock:/var/run/docker.sock

View File

@@ -21,9 +21,16 @@ export default defineConfig({
{ text: "Architecture", link: "/architecture" },
{ text: "Better Auth Baseline", link: "/product-engineering/auth-baseline" },
{ text: "CRUD Baseline", link: "/product-engineering/crud-baseline" },
{ text: "CRUD Examples", link: "/product-engineering/crud-examples" },
{ 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: "Domain Glossary", link: "/product-engineering/domain-glossary" },
{ text: "Environment Runbook", link: "/product-engineering/environment-runbook" },
{ text: "Delivery Pipeline", link: "/product-engineering/delivery-pipeline" },
{ text: "Git Flow Governance", link: "/product-engineering/git-flow-governance" },
{ text: "Testing Strategy", link: "/product-engineering/testing-strategy" },
{ text: "ADR Index", link: "/adr/" },
{ text: "Workflow", link: "/workflow" },
],
},
@@ -33,7 +40,17 @@ export default defineConfig({
},
{
text: "Public API",
items: [{ text: "Section Overview", link: "/public-api/" }],
items: [
{ text: "Section Overview", link: "/public-api/" },
{ text: "Glossary", link: "/public-api/glossary" },
],
},
{
text: "ADR",
items: [
{ text: "Index", link: "/adr/" },
{ text: "0001 Monorepo Foundation", link: "/adr/0001-monorepo-foundation" },
],
},
],
socialLinks: [{ icon: "github", link: "https://example.com/replace-with-repo" }],

View File

@@ -0,0 +1,37 @@
# ADR 0001: Monorepo Foundation
- Status: Accepted
- Date: 2026-02-10
## Context
The CMS platform requires:
- separate admin and public apps
- shared domain contracts and data access
- consistent tooling and CI quality gates
- incremental delivery through MVP stages
A fragmented multi-repo setup would increase coordination overhead and duplicate shared contracts.
## Decision
Adopt a Bun workspace monorepo with:
- `apps/admin` and `apps/web` for runtime surfaces
- shared packages (`@cms/content`, `@cms/db`, `@cms/crud`, `@cms/ui`, `@cms/i18n`)
- shared quality tooling (Biome, TypeScript, Vitest, Playwright, Turbo)
## Consequences
### Positive
- shared contract updates propagate in one change set
- easier cross-app refactors and testing
- single CI pipeline with consistent gates
### Negative
- stronger need for workspace discipline and clear boundaries
- larger repository clone/build surface
- potential for cross-package coupling if conventions are not enforced

17
docs/adr/README.md Normal file
View File

@@ -0,0 +1,17 @@
# ADR Index
Architecture Decision Records (ADRs) capture important technical decisions and context.
## Format
- Numbered files: `0001-<short-title>.md`
- Immutable once accepted (new ADRs supersede old decisions)
- Include:
- Status
- Context
- Decision
- Consequences
## Records
- [0001 - Monorepo Foundation](./0001-monorepo-foundation.md)

View File

@@ -7,6 +7,7 @@ Engineering documentation hub for this repository.
- [Product / Engineering](/product-engineering/)
- [Admin / User Guides](/admin-user-guides/)
- [Public API](/public-api/)
- [ADR Index](/adr/)
## Core Sources
@@ -14,6 +15,7 @@ Engineering documentation hub for this repository.
- Branching and promotion flow: `BRANCHING.md`
- Contribution and commit schema: `CONTRIBUTING.md`
- Release history: `CHANGELOG.md`
- Versioning and release policy: `VERSIONING.md`
## Documentation Scope

View File

@@ -0,0 +1,52 @@
# Artist CMS Inspiration Notes
## Scope
Inspiration-only notes for implementation direction.
These are not direct copy targets and do not override current CMS roadmap decisions.
## Useful Patterns Observed in `gaertan`
### Media and Delivery
- S3-backed storage with signed URL/object access patterns.
- Route-level image streaming/proxy from storage keys.
- Multiple artwork variants/renditions for different view contexts.
- Dedicated actions for generated gallery variants and missing-variant backfill.
### Portfolio Domain
- Artwork linked to galleries/albums/tags/categories.
- Filterable portfolio pages (album/year/tag/search).
- Gallery components designed for responsive/justified layouts.
### Commissions Domain
- Rich commission model:
- types
- options
- extras
- custom cards
- custom inputs
- Public request form + admin request management.
- Commission status/kanban-like mapping for intake/in-progress/completed.
### Color and Processing
- Artwork color extraction workflows (palette/tones) from stored image files.
- Potential pipeline point for future theming and discovery filters.
## How We Should Reuse These Ideas Here
- Keep the domain approach, but normalize to current CMS architecture (`@cms/*` packages, Next app router, shared CRUD services).
- Start with deterministic MVP1 primitives:
- media CRUD + rendition slots
- portfolio grouping entities
- commission/customer linking
- Defer heavy media automation (advanced transforms/watermark/palette orchestration) to MVP2 after baseline reliability is proven.
## Guardrails
- No direct schema/code lift from `gaertan`; re-model explicitly for this repository.
- Keep upload and processing abstraction pluggable (S3 now, alternative provider later).
- Favor explicit auditability for media/commission mutations.

View File

@@ -0,0 +1,244 @@
# CMS Feature Topics (Domain-Centric)
## Purpose
Describe the CMS by feature domains/modules (not personas), so implementation and UI structure stay clear.
## 1) Pages
Scope:
- create/edit/publish/unpublish/schedule pages
- slug + SEO metadata
- draft and publish states
Core entities:
- `Page`
- `PageVersion` (later)
- `SeoMeta`
MVP fit:
- MVP1 core
## 2) Navigation
Scope:
- menus, nested items, ordering, visibility
- route linking to pages or external URLs
Core entities:
- `NavigationMenu`
- `NavigationItem`
MVP fit:
- MVP1 core
## 3) Media
Scope:
- upload/browse/replace/delete media
- media-type classification (artwork, banner, promo, generic, video/gif)
- metadata management
Core entities:
- `MediaAsset`
- `MediaMetadata`
- `MediaVariant` (renditions)
MVP fit:
- MVP1 core
## 4) Portfolio / Artworks
Scope:
- artworks with grouped structures
- grouping by galleries/albums/categories/tags
- ordering and visibility
Core entities:
- `Artwork`
- `Gallery`
- `Album`
- `Category`
- `Tag`
MVP fit:
- MVP1 core
## 5) Cards and Reusable Blocks
Scope:
- reusable content blocks for pages
- card-based sections (price cards, promo cards, feature cards)
Core entities:
- `BlockTemplate`
- `BlockInstance`
- `CardPreset`
MVP fit:
- MVP1 (baseline blocks), MVP2 (advanced builder UX)
## 6) Forms
Scope:
- embeddable forms on pages
- schema-driven field definitions
- submission handling and moderation
Core entities:
- `FormDefinition`
- `FormField`
- `FormSubmission`
MVP fit:
- MVP1 for commission request path
- MVP2 for generic form builder
## 7) Announcements and Banners
Scope:
- prominent notices on public pages
- schedule windows and priority
Core entities:
- `Announcement`
- `HeaderBanner`
MVP fit:
- MVP1 core
## 8) News / Blog
Scope:
- editorial posts and updates
- author metadata, status flow
Core entities:
- `NewsPost`
- `NewsCategory`
MVP fit:
- MVP1 secondary core
## 9) Commissions
Scope:
- commission request intake
- admin processing and kanban status transitions
Core entities:
- `CommissionRequest`
- `CommissionStatus`
- `CommissionType` (options/extras/custom fields)
MVP fit:
- MVP1 core
## 10) Customers (CRM-Lite)
Scope:
- recurring customer records
- customer-to-commission linking and reuse
Core entities:
- `Customer`
- `CustomerContact`
- `CustomerCommissionLink`
MVP fit:
- MVP1 core
## 11) Users, Roles, and Permissions
Scope:
- users, role assignment, status (active/banned)
- protected owner/support invariants
Core entities:
- `User`
- `Role`
- `Permission`
MVP fit:
- MVP0/MVP1 bridge; refinements in MVP2
## 12) Settings
Scope:
- system settings and feature flags
- registration policy and future locale toggles
Core entities:
- `SystemSetting`
MVP fit:
- MVP0 baseline, expanded in MVP1/MVP2
## 13) Processing Pipelines (Later)
Scope:
- watermarking
- color extraction
- advanced media transforms
- queue/retry visibility
Core entities:
- `MediaJob`
- `MediaJobRun`
- `ExtractedPalette`
MVP fit:
- MVP2
## Suggested Admin IA Alignment
- Dashboard
- Pages
- Navigation
- Media
- Portfolio
- Announcements
- News
- Commissions
- Customers
- Users
- Settings

View File

@@ -38,3 +38,4 @@ The admin dashboard currently includes a temporary posts CRUD sandbox to validat
- This is the base layer for future entities (pages, navigation, media, users, commissions).
- Audit hook persistence/transport is intentionally left for later implementation work.
- Implementation examples are documented in `crud-examples.md`.

View File

@@ -0,0 +1,69 @@
# CRUD Examples
## Goal
Provide concrete implementation patterns for new entities adopting `@cms/crud`.
## Example: Service Factory Wiring
```ts
import { createCrudService } from "@cms/crud"
import { createPageInputSchema, updatePageInputSchema } from "@cms/content"
const pageCrudService = createCrudService({
resource: "page",
repository: pageRepository,
schemas: {
create: createPageInputSchema,
update: updatePageInputSchema,
},
auditHooks: pageAuditHooks,
})
```
## Example: Repository Contract
```ts
const pageRepository = {
list: () => db.page.findMany({ orderBy: { updatedAt: "desc" } }),
findById: (id: string) => db.page.findUnique({ where: { id } }),
create: (input: CreatePageInput) => db.page.create({ data: input }),
update: (id: string, input: UpdatePageInput) =>
db.page.update({
where: { id },
data: input,
}),
delete: (id: string) => db.page.delete({ where: { id } }),
}
```
## Example: Action Usage
```ts
export async function createPage(input: unknown, context?: CrudMutationContext) {
return pageCrudService.create(input, context)
}
export async function updatePage(id: string, input: unknown, context?: CrudMutationContext) {
return pageCrudService.update(id, input, context)
}
export async function deletePage(id: string, context?: CrudMutationContext) {
return pageCrudService.delete(id, context)
}
```
## Testing Expectations
- validation failure returns `CrudValidationError`
- missing IDs return `CrudNotFoundError`
- repository methods are called in expected order
- audit hooks receive `actor`, `metadata`, `before`, `after`
## Adoption Checklist
1. Add entity schemas in `@cms/content`
2. Add repository + service in `@cms/db`
3. Add unit tests for contract + validation
4. Wire route/action permission checks before mutations
5. Add docs links and TODO updates

View File

@@ -0,0 +1,83 @@
# Delivery Pipeline
## Scope
Operational pipeline baseline for image build/push, staging deploy, production promotion, and rollback.
## Registry Credentials Strategy
Use scoped Gitea secrets:
- `CMS_IMAGE_REGISTRY`
- `CMS_IMAGE_NAMESPACE`
- `CMS_IMAGE_REGISTRY_USER`
- `CMS_IMAGE_REGISTRY_PASSWORD`
Policy:
- credentials only in CI secrets
- no plaintext credentials in repo
- least privilege: push/pull for target namespace only
## Build and Push Flow
- Workflow: `.gitea/workflows/release.yml`
- Trigger:
- tag push `vX.Y.Z`
- manual `workflow_dispatch`
- Steps:
1. validate tag vs root `package.json` version
2. generate changelog
3. extract release notes from `CHANGELOG.md`
4. docker login
5. build and push `cms-web` and `cms-admin` images
6. publish/update Gitea release notes through API
Additional required secret:
- `GITEA_RELEASE_TOKEN`
## Staging Deployment Automation
- Workflow: `.gitea/workflows/deploy.yml`
- Manual input:
- `environment=staging`
- `image_tag=vX.Y.Z`
- Remote deployment uses SSH + compose file:
- `docker-compose.staging.yml`
Required secrets:
- `CMS_STAGING_HOST`
- `CMS_STAGING_USER`
- `CMS_DEPLOY_KEY`
- `CMS_REMOTE_DEPLOY_PATH`
## Production Promotion and Rollback
Promotion:
- run deploy workflow with:
- `environment=production`
- `image_tag=vX.Y.Z`
Rollback:
- release workflow supports manual production rollback by `rollback_image_tag`
- deploy workflow supports `rollback_tag` input for environment-specific rollback
- recovery action:
- rerun deploy/rollback with previous known-good tag
## Deployment Verification
After deploy:
1. app health checks (web/admin)
2. auth smoke flow
3. i18n smoke flow
4. critical route checks (`/`, `/login`, `/todo`)
## Notes
- Current workflows are production-oriented scaffolds and require secret provisioning in Gitea.
- Host hardening, network ACLs, and backup policy remain mandatory operational controls.

View File

@@ -0,0 +1,35 @@
# Domain Glossary
## Core Terms
### Owner
Highest-privilege admin role. Exactly one canonical owner must exist at all times.
### Support User
Hidden technical support account used for break-glass access and operational recovery.
### Admin Registration Policy
Runtime policy controlling whether `/register` can create additional admin users after owner bootstrap.
### Protected Account
Account that cannot be deleted/demoted through self-service flows (support + canonical owner).
### CRUD Service
Shared `@cms/crud` service abstraction combining schema validation, repository orchestration, and audit hooks.
### Permission Scope
RBAC access scope granularity: `own`, `team`, `global`.
### Roadmap Source Of Truth
`TODO.md` in repository root. Rendered in admin via `/todo`.
### Header Banner
Public-site announcement strip configured through `system_setting` key `public.header_banner`.

View File

@@ -0,0 +1,103 @@
# Environment and Deployment Runbook
## Scope
Operational baseline for `dev`, `staging`, and `production`.
## Environments
### Dev (local)
- Runtime: Bun + local Next dev servers
- Entry point: `bun run dev`
- Database: local/remote dev Postgres from `.env`
- Characteristics:
- fastest feedback
- non-production data acceptable
- migrations created here first
### Staging
- Runtime: Docker Compose (`docker-compose.staging.yml`)
- Purpose: integration validation and release candidate checks
- Characteristics:
- production-like environment
- controlled test data
- candidate for production promotion
### Production
- Runtime: Docker Compose (`docker-compose.production.yml`)
- Purpose: end-user traffic
- Characteristics:
- protected secrets and stricter access controls
- immutable release artifacts
- rollback procedures required
## Core Commands
### Local development
```bash
bun install
bun run db:generate
bun run db:migrate
bun run db:seed
bun run dev
```
### Staging compose
```bash
bun run docker:staging:up
bun run docker:staging:down
```
### Production compose
```bash
bun run docker:production:up
bun run docker:production:down
```
## Release Flow
1. Complete work on task branch.
2. Merge into `dev` and pass quality gates.
3. Promote `dev` -> `staging`.
4. Validate staging smoke/e2e + manual checks.
5. Promote `staging` -> `main` and tag release.
## Migration Policy
- Create migrations in development only.
- Apply migrations in deployment using `prisma migrate deploy`.
- Never hand-edit applied migration history.
## Rollback Baseline
Current baseline strategy:
- rollback app image/tag to previous known-good release
- restore database from backup when schema/data changes require recovery
## Secrets and Config
- Dev: `.env`
- Staging: `.env.staging` (from `.env.staging.example`)
- Production: `.env.production` (from `.env.production.example`)
Minimum sensitive values:
- `DATABASE_URL`
- `BETTER_AUTH_SECRET`
- `CMS_SUPPORT_*` credentials/keys
## Verification Checklist
- `bun run check`
- `bun run typecheck`
- `bun run test`
- `bun run test:e2e`
- app startup health for web/admin
- login flow and permissions smoke

View File

@@ -0,0 +1,93 @@
# Git Flow Governance
## Scope
Governance rules for branch protections, PR gates, branch naming, and merge discipline.
## Branch Protection
Protected branches:
- `main`
- `staging`
Apply protections using:
- Gitea UI settings
- or automation script: `.gitea/scripts/configure-branch-protection.sh`
Minimum policy:
- no direct pushes
- PR merge required
- required status checks
- at least one reviewer approval
## Branch Protection Verification Checklist
Use this checklist in Gitea repository settings after applying policy:
1. `main` protection exists and direct push is disabled.
2. `staging` protection exists and direct push is disabled.
3. Required checks include:
- `Governance Checks`
- `Lint Typecheck Unit E2E`
4. Pull request approval is required.
5. Branch must be up to date before merge (recommended in protected branches).
API automation example:
```bash
sh .gitea/scripts/configure-branch-protection.sh \
"$GITEA_URL" \
"$GITEA_OWNER" \
"$GITEA_REPO" \
"$GITEA_ADMIN_TOKEN"
```
Notes:
- The script applies baseline protection for `main` and `staging`.
- Final verification is still required in the Gitea UI to confirm repository-specific policies.
## PR Gates
Required checks are implemented in `.gitea/workflows/ci.yml`:
- Governance Checks
- Lint Typecheck Unit E2E
## Branch Naming and TODO Scope
Allowed branch prefixes:
- `todo/`
- `refactor/`
- `code/`
Validation script:
- `.gitea/scripts/check-branch-name.sh`
Rule:
- one primary TODO item per delivery branch
PR TODO reference enforcement:
- template: `.gitea/PULL_REQUEST_TEMPLATE.md`
- CI check: `.gitea/scripts/check-pr-todo-reference.sh`
## Branch Lifecycle
1. Create short-lived branch from latest integration tip.
2. Implement one primary scope.
3. Open PR and pass required checks.
4. Merge into `dev`.
5. Promote `dev -> staging -> main`.
## Commit and Tag Policy
- Conventional commits required (`CONTRIBUTING.md`)
- release tags: `vX.Y.Z`
- changelog generated from commit history

View File

@@ -18,4 +18,4 @@ Current baseline:
- Public app locale is resolved through `next-intl` middleware + cookie.
- Enabled locales are currently static in code and will later be managed from admin settings.
- Translation key conventions and workflow docs are tracked in `TODO.md`.
- Translation key and workflow standards are documented in `i18n-conventions.md`.

View File

@@ -0,0 +1,86 @@
# i18n Conventions
## Scope
This document defines translation conventions for both apps in MVP0+.
- Public app i18n: `next-intl` message namespaces and route-level usage
- Admin app i18n: JSON dictionaries + runtime resolver/provider
- Shared locale contract: `@cms/i18n` (`de`, `en`, `es`, `fr`; default `en`)
## Locale Policy
- Source of truth: `packages/i18n/src/index.ts`
- Current enabled locales are code-driven and shared across web/admin.
- Admin-managed locale toggles are planned for a later MVP.
## Key Naming Conventions
- Use `camelCase` for keys.
- Group by domain namespace (not by component filename).
- Keep keys stable; update values, not key names, during copy edits.
### Public app namespaces
- `Layout.*`
- `Home.*`
- `LanguageSwitcher.*`
- Page-specific namespaces, e.g. `About.*`, `Contact.*`
- Metadata namespace: `Seo.*`
### Admin app namespaces
- `common.*`
- `auth.*`
- `dashboard.*`
- `settings.*`
## Message Structure
- Keep messages as nested JSON objects.
- Avoid very deep nesting (prefer 2-3 levels).
- Keep punctuation in translation values, not code.
- Avoid embedding HTML in message strings.
## Fallback Rules
- Unknown/invalid locale values fallback to default locale `en`.
- Missing translation key behavior:
- Admin: `translateMessage` returns provided fallback, else key.
- Public: ensure required keys exist in locale JSON; avoid runtime missing-key states.
## Adding New Translation Keys
1. Add key/value in `apps/*/src/messages/en.json`.
2. Add equivalent key in `de/es/fr` JSON files.
3. Use key via translator:
- Web: `useTranslations("Namespace")` or `getTranslations("Namespace")`
- Admin: `useAdminT()` or server-side `translateMessage(...)`
4. Add/adjust tests for behavior where relevant.
## Translation Workflow
1. Author English source copy first.
2. Add keys in all supported locales in same change.
3. Keep semantic parity across locales.
4. Run checks:
- `bun run check`
- `bun run typecheck`
- `bun run test`
5. For route-level i18n behavior changes, run e2e smoke:
- `bunx playwright test --grep "i18n smoke"`
## QA Checklist
- Locale switch persists after refresh.
- Page headings and navigation labels translate correctly.
- Metadata (`Seo`) strings resolve per locale.
- No missing-key placeholders visible in UI.
## Related Files
- `apps/web/src/i18n/request.ts`
- `apps/web/src/i18n/routing.ts`
- `apps/admin/src/i18n/server.ts`
- `apps/admin/src/i18n/messages.ts`
- `packages/i18n/src/index.ts`

View File

@@ -8,7 +8,19 @@ This section covers platform and implementation documentation for engineers and
- [Architecture](/architecture)
- [Better Auth Baseline](/product-engineering/auth-baseline)
- [RBAC And Permissions](/product-engineering/rbac-permission-model)
- [i18n Conventions](/product-engineering/i18n-conventions)
- [CRUD Examples](/product-engineering/crud-examples)
- [Package Catalog And Decision Notes](/product-engineering/package-catalog)
- [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)
- [Artist CMS Inspiration Notes](/product-engineering/artist-cms-inspiration)
- [Environment Runbook](/product-engineering/environment-runbook)
- [Staging Deployment Checklist](/product-engineering/staging-deployment-checklist)
- [Delivery Pipeline](/product-engineering/delivery-pipeline)
- [Git Flow Governance](/product-engineering/git-flow-governance)
- [Testing Strategy Baseline](/product-engineering/testing-strategy)
- [ADR Index](/adr/)
- [Workflow](/workflow)
## Scope
@@ -20,6 +32,4 @@ This section covers platform and implementation documentation for engineers and
## Planned Expansions
- Domain model and glossary
- ADR (Architecture Decision Record) index
- Operational playbooks (incident, rollback, recovery)

View File

@@ -0,0 +1,153 @@
# Package Catalog And Decision Notes
## Purpose
Track package decisions in one place:
- what is already used
- why it is used
- when to keep/remove/replace
- which packages are candidates for later MVPs
This file is decision support, not a lockfile replacement.
## Current Core Stack (Used Now)
### Runtime and App Foundation
- `bun`:
workspace package manager + runtime for scripts and local dev.
- `next` + `react` + `react-dom`:
app framework for `admin` and `web`.
- `typescript`:
typed contracts across apps/packages.
### Data and Validation
- `prisma` + `@prisma/client` + `pg` + `@prisma/adapter-pg`:
DB schema/migrations + typed DB access on PostgreSQL.
- `zod`:
shared runtime validation for domain schemas and CRUD inputs.
### Auth, State, Data Fetching
- `better-auth`:
admin auth/session + role metadata baseline.
- `zustand`:
lightweight client state (e.g. locale/UI state).
- `@tanstack/react-query`:
async state/query cache patterns for admin/public app data fetching.
- `@tanstack/react-form` and `@tanstack/react-table`:
form/table primitives in admin workflows.
### UI and Styling
- `tailwindcss` + `@tailwindcss/postcss`:
utility-first styling baseline.
- `class-variance-authority`, `clsx`, `tailwind-merge`:
component variant + class composition in `@cms/ui`.
### Testing and Quality
- `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`, `msw`, `jsdom`:
unit/integration tests + UI interaction + API mocking.
- `@playwright/test`:
end-to-end tests.
- `@biomejs/biome`:
lint/format/check baseline.
- `turbo`:
monorepo task orchestration.
### Docs and Release Governance
- `vitepress`:
docs site.
- `conventional-changelog-cli`:
changelog generation from conventional commits.
- `@commitlint/cli` + `@commitlint/config-conventional`:
commit message schema enforcement.
## Media and Color Processing Notes
### Why `sharp` is typically the default choice
`sharp` is usually the best baseline for server-side image processing because:
- strong performance and memory behavior
- reliable resize/crop/format conversion pipeline
- robust support for production workloads
- good integration in Node/Bun server contexts
Use cases for this CMS:
- generate artwork renditions (thumb/card/full/custom)
- normalize uploads and output formats
- create banner/promo safe-size outputs
- optional watermark compositing pipeline
### Color extraction package options
1. `node-vibrant`
- Best for: quick dominant palette extraction for UI accents and tagging.
- Tradeoff: less control over advanced color science.
2. `colorthief`
- Best for: simple dominant-color extraction with minimal setup.
- Tradeoff: more limited output and tuning compared to richer libraries.
3. `culori` / `chroma-js` (supporting libs, often combined with extractor)
- Best for: color manipulation, conversion, contrast checks, palette normalization.
- Tradeoff: not a full image extractor by itself.
Recommended approach:
- MVP2 start with `sharp` + `node-vibrant` + `culori`
- keep extraction pipeline behind an internal adapter so replacement is easy
## Candidate Packages For Later (Not Installed Yet)
### File Upload and Storage Abstraction
- `@aws-sdk/client-s3` (+ presign utilities):
for S3/R2/object storage adapters and signed upload/download flows.
- `uploadthing` or custom presigned-upload implementation:
faster admin upload UX with secure direct-to-storage path.
### Rich Text / Page Builder
- `tiptap`:
rich editorial experience for pages/news.
- `@dnd-kit/core`:
drag-and-drop block ordering/page-builder interactions.
### Media Pipelines / Jobs
- `bullmq` + `ioredis`:
background job queue for heavy media transforms (watermark/video/etc).
### Commissions and CRM Extensions
- `@tanstack/react-virtual`:
large admin tables (requests/customers) without rendering bottlenecks.
### Observability / Reliability
- `@sentry/nextjs`:
app error monitoring.
- `pino`:
structured logs for services/workflows.
## Add/Remove Decision Rules
When adding a package, document:
1. problem it solves
2. why existing stack is insufficient
3. expected maintenance/runtime cost
4. fallback/exit plan
When removing/replacing:
1. list impacted modules
2. verify tests and migration path
3. update this catalog and related ADR/docs

View File

@@ -0,0 +1,100 @@
# Staging Deployment Checklist
## Purpose
Operational checklist for the first real staging deployment using `.gitea/workflows/deploy.yml`.
Use this once end-to-end, save the record, then mark MVP0 staging deployment as complete in `TODO.md`.
## Preconditions
- Docker host for staging is reachable via SSH.
- Gitea repo secrets are configured:
- `CMS_STAGING_HOST`
- `CMS_STAGING_USER`
- `CMS_DEPLOY_KEY`
- `CMS_REMOTE_DEPLOY_PATH`
- `CMS_IMAGE_REGISTRY`
- `CMS_IMAGE_NAMESPACE`
- `CMS_IMAGE_REGISTRY_USER`
- `CMS_IMAGE_REGISTRY_PASSWORD`
- Release image tag exists in registry (e.g. `v0.1.0`).
- Remote deploy path contains:
- `docker-compose.staging.yml`
- staging env file(s) needed by compose
## Step-by-Step Execution
1. Verify release images exist:
- `cms-web:<tag>`
- `cms-admin:<tag>`
2. In Gitea Actions, run `CMS Deploy` workflow.
3. Inputs:
- `environment=staging`
- `image_tag=<tag>`
- `rollback_tag=` (empty for normal deploy)
4. Confirm workflow success.
5. Validate staging endpoints:
- web base route
- admin login route
6. Run smoke checks on staging:
- auth login
- i18n route/switch baseline
- admin dashboard route access
7. If failure:
- rerun `CMS Deploy` with `rollback_tag=<previous-tag>`
- capture root cause and remediation notes
## Evidence To Capture
- Workflow run URL
- Deployed image tag
- Timestamp (UTC)
- Validation results (pass/fail)
- Rollback performed or not
## Deployment Record Template
Copy the block below into a new file under `docs/product-engineering/staging-deployments/`.
```md
# Staging Deployment Record - <YYYY-MM-DD>
- Date (UTC):
- Operator:
- Workflow run URL:
- Target environment: staging
- Image tag:
- Previous tag:
## Preconditions
- [ ] Secrets configured in Gitea
- [ ] Registry images available
- [ ] Remote compose path verified
## Execution
1. Triggered `CMS Deploy` with `environment=staging`, `image_tag=<tag>`
2. Workflow status: <!-- pass/fail -->
## Validation
- [ ] Web route check
- [ ] Admin login route check
- [ ] Auth smoke flow
- [ ] i18n smoke flow
- [ ] Admin dashboard access
## Rollback
- Performed: <!-- yes/no -->
- Rollback tag:
- Rollback workflow run URL:
## Outcome
- Result: <!-- success/failed -->
- Notes:
- Follow-up actions:
```

View File

@@ -0,0 +1,116 @@
# User Personas And Use-Case Topics
## Purpose
Define who uses this CMS and which feature topics matter for each role.
This keeps roadmap decisions grounded in real workflows instead of isolated features.
## Primary Personas
### 1. Owner Artist (Primary Operator)
Main goals:
- publish and maintain portfolio website content
- manage artworks, grouped collections, and featured content
- open/close commissions and track incoming requests
Core topics:
- pages + navigation builder
- media library + artwork metadata + renditions
- announcement/banner management
- commissions + customer records
- news/blog updates
### 2. Studio Manager / Assistant
Main goals:
- handle operational content updates and commission administration
- manage customer communication and request statuses
Core topics:
- commission kanban and request triage
- customer profile maintenance
- media organization and moderation
- limited page edits under role constraints
### 3. Content Editor / Social Manager
Main goals:
- publish updates, news posts, and campaign visuals
- keep public-facing content fresh without deep admin privileges
Core topics:
- news/blog authoring
- announcements/promotions
- selected media uploads and metadata edits
- landing page block updates (where permitted)
### 4. Technical Support (Protected Role)
Main goals:
- break-glass access for incident support
- diagnostics and recovery support without owning business content
Core topics:
- support access route/key flow
- protected account safeguards
- operational diagnostics and rollback awareness
### 5. Returning Customer (Commission Client)
Main goals:
- submit repeat commission requests with reduced data re-entry
- track active request state
Core topics:
- customer-linked commission intake
- commission status visibility
- communication and requirement updates
### 6. Public Visitor / Collector / Fan
Main goals:
- discover artwork, updates, and commission availability
- navigate pages and portfolio smoothly
Core topics:
- portfolio browsing (gallery/album/tag/category)
- announcement visibility
- news/blog consumption
- commission request entry points
## Role-to-Feature Responsibility Map
- Owner Artist:
all core CMS domains
- Studio Manager:
commissions/customers/media operations
- Content Editor:
editorial/news/announcements + constrained page/media tasks
- Technical Support:
operational support only, no business ownership transfer
- Public personas:
consumption and request flows on public app
## Planning Guidance
When adding a roadmap item, always specify:
1. target persona(s)
2. primary user outcome
3. permissions required
4. public impact (if any)
If an item cannot be mapped to at least one clear persona outcome, it should not be prioritized.

View File

@@ -0,0 +1,43 @@
# Public API Glossary
## Scope
Baseline terms for future public API design and integration discussions.
## Terms
### Public API
Externally consumable endpoints intended for non-admin clients.
### Resource
Entity exposed by an API endpoint (for example: `page`, `media`, `news`).
### Contract
The stable request/response schema for an endpoint version.
### Version
Compatibility boundary for API contracts (for example: `v1`).
### Authentication
Identity verification mechanism for protected API routes.
### Authorization
Permission check determining whether an authenticated actor can access a resource/action.
### Pagination
Mechanism for splitting large result sets across requests.
### Idempotency
Property where repeating a request does not change final state beyond the first successful call.
### Rate Limit
Request threshold policy applied per consumer/time window.

View File

@@ -11,6 +11,11 @@ No stable public API surface is documented yet.
- Add API docs when real endpoints are implemented and versioned
- Use OpenAPI as source of truth for endpoint reference
- Keep integration guides and authentication examples here
- Use glossary terms consistently across API specs and guides
## Reference
- [Public API Glossary](/public-api/glossary)
## Notes

View File

@@ -28,3 +28,9 @@ Follow `BRANCHING.md`:
```bash
bun run changelog:release
```
## Governance
- Branch and PR governance checks run in `.gitea/workflows/ci.yml`.
- PR template: `.gitea/PULL_REQUEST_TEMPLATE.md`
- Versioning policy: `VERSIONING.md`

View File

@@ -1,10 +1,13 @@
import { expect, test } from "@playwright/test"
const BUILD_INFO_PATTERN = /Build v\S+ \+sha\.[a-z0-9]{5,7}/i
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.getByText(BUILD_INFO_PATTERN)).toBeVisible()
return
}
@@ -12,6 +15,7 @@ test("smoke", async ({ page }, testInfo) => {
if (await dashboardHeading.isVisible({ timeout: 2000 })) {
await expect(dashboardHeading).toBeVisible()
await expect(page.getByText(BUILD_INFO_PATTERN)).toBeVisible()
return
}

View File

@@ -44,22 +44,22 @@
"docker:production:down": "docker compose -f docker-compose.production.yml down"
},
"devDependencies": {
"@playwright/test": "1.58.2",
"@biomejs/biome": "2.3.14",
"@commitlint/cli": "20.4.1",
"@commitlint/config-conventional": "20.4.1",
"@playwright/test": "1.58.2",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@vitejs/plugin-react": "5.1.3",
"@vitest/coverage-istanbul": "4.0.18",
"@biomejs/biome": "2.3.14",
"conventional-changelog-cli": "5.0.0",
"jsdom": "28.0.0",
"msw": "2.12.9",
"conventional-changelog-cli": "5.0.0",
"turbo": "2.8.3",
"turbo": "^2.8.6",
"typescript": "5.9.3",
"vitepress": "1.6.4",
"vite-tsconfig-paths": "6.1.0",
"vitepress": "1.6.4",
"vitest": "4.0.18"
}
}

View File

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

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest"
import {
attachArtworkRenditionInputSchema,
createGroupingInputSchema,
createMediaAssetInputSchema,
linkArtworkGroupingInputSchema,
} from "./media"
describe("media schemas", () => {
it("accepts supported media asset type payload", () => {
const parsed = createMediaAssetInputSchema.parse({
type: "artwork",
title: "Artwork",
tags: ["tag-a"],
})
expect(parsed.type).toBe("artwork")
expect(parsed.tags).toEqual(["tag-a"])
})
it("validates grouping link payload", () => {
const parsed = linkArtworkGroupingInputSchema.parse({
artworkId: "f40f4bcc-7148-45d7-a19d-856f7146a47e",
groupType: "gallery",
groupId: "f4e094df-0edf-4d5a-8b7b-c51f09cae95e",
})
expect(parsed.groupType).toBe("gallery")
})
it("enforces rendition slot enum", () => {
const parsed = attachArtworkRenditionInputSchema.parse({
artworkId: "f40f4bcc-7148-45d7-a19d-856f7146a47e",
mediaAssetId: "f4e094df-0edf-4d5a-8b7b-c51f09cae95e",
slot: "thumbnail",
})
expect(parsed.slot).toBe("thumbnail")
})
it("supports grouping defaults", () => {
const parsed = createGroupingInputSchema.parse({
name: "Featured",
slug: "featured",
})
expect(parsed.sortOrder).toBe(0)
expect(parsed.isVisible).toBe(true)
})
})

View File

@@ -0,0 +1,65 @@
import { z } from "zod"
export const mediaAssetTypeSchema = z.enum([
"artwork",
"banner",
"promotion",
"video",
"gif",
"generic",
])
export const artworkRenditionSlotSchema = z.enum(["thumbnail", "card", "full", "custom"])
export const createMediaAssetInputSchema = z.object({
type: mediaAssetTypeSchema,
title: z.string().min(1).max(180),
description: z.string().max(5000).optional(),
altText: z.string().max(1000).optional(),
source: z.string().max(500).optional(),
copyright: z.string().max(500).optional(),
author: z.string().max(180).optional(),
tags: z.array(z.string().min(1).max(100)).default([]),
})
export const createArtworkInputSchema = z.object({
title: z.string().min(1).max(180),
slug: z.string().min(1).max(180),
description: z.string().max(5000).optional(),
medium: z.string().max(180).optional(),
dimensions: z.string().max(180).optional(),
year: z.number().int().min(1000).max(9999).optional(),
framing: z.string().max(180).optional(),
availability: z.string().max(180).optional(),
})
export const createGroupingInputSchema = z.object({
name: z.string().min(1).max(180),
slug: z.string().min(1).max(180),
description: z.string().max(5000).optional(),
sortOrder: z.number().int().min(0).default(0),
isVisible: z.boolean().default(true),
})
export const linkArtworkGroupingInputSchema = z.object({
artworkId: z.string().uuid(),
groupType: z.enum(["gallery", "album", "category", "tag"]),
groupId: z.string().uuid(),
})
export const attachArtworkRenditionInputSchema = z.object({
artworkId: z.string().uuid(),
mediaAssetId: z.string().uuid(),
slot: artworkRenditionSlotSchema,
width: z.number().int().positive().optional(),
height: z.number().int().positive().optional(),
isPrimary: z.boolean().default(false),
})
export type MediaAssetType = z.infer<typeof mediaAssetTypeSchema>
export type ArtworkRenditionSlot = z.infer<typeof artworkRenditionSlotSchema>
export type CreateMediaAssetInput = z.infer<typeof createMediaAssetInputSchema>
export type CreateArtworkInput = z.infer<typeof createArtworkInputSchema>
export type CreateGroupingInput = z.infer<typeof createGroupingInputSchema>
export type LinkArtworkGroupingInput = z.infer<typeof linkArtworkGroupingInputSchema>
export type AttachArtworkRenditionInput = z.infer<typeof attachArtworkRenditionInputSchema>

View File

@@ -0,0 +1,235 @@
-- CreateTable
CREATE TABLE "MediaAsset" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"altText" TEXT,
"source" TEXT,
"copyright" TEXT,
"author" TEXT,
"tags" TEXT[],
"storageKey" TEXT,
"mimeType" TEXT,
"width" INTEGER,
"height" INTEGER,
"sizeBytes" INTEGER,
"isPublished" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MediaAsset_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Artwork" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"medium" TEXT,
"dimensions" TEXT,
"year" INTEGER,
"framing" TEXT,
"availability" TEXT,
"isPublished" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Artwork_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ArtworkRendition" (
"id" TEXT NOT NULL,
"artworkId" TEXT NOT NULL,
"mediaAssetId" TEXT NOT NULL,
"slot" TEXT NOT NULL,
"width" INTEGER,
"height" INTEGER,
"isPrimary" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ArtworkRendition_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Gallery" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" 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 "Gallery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Album" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" 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 "Album_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" 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 "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ArtworkGallery" (
"id" TEXT NOT NULL,
"artworkId" TEXT NOT NULL,
"galleryId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ArtworkGallery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ArtworkAlbum" (
"id" TEXT NOT NULL,
"artworkId" TEXT NOT NULL,
"albumId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ArtworkAlbum_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ArtworkCategory" (
"id" TEXT NOT NULL,
"artworkId" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ArtworkCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ArtworkTag" (
"id" TEXT NOT NULL,
"artworkId" TEXT NOT NULL,
"tagId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ArtworkTag_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "MediaAsset_storageKey_key" ON "MediaAsset"("storageKey");
-- CreateIndex
CREATE INDEX "MediaAsset_type_idx" ON "MediaAsset"("type");
-- CreateIndex
CREATE INDEX "MediaAsset_isPublished_idx" ON "MediaAsset"("isPublished");
-- CreateIndex
CREATE UNIQUE INDEX "Artwork_slug_key" ON "Artwork"("slug");
-- CreateIndex
CREATE INDEX "Artwork_isPublished_idx" ON "Artwork"("isPublished");
-- CreateIndex
CREATE INDEX "ArtworkRendition_mediaAssetId_idx" ON "ArtworkRendition"("mediaAssetId");
-- CreateIndex
CREATE UNIQUE INDEX "ArtworkRendition_artworkId_slot_key" ON "ArtworkRendition"("artworkId", "slot");
-- CreateIndex
CREATE UNIQUE INDEX "Gallery_slug_key" ON "Gallery"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Album_slug_key" ON "Album"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Tag_slug_key" ON "Tag"("slug");
-- CreateIndex
CREATE INDEX "ArtworkGallery_galleryId_idx" ON "ArtworkGallery"("galleryId");
-- CreateIndex
CREATE UNIQUE INDEX "ArtworkGallery_artworkId_galleryId_key" ON "ArtworkGallery"("artworkId", "galleryId");
-- CreateIndex
CREATE INDEX "ArtworkAlbum_albumId_idx" ON "ArtworkAlbum"("albumId");
-- CreateIndex
CREATE UNIQUE INDEX "ArtworkAlbum_artworkId_albumId_key" ON "ArtworkAlbum"("artworkId", "albumId");
-- CreateIndex
CREATE INDEX "ArtworkCategory_categoryId_idx" ON "ArtworkCategory"("categoryId");
-- CreateIndex
CREATE UNIQUE INDEX "ArtworkCategory_artworkId_categoryId_key" ON "ArtworkCategory"("artworkId", "categoryId");
-- CreateIndex
CREATE INDEX "ArtworkTag_tagId_idx" ON "ArtworkTag"("tagId");
-- CreateIndex
CREATE UNIQUE INDEX "ArtworkTag_artworkId_tagId_key" ON "ArtworkTag"("artworkId", "tagId");
-- AddForeignKey
ALTER TABLE "ArtworkRendition" ADD CONSTRAINT "ArtworkRendition_artworkId_fkey" FOREIGN KEY ("artworkId") REFERENCES "Artwork"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkRendition" ADD CONSTRAINT "ArtworkRendition_mediaAssetId_fkey" FOREIGN KEY ("mediaAssetId") REFERENCES "MediaAsset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkGallery" ADD CONSTRAINT "ArtworkGallery_artworkId_fkey" FOREIGN KEY ("artworkId") REFERENCES "Artwork"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkGallery" ADD CONSTRAINT "ArtworkGallery_galleryId_fkey" FOREIGN KEY ("galleryId") REFERENCES "Gallery"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkAlbum" ADD CONSTRAINT "ArtworkAlbum_artworkId_fkey" FOREIGN KEY ("artworkId") REFERENCES "Artwork"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkAlbum" ADD CONSTRAINT "ArtworkAlbum_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkCategory" ADD CONSTRAINT "ArtworkCategory_artworkId_fkey" FOREIGN KEY ("artworkId") REFERENCES "Artwork"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkCategory" ADD CONSTRAINT "ArtworkCategory_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkTag" ADD CONSTRAINT "ArtworkTag_artworkId_fkey" FOREIGN KEY ("artworkId") REFERENCES "Artwork"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkTag" ADD CONSTRAINT "ArtworkTag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -96,3 +96,159 @@ model SystemSetting {
@@map("system_setting")
}
model MediaAsset {
id String @id @default(uuid())
type String
title String
description String?
altText String?
source String?
copyright String?
author String?
tags String[]
storageKey String? @unique
mimeType String?
width Int?
height Int?
sizeBytes Int?
isPublished Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
artworkLinks ArtworkRendition[]
@@index([type])
@@index([isPublished])
}
model Artwork {
id String @id @default(uuid())
title String
slug String @unique
description String?
medium String?
dimensions String?
year Int?
framing String?
availability String?
isPublished Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
renditions ArtworkRendition[]
galleryLinks ArtworkGallery[]
albumLinks ArtworkAlbum[]
categoryLinks ArtworkCategory[]
tagLinks ArtworkTag[]
@@index([isPublished])
}
model ArtworkRendition {
id String @id @default(uuid())
artworkId String
mediaAssetId String
slot String
width Int?
height Int?
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
artwork Artwork @relation(fields: [artworkId], references: [id], onDelete: Cascade)
mediaAsset MediaAsset @relation(fields: [mediaAssetId], references: [id], onDelete: Cascade)
@@unique([artworkId, slot])
@@index([mediaAssetId])
}
model Gallery {
id String @id @default(uuid())
name String
slug String @unique
description String?
sortOrder Int @default(0)
isVisible Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
artworkLinks ArtworkGallery[]
}
model Album {
id String @id @default(uuid())
name String
slug String @unique
description String?
sortOrder Int @default(0)
isVisible Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
artworkLinks ArtworkAlbum[]
}
model Category {
id String @id @default(uuid())
name String
slug String @unique
description String?
sortOrder Int @default(0)
isVisible Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
artworkLinks ArtworkCategory[]
}
model Tag {
id String @id @default(uuid())
name String
slug String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
artworkLinks ArtworkTag[]
}
model ArtworkGallery {
id String @id @default(uuid())
artworkId String
galleryId String
createdAt DateTime @default(now())
artwork Artwork @relation(fields: [artworkId], references: [id], onDelete: Cascade)
gallery Gallery @relation(fields: [galleryId], references: [id], onDelete: Cascade)
@@unique([artworkId, galleryId])
@@index([galleryId])
}
model ArtworkAlbum {
id String @id @default(uuid())
artworkId String
albumId String
createdAt DateTime @default(now())
artwork Artwork @relation(fields: [artworkId], references: [id], onDelete: Cascade)
album Album @relation(fields: [albumId], references: [id], onDelete: Cascade)
@@unique([artworkId, albumId])
@@index([albumId])
}
model ArtworkCategory {
id String @id @default(uuid())
artworkId String
categoryId String
createdAt DateTime @default(now())
artwork Artwork @relation(fields: [artworkId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@unique([artworkId, categoryId])
@@index([categoryId])
}
model ArtworkTag {
id String @id @default(uuid())
artworkId String
tagId String
createdAt DateTime @default(now())
artwork Artwork @relation(fields: [artworkId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@unique([artworkId, tagId])
@@index([tagId])
}

View File

@@ -13,6 +13,75 @@ async function main() {
},
})
const media = await db.mediaAsset.upsert({
where: { storageKey: "seed/artwork-welcome.jpg" },
update: {},
create: {
type: "artwork",
title: "Seed Artwork Image",
altText: "Seed artwork placeholder",
tags: ["seed", "portfolio"],
storageKey: "seed/artwork-welcome.jpg",
mimeType: "image/jpeg",
isPublished: true,
},
})
const artwork = await db.artwork.upsert({
where: { slug: "seed-artwork-welcome" },
update: {},
create: {
title: "Seed Artwork",
slug: "seed-artwork-welcome",
description: "Baseline seeded artwork for MVP1 media foundation.",
medium: "Digital",
year: 2026,
availability: "available",
isPublished: true,
},
})
const gallery = await db.gallery.upsert({
where: { slug: "featured" },
update: {},
create: {
name: "Featured",
slug: "featured",
description: "Featured artwork selection.",
isVisible: true,
},
})
await db.artworkGallery.upsert({
where: {
artworkId_galleryId: {
artworkId: artwork.id,
galleryId: gallery.id,
},
},
create: {
artworkId: artwork.id,
galleryId: gallery.id,
},
update: {},
})
await db.artworkRendition.upsert({
where: {
artworkId_slot: {
artworkId: artwork.id,
slot: "thumbnail",
},
},
create: {
artworkId: artwork.id,
mediaAssetId: media.id,
slot: "thumbnail",
isPrimary: true,
},
update: {},
})
await db.systemSetting.upsert({
where: { key: "public.header_banner" },
update: {},

View File

@@ -1,4 +1,18 @@
export { db } from "./client"
export {
attachArtworkRendition,
createAlbum,
createArtwork,
createCategory,
createGallery,
createMediaAsset,
createTag,
getMediaFoundationSummary,
linkArtworkToGrouping,
listArtworks,
listMediaAssets,
listMediaFoundationGroups,
} from "./media-foundation"
export {
createPost,
deletePost,

View File

@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const { mockDb } = vi.hoisted(() => ({
mockDb: {
artworkGallery: { upsert: vi.fn() },
artworkAlbum: { upsert: vi.fn() },
artworkCategory: { upsert: vi.fn() },
artworkTag: { upsert: vi.fn() },
artworkRendition: { upsert: vi.fn() },
mediaAsset: { create: vi.fn() },
artwork: { create: vi.fn() },
gallery: { create: vi.fn() },
album: { create: vi.fn() },
category: { create: vi.fn() },
tag: { create: vi.fn() },
},
}))
vi.mock("./client", () => ({
db: mockDb,
}))
import {
attachArtworkRendition,
createArtwork,
createMediaAsset,
linkArtworkToGrouping,
} from "./media-foundation"
describe("media foundation service", () => {
beforeEach(() => {
for (const value of Object.values(mockDb)) {
if ("upsert" in value) {
value.upsert.mockReset()
}
if ("create" in value) {
value.create.mockReset()
}
}
})
it("routes grouping links to the correct link table", async () => {
mockDb.artworkAlbum.upsert.mockResolvedValue({ id: "link" })
await linkArtworkToGrouping({
artworkId: "f40f4bcc-7148-45d7-a19d-856f7146a47e",
groupType: "album",
groupId: "f4e094df-0edf-4d5a-8b7b-c51f09cae95e",
})
expect(mockDb.artworkAlbum.upsert).toHaveBeenCalledTimes(1)
expect(mockDb.artworkGallery.upsert).not.toHaveBeenCalled()
})
it("upserts rendition by artwork and slot", async () => {
mockDb.artworkRendition.upsert.mockResolvedValue({ id: "rendition" })
await attachArtworkRendition({
artworkId: "f40f4bcc-7148-45d7-a19d-856f7146a47e",
mediaAssetId: "f4e094df-0edf-4d5a-8b7b-c51f09cae95e",
slot: "thumbnail",
isPrimary: true,
})
expect(mockDb.artworkRendition.upsert).toHaveBeenCalledTimes(1)
expect(mockDb.artworkRendition.upsert.mock.calls[0]?.[0]).toMatchObject({
where: {
artworkId_slot: {
artworkId: "f40f4bcc-7148-45d7-a19d-856f7146a47e",
slot: "thumbnail",
},
},
})
})
it("parses and forwards media and artwork creation payloads", async () => {
mockDb.mediaAsset.create.mockResolvedValue({ id: "asset" })
mockDb.artwork.create.mockResolvedValue({ id: "artwork" })
await createMediaAsset({
type: "generic",
title: "Asset",
tags: [],
})
await createArtwork({
title: "Artwork",
slug: "artwork",
})
expect(mockDb.mediaAsset.create).toHaveBeenCalledTimes(1)
expect(mockDb.artwork.create).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,259 @@
import {
attachArtworkRenditionInputSchema,
createArtworkInputSchema,
createGroupingInputSchema,
createMediaAssetInputSchema,
linkArtworkGroupingInputSchema,
} from "@cms/content"
import { db } from "./client"
export async function listMediaAssets(limit = 24) {
return db.mediaAsset.findMany({
orderBy: { updatedAt: "desc" },
take: limit,
})
}
export async function listArtworks(limit = 24) {
return db.artwork.findMany({
orderBy: { updatedAt: "desc" },
take: limit,
include: {
renditions: {
select: {
id: true,
slot: true,
mediaAssetId: true,
},
},
galleryLinks: {
include: {
gallery: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
albumLinks: {
include: {
album: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
categoryLinks: {
include: {
category: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
tagLinks: {
include: {
tag: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
},
})
}
export async function listMediaFoundationGroups() {
const [galleries, albums, categories, tags] = await Promise.all([
db.gallery.findMany({
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
}),
db.album.findMany({
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
}),
db.category.findMany({
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
}),
db.tag.findMany({
orderBy: { name: "asc" },
}),
])
return {
galleries,
albums,
categories,
tags,
}
}
export async function createMediaAsset(input: unknown) {
const payload = createMediaAssetInputSchema.parse(input)
return db.mediaAsset.create({
data: payload,
})
}
export async function createArtwork(input: unknown) {
const payload = createArtworkInputSchema.parse(input)
return db.artwork.create({
data: payload,
})
}
export async function createGallery(input: unknown) {
const payload = createGroupingInputSchema.parse(input)
return db.gallery.create({
data: payload,
})
}
export async function createAlbum(input: unknown) {
const payload = createGroupingInputSchema.parse(input)
return db.album.create({
data: payload,
})
}
export async function createCategory(input: unknown) {
const payload = createGroupingInputSchema.parse(input)
return db.category.create({
data: payload,
})
}
export async function createTag(input: unknown) {
const payload = createGroupingInputSchema
.pick({
name: true,
slug: true,
})
.parse(input)
return db.tag.create({
data: payload,
})
}
export async function linkArtworkToGrouping(input: unknown) {
const payload = linkArtworkGroupingInputSchema.parse(input)
if (payload.groupType === "gallery") {
return db.artworkGallery.upsert({
where: {
artworkId_galleryId: {
artworkId: payload.artworkId,
galleryId: payload.groupId,
},
},
create: {
artworkId: payload.artworkId,
galleryId: payload.groupId,
},
update: {},
})
}
if (payload.groupType === "album") {
return db.artworkAlbum.upsert({
where: {
artworkId_albumId: {
artworkId: payload.artworkId,
albumId: payload.groupId,
},
},
create: {
artworkId: payload.artworkId,
albumId: payload.groupId,
},
update: {},
})
}
if (payload.groupType === "category") {
return db.artworkCategory.upsert({
where: {
artworkId_categoryId: {
artworkId: payload.artworkId,
categoryId: payload.groupId,
},
},
create: {
artworkId: payload.artworkId,
categoryId: payload.groupId,
},
update: {},
})
}
return db.artworkTag.upsert({
where: {
artworkId_tagId: {
artworkId: payload.artworkId,
tagId: payload.groupId,
},
},
create: {
artworkId: payload.artworkId,
tagId: payload.groupId,
},
update: {},
})
}
export async function attachArtworkRendition(input: unknown) {
const payload = attachArtworkRenditionInputSchema.parse(input)
return db.artworkRendition.upsert({
where: {
artworkId_slot: {
artworkId: payload.artworkId,
slot: payload.slot,
},
},
create: payload,
update: {
mediaAssetId: payload.mediaAssetId,
width: payload.width,
height: payload.height,
isPrimary: payload.isPrimary,
},
})
}
export async function getMediaFoundationSummary() {
const [mediaAssets, artworks, galleries, albums, categories, tags] = await Promise.all([
db.mediaAsset.count(),
db.artwork.count(),
db.gallery.count(),
db.album.count(),
db.category.count(),
db.tag.count(),
])
return {
mediaAssets,
artworks,
galleries,
albums,
categories,
tags,
}
}