diff --git a/.cursor/rules/api-routes.mdc b/.cursor/rules/api-routes.mdc index 79d428c..42c9f31 100644 --- a/.cursor/rules/api-routes.mdc +++ b/.cursor/rules/api-routes.mdc @@ -45,9 +45,19 @@ Keep new routes within this shape so auth, config, and validation stay uniform. 4. **Prisma access** via `import { prisma } from "lib/server/db"`. Do not instantiate `PrismaClient` directly. -5. **Responses** via `NextResponse.json(...)`. Shared shapes (`dbUnavailable`) - live in `lib/server/responses.ts`; add new shared responses there when a - pattern repeats in two routes. +5. **Responses** via `NextResponse.json(...)`. Shared shapes + (`dbUnavailable`, `unauthorized`, `notFound`, `rateLimited`, + `serverMisconfigured`, `internalError`) and the generic `errorJson(code, + message, status, opts?)` live in `lib/server/responses.ts`. Add new + shared responses there when a pattern repeats in two routes. + +6. **Errors + observability.** All 4xx/5xx bodies use the canonical shape + `{ error: { code, message }, details? }` with codes from the + `ApiErrorCode` union in `lib/server/responses.ts`. Wrap handlers with + `apiRoute("scope.name", async (req, ctx, { requestId }) => { ... })` + from `lib/server/apiRoute.ts` so an `x-request-id` is generated / + forwarded onto every response and uncaught throws return a canonical + 500 with the id logged via `lib/logger`. # Server-only isolation @@ -68,9 +78,8 @@ instead of introducing new patterns: - **Rate limiting.** `lib/server/rateLimit.ts` is an in-memory stopgap marked for replacement. Reuse `rateLimitKey()` where limiting is needed; don't - design a new limiter. -- **Error response shape.** Currently `{ error: string }` + HTTP status. No - error codes yet — don't add a taxonomy until one is designed. + design a new limiter. When returning 429, prefer `rateLimited(retryAfterMs)` + from `responses.ts` so the body and `Retry-After` header stay uniform. - **Pagination / filtering.** Only `rules/route.ts` paginates (`take` capped at 100). Mirror it if you add list endpoints; don't invent cursors or offset contracts unilaterally. diff --git a/.cursor/rules/coding-guidelines.mdc b/.cursor/rules/coding-guidelines.mdc new file mode 100644 index 0000000..edd317f --- /dev/null +++ b/.cursor/rules/coding-guidelines.mdc @@ -0,0 +1,70 @@ +--- +description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria. +alwaysApply: true +--- + +# Karpathy behavioral guidelines + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. diff --git a/.env.example b/.env.example index 1a818e9..4e6ab5a 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,10 @@ SMTP_FROM="Community Rule " # Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in. NEXT_PUBLIC_ENABLE_BACKEND_SYNC= + +# Web vitals API (CR-80): `external` = structured logs only, no writes under `.next` (default in production). +# `local` = file-based aggregates under `.next/web-vitals` (default in development). Omit to use defaults. +# WEB_VITALS_STORAGE=external + +# Optional: URL shown on /monitor when using external storage (Grafana, Kibana, vendor RUM, etc.). +# NEXT_PUBLIC_RUM_DASHBOARD_URL= diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml deleted file mode 100644 index 97d98f2..0000000 --- a/.gitea/workflows/ci.yaml +++ /dev/null @@ -1,469 +0,0 @@ -name: CI Pipeline -run-name: "${{ gitea.actor }} triggered CI pipeline" - -on: - workflow_dispatch: {} # Manual trigger only - run tests locally before merging - # Auto-runs disabled for solo development - # Re-enable when ready for collaborators: - # pull_request: - # branches: [main] - # types: [opened, reopened, synchronize] - -env: - NODE_VERSION: "20" - NEXT_TELEMETRY_DISABLED: "1" - -jobs: - test: - runs-on: [self-hosted, macos-latest] - env: - NODE_OPTIONS: "--max_old_space_size=8192 --max_semi_space_size=128" - CI: true - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "${{ env.NODE_VERSION }}" - cache: npm - - run: npm ci --no-audit --fund=false - - run: npm test -- --reporter=dot --maxConcurrency=1 - # If the Codecov Action fails on Gitea, replace this with the bash uploader below - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - token: "${{ secrets.CODECOV_TOKEN }}" - files: ./coverage/lcov.info - flags: unittests - - # Bash uploader alternative (uncomment if the action above has issues) - # - name: Upload coverage to Codecov (bash) - # run: | - # curl -s https://codecov.io/bash > codecov.sh - # bash codecov.sh -t "${{ secrets.CODECOV_TOKEN }}" -f coverage/lcov.info -F unittests - - e2e: - runs-on: [self-hosted, macos-latest] - strategy: - matrix: - browser: [chromium, firefox, webkit] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "${{ env.NODE_VERSION }}" - cache: npm - - run: npm ci --no-audit --fund=false - - name: Install Playwright - run: "npx playwright install --with-deps ${{ matrix.browser }}" - - run: npm run build - - - name: E2E (start + test + teardown) - run: | - set -euo pipefail - - export PORT="${PORT:-3010}" - export HOST="127.0.0.1" - mkdir -p .next - - # ensure build exists - test -d .next || { echo "❌ Missing .next build output"; exit 1; } - - echo "🚀 Starting Next.js server for E2E testing..." - - # Start Next directly with node so $! is the real node PID - node node_modules/next/dist/bin/next start -p "$PORT" -H "$HOST" > .next/runner.log 2>&1 & - SVPID=$! - echo "$SVPID" > .next/runner.pid - echo "🌐 Server PID: $SVPID" - - # Wait for readiness - echo "⏳ Waiting for server to be ready..." - npx wait-on -t 120000 "tcp:$HOST:$PORT" - curl -fsS "http://$HOST:$PORT" >/dev/null - echo "✅ App is responding at http://$HOST:$PORT" - - # Run tests - echo "🧪 Running E2E tests for ${{ matrix.browser }}..." - BASE_URL="http://$HOST:$PORT" npx playwright test --project=${{ matrix.browser }} --reporter=list || TEST_EXIT_CODE=$? - - # Teardown - echo "🧹 Cleaning up server..." - kill "$SVPID" 2>/dev/null || true - echo "✅ Server cleanup complete" - env: - NEXT_TELEMETRY_DISABLED: "1" - NODE_ENV: production - NODE_OPTIONS: "--max-old-space-size=8192" - - # package artifacts (keeps file count small) - - name: Package E2E artifacts - if: failure() - run: | - tar -czf playwright-${{ matrix.browser }}.tgz playwright-report test-results || true - - - name: Upload E2E artifacts - if: failure() - uses: actions/upload-artifact@v3 - with: - name: playwright-results-${{ matrix.browser }} - path: playwright-${{ matrix.browser }}.tgz - retention-days: 30 - - visual-regression: - runs-on: [self-hosted, macos-latest] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "${{ env.NODE_VERSION }}" - cache: npm - - run: npm ci --no-audit --fund=false - - name: Install Playwright - run: npx playwright install --with-deps - - run: npm run build - # 1) Sanity check that the build exists - - name: Verify Next build output - run: | - set -euo pipefail - ls -la .next || true - test -f .next/BUILD_ID || (echo "No Next build output (.next) – did build fail?" && exit 1) - - - name: Visual Regression (start + test + teardown) - run: | - set -euo pipefail - - export PORT="${PORT:-3010}" - export HOST="127.0.0.1" - mkdir -p .next - - # ensure build exists - test -d .next || { echo "❌ Missing .next build output"; exit 1; } - - echo "🚀 Starting Next.js server for visual regression testing..." - - # Ensure port is free before starting - echo "🔍 Checking if port $PORT is available..." - if lsof -ti:$PORT >/dev/null 2>&1; then - echo "⚠️ Port $PORT is in use, killing existing processes..." - lsof -ti:$PORT | xargs kill -9 2>/dev/null || true - sleep 2 - fi - - # Start Next with explicit memory settings for CI stability - echo "🚀 Starting Next.js server on $HOST:$PORT..." - - # Set environment variable and start server - export NODE_OPTIONS="--max-old-space-size=4096" - nohup node node_modules/next/dist/bin/next start -p "$PORT" -H "$HOST" > .next/runner.log 2>&1 & - SVPID=$! - echo "$SVPID" > .next/runner.pid - echo "🌐 Server PID: $SVPID" - - # Give the server a moment to start - sleep 5 - - # Check if the server process is still running - if ! kill -0 "$SVPID" 2>/dev/null; then - echo "❌ Server process died immediately after starting" - echo "📋 Server logs:" - cat .next/runner.log || true - exit 1 - fi - echo "✅ Server process is running (PID: $SVPID)" - - # Wait for readiness with better error handling - echo "⏳ Waiting for server to be ready..." - npx wait-on -t 120000 "tcp:$HOST:$PORT" - - # Verify server is actually responding to all test routes - echo "🔍 Verifying server readiness for all test routes..." - for i in {1..15}; do - # Check all routes that will be tested in visual regression - if curl -fsS "http://$HOST:$PORT" >/dev/null 2>&1 && \ - curl -fsS "http://$HOST:$PORT/blog" >/dev/null 2>&1 && \ - curl -fsS "http://$HOST:$PORT/blog/resolving-active-conflicts" >/dev/null 2>&1; then - echo "✅ App is responding to all test routes at http://$HOST:$PORT" - break - else - echo "⏳ Attempt $i/15: Server not ready for all routes yet, waiting..." - sleep 3 - if [ $i -eq 15 ]; then - echo "❌ Server failed to respond to all routes after 15 attempts" - echo "📋 Server logs:" - cat .next/runner.log || true - echo "🔍 Testing individual routes:" - curl -I "http://$HOST:$PORT" || echo "❌ Homepage failed" - curl -I "http://$HOST:$PORT/blog" || echo "❌ Blog failed" - curl -I "http://$HOST:$PORT/blog/resolving-active-conflicts" || echo "❌ Blog post failed" - exit 1 - fi - fi - done - - # Give server a moment to fully settle after all routes are ready - echo "⏳ Allowing server to fully settle..." - sleep 10 - - # Final verification that server is still responding - echo "🔍 Final server health check..." - if ! curl -fsS "http://$HOST:$PORT" >/dev/null 2>&1; then - echo "❌ Server health check failed after settlement period" - echo "📋 Server logs:" - cat .next/runner.log || true - exit 1 - fi - echo "✅ Server is healthy and ready for tests" - - # Run visual regression tests with server monitoring - echo "🧪 Running visual regression tests..." - - # Start comprehensive server monitoring in background - ( - while true; do - # Check if server process is still running - if ! kill -0 "$SVPID" 2>/dev/null; then - echo "❌ Server process died during test execution" - echo "📋 Server logs:" - cat .next/runner.log || true - break - fi - - # Check if server is responding - if ! curl -fsS "http://$HOST:$PORT" >/dev/null 2>&1; then - echo "⚠️ Server health check failed - server may have crashed" - echo "📋 Current server logs:" - tail -20 .next/runner.log || true - break - fi - sleep 5 - done - ) & - HEALTH_PID=$! - - # Run tests with increased timeout and conservative settings for CI stability - BASE_URL="http://$HOST:$PORT" npx playwright test tests/e2e/visual-regression.spec.ts --timeout=120000 --workers=1 --retries=1 - - # Stop health monitoring - kill $HEALTH_PID 2>/dev/null || true - - # Teardown with better error handling - echo "🧹 Cleaning up server..." - kill "$SVPID" 2>/dev/null || true - - # Wait for server to actually stop - for i in {1..10}; do - if ! kill -0 "$SVPID" 2>/dev/null; then - echo "✅ Server process stopped" - break - else - echo "⏳ Waiting for server to stop... ($i/10)" - sleep 2 - if [ $i -eq 10 ]; then - echo "⚠️ Force killing server process" - kill -9 "$SVPID" 2>/dev/null || true - fi - fi - done - - echo "✅ Server cleanup complete" - env: - NEXT_TELEMETRY_DISABLED: "1" - NODE_ENV: production - NODE_OPTIONS: "--max-old-space-size=8192" - - - name: Package visual artifacts - if: failure() - run: | - # Include server logs for debugging - echo "📋 Server logs for debugging:" - cat .next/runner.log || echo "No server logs found" - - # Package test results and logs - tar -czf visual-regression.tgz test-results tests/e2e/visual-regression.spec.ts-snapshots .next/runner.log || true - - - name: Upload visual artifacts - if: failure() - uses: actions/upload-artifact@v3 - with: - name: visual-regression-results - path: visual-regression.tgz - retention-days: 30 - - - name: Stop app - if: always() - run: | - if [ -f .next/runner.pid ]; then - kill $(cat .next/runner.pid) 2>/dev/null || true - fi - - performance: - runs-on: [self-hosted, macos-latest] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "${{ env.NODE_VERSION }}" - cache: npm - - run: npm ci --no-audit --fund=false - - - name: Build application - run: npm run build - - # 1) Sanity check that the build exists - - name: Verify Next build output - run: | - set -euo pipefail - ls -la .next || true - test -f .next/BUILD_ID || (echo "No Next build output (.next) – did build fail?" && exit 1) - - - name: Install Chrome via Puppeteer (mac_arm) - run: | - # Install Chrome (arm64) into a local cache - set -euo pipefail - mkdir -p .cache/puppeteer - - # 1) Install and capture the build id that was actually installed - INSTALL_OUT="$(npx @puppeteer/browsers install chrome@stable --platform=mac_arm --path .cache/puppeteer)" - echo "$INSTALL_OUT" - - # INSTALL_OUT looks like: "chrome@140.0.7339.80 /abs/path/to/.../Google Chrome for Testing" - BUILD_ID="$(printf '%s\n' "$INSTALL_OUT" | awk '{print $1}' | cut -d@ -f2)" - echo "Detected Chrome build: $BUILD_ID" - - # 2) Ask for the executable path using the explicit build id - CHROME_PATH="$(npx @puppeteer/browsers executable-path chrome@"$BUILD_ID" --platform=mac_arm --path .cache/puppeteer || true)" - echo "Chrome executable path (via CLI): ${CHROME_PATH:-}" - - # 3) Fallback: resolve the binary directly from the cache if the CLI returned empty - if [ -z "$CHROME_PATH" ]; then - CHROME_PATH="$(/usr/bin/find ".cache/puppeteer/chrome" -type f -path "*/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" -print -quit || true)" - echo "Chrome executable path (via find): ${CHROME_PATH:-}" - fi - - # 4) Hard fail if still empty - if [ -z "$CHROME_PATH" ] || [ ! -x "$CHROME_PATH" ]; then - echo "❌ Chrome path is empty or not executable" - ls -la .cache/puppeteer || true - exit 1 - fi - - # 5) Export for subsequent steps in this job and later ones - echo "CHROME_PATH=$CHROME_PATH" >> "$GITHUB_ENV" - "$CHROME_PATH" --version || true - - - name: Ensure arm64 Node for Lighthouse - run: | - set -euo pipefail - echo "node before: $(node -v) arch=$(node -p 'process.arch')" - if [ "$(node -p 'process.arch')" != "arm64" ]; then - NODE_VER=20.17.0 - curl -fsSLO "https://nodejs.org/dist/v${NODE_VER}/node-v${NODE_VER}-darwin-arm64.tar.xz" - tar -xJf "node-v${NODE_VER}-darwin-arm64.tar.xz" - # Make arm64 node take effect in THIS step: - export PATH="$PWD/node-v${NODE_VER}-darwin-arm64/bin:$PATH" - # And persist for subsequent steps: - echo "$PWD/node-v${NODE_VER}-darwin-arm64/bin" >> "$GITHUB_PATH" - fi - echo "node after: $(node -v) arch=$(node -p 'process.arch')" - echo "uname -m: $(uname -m)" - # Get Chrome path for this step - CHROME_PATH=$(npx @puppeteer/browsers executable-path chrome@stable --platform=mac_arm --path .cache/puppeteer) - echo "Chrome path: $CHROME_PATH" - "$CHROME_PATH" --version || true - - - name: Performance (start + test + teardown) - run: | - set -euo pipefail - - export PORT=3010 HOST=127.0.0.1 - mkdir -p .next - test -d .next || { echo "❌ Missing .next build output"; exit 1; } - - echo "🚀 Starting Next.js server for performance testing..." - node node_modules/next/dist/bin/next start -p "$PORT" -H "$HOST" > .next/runner.log 2>&1 & - SVPID=$! - npx wait-on -t 120000 "tcp:$HOST:$PORT" - curl -fsS "http://$HOST:$PORT" >/dev/null - echo "✅ App is responding at http://$HOST:$PORT" - - # Ensure we're using arm64 Node for Lighthouse - echo "Node arch: $(node -p "process.arch")" - - # Get Chrome path directly in this step (same logic as installation step) - INSTALL_OUT="$(npx @puppeteer/browsers install chrome@stable --platform=mac_arm --path .cache/puppeteer 2>/dev/null || true)" - BUILD_ID="$(printf '%s\n' "$INSTALL_OUT" | awk '{print $1}' | cut -d@ -f2)" - echo "Using Chrome build: $BUILD_ID" - - # Try CLI first, then fallback to find - CHROME_PATH="$(npx @puppeteer/browsers executable-path chrome@"$BUILD_ID" --platform=mac_arm --path .cache/puppeteer 2>/dev/null || true)" - if [ -z "$CHROME_PATH" ]; then - CHROME_PATH="$(/usr/bin/find ".cache/puppeteer/chrome" -type f -path "*/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" -print -quit 2>/dev/null || true)" - fi - - echo "Chrome path: $CHROME_PATH" - - # Verify Chrome path is not empty - if [ -z "$CHROME_PATH" ]; then - echo "❌ Chrome path is empty - Chrome installation may have failed" - exit 1 - fi - - # Verify Chrome executable exists and is executable - if [ ! -x "$CHROME_PATH" ]; then - echo "❌ Chrome executable not found or not executable: $CHROME_PATH" - ls -la .cache/puppeteer/ || true - exit 1 - fi - - "$CHROME_PATH" --version - - # Run LHCI with arm64 Node + arm64 Chrome - # Test homepage and blog pages using config file - npx lhci autorun --chrome-path="$CHROME_PATH" - - kill "$SVPID" 2>/dev/null || true - env: - NEXT_TELEMETRY_DISABLED: "1" - NODE_ENV: production - NODE_OPTIONS: "--max-old-space-size=8192" - - - name: Upload Performance Artifacts - if: failure() - uses: actions/upload-artifact@v3 - with: - name: performance-results - path: | - lhci-results - .next/analyze - .next/monitoring - .next/web-vitals - .next/test-results - retention-days: 30 - - lint: - runs-on: [self-hosted, macos-latest] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "${{ env.NODE_VERSION }}" - cache: npm - - run: npm ci --no-audit --fund=false - - name: Prisma schema - run: npx prisma validate - env: - DATABASE_URL: postgresql://ci:ci@127.0.0.1:5432/ci - - run: npm run lint - - run: npm exec prettier -- --check "**/*.{js,jsx,ts,tsx,json,css,md}" - - build: - runs-on: [self-hosted, macos-latest] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "${{ env.NODE_VERSION }}" - cache: npm - - run: npm ci --no-audit --fund=false - - run: npm run build - - run: npm run storybook:build:github diff --git a/AGENTS.md b/AGENTS.md index fd83a27..c750780 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ removal trigger. ## Verification recipe -Run these (in order) before declaring a change done. They mirror CI: +Run these (in order) before declaring a change done: ```bash rm -rf .next # only if you moved/renamed routes or layouts @@ -71,6 +71,8 @@ npx next build # production build + route manifest For UI-only changes, also: `npm run storybook` and visually confirm. For E2E-relevant changes: `npm run e2e`. +For changes under `prisma/`: `npm run migrate:smoke` (see +[docs/testing-guide.md](docs/testing-guide.md) § *Running tests*). ## Where else to look diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3231c04..97ab22d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,12 +15,20 @@ Use `npx prisma studio` to inspect the database. +Deploying to staging or production (MEDLab Cloudron) — see +[docs/guides/ops-backend-deploy.md](docs/guides/ops-backend-deploy.md) +for the admin handoff and the linked Linear tickets for the actual +deployment-pipeline work. + ### Prisma migrations - **Never edit** a migration that has already been applied to staging, production, or any shared database. Add a **new** migration that corrects the schema instead. Full policy: [docs/guides/backend-roadmap.md](docs/guides/backend-roadmap.md) §8. +- Any change under **`prisma/`**: run **`npm run migrate:smoke`** (see + [docs/testing-guide.md](docs/testing-guide.md#running-tests), **Prisma** + under *Running tests*). ### API routes @@ -35,7 +43,7 @@ Use `npx prisma studio` to inspect the database. | GET / POST | `/api/rules` | List or publish rules. | | GET | `/api/templates` | List curated templates. Optional repeatable `facet.=` query params re-rank results (and may include `scores` in the JSON). See [docs/guides/template-recommendation-matrix.md](docs/guides/template-recommendation-matrix.md) §9.1. | | GET | `/api/create-flow/methods` | Facet-aware scores for custom-rule card steps: required `section` (`communication` \| `membership` \| `decisionApproaches` \| `conflictManagement`) and optional `facet.*` params (same facet groups as `/api/templates`). Returns `methods` with match metadata for re-ordering in the wizard. | -| POST / GET | `/api/web-vitals` | Ingest or read aggregated web vitals (file-based store under `.next` today; not ideal for multi-instance — see [docs/guides/backend-roadmap.md](docs/guides/backend-roadmap.md) §7). | +| POST / GET | `/api/web-vitals` | Ingest or read web vitals. **Production default:** `external` — structured logs only (no writes under `.next`; safe for read-only FS). **Development default:** `local` — aggregates under `.next/web-vitals`. Override with `WEB_VITALS_STORAGE`. See [docs/guides/backend-roadmap.md](docs/guides/backend-roadmap.md) §7. | ### Magic-link sign-in @@ -72,7 +80,6 @@ Ticket 17. 1. Branch from `main`: `git checkout -b feature/`. 2. Make the change and add/update tests. -3. `npm test && npm run e2e` (and `npm run storybook:build` if you touched - stories). +3. Before merging, run [docs/testing-guide.md](docs/testing-guide.md#running-tests) *Running tests*. 4. Commit using a clear message (`feat:`, `fix:`, `chore:`, …). -5. Open a PR; CI runs unit, E2E, visual regression, and Lighthouse. +5. Open a pull request. diff --git a/app/(admin)/monitor/MonitorPageContent.tsx b/app/(admin)/monitor/MonitorPageContent.tsx new file mode 100644 index 0000000..dd839f9 --- /dev/null +++ b/app/(admin)/monitor/MonitorPageContent.tsx @@ -0,0 +1,168 @@ +"use client"; + +import WebVitalsDashboard from "../../components/sections/WebVitalsDashboard"; +import TopNav from "../../components/navigation/TopNav"; +import Footer from "../../components/navigation/Footer"; +import { useMessages } from "../../contexts/MessagesContext"; + +export default function MonitorPageContent() { + const m = useMessages(); + const p = m.pages.monitor; + + return ( +
+ + +
+
+
+

+ {p.title} +

+

+ {p.description} +

+
+ +
+
+

+ {p.performanceTargets.title} +

+
+
+ + {p.performanceTargets.loadTime} + + + {p.performanceTargets.loadTimeTarget} + +
+
+ + {p.performanceTargets.lcp} + + + {p.performanceTargets.lcpTarget} + +
+
+ + {p.performanceTargets.fid} + + + {p.performanceTargets.fidTarget} + +
+
+ + {p.performanceTargets.cls} + + + {p.performanceTargets.clsTarget} + +
+
+ + {p.performanceTargets.lighthouse} + + + {p.performanceTargets.lighthouseTarget} + +
+
+
+ +
+

+ {p.optimizationStatus.title} +

+
+
+ + + {p.optimizationStatus.codeSplitting} + +
+
+ + + {p.optimizationStatus.reactMemo} + +
+
+ + + {p.optimizationStatus.imageOptimization} + +
+
+ + + {p.optimizationStatus.fontPreloading} + +
+
+ + + {p.optimizationStatus.bundleAnalysis} + +
+
+ + + {p.optimizationStatus.errorBoundaries} + +
+
+
+
+ + + +
+

+ {p.monitoringCommands.title} +

+
+
+

+ {p.monitoringCommands.bundleAnalyze.title} +

+ + {p.monitoringCommands.bundleAnalyze.command} + +
+
+

+ {p.monitoringCommands.e2ePerformance.title} +

+ + {p.monitoringCommands.e2ePerformance.command} + +
+
+

+ {p.monitoringCommands.lhciDesktop.title} +

+ + {p.monitoringCommands.lhciDesktop.command} + +
+
+

+ {p.monitoringCommands.performanceBudget.title} +

+ + {p.monitoringCommands.performanceBudget.command} + +
+
+
+
+
+ +
+
+ ); +} diff --git a/app/(admin)/monitor/page.tsx b/app/(admin)/monitor/page.tsx index 9ba8ff2..fe924de 100644 --- a/app/(admin)/monitor/page.tsx +++ b/app/(admin)/monitor/page.tsx @@ -1,159 +1,5 @@ -import WebVitalsDashboard from "../../components/sections/WebVitalsDashboard"; -import TopNav from "../../components/navigation/TopNav"; -import Footer from "../../components/navigation/Footer"; +import MonitorPageContent from "./MonitorPageContent"; export default function MonitorPage() { - return ( -
- - -
-
-
-

- Performance Monitoring -

-

- Real-time monitoring of Core Web Vitals and performance metrics - for Community Rule 3.0 -

-
- -
-
-

- Performance Targets -

-
-
- - Load Time - - - < 2 seconds - -
-
- - LCP - - - < 2.5s - -
-
- - FID - - - < 100ms - -
-
- - CLS - - < 0.1 -
-
- - Lighthouse Score - - > 90 -
-
-
- -
-

- Optimization Status -

-
-
- - - Code Splitting & Dynamic Imports - -
-
- - - React.memo Optimizations - -
-
- - - Image Optimization - -
-
- - - Font Preloading - -
-
- - - Bundle Analysis - -
-
- - - Error Boundaries - -
-
-
-
- - - -
-

- Monitoring Commands -

-
-
-

- Bundle Analysis -

- - npm run bundle:analyze - -
-
-

- Performance Monitor -

- - npm run performance:monitor - -
-
-

- Web Vitals Tracking -

- - npm run web-vitals:track - -
-
-

- All Monitoring -

- - npm run monitor:all - -
-
-
-
-
- -
-
- ); + return ; } diff --git a/app/(marketing)/rules/[id]/page.tsx b/app/(marketing)/rules/[id]/page.tsx new file mode 100644 index 0000000..e8f4f62 --- /dev/null +++ b/app/(marketing)/rules/[id]/page.tsx @@ -0,0 +1,72 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules"; +import { parseDocumentSectionsForDisplay } from "../../../../lib/create/buildPublishPayload"; +import CommunityRuleDocument from "../../../components/sections/CommunityRuleDocument"; +import HeaderLockup from "../../../components/type/HeaderLockup"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export async function generateMetadata({ + params, +}: PageProps): Promise { + const { id } = await params; + const rule = await getPublicPublishedRuleById(id); + if (!rule) { + return { + title: "Rule Not Found", + description: "The requested CommunityRule could not be found.", + }; + } + const description = + typeof rule.summary === "string" && rule.summary.trim().length > 0 + ? rule.summary + : undefined; + return { + title: rule.title, + description, + openGraph: { + title: rule.title, + description, + type: "article", + url: `https://communityrule.com/rules/${rule.id}`, + siteName: "CommunityRule", + }, + twitter: { + card: "summary_large_image", + title: rule.title, + description, + }, + }; +} + +export default async function PublicRuleDetailPage({ params }: PageProps) { + const { id } = await params; + const rule = await getPublicPublishedRuleById(id); + if (!rule) { + notFound(); + } + + const sections = parseDocumentSectionsForDisplay(rule.document); + const description = + typeof rule.summary === "string" && rule.summary.trim().length > 0 + ? rule.summary + : undefined; + + return ( +
+
+ + +
+
+ ); +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 2602fb9..dfa2936 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -2,12 +2,13 @@ import { NextResponse } from "next/server"; import { isDatabaseConfigured } from "../../../../lib/server/env"; import { dbUnavailable } from "../../../../lib/server/responses"; import { destroySessionFromRequest } from "../../../../lib/server/session"; +import { apiRoute } from "../../../../lib/server/apiRoute"; -export async function POST() { +export const POST = apiRoute("auth.logout", async () => { if (!isDatabaseConfigured()) { return dbUnavailable(); } await destroySessionFromRequest(); return NextResponse.json({ ok: true }); -} +}); diff --git a/app/api/auth/magic-link/request/route.ts b/app/api/auth/magic-link/request/route.ts index 067f2b6..c91a166 100644 --- a/app/api/auth/magic-link/request/route.ts +++ b/app/api/auth/magic-link/request/route.ts @@ -10,13 +10,20 @@ import { } from "../../../../../lib/server/hash"; import { sendMagicLinkEmail } from "../../../../../lib/server/mail"; import { rateLimitKey } from "../../../../../lib/server/rateLimit"; -import { dbUnavailable } from "../../../../../lib/server/responses"; -import { logger } from "../../../../../lib/logger"; +import { + dbUnavailable, + errorJson, + rateLimited, + serverMisconfigured, +} from "../../../../../lib/server/responses"; +import { logRouteError } from "../../../../../lib/server/requestId"; +import { apiRoute } from "../../../../../lib/server/apiRoute"; import { safeInternalPath } from "../../../../../lib/safeInternalPath"; const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; const EMAIL_MIN_INTERVAL_MS = 60 * 1000; const IP_MIN_INTERVAL_MS = 20 * 1000; +const SCOPE = "auth.magicLink.request"; function normalizeEmail(raw: unknown): string | null { if (typeof raw !== "string") return null; @@ -32,7 +39,7 @@ function readNextPath(body: unknown): string | null { return safeInternalPath(n); } -export async function POST(request: NextRequest) { +export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { requestId }) => { if (!isDatabaseConfigured()) { return dbUnavailable(); } @@ -41,7 +48,7 @@ export async function POST(request: NextRequest) { try { body = await request.json(); } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + return errorJson("invalid_json", "Invalid JSON", 400); } const email = normalizeEmail( @@ -50,10 +57,7 @@ export async function POST(request: NextRequest) { : null, ); if (!email) { - return NextResponse.json( - { error: "Valid email required" }, - { status: 400 }, - ); + return errorJson("validation_error", "Valid email required", 400); } const ip = @@ -63,28 +67,19 @@ export async function POST(request: NextRequest) { const rlEmail = rateLimitKey(`magic-email:${email}`, EMAIL_MIN_INTERVAL_MS); if (rlEmail.ok === false) { - return NextResponse.json( - { error: "Too many requests", retryAfterMs: rlEmail.retryAfterMs }, - { status: 429 }, - ); + return rateLimited(rlEmail.retryAfterMs); } const rlIp = rateLimitKey(`magic-ip:${ip}`, IP_MIN_INTERVAL_MS); if (rlIp.ok === false) { - return NextResponse.json( - { error: "Too many requests", retryAfterMs: rlIp.retryAfterMs }, - { status: 429 }, - ); + return rateLimited(rlIp.retryAfterMs); } let pepper: string; try { pepper = getSessionPepper(); } catch { - return NextResponse.json( - { error: "Server misconfiguration" }, - { status: 500 }, - ); + return serverMisconfigured(); } const token = newSessionToken(); @@ -108,13 +103,10 @@ export async function POST(request: NextRequest) { try { await sendMagicLinkEmail(email, verifyUrl); } catch (err) { - logger.error("sendMagicLinkEmail failed:", err); + logRouteError(SCOPE, requestId, err, { phase: "sendMagicLinkEmail", email }); await prisma.magicLinkToken.deleteMany({ where: { email } }); - return NextResponse.json( - { error: "Could not send email" }, - { status: 502 }, - ); + return errorJson("mail_failed", "Could not send email", 502); } return NextResponse.json({ ok: true }); -} +}); diff --git a/app/api/auth/magic-link/verify/route.ts b/app/api/auth/magic-link/verify/route.ts index a8e2261..02f24cd 100644 --- a/app/api/auth/magic-link/verify/route.ts +++ b/app/api/auth/magic-link/verify/route.ts @@ -10,52 +10,83 @@ import { setSessionCookie, } from "../../../../../lib/server/session"; import { dbUnavailable } from "../../../../../lib/server/responses"; +import { + REQUEST_ID_HEADER, + getOrCreateRequestId, + logRouteError, +} from "../../../../../lib/server/requestId"; import { safeInternalPath } from "../../../../../lib/safeInternalPath"; +const SCOPE = "auth.magicLink.verify"; + export async function GET(request: NextRequest) { + const requestId = getOrCreateRequestId(request); + if (!isDatabaseConfigured()) { - return dbUnavailable(); + const res = dbUnavailable(); + res.headers.set(REQUEST_ID_HEADER, requestId); + return res; } - const token = request.nextUrl.searchParams.get("token"); - if (!token || token.length < 10) { - return NextResponse.redirect( - new URL("/login?error=invalid_link", request.url), - ); - } - - let pepper: string; try { - pepper = getSessionPepper(); - } catch { - return NextResponse.redirect(new URL("/login?error=server", request.url)); - } + const token = request.nextUrl.searchParams.get("token"); + if (!token || token.length < 10) { + return redirectWithRequestId( + request, + "/login?error=invalid_link", + requestId, + ); + } - const tokenHash = hashSessionToken(token, pepper); + let pepper: string; + try { + pepper = getSessionPepper(); + } catch (err) { + logRouteError(SCOPE, requestId, err, { phase: "getSessionPepper" }); + return redirectWithRequestId(request, "/login?error=server", requestId); + } - const row = await prisma.magicLinkToken.findUnique({ - where: { tokenHash }, - }); + const tokenHash = hashSessionToken(token, pepper); - if (!row || row.expiresAt < new Date()) { - return NextResponse.redirect( - new URL("/login?error=expired_link", request.url), + const row = await prisma.magicLinkToken.findUnique({ + where: { tokenHash }, + }); + + if (!row || row.expiresAt < new Date()) { + return redirectWithRequestId( + request, + "/login?error=expired_link", + requestId, + ); + } + + await prisma.magicLinkToken.delete({ where: { id: row.id } }); + + const user = await prisma.user.upsert({ + where: { email: row.email }, + create: { email: row.email }, + update: {}, + }); + + const { token: sessionToken, expiresAt } = await createSessionForUser( + user.id, ); + await setSessionCookie(sessionToken, expiresAt); + + const dest = safeInternalPath(row.nextPath); + return redirectWithRequestId(request, dest, requestId); + } catch (err) { + logRouteError(SCOPE, requestId, err); + return redirectWithRequestId(request, "/login?error=server", requestId); } - - await prisma.magicLinkToken.delete({ where: { id: row.id } }); - - const user = await prisma.user.upsert({ - where: { email: row.email }, - create: { email: row.email }, - update: {}, - }); - - const { token: sessionToken, expiresAt } = await createSessionForUser( - user.id, - ); - await setSessionCookie(sessionToken, expiresAt); - - const dest = safeInternalPath(row.nextPath); - return NextResponse.redirect(new URL(dest, request.url)); +} + +function redirectWithRequestId( + request: NextRequest, + path: string, + requestId: string, +): NextResponse { + const res = NextResponse.redirect(new URL(path, request.url)); + res.headers.set(REQUEST_ID_HEADER, requestId); + return res; } diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 430262b..fb4aed7 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -2,8 +2,9 @@ import { NextResponse } from "next/server"; import { isDatabaseConfigured } from "../../../../lib/server/env"; import { dbUnavailable } from "../../../../lib/server/responses"; import { getSessionUser } from "../../../../lib/server/session"; +import { apiRoute } from "../../../../lib/server/apiRoute"; -export async function GET() { +export const GET = apiRoute("auth.session", async () => { if (!isDatabaseConfigured()) { return dbUnavailable(); } @@ -16,4 +17,4 @@ export async function GET() { return NextResponse.json({ user: { id: user.id, email: user.email }, }); -} +}); diff --git a/app/api/drafts/me/route.ts b/app/api/drafts/me/route.ts index dbde8ca..667cc9d 100644 --- a/app/api/drafts/me/route.ts +++ b/app/api/drafts/me/route.ts @@ -2,20 +2,24 @@ import type { Prisma } from "@prisma/client"; import { NextRequest, NextResponse } from "next/server"; import { prisma } from "../../../../lib/server/db"; import { isDatabaseConfigured } from "../../../../lib/server/env"; -import { dbUnavailable } from "../../../../lib/server/responses"; +import { + dbUnavailable, + unauthorized, +} from "../../../../lib/server/responses"; import { getSessionUser } from "../../../../lib/server/session"; +import { apiRoute } from "../../../../lib/server/apiRoute"; import { putDraftBodySchema } from "../../../../lib/server/validation/createFlowSchemas"; import { readLimitedJson } from "../../../../lib/server/validation/requestBody"; import { jsonFromZodError } from "../../../../lib/server/validation/zodHttp"; -export async function GET() { +export const GET = apiRoute("drafts.me.get", async () => { if (!isDatabaseConfigured()) { return dbUnavailable(); } const user = await getSessionUser(); if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return unauthorized(); } const draft = await prisma.ruleDraft.findUnique({ @@ -27,16 +31,16 @@ export async function GET() { ? { payload: draft.payload, updatedAt: draft.updatedAt } : null, }); -} +}); -export async function PUT(request: NextRequest) { +export const PUT = apiRoute("drafts.me.put", async (request: NextRequest) => { if (!isDatabaseConfigured()) { return dbUnavailable(); } const user = await getSessionUser(); if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return unauthorized(); } const parsedBody = await readLimitedJson(request); @@ -67,16 +71,16 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ draft: { payload: draft.payload, updatedAt: draft.updatedAt }, }); -} +}); -export async function DELETE() { +export const DELETE = apiRoute("drafts.me.delete", async () => { if (!isDatabaseConfigured()) { return dbUnavailable(); } const user = await getSessionUser(); if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return unauthorized(); } // Idempotent: missing draft is a no-op so callers can fire-and-forget after @@ -84,4 +88,4 @@ export async function DELETE() { await prisma.ruleDraft.deleteMany({ where: { userId: user.id } }); return NextResponse.json({ ok: true }); -} +}); diff --git a/app/api/rules/[id]/route.ts b/app/api/rules/[id]/route.ts new file mode 100644 index 0000000..5d6e497 --- /dev/null +++ b/app/api/rules/[id]/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { isDatabaseConfigured } from "../../../../lib/server/env"; +import { dbUnavailable, notFound } from "../../../../lib/server/responses"; +import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules"; +import { apiRoute } from "../../../../lib/server/apiRoute"; + +type RouteContext = { params: Promise<{ id: string }> }; + +export const GET = apiRoute( + "rules.byId", + async (_request, context) => { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const { id } = await context.params; + + const rule = await getPublicPublishedRuleById(id); + if (!rule) { + return notFound(); + } + + return NextResponse.json({ rule }); + }, +); diff --git a/app/api/rules/route.ts b/app/api/rules/route.ts index 5b9d352..e003871 100644 --- a/app/api/rules/route.ts +++ b/app/api/rules/route.ts @@ -2,13 +2,17 @@ import type { Prisma } from "@prisma/client"; import { NextRequest, NextResponse } from "next/server"; import { prisma } from "../../../lib/server/db"; import { isDatabaseConfigured } from "../../../lib/server/env"; -import { dbUnavailable } from "../../../lib/server/responses"; +import { + dbUnavailable, + unauthorized, +} from "../../../lib/server/responses"; import { getSessionUser } from "../../../lib/server/session"; +import { apiRoute } from "../../../lib/server/apiRoute"; import { publishRuleBodySchema } from "../../../lib/server/validation/createFlowSchemas"; import { readLimitedJson } from "../../../lib/server/validation/requestBody"; import { jsonFromZodError } from "../../../lib/server/validation/zodHttp"; -export async function GET(request: NextRequest) { +export const GET = apiRoute("rules.list", async (request: NextRequest) => { if (!isDatabaseConfigured()) { return dbUnavailable(); } @@ -29,16 +33,16 @@ export async function GET(request: NextRequest) { }); return NextResponse.json({ rules }); -} +}); -export async function POST(request: NextRequest) { +export const POST = apiRoute("rules.publish", async (request: NextRequest) => { if (!isDatabaseConfigured()) { return dbUnavailable(); } const user = await getSessionUser(); if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return unauthorized(); } const parsedBody = await readLimitedJson(request); @@ -70,4 +74,4 @@ export async function POST(request: NextRequest) { createdAt: rule.createdAt, }, }); -} +}); diff --git a/app/api/web-vitals/route.ts b/app/api/web-vitals/route.ts index a1beafe..ea2b1df 100644 --- a/app/api/web-vitals/route.ts +++ b/app/api/web-vitals/route.ts @@ -1,90 +1,71 @@ import { NextRequest, NextResponse } from "next/server"; -import fs from "fs"; -import path from "path"; import { logger } from "../../../lib/logger"; +import { getWebVitalsStorageMode } from "../../../lib/server/webVitals/mode"; +import { + appendLocalWebVital, + readLocalAggregatedMetrics, + type WebVitalData, +} from "../../../lib/server/webVitals/localFileStore"; +import { readLimitedJson } from "../../../lib/server/validation/requestBody"; +import { webVitalIngestSchema } from "../../../lib/server/validation/webVitalsSchema"; +import { jsonFromZodError } from "../../../lib/server/validation/zodHttp"; -const WEB_VITALS_DIR = path.join(process.cwd(), ".next", "web-vitals"); - -interface WebVitalData { - metric: string; - data: { - value: number; - rating: string; - }; - url: string; - userAgent: string; - timestamp: string; - receivedAt: string; +function normalizeTimestamp(raw: string | number): string { + if (typeof raw === "number" && Number.isFinite(raw)) { + return new Date(raw).toISOString(); + } + return new Date(raw).toISOString(); } -interface WebVitalMetrics { - [metric: string]: { - count: number; - average: number; - min: number; - max: number; - goodCount: number; - needsImprovementCount: number; - poorCount: number; - lastUpdated: string; - }; -} - -// Ensure web-vitals directory exists -if (!fs.existsSync(WEB_VITALS_DIR)) { - fs.mkdirSync(WEB_VITALS_DIR, { recursive: true }); +function logExternalIngest(body: WebVitalData): void { + const line = JSON.stringify({ + kind: "web_vital_ingest", + metric: body.metric, + value: body.data.value, + rating: body.data.rating, + url: body.url, + receivedAt: body.receivedAt, + }); + logger.info(line); } export async function POST(request: NextRequest) { try { - const body = await request.json(); - const { metric, data, url, userAgent, timestamp } = body as { - metric: string; - data: { value: number; rating: string }; - url: string; - userAgent: string; - timestamp: string; - }; + const limited = await readLimitedJson(request); + if (limited.ok === false) { + return limited.response; + } + + const parsed = webVitalIngestSchema.safeParse(limited.value); + if (!parsed.success) return jsonFromZodError(parsed.error); + + const body = parsed.data; - // Store the metric data const vitalsData: WebVitalData = { - metric, - data, - url, - userAgent, - timestamp: new Date(timestamp).toISOString(), + metric: body.metric, + data: { + value: body.data.value, + rating: body.data.rating, + }, + url: body.url, + userAgent: body.userAgent, + timestamp: normalizeTimestamp(body.timestamp), receivedAt: new Date().toISOString(), }; - // Save to file (in production, you would save to a database) - const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`); - let existingData: WebVitalData[] = []; + const mode = getWebVitalsStorageMode(); - if (fs.existsSync(filePath)) { - try { - const fileContent = fs.readFileSync(filePath, "utf8"); - existingData = JSON.parse(fileContent) as WebVitalData[]; - } catch (error) { - const err = error as Error; - logger.warn("Could not parse existing vitals data:", err.message); - } + if (mode === "external") { + logExternalIngest(vitalsData); + return NextResponse.json({ success: true, storage: "external" }); } - existingData.push(vitalsData); - - // Keep only last 100 entries per metric - if (existingData.length > 100) { - existingData = existingData.slice(-100); - } - - fs.writeFileSync(filePath, JSON.stringify(existingData, null, 2)); - - // Log for monitoring + appendLocalWebVital(vitalsData); logger.info( - `Web Vital received: ${metric} = ${data.value}ms (${data.rating})`, + `Web Vital received: ${body.metric} = ${body.data.value}ms (${body.data.rating})`, ); - return NextResponse.json({ success: true }); + return NextResponse.json({ success: true, storage: "local" }); } catch (error) { logger.error("Error processing web vital:", error); return NextResponse.json( @@ -96,51 +77,17 @@ export async function POST(request: NextRequest) { export async function GET() { try { - const metrics: WebVitalMetrics = {}; + const mode = getWebVitalsStorageMode(); - if (fs.existsSync(WEB_VITALS_DIR)) { - const files = fs.readdirSync(WEB_VITALS_DIR); - - files.forEach((file) => { - if (file.endsWith(".json")) { - const metric = file.replace(".json", ""); - const fileContent = fs.readFileSync( - path.join(WEB_VITALS_DIR, file), - "utf8", - ); - const data = JSON.parse(fileContent) as WebVitalData[]; - - if (data.length > 0) { - const values = data - .map((d) => d.data.value) - .filter((v) => v !== undefined); - const ratings = data - .map((d) => d.data.rating) - .filter((r) => r !== undefined); - - metrics[metric] = { - count: data.length, - average: - values.length > 0 - ? Math.round( - values.reduce((a, b) => a + b, 0) / values.length, - ) - : 0, - min: values.length > 0 ? Math.min(...values) : 0, - max: values.length > 0 ? Math.max(...values) : 0, - goodCount: ratings.filter((r) => r === "good").length, - needsImprovementCount: ratings.filter( - (r) => r === "needs-improvement", - ).length, - poorCount: ratings.filter((r) => r === "poor").length, - lastUpdated: data[data.length - 1]?.receivedAt || "", - }; - } - } + if (mode === "external") { + return NextResponse.json({ + metrics: {}, + storage: "external" as const, }); } - return NextResponse.json({ metrics }); + const metrics = readLocalAggregatedMetrics(); + return NextResponse.json({ metrics, storage: "local" as const }); } catch (error) { logger.error("Error fetching web vitals:", error); return NextResponse.json( diff --git a/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.container.tsx b/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.container.tsx index 8f2d7ce..05767a1 100644 --- a/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.container.tsx +++ b/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.container.tsx @@ -1,6 +1,7 @@ "use client"; import { memo, useEffect, useState } from "react"; +import { useMessages } from "../../../contexts/MessagesContext"; import { logger } from "../../../../lib/logger"; import WebVitalsDashboardView from "./WebVitalsDashboard.view"; import type { Metrics, Vitals, VitalData } from "./WebVitalsDashboard.types"; @@ -18,17 +19,55 @@ const createInitialVitals = (): Vitals => ({ ttfb: createInitialVital(), }); +function reportWebVitalToApi( + metric: keyof Vitals, + value: number, + rating: VitalData["rating"], +): void { + if (typeof window === "undefined") return; + if (rating === "unknown") return; + + const body = { + metric, + data: { value, rating }, + url: window.location.href, + userAgent: navigator.userAgent, + timestamp: new Date().toISOString(), + }; + + void fetch("/api/web-vitals", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }).catch((err: unknown) => { + logger.error("Web vitals ingest failed:", err); + }); +} + const WebVitalsDashboardContainer = memo(() => { + const m = useMessages(); + const copy = m.webVitalsDashboard; const [vitals, setVitals] = useState(createInitialVitals); const [metrics, setMetrics] = useState({}); const [loading, setLoading] = useState(true); + const [storage, setStorage] = useState<"external" | "local">("local"); + + const rumDashboardUrl = + typeof process.env.NEXT_PUBLIC_RUM_DASHBOARD_URL === "string" && + process.env.NEXT_PUBLIC_RUM_DASHBOARD_URL.trim() !== "" + ? process.env.NEXT_PUBLIC_RUM_DASHBOARD_URL.trim() + : null; useEffect(() => { const fetchVitals = async () => { try { const response = await fetch("/api/web-vitals"); - const data = (await response.json()) as { metrics?: Metrics }; + const data = (await response.json()) as { + metrics?: Metrics; + storage?: "external" | "local"; + }; setMetrics(data.metrics || {}); + setStorage(data.storage === "external" ? "external" : "local"); } catch (error) { logger.error("Error fetching web vitals:", error); } finally { @@ -39,77 +78,71 @@ const WebVitalsDashboardContainer = memo(() => { fetchVitals(); if (typeof window !== "undefined") { - import("web-vitals").then((webVitals) => { - // web-vitals v4 typings don't expose legacy get* names the same way; runtime bundle still provides them for this dashboard. - const { getCLS, getFID, getFCP, getLCP, getTTFB } = - webVitals as unknown as { - getCLS: ( - _fn: (_m: { value: number; rating: string }) => void, - ) => void; - getFID: ( - _fn: (_m: { value: number; rating: string }) => void, - ) => void; - getFCP: ( - _fn: (_m: { value: number; rating: string }) => void, - ) => void; - getLCP: ( - _fn: (_m: { value: number; rating: string }) => void, - ) => void; - getTTFB: ( - _fn: (_m: { value: number; rating: string }) => void, - ) => void; - }; + // web-vitals v4+ exposes onLCP / onCLS / … — legacy getLCP was removed. + import("web-vitals").then( + ({ onCLS, onFID, onFCP, onLCP, onTTFB }) => { + onLCP((metric) => { + const rating = metric.rating as VitalData["rating"]; + setVitals((prev) => ({ + ...prev, + lcp: { + value: Math.round(metric.value), + rating, + }, + })); + reportWebVitalToApi("lcp", Math.round(metric.value), rating); + }); - getLCP((metric: { value: number; rating: VitalData["rating"] }) => { - setVitals((prev) => ({ - ...prev, - lcp: { - value: Math.round(metric.value), - rating: metric.rating, - }, - })); - }); + onFID((metric) => { + const rating = metric.rating as VitalData["rating"]; + setVitals((prev) => ({ + ...prev, + fid: { + value: Math.round(metric.value), + rating, + }, + })); + reportWebVitalToApi("fid", Math.round(metric.value), rating); + }); - getFID((metric: { value: number; rating: VitalData["rating"] }) => { - setVitals((prev) => ({ - ...prev, - fid: { - value: Math.round(metric.value), - rating: metric.rating, - }, - })); - }); + onCLS((metric) => { + const rounded = Math.round(metric.value * 1000) / 1000; + const rating = metric.rating as VitalData["rating"]; + setVitals((prev) => ({ + ...prev, + cls: { + value: rounded, + rating, + }, + })); + reportWebVitalToApi("cls", rounded, rating); + }); - getCLS((metric: { value: number; rating: VitalData["rating"] }) => { - setVitals((prev) => ({ - ...prev, - cls: { - value: Math.round(metric.value * 1000) / 1000, - rating: metric.rating, - }, - })); - }); + onFCP((metric) => { + const rating = metric.rating as VitalData["rating"]; + setVitals((prev) => ({ + ...prev, + fcp: { + value: Math.round(metric.value), + rating, + }, + })); + reportWebVitalToApi("fcp", Math.round(metric.value), rating); + }); - getFCP((metric: { value: number; rating: VitalData["rating"] }) => { - setVitals((prev) => ({ - ...prev, - fcp: { - value: Math.round(metric.value), - rating: metric.rating, - }, - })); - }); - - getTTFB((metric: { value: number; rating: VitalData["rating"] }) => { - setVitals((prev) => ({ - ...prev, - ttfb: { - value: Math.round(metric.value), - rating: metric.rating, - }, - })); - }); - }); + onTTFB((metric) => { + const rating = metric.rating as VitalData["rating"]; + setVitals((prev) => ({ + ...prev, + ttfb: { + value: Math.round(metric.value), + rating, + }, + })); + reportWebVitalToApi("ttfb", Math.round(metric.value), rating); + }); + }, + ); } }, []); @@ -118,6 +151,9 @@ const WebVitalsDashboardContainer = memo(() => { vitals={vitals} metrics={metrics} loading={loading} + storage={storage} + copy={copy} + rumDashboardUrl={rumDashboardUrl} /> ); }); diff --git a/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.types.ts b/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.types.ts index 945bb7f..bb6deeb 100644 --- a/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.types.ts +++ b/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.types.ts @@ -1,3 +1,5 @@ +import type messages from "../../../../messages/en/index"; + export interface VitalData { value: number; rating: "good" | "needs-improvement" | "poor" | "unknown"; @@ -26,8 +28,13 @@ export interface Metrics { [key: string]: MetricData; } +export type WebVitalsDashboardCopy = typeof messages.webVitalsDashboard; + export interface WebVitalsDashboardViewProps { vitals: Vitals; metrics: Metrics; loading: boolean; + storage: "external" | "local"; + copy: WebVitalsDashboardCopy; + rumDashboardUrl: string | null; } diff --git a/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.view.tsx b/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.view.tsx index ec1d073..593a915 100644 --- a/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.view.tsx +++ b/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.view.tsx @@ -26,17 +26,20 @@ const getRatingIcon = (rating: string): string => { } }; -const formatValue = (metric: string, value: number): string => { +function formatValue(metric: string, value: number): string { if (metric === "cls") { return value.toFixed(3); } return `${value}ms`; -}; +} function WebVitalsDashboardView({ vitals, metrics, loading, + storage, + copy, + rumDashboardUrl, }: WebVitalsDashboardViewProps) { if (loading) { return ( @@ -59,9 +62,31 @@ function WebVitalsDashboardView({ return (

- Web Vitals Dashboard + {copy.title}

+ {storage === "external" && ( +
+

+ {copy.externalNoticeTitle} +

+

{copy.externalNoticeBody}

+ {rumDashboardUrl ? ( + + {copy.externalDashboardLinkLabel} + + ) : null} +
+ )} +
{Object.entries(vitals).map(([metric, data]) => (
- Value: {formatValue(metric, data.value)} + {copy.valueLabel}: {formatValue(metric, data.value)}
- Rating: {data.rating.replace("-", " ")} + {copy.ratingLabel}: {data.rating.replace("-", " ")}
))}
- {/* Historical Metrics */} {Object.keys(metrics).length > 0 && (

- Historical Metrics + {copy.historicalMetricsTitle}

{Object.entries(metrics).map(([metric, data]) => ( @@ -98,20 +122,26 @@ function WebVitalsDashboardView({ >

{metric.toUpperCase()}

-
Count: {data.count}
-
Average: {formatValue(metric, data.average)}
- Range: {formatValue(metric, data.min)} -{" "} + {copy.countLabel}: {data.count} +
+
+ {copy.averageLabel}: {formatValue(metric, data.average)} +
+
+ {copy.rangeLabel}: {formatValue(metric, data.min)} -{" "} {formatValue(metric, data.max)}
- Good: {data.goodCount} + {copy.goodLabel}: {data.goodCount} - Needs Improvement: {data.needsImprovementCount} + {copy.needsImprovementLabel}: {data.needsImprovementCount} + + + {copy.poorLabel}: {data.poorCount} - Poor: {data.poorCount}
@@ -120,32 +150,16 @@ function WebVitalsDashboardView({
)} - {/* Performance Guidelines */}

- Performance Guidelines + {copy.performanceGuidelinesTitle}

    -
  • - • LCP: Good < 2.5s, Needs Improvement 2.5-4s, - Poor > 4s -
  • -
  • - • FID: Good < 100ms, Needs Improvement - 100-300ms, Poor > 300ms -
  • -
  • - • CLS: Good < 0.1, Needs Improvement 0.1-0.25, - Poor > 0.25 -
  • -
  • - • FCP: Good < 1.8s, Needs Improvement 1.8-3s, - Poor > 3s -
  • -
  • - • TTFB: Good < 800ms, Needs Improvement - 800-1800ms, Poor > 1800ms -
  • +
  • • {copy.guidelines.lcp}
  • +
  • • {copy.guidelines.fid}
  • +
  • • {copy.guidelines.cls}
  • +
  • • {copy.guidelines.fcp}
  • +
  • • {copy.guidelines.ttfb}
diff --git a/docs/README.md b/docs/README.md index 79b4c7f..ba49832 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,8 +6,8 @@ User-facing docs. Implementation conventions live in `.cursor/rules/`. - [create-flow.md](./create-flow.md) — Custom create-rule wizard: stages, URLs, persistence. Source of truth for product/eng alignment. -- [testing-guide.md](./testing-guide.md) — Testing philosophy and what to - cover at each layer. +- [testing-guide.md](./testing-guide.md) — Testing philosophy, layer + coverage, and Prisma migration smoke before merge. ## Author guides (`guides/`) @@ -20,9 +20,11 @@ User-facing docs. Implementation conventions live in `.cursor/rules/`. These will be deleted once the backend services are stood up: +- [relaunch-brief.md](./relaunch-brief.md) — short executive summary for MEDLab Cloudron admin: what the relaunch is, what's being replaced, how cutover works. - [guides/backend-roadmap.md](./guides/backend-roadmap.md) - [guides/backend-linear-tickets.md](./guides/backend-linear-tickets.md) - [guides/template-recommendation-matrix.md](./guides/template-recommendation-matrix.md) +- [guides/ops-backend-deploy.md](./guides/ops-backend-deploy.md) — technical deploy handoff + cutover plan (Cloudron, env vars, health checks, follow-up tickets). ## Cursor rules diff --git a/docs/guides/backend-linear-tickets.md b/docs/guides/backend-linear-tickets.md index e7e3983..6796275 100644 --- a/docs/guides/backend-linear-tickets.md +++ b/docs/guides/backend-linear-tickets.md @@ -6,12 +6,13 @@ Copy each block into Linear (or your tracker) as a separate issue, **in order**. ### Review sync (relevant feedback only) -A backend review was merged into **[docs/backend-roadmap.md](backend-roadmap.md)** after checking the repo. **Incorporated:** custom session lifecycle follow-ups (not a mandate to adopt Auth.js/Lucia), **passwordless email (magic-link request)** rate limits in-memory until multi-instance + shared store, `RuleDraft` already has `updatedAt` (no migration to add it), **prefer external web vitals** over product Postgres by default, API error shape + request-id observability targets, **authorization v1** aligned with `app/api/rules`, Prisma **never edit applied migrations**, **profile / my rules / account** scope from Figma profile (`22143:900069`) as **Ticket 15** (change email deferred). **Excluded:** requiring NextAuth/Lucia; “add `updatedAt` on drafts”; hard ban on DB for vitals (softened to default external). **Parallel Linear issues:** **CR-84** (API errors — **unblocked** now that **CR-73** is Done), **CR-85** (session lifecycle — **unblocked** now that **CR-75** is Done)—see **Linear** table at the end of this doc. +A backend review was merged into **[docs/backend-roadmap.md](backend-roadmap.md)** after checking the repo. **Incorporated:** custom session lifecycle follow-ups (not a mandate to adopt Auth.js/Lucia), **passwordless email (magic-link request)** rate limits in-memory until multi-instance + shared store, `RuleDraft` already has `updatedAt` (no migration to add it), **prefer external web vitals** over product Postgres by default, API error shape + request-id observability targets, **authorization v1** aligned with `app/api/rules`, Prisma **never edit applied migrations**, **profile / my rules / account** scope from Figma profile (`22143:900069`) as **Ticket 15** (change email deferred). **Excluded:** requiring NextAuth/Lucia; “add `updatedAt` on drafts”; hard ban on DB for vitals (softened to default external). **Parallel Linear issues:** **CR-84** (API errors — **Done**), **CR-85** (session lifecycle — **Done**)—see **Linear** table at the end of this doc. ### Audit note (Linear CR-72+ vs repo, 2026-04) - **Done in Linear and shipped:** **CR-72–CR-76**, **CR-77** (publish from create flow), **CR-78** (template seed), **CR-79**, **CR-88**, **CR-89**. The **CR-72 → CR-83** numbering is the original **sequential plan**, not current blocking order; the **core product vertical** through publish + templates is effectively complete in-repo. -- **Backlog (still open):** **CR-80** (web vitals — file-based route remains), **CR-81** (public rule detail — no `GET /api/rules/[id]` or marketing detail page yet), **CR-82** (CI migrate smoke), **CR-83** (no `docs/ops-backend-deploy.md` yet), **CR-84** / **CR-85** (parallel hygiene), **CR-86** (profile + account + draft resume — UI mostly placeholder), **CR-90** / **CR-91**, **CR-93** (template grid facets on marketing). +- **Backlog (still open):** **CR-80** (web vitals — file-based route remains), **CR-81** (public rule detail — no `GET /api/rules/[id]` or marketing detail page yet), **CR-86** (profile + account + draft resume — UI mostly placeholder), **CR-90** / **CR-91**, **CR-93** (template grid facets on marketing). **CR-82** (migrate smoke): **local** `npm run migrate:smoke` + [CONTRIBUTING.md](../../CONTRIBUTING.md) / [docs/testing-guide.md](../testing-guide.md) — in-repo Gitea workflow YAML **removed**; optional future remote job if hosted runners return. **CR-84 Done** — canonical error contract `{ error: { code, message }, details? }` and `x-request-id` propagation shipped via `lib/server/{responses,requestId,apiRoute}.ts`; auth + drafts + rules routes migrated, remaining `app/api/*` are a follow-up pass. **CR-85 Done** — multi-device session policy + lazy expired-row cleanup (per-user prune on every sign-in plus ~5% global sweep, no cron); ADR comment block in [`lib/server/session.ts`](../../lib/server/session.ts). +- **CR-83 Done (admin handoff + cutover plan):** [`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md) shipped. Cloudron admin access on `cloud.medlab.host` granted; doc now covers (a) what's in place, (b) the side-by-side → apex cutover plan, and (c) the two open product questions + registry decision still outstanding. Steady-state operator runbook is split out into a follow-up — see [Ticket 12 / CR-83 follow-ups](#follow-up-tickets-filed-under-cr-83) below. Key new finding: legacy `communityrule.info` is a single Cloudron **LAMP** app (`lamp.cloudronapp.php74@5.1.2`) hosting marketing site + Express/MySQL backend + a broken Flask chatbot all in one container; all three retire together via CR-99 + CR-101. - **CR-86** is **no longer blocked** by publish — **CR-77** is **Done**; profile work is gated by **implementation**, not waiting on publish wiring. - **Not in this ticket list** but called out in **[docs/backend-roadmap.md](backend-roadmap.md):** shared **rate-limit store** (e.g. Redis) before multi-instance; **`GET /api/create-flow/methods`** exists for facet scoring (Ticket 16 / CR-88) but is not duplicated as a separate doc ticket. @@ -55,7 +56,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi | 4–8 | **No** | Local or staging URL is still “your” deploy—admin only if that URL is on their infra. | | 9 | **No** to implement; **Yes** when **production** uses multiple instances or read-only FS | **Default** is external RUM/log drain; Postgres vitals only if ops explicitly wants one datastore—may need vendor keys for SaaS. | | 10 | **No** to code | Same deploy pipeline as the rest of the app. | -| 11 | **Maybe** | Whoever owns **Gitea runners**: can they run Postgres in CI? Not the same as production server, but often the same “infra” person. | +| 11 | **No** | **Migrate smoke** is local (`npm run migrate:smoke`); no server for CI. | | 12 | **Yes—this is the handoff ticket** | You (or admin) write **`docs/ops-backend-deploy.md`** so deploy steps are explicit; **you need admin input** to fill in hostnames, DB provider, SMTP, backup policy. | ### One-line summary @@ -512,52 +513,79 @@ _Section B — Final Review screen `+` button per category:_ --- -## Ticket 11 — CI: database migration smoke (optional, runner-dependent) +## Ticket 11 — Database migration smoke (local) -**Depends on:** existing [`.gitea/workflows/ci.yaml`](.gitea/workflows/ci.yaml). - -**Server / admin:** **Not production server**—but you may need whoever runs **Gitea/self-hosted runners** to allow **Postgres in CI** (Docker service / sidecar) or to accept a **manual migrate** process documented instead. +**Depends on:** nothing in production; **Docker** on the developer machine. **Goal:** Catch broken SQL migrations before merge. -**Context:** Lint job already runs `prisma validate` with a dummy `DATABASE_URL`. **Migrate** needs a real Postgres reachable from the runner. - -**Implementation:** - -1. If Gitea runners support **Docker sidecar** or **postgres service**, add a job: start Postgres, set `DATABASE_URL`, `npx prisma migrate deploy`, then run a minimal test that hits `/api/health` with DB connected (may require `next build` + short `next start` + curl). -2. If **macOS self-hosted** runners cannot run service containers easily, document in CONTRIBUTING: “run `migrate deploy` against staging before prod” and keep validate-only in CI. +**Implementation (shipped in repo):** `npm run migrate:smoke` runs [`scripts/migrate-smoke-local.sh`](../../scripts/migrate-smoke-local.sh) — ephemeral Postgres on `127.0.0.1:5433`, `prisma migrate deploy`, connection check, teardown. Documented in [CONTRIBUTING.md](../../CONTRIBUTING.md) and [docs/testing-guide.md](../testing-guide.md) § *Running tests* (Prisma). In-repo **Gitea workflow YAML** for this was removed in favor of the local script; reintroducing a remote job is optional if self-hosted runners are available again. **Acceptance criteria:** -- [ ] Broken migration fails CI **or** documented alternative process is explicit. +- [x] Documented + scripted local migrate smoke before merge. +- [ ] (Optional) Remote CI job, if you later restore runners and want parity. -**Files:** [.gitea/workflows/ci.yaml](.gitea/workflows/ci.yaml), [CONTRIBUTING.md](CONTRIBUTING.md). +**Files:** [`scripts/migrate-smoke-local.sh`](../../scripts/migrate-smoke-local.sh), [`package.json`](../../package.json) (`migrate:smoke`), [CONTRIBUTING.md](../../CONTRIBUTING.md), [docs/testing-guide.md](../testing-guide.md). --- -## Ticket 12 — Staging / production runbook (operator checklist) +## Ticket 12 — Staging / production admin handoff (Cloudron at MEDLab) **Depends on:** Tickets 1–8 complete enough to deploy a vertical slice. -**Server / admin:** **This is the main ticket where you need the admin.** You draft the runbook; **admin fills in real hostnames, DB endpoint, SMTP, backup tooling, and who runs `migrate deploy`.** Without their input, you cannot complete production-ready deploy steps. +**Server / admin:** Cloudron admin access on `cloud.medlab.host` granted. Scope of this ticket is the **handoff doc + cutover plan** — exactly what's in place, what the side-by-side cutover looks like, and what open product/infra questions remain. The steady-state operator runbook is split out into [CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook) (we write it after we've done the work). -**Goal:** Single doc for admin: env vars, TLS, DB backups, migrations, Docker, SMTP, health checks. +**Goal:** Short doc that captures (a) granted access + auto-injected vs. manually-set env vars + platform settings, (b) the side-by-side → apex cutover plan with the legacy `communityrule.info` service, and (c) the remaining open questions (apex vs. permanent-subdomain final URL, legacy `rules` data communication, container registry choice). -**Implementation:** +**Platform context:** Target is **Cloudron at MEDLab** (`cloud.medlab.host`). The legacy `communityrule.info` is a single Cloudron **LAMP** app (`lamp.cloudronapp.php74@5.1.2`, 512 MiB at apex) hosting **three things stuffed into one container** under `/app/data/public/`: the static marketing site, the Express/MySQL backend at [`CommunityRule/CommunityRuleBackend`](https://git.medlab.host/CommunityRule/CommunityRuleBackend) (kept alive by a 30-min `run.sh` watchdog on port 3000; MySQL is the LAMP package's bundled MySQL, not a Cloudron addon), and the Flask chatbot at [`CommunityRule/CommunityRuleChatBot`](https://git.medlab.host/CommunityRule/CommunityRuleChatBot) (currently crash-looping with `ModuleNotFoundError`, last touched May 2024). New app is a properly packaged Cloudron app (Docker image + `CloudronManifest.json`, **postgresql + sendmail + localstorage** addons) and replaces all three — **no data migration**. Cloudron's container supervisor replaces the watchdog. -1. Add `docs/ops-backend-deploy.md` (or similar) with numbered steps: - - Required env: `DATABASE_URL`, `SESSION_SECRET`, `SMTP_URL`, `SMTP_FROM`, optional `NEXT_PUBLIC_ENABLE_BACKEND_SYNC`. - - `docker compose` vs `Dockerfile` deploy; `prisma migrate deploy` before traffic. - - Reverse proxy: `GET /api/health` for LB health. - - Backups and restore drill for Postgres. - - SMTP DNS (SPF/DKIM). -2. Cross-link [docs/backend-roadmap.md](docs/backend-roadmap.md) §11 (environments) and §8 (migrations policy); note **never rewrite applied migrations** and where application logs go. +**Implementation (shipped):** + +1. [`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md): + - **§1 Context** — what the legacy LAMP slot actually contains and why side-by-side cutover is the safe path. + - **§2 Access** — what Cloudron admin already grants self-serve; only outstanding admin-side step is generating a CLI token. + - **§3 Env vars** split into Cloudron auto-injected (`CLOUDRON_POSTGRESQL_URL`, `CLOUDRON_MAIL_SMTP_*`) vs. manually-set (`SESSION_SECRET`, `SMTP_FROM`, `NEXT_PUBLIC_ENABLE_BACKEND_SYNC`). Notes that addons are manifest-declared, not platform-enabled, and that platform mail is SES-relayed on `communityrule.info` with custom-from allowed. + - **§4 Platform settings** (`httpPort: 3000`, `healthCheckPath: /api/health`, 512 MiB to start, automatic backups already on). + - **§5 Cutover plan** — staging at `staging.communityrule.info`, soft-launch, apex cutover at scheduled low-traffic window (~5–15 min downtime). + - **§6 Open questions** — apex vs. permanent subdomain final URL; legacy `rules` data communication; container registry choice. + - **§7 Old vs new deltas** (LAMP-package detail, watchdog, OTP→magic link, sender, API surface, chatbot). + - **§8 Follow-up tickets** (the six tickets below). +2. Cross-links: [`docs/guides/backend-roadmap.md`](backend-roadmap.md) §11 (environments — names Cloudron at MEDLab) and §8 (migrations policy — never rewrite applied migrations). **Acceptance criteria:** -- [ ] Someone who did not write the code can deploy and roll back migrations with only the doc. +- [x] Admin handoff covers exactly the access that was needed (most self-serve via Cloudron admin login). +- [x] Cutover plan is side-by-side and explicitly avoids in-place apex replacement. +- [x] Six follow-up tickets enumerated and linked, with CR-99 + CR-101 scope corrected to reflect that legacy is one LAMP slot containing marketing + backend + chatbot (all retire together). +- [x] Open product/infra questions surfaced rather than assumed. -**Files:** new `docs/ops-backend-deploy.md`. +**Files:** [`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md), [`docs/guides/backend-roadmap.md`](backend-roadmap.md), [`docs/README.md`](../README.md), [`CONTRIBUTING.md`](../../CONTRIBUTING.md). + +**Status:** [CR-83](https://linear.app/community-rule/issue/CR-83/backend-stagingproduction-runbook-admin-handoff-docsops-backend) **Done**. Deployment-pipeline implementation tracked in the follow-up tickets below. + +### Follow-up tickets filed under CR-83 + +All six are titled `[Backend] …`, assigned to Vinod, in the **community-rule** team, **Backlog** state. IDs filled in once filed via Linear MCP. + +| # | Linear | Title | Depends on | +| - | ------ | ----- | ---------- | +| 1 | [CR-96](https://linear.app/community-rule/issue/CR-96/backend-bridge-cloudron-env-vars-to-canonical-names) | `[Backend] Bridge CLOUDRON_* env vars to canonical names` | none — can ship now | +| 2 | [CR-97](https://linear.app/community-rule/issue/CR-97/backend-container-image-registry-choose-build-push) | `[Backend] Container image registry: choose, build, push` | registry decision (handoff §5) | +| 3 | [CR-98](https://linear.app/community-rule/issue/CR-98/backend-cloudron-staging-install-smoke) | `[Backend] Cloudron staging install + smoke` | CR-96 + CR-97 + Cloudron CLI access + staging DNS | +| 4 | [CR-99](https://linear.app/community-rule/issue/CR-99/backend-cloudron-production-install-apex-cutover) | `[Backend] Cloudron production install + apex cutover` | CR-98 green for the agreed overlap window | +| 5 | [CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook) | `[Backend] Steady-state operator runbook` | CR-98 (write what we actually did) | +| 6 | [CR-101](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-communityrule-lamp-app) | `[Backend] Decommission legacy CommunityRule LAMP app` | CR-99 + sign-off window | +| 7 | [CR-102](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export) | `[Backend] Decide fate of legacy rules table (read-only export?)` | must resolve before CR-99 maintenance window | + +**Per-ticket detail:** + +1. **Bridge `CLOUDRON_*` env vars to canonical names.** Cloudron injects `CLOUDRON_POSTGRESQL_URL` and `CLOUDRON_MAIL_SMTP_SERVER/PORT/USERNAME/PASSWORD`; the app reads `DATABASE_URL` / `SMTP_URL`. Recommended approach: read both names in [`lib/server/env.ts`](../../lib/server/env.ts) and assemble `SMTP_URL` from the four parts in [`lib/server/mail.ts`](../../lib/server/mail.ts) when only the Cloudron names are present. Alternative: a `start.sh` shim in the image. Acceptance: with only `CLOUDRON_*` set, app connects to DB and sends mail; with only canonical names set (current behavior), unchanged; unit tests cover both. +2. **Container image registry: choose, build, push.** Acceptance: `docker pull /communityrule:` works from a Cloudron-reachable network. CI builds and pushes on merge to `main` (stretch). +3. **Cloudron staging install + smoke.** Acceptance: `curl https:///api/health` returns `{"ok":true,"database":"connected"}`; magic-link request → click link → `GET /api/auth/session` returns a user; publishing a rule succeeds. +4. **Cloudron production install + DNS cutover.** Acceptance: production subdomain resolves to the new app; old subdomain still works during overlap; sign-in + publish succeed against production; backups confirmed. +5. **Steady-state operator runbook.** Lives at `docs/guides/ops-runbook.md` (sibling to the handoff). Covers deploy a new version, rollback, restore drill cadence, multi-instance limitations from [`backend-roadmap.md`](backend-roadmap.md) §5/§7. Acceptance: a fresh reader can deploy + roll back using only this doc. +6. **Decommission legacy Express/MySQL backend.** Acceptance: old Cloudron app stopped + uninstalled; old MySQL addon backed up once and removed; legacy Gitea repo README updated to point at this app. Priority: Low. --- @@ -577,12 +605,12 @@ _Section B — Final Review screen `+` button per category:_ **Acceptance criteria:** -- [ ] At least auth + draft + rules routes return the agreed shape for new code paths. -- [ ] Errors in logs include request id when available. +- [x] At least auth + draft + rules routes return the agreed shape for new code paths. +- [x] Errors in logs include request id when available. -**Files:** `lib/server/` (new helper), selected `app/api/**/route.ts`, optional tests. +**Files:** [`lib/server/responses.ts`](lib/server/responses.ts), [`lib/server/requestId.ts`](lib/server/requestId.ts), [`lib/server/apiRoute.ts`](lib/server/apiRoute.ts); migrated `app/api/auth/**/route.ts`, `app/api/drafts/me/route.ts`, `app/api/rules/route.ts`, `app/api/rules/[id]/route.ts`; tests in `tests/unit/{responses,requestId,apiRoute,draftsMeRoute,rulesByIdRoute}.test.ts`. -**Linear:** [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) (**CR-73** Done — ready to pick up). +**Linear:** [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) **Done** (**CR-73** Done — was ready to pick up). --- @@ -608,7 +636,7 @@ _Section B — Final Review screen `+` button per category:_ **Files:** [lib/server/session.ts](lib/server/session.ts), [app/api/auth/magic-link/verify/route.ts](app/api/auth/magic-link/verify/route.ts), optional `prisma` migration if new columns (unlikely). -**Linear:** [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) (**unblocked** — **CR-75** Done). +**Linear:** [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) **Done** — multi-device policy; lazy expired-row cleanup on every sign-in (per-user prune via `@@index([userId])` + ~5% global sweep); no rotation in v1; cleanup failures logged but never fail sign-in. ADR comment block lives at the top of [lib/server/session.ts](../../lib/server/session.ts); no Prisma migration needed. --- @@ -660,8 +688,8 @@ _Section B — Final Review screen `+` button per category:_ | 8 | 8 | Templates in UI | | 9 | 9 | Web vitals persistence | | 10 | 10 | Public rule detail (optional) | -| 11 | 11 | CI migrate smoke (optional) | -| 12 | 12 | Ops runbook | +| 11 | 11 | Migrate smoke (local) **Done in repo** | +| 12 | 12 | Ops admin handoff (Cloudron) **Done** | | 13 | 13 | API errors + request-id logging | | 14 | 14 | Session lifecycle + cleanup | | 15 | 15 | Profile + account (Figma profile) | @@ -672,13 +700,13 @@ _Section B — Final Review screen `+` button per category:_ **Follow-up (no doc ticket #):** **[CR-93](https://linear.app/community-rule/issue/CR-93/product-rank-template-cards-by-community-facets-reuse-get-apitemplates)** — marketing template grids ranked by user facets (API-ready; tests deferred with that issue). -Tickets **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 6 / CR-77** (publish) is **Done**. **Ticket 16** / **CR-88** (facet data + APIs + wizard method ranking) shipped **after 7–8**; **CR-93** tracks **marketing** template grids ranked by user facets (API-ready). **Ticket 17** / **CR-89** (**[Done](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)**) canonizes the **custom** wizard in [`docs/create-flow.md`](create-flow.md) (progress bar, `[screenId]` routing). **Draft resume / hydration** follow-ups: **CR-86**. **Tickets 13–14** are parallel (**CR-84** / **CR-85** — **Backlog**). **Ticket 15 / CR-86** is **parallel** (publish prerequisite met); implementation backlog. **Ticket 18** (**[CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders)**) adds real **email-based stakeholder invites** to the `confirm-stakeholders` step — currently ships as a label-only chip list despite copy promising invites; **parallel** to the main chain, awaits design + product brief before implementation. **Ticket 19** (**[CR-91](https://linear.app/community-rule/issue/CR-91/productdesign-add-button-behavior-on-custom-rule-pages-and-final)**) is a **product/design** clarification ticket: the `Add` affordance is inconsistent across custom-rule pages (full custom-chip flow only on `core-values`; an `add` link that just expands the card stack on the four card-style pages) and the Final Review screen renders a `+` button per category that today is a no-op; needs a brief + Figma before any implementation lands. +Tickets **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 6 / CR-77** (publish) is **Done**. **Ticket 16** / **CR-88** (facet data + APIs + wizard method ranking) shipped **after 7–8**; **CR-93** tracks **marketing** template grids ranked by user facets (API-ready). **Ticket 17** / **CR-89** (**[Done](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)**) canonizes the **custom** wizard in [`docs/create-flow.md`](create-flow.md) (progress bar, `[screenId]` routing). **Draft resume / hydration** follow-ups: **CR-86**. **Tickets 13–14** are parallel (**CR-84** / **CR-85** — both **Done**). **Ticket 15 / CR-86** is **parallel** (publish prerequisite met); implementation backlog. **Ticket 18** (**[CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders)**) adds real **email-based stakeholder invites** to the `confirm-stakeholders` step — currently ships as a label-only chip list despite copy promising invites; **parallel** to the main chain, awaits design + product brief before implementation. **Ticket 19** (**[CR-91](https://linear.app/community-rule/issue/CR-91/productdesign-add-button-behavior-on-custom-rule-pages-and-final)**) is a **product/design** clarification ticket: the `Add` affordance is inconsistent across custom-rule pages (full custom-chip flow only on `core-values`; an `add` link that just expands the card stack on the four card-style pages) and the Final Review screen renders a `+` button per category that today is a no-op; needs a brief + Figma before any implementation lands. --- ## Linear (Community-rule team) -**Main chain (historical):** **CR-72 → CR-83** was the original **strict sequence**; **repo + Linear status today:** **CR-72–CR-79**, **CR-88**, **CR-89** are **Done**; **CR-77** (publish) **Done**; **CR-80–CR-83** remain **Backlog** (web vitals, public rule detail, CI migrate smoke, ops runbook — optional / ops tail). **Parallel:** **CR-84**, **CR-85** (**Backlog**); **CR-86** / Ticket 15 (**Backlog** — publish **not** a blocker); **CR-93** (**Backlog**); **CR-90** / Ticket 18 (stakeholder invites); **CR-91** / Ticket 19 (`Add` button behavior). +**Main chain (historical):** **CR-72 → CR-83** was the original **strict sequence**; **repo + Linear status today:** **CR-72–CR-79**, **CR-83**, **CR-84**, **CR-85**, **CR-88**, **CR-89** are **Done**; **CR-77** (publish) **Done**; **CR-80–CR-81** remain **Backlog** (web vitals, public rule detail). **CR-82** covered by local `migrate:smoke` (see Ticket 11). **CR-83** (admin handoff) shipped as a narrow handoff sheet; the actual Cloudron deployment pipeline is split into the **`[Backend]` follow-up tickets** filed under it (env-var bridging → image registry → staging → production cutover → operator runbook → legacy decommission). **Parallel (still open):** **CR-86** / Ticket 15 (**Backlog** — publish **not** a blocker); **CR-93** (**Backlog**); **CR-90** / Ticket 18 (stakeholder invites); **CR-91** / Ticket 19 (`Add` button behavior). | Doc ticket | Linear | Title (short) | | ---------: | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | @@ -692,10 +720,17 @@ Tickets **10–11** can be deferred without blocking the core “auth + drafts + | 8 | [CR-79](https://linear.app/community-rule/issue/CR-79/backend-load-rule-templates-from-get-apitemplates-in-ui) | Templates in UI | | 9 | [CR-80](https://linear.app/community-rule/issue/CR-80/backend-persist-web-vitals-outside-next-db-or-external-rum) | Web vitals (prefer external) | | 10 | [CR-81](https://linear.app/community-rule/issue/CR-81/backend-public-rule-detail-page-get-apirulesid-optional) | Public rule detail (optional) | -| 11 | [CR-82](https://linear.app/community-rule/issue/CR-82/backend-ci-postgres-migration-smoke-optional) | CI migrate smoke (optional) | -| 12 | [CR-83](https://linear.app/community-rule/issue/CR-83/backend-stagingproduction-runbook-admin-handoff-docsops-backend) | Ops runbook / admin handoff | +| 11 | [CR-82](https://linear.app/community-rule/issue/CR-82/backend-ci-postgres-migration-smoke-optional) | Local migrate smoke (**Done in repo**; optional remote CI) | +| 12 | [CR-83](https://linear.app/community-rule/issue/CR-83/backend-stagingproduction-runbook-admin-handoff-docsops-backend) | Ops admin handoff (Cloudron) **Done** | +| 12.1 | [CR-96](https://linear.app/community-rule/issue/CR-96/backend-bridge-cloudron-env-vars-to-canonical-names) | `[Backend] Bridge CLOUDRON_* env vars to canonical names` | +| 12.2 | [CR-97](https://linear.app/community-rule/issue/CR-97/backend-container-image-registry-choose-build-push) | `[Backend] Container image registry: choose, build, push` | +| 12.3 | [CR-98](https://linear.app/community-rule/issue/CR-98/backend-cloudron-staging-install-smoke) | `[Backend] Cloudron staging install + smoke` | +| 12.4 | [CR-99](https://linear.app/community-rule/issue/CR-99/backend-cloudron-production-install-apex-cutover) | `[Backend] Cloudron production install + apex cutover` | +| 12.5 | [CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook) | `[Backend] Steady-state operator runbook` | +| 12.6 | [CR-101](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-communityrule-lamp-app) | `[Backend] Decommission legacy CommunityRule LAMP app` | +| 12.7 | [CR-102](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export) | `[Backend] Decide fate of legacy rules table (read-only export?)` | | 13 | [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) | API errors + request-id logging | -| 14 | [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) | Session lifecycle + cleanup | +| 14 | [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) | Session lifecycle + cleanup **Done** | | 15 | [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) | Profile + account (Figma 22143:900069) | | 16 | [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-facet-data-seed-and-apis-no) | Template matrix (facets; no xlsx) | | 17 | [CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo) | Canon create-flow (custom wizard + docs) **Done** | diff --git a/docs/guides/backend-roadmap.md b/docs/guides/backend-roadmap.md index 3c3ec32..b9a0bd2 100644 --- a/docs/guides/backend-roadmap.md +++ b/docs/guides/backend-roadmap.md @@ -10,8 +10,8 @@ Temporary working notes for building the backend. Safe to delete once the stack - **PostgreSQL + Prisma**: schema and migrations under `prisma/`; product APIs under `app/api/*` (health, auth/magic-link, session, drafts, rules, templates, web-vitals). - **Server modules** in `lib/server/` (db, session, mail, rate limiting, etc.). - **Create flow:** **Anonymous** users mirror in-progress state to **`create-flow-anonymous`** in `localStorage`; **Exit** opens the save-progress magic-link modal; after verify, [`PostLoginDraftTransfer`](app/(app)/create/PostLoginDraftTransfer.tsx) can **PUT** `/api/drafts/me` when **`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`**. **Signed-in** users get a **fresh** in-memory session per “Create rule” entry, but with sync on the layout may **hydrate** from **`GET /api/drafts/me`** via [`SignedInDraftHydration`](app/(app)/create/SignedInDraftHydration.tsx); **Save & Exit** (from `community-structure` onward) **PUT**s when sync is on. **Log in** from the marketing header uses the global modal ([`AuthModalProvider`](app/contexts/AuthModalContext.tsx)); **`/login`** remains for verify errors and deep links. **Step order and URLs:** [`docs/create-flow.md`](docs/create-flow.md) and [`app/(app)/create/utils/flowSteps.ts`](app/(app)/create/utils/flowSteps.ts). -- **Web vitals** [`app/api/web-vitals/route.ts`](app/api/web-vitals/route.ts) still use **file-based** storage under `.next` (not suitable for multi-instance production). -- **CI:** [`.gitea/workflows/ci.yaml`](.gitea/workflows/ci.yaml) (build, test, lint, `prisma validate`); no in-repo production deploy definition. +- **Web vitals** [`app/api/web-vitals/route.ts`](app/api/web-vitals/route.ts): **production default** is **`external`** (structured logs; no `.next` writes). **`local`** file-based mode remains for development (`WEB_VITALS_STORAGE`). +- **Pre-merge checks:** run locally (see [docs/testing-guide.md](../testing-guide.md) § *Running tests*; [CONTRIBUTING.md](../CONTRIBUTING.md) pull request workflow). No in-repo remote CI workflow; production deploy is out of band ([ops-backend-deploy.md](ops-backend-deploy.md)). ### HTTP API (implemented in repo) @@ -28,7 +28,7 @@ Mirrors [CONTRIBUTING.md](../CONTRIBUTING.md) **API routes** table (including `/ | GET / POST | `/api/rules` | List or publish rules | | GET | `/api/templates` | List curated templates; optional `facet.*` re-ranks (see [template-recommendation-matrix.md](template-recommendation-matrix.md)) | | GET | `/api/create-flow/methods` | Facet scores for wizard method lists (`section` + optional `facet.*`) | -| POST / GET | `/api/web-vitals` | Web vitals ingest / aggregate (file-based under `.next` today; production path §7) | +| POST / GET | `/api/web-vitals` | Web vitals ingest / read aggregates (`external` default in production — logs only; `local` under `.next` in dev — see §7) | **Product sign-in** uses **magic link** (`/api/auth/magic-link/*`). @@ -84,7 +84,7 @@ Plain-English entities (names can evolve): **RuleTemplate — recommendation matrix (after v1 list):** Product may author templates in **spreadsheets** (e.g. one row per governance pattern, columns for **matching dimensions** such as group size, organization type, location, maturity, plus long-form fields for create-flow prefill). That implies: **normalized schema or versioned JSON** for dimensions × template fit (✓/✗, weights, or scores), an **import path** (export `.xlsx` / Sheets → validate → DB or build-time artifact), and **`GET /api/templates` (or a sibling route)** that accepts **user- or wizard-selected facets** and returns a **ranked or filtered** set. **Out of scope for first ship** of Tickets 7–8 (seed + display list); tracked as **Ticket 16** in [docs/backend-linear-tickets.md](backend-linear-tickets.md) and Linear **[CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-facet-data-seed-and-apis-no)** (**Done** — committed JSON + seed; no runtime `.xlsx`). Prefer **batch import** over live Google Sheets API in production unless ops explicitly wants sync. -**Session follow-ups to implement or decide:** token **rotation** on sensitive events, whether **new login invalidates other sessions**, and **cleanup** of expired `Session` rows (job or lazy delete). Revisit a small auth library (e.g. Auth.js, Lucia) only if maintaining custom code becomes costly. +**Session lifecycle (shipped, [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy)):** **Multi-device** policy — a new sign-in does **not** invalidate the user's other valid sessions. **Cleanup is lazy and cron-free:** every `createSessionForUser` prunes that user's expired rows (uses `@@index([userId])`); ~5% of sign-ins also run a global sweep so rows from users who never return remain bounded over months. Cleanup failures are logged but never fail the sign-in. **Rotation** on privilege-sensitive actions is deferred to v1.1. See the ADR comment block at the top of [`lib/server/session.ts`](../../lib/server/session.ts). Revisit a small auth library (e.g. Auth.js, Lucia) only if maintaining custom code becomes costly. **RuleDraft future (not v1):** versioning, multiple drafts per user, easier corruption recovery—only if product needs them. @@ -96,6 +96,7 @@ Align JSON shapes with `app/(app)/create/types.ts` as it matures. - **Decision:** **Custom** database-backed sessions + **email magic link**; cookies are **httpOnly**; session and magic-link tokens are hashed at rest. - **Rate limiting (magic-link request):** **In-memory** is acceptable for a **single Node process**. It does **not** coordinate across instances—**add a shared limiter (e.g. Redis)** before horizontal scaling or serious abuse exposure. +- **Lifecycle policy (shipped, [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy)):** multi-device (sign-in does not revoke other valid sessions); lazy expired-row cleanup on every sign-in (per-user prune + ~5% global sweep) — no cron required. Token rotation deferred to v1.1. Canonical comment block lives at the top of [`lib/server/session.ts`](../../lib/server/session.ts). - Do **not** treat “switch to NextAuth/Lucia” as required for v1; document the custom lifecycle above instead. --- @@ -115,9 +116,9 @@ Match the current API behavior; tighten as product evolves: ## 7. API responses, errors, and observability -**Error JSON (target):** Prefer a stable shape, e.g. `{ "error": { "code": "string", "message": "string" }, "details"?: ... }` for 4xx/5xx, instead of only `{ "error": "string" }`. Validation errors can map into `details`. Implement gradually in route handlers. +**Error JSON (implemented):** 4xx/5xx bodies use the canonical shape `{ "error": { "code": "string", "message": "string" }, "details"?: ... }`. Codes come from the `ApiErrorCode` union in [`lib/server/responses.ts`](../lib/server/responses.ts) (helpers: `errorJson`, `dbUnavailable`, `unauthorized`, `notFound`, `rateLimited`, `serverMisconfigured`, `internalError`); validation failures use [`jsonFromZodError`](../lib/server/validation/zodHttp.ts) and surface flattened issues in `details`. **Migrated:** auth (`/api/auth/*`), drafts (`/api/drafts/me`), rules (`/api/rules`, `/api/rules/[id]`). Remaining `app/api/*` handlers (e.g. `web-vitals`, `templates`, `create-flow/methods`, `health`) are a follow-up pass; new routes should adopt the helpers from day one. -**Logging:** Use the shared [`lib/logger.ts`](../lib/logger.ts) where possible. Include a **request correlation id** (reuse `x-request-id` if present, else generate) on API routes and log it with errors so support can tie logs together. +**Logging:** Use the shared [`lib/logger.ts`](../lib/logger.ts) where possible. Wrap route handlers with [`apiRoute(scope, handler)`](../lib/server/apiRoute.ts) so a sanitized `x-request-id` is generated (or forwarded) onto every response and uncaught throws return the canonical 500 with the id logged via `logRouteError` ([`lib/server/requestId.ts`](../lib/server/requestId.ts)). Pass the `requestId` through to in-handler `logRouteError(scope, requestId, err, extra?)` calls when catching expected failures (e.g. mail send) so support can tie logs together. **Metrics:** No vendor required for v1; optional later: request duration, error counts. @@ -136,7 +137,7 @@ Match the current API behavior; tighten as product evolves: **Operator / local (always manual):** Steps 1–4 — env file, Docker Postgres, `npm ci`, `prisma migrate dev`, `npm run dev`. -**Backend behavior already in the repo:** Steps **5–10** match implemented Route Handlers and middleware (`lib/server/*`). **Step 11** (web vitals) is **not** production-ready (files under `.next`); treat as follow-up work aligned with §7. +**Backend behavior already in the repo:** Steps **5–10** match implemented Route Handlers and middleware (`lib/server/*`). **Step 11** (web vitals): production defaults to **`external`** (no `.next` writes); optional vendor RUM or DB persistence remains a deliberate ops choice per §7. **Product / frontend still open (not only “backend exists”):** Sign-in UI, wiring publish from the create flow, template seed + UI consumption (flat list first), **canon create-flow alignment** (Ticket 17 / [CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo) — progress bar, resume URL, `[step]` cleanup; spec in [`docs/create-flow.md`](create-flow.md)), **spreadsheet-driven template recommendations** (Ticket 16 / [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion) — after v1 templates), **profile / my rules dashboard** (Ticket 15)—see §12 and [docs/backend-linear-tickets.md](backend-linear-tickets.md). @@ -182,7 +183,7 @@ npm run dev **Step 10.** **Frontend draft sync:** Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` so **Save & Exit** and **post-login anonymous → account transfer** can **PUT** `/api/drafts/me`. Without sync, drafts are **not** written to the server (anonymous progress still lives in `localStorage` only). -**Step 11.** **Web vitals:** Move off `.next` files—**prefer an external analytics or logging pipeline** (see §7). Use Postgres for vitals only as a deliberate ops choice. +**Step 11.** **Web vitals:** Production uses **`external`** storage (structured logs). Add a browser RUM SDK or Postgres-backed vitals only as a deliberate ops choice (see §7). --- @@ -206,13 +207,15 @@ npm run dev **Optional QA:** Run automated tests against an **ephemeral** database in CI instead of maintaining a fourth long-lived server. +**Target platform:** **Cloudron at MEDLab** — same host as the legacy [`CommunityRule/CommunityRuleBackend`](https://git.medlab.host/CommunityRule/CommunityRuleBackend) (Express + MySQL). The new app is packaged as a proper Cloudron app (Docker image + `CloudronManifest.json`, **postgresql + sendmail + localstorage** addons). Cloudron's container supervisor replaces the legacy 30-min `run.sh` watchdog. Admin handoff (access, env vars, platform settings, open decisions): [`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md). Note: Cloudron injects `CLOUDRON_POSTGRESQL_URL` and `CLOUDRON_MAIL_SMTP_*`; the app reads `DATABASE_URL` / `SMTP_URL`, so a small env-var bridge in [`lib/server/env.ts`](../../lib/server/env.ts) / [`lib/server/mail.ts`](../../lib/server/mail.ts) is needed (tracked in [**CR-96**](https://linear.app/community-rule/issue/CR-96/backend-bridge-cloudron-env-vars-to-canonical-names), filed under CR-83 — see [backend-linear-tickets.md](backend-linear-tickets.md) Ticket 12 follow-ups). + **Admin / infra (coordinate with whoever runs the server):** -1. TLS certificates and hostnames. -2. PostgreSQL backups and restore drill. -3. SMTP DNS (SPF, DKIM). -4. Health check URL for reverse proxy (`/api/health`). -5. Log retention and alerts for 5xx errors. +1. TLS certificates and hostnames. _On Cloudron: handled by the platform per chosen subdomain._ +2. PostgreSQL backups and restore drill. _On Cloudron: daily snapshots; configure retention in admin UI._ +3. SMTP DNS (SPF, DKIM). _On Cloudron: handled for the platform-managed domain._ +4. Health check URL for reverse proxy (`/api/health`). _On Cloudron: set `healthCheckPath` in `CloudronManifest.json`._ +5. Log retention and alerts for 5xx errors. _On Cloudron: app log viewer; export off-platform if longer retention is needed._ --- diff --git a/docs/guides/ops-backend-deploy.md b/docs/guides/ops-backend-deploy.md new file mode 100644 index 0000000..6e82f41 --- /dev/null +++ b/docs/guides/ops-backend-deploy.md @@ -0,0 +1,219 @@ +# Backend deploy — admin handoff + cutover plan + +This doc captures everything needed to deploy the new CommunityRule +(Next.js + Postgres) onto MEDLab's Cloudron and replace the legacy +LAMP-packaged service at `communityrule.info`. Cloudron admin access +has been granted; remaining open item is the registry decision in §6. + +> **For a plain-language summary to hand to MEDLab's Cloudron admin, +> see [`../relaunch-brief.md`](../relaunch-brief.md).** This doc is the +> technical version. + +## 1. Context + +- This app **fully replaces** the existing `communityrule.info` + service — both the marketing site and the backend API. +- The existing service is a single Cloudron **LAMP** app + (`lamp.cloudronapp.php74@5.1.2`, installed at the + `communityrule.info` apex, 512 MiB) that hosts three things stuffed + into one container under `/app/data/public/`: + 1. The static **marketing site** (HTML / CSS / images). + 2. The **Express/MySQL backend** at + [`CommunityRule/CommunityRuleBackend`](https://git.medlab.host/CommunityRule/CommunityRuleBackend), + kept alive by a 30-min `lsof`-based `run.sh` watchdog on port + 3000. MySQL is the LAMP package's bundled MySQL, persisted + inside `/app/data` (not a Cloudron addon). + 3. A **Flask chatbot** at + [`CommunityRule/CommunityRuleChatBot`](https://git.medlab.host/CommunityRule/CommunityRuleChatBot) + on port 5000, also watchdog-supervised; currently crash-looping + with `ModuleNotFoundError: No module named 'flask'` and last + touched in May 2024. **Not migrated.** Dies with the LAMP + container at decommission. +- The new app is a **properly packaged Cloudron app** (Docker image + + `CloudronManifest.json`, postgresql + sendmail + localstorage + addons). Cloudron's container supervisor replaces the watchdog. +- **Greenfield Postgres.** No data migration from the LAMP container's + internal MySQL. Old auth (4-digit OTP in `email_otp`) is replaced + by hashed magic-link tokens. Old API and `rules` / + `version_history` tables do not map to anything in the new app. + +## 2. Access — granted + +Cloudron admin login on `cloud.medlab.host` granted. From the +dashboard the deployer can self-serve: + +- [x] **Cloudron admin login** (full admin on the MEDLab instance). +- [x] **DNS for `communityrule.info`** — domain is managed inside + Cloudron, so new subdomains and TLS certs are one-click. +- [x] **App log access** — Cloudron web log viewer. +- [x] **Read of legacy app config** — visible in admin UI. +- [ ] **`cloudron` CLI token** — generate at *Profile → API Tokens* + before first install. Save in 1Password. + +## 3. Environment variables + +### Cloudron auto-injects (provisioned by addons declared in `CloudronManifest.json`) + +Cloudron addons are not "enabled" platform-wide; they are requested +per-app in the manifest and provisioned at install time. + +- `CLOUDRON_POSTGRESQL_URL` — from the **postgresql** addon. The app + reads `DATABASE_URL`; bridging is a small in-app code change (see + §8 [CR-96](https://linear.app/community-rule/issue/CR-96/backend-bridge-cloudron-env-vars-to-canonical-names)). +- `CLOUDRON_MAIL_SMTP_SERVER` / `_PORT` / `_USERNAME` / `_PASSWORD` — + from the **sendmail** addon. The platform Mail server is configured + for `communityrule.info` with **Amazon SES relay** + "allow custom + from address" on, so `SMTP_FROM` of our choice will deliver. The + app reads `SMTP_URL`; bridged the same way. + +### I set manually via `cloudron configure --app --set-env` + +- `SESSION_SECRET` — long random (`openssl rand -hex 32`). Required, + ≥ 16 chars. Rotating it logs everyone out. +- `SMTP_FROM` — visible "From:" address on sign-in emails. Cloudron + does not inject this. Use `hello@communityrule.info` (continuity + with the legacy service; SES relay accepts it). +- `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` — turns on Postgres draft + persistence for signed-in users. Required in production. + +## 4. Platform settings + +- Container `httpPort`: **3000** (matches [`Dockerfile`](../../Dockerfile) + `ENV PORT=3000`). +- Health-check path: **`/api/health`** + ([`app/api/health/route.ts`](../../app/api/health/route.ts) returns + `200 {"ok":true,"database":"connected"}` when healthy, `503` + otherwise). +- Memory limit: start at **512 MiB** (matches what the legacy LAMP + app has been running fine on for two years); raise if Next.js + standalone OOMs under load. +- Backups: Cloudron's automatic backups are already on for the host + (legacy app shows weekly snapshots ~451 MB each). Same default + applies to new apps. +- TLS / DNS / SPF / DKIM: handled by Cloudron for any subdomain of + `communityrule.info`. + +## 5. Cutover plan (side-by-side, never in-place) + +The legacy app is at the apex `communityrule.info` and is still +serving real traffic. Best practice is **side-by-side cutover** — new +app gets validated at a fresh subdomain before any swap touches the +apex. + +### Phases + +1. **Staging install** — `cloudron install --image + --location staging.communityrule.info`. Set env vars from §3. Run + `prisma migrate deploy`. Smoke per + [CR-98](https://linear.app/community-rule/issue/CR-98/backend-cloudron-staging-install-smoke). +2. **Soft launch / acceptance** — share the staging URL with a small + group, exercise sign-in + publish + draft sync end-to-end. Hold + here until confident. +3. **Apex cutover at a scheduled low-traffic window** — this is the + only step with brief downtime (~5–15 min). Sequence: + 1. Take one final manual backup of the legacy LAMP app (Cloudron + *Backups* tab → *Backup now*). + 2. `cloudron uninstall` the legacy app at `communityrule.info`. + 3. `cloudron configure --location communityrule.info` to move the + validated staging install to the apex (or `cloudron install` + fresh at apex if cleaner). + 4. Re-run `prisma migrate deploy`, re-set production env vars if + not preserved by the move, smoke again. +4. **Decommission** — see [CR-101](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-expressmysql-backend). + Hold the final LAMP backup ≥ 90 days for safety. + +### Why not in-place? + +Uninstalling the legacy app and installing the new one at apex +without a staging step means the live site is down for the entire +duration of the first install — and the first install is exactly when +all the env-var / addon / port surprises happen. Side-by-side keeps +those surprises out of view. + +## 6. Decisions — status + +Product decisions (closed): + +1. **Final URL — `communityrule.info` apex.** New app fully replaces + the legacy site, including the marketing surface. Brief cutover + downtime (~5–15 min) is accepted. +2. **Legacy `rules` data — not migrated.** No data moves into the new + app's Postgres. A pre-cutover **read-only export** of the + `rules` + `version_history` MySQL tables is under consideration; + approach depends on the actual row count, which we'll pull as + part of the CR-99 pre-cutover backup. Tracked in + [CR-102](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export). + +Infra decision still open: + +3. **Container registry** — GHCR (under your personal / org account, + lowest friction), Docker Hub, or MEDLab self-hosted. Tracked in + [CR-97](https://linear.app/community-rule/issue/CR-97/backend-container-image-registry-choose-build-push). + +## 7. Old vs new deltas + +So nothing surprises anyone at cutover: + +- Legacy is a **LAMP package** with bundled MySQL inside the + container. New app uses the Cloudron **postgresql + sendmail + + localstorage** addons — entirely different storage, no shared + state. +- Legacy stuffs three apps (marketing + Node backend + Python + chatbot) into one container with a `run.sh` watchdog. New app is + one Next.js process, supervised by Cloudron natively. +- Old auth = plaintext 4-digit OTP. New auth = hashed magic **link** + in email. If users report "I'm not getting a code," remind them to + look for a link instead. +- Old code hardcoded `from: 'hello@communityrule.info'` in + [`controllers/emailController.js`](https://git.medlab.host/CommunityRule/CommunityRuleBackend/raw/branch/master/controllers/emailController.js) + because Cloudron does not inject a `MAIL_FROM`. New app reads + `SMTP_FROM` — see §3. +- Old API surface (`/api/send_otp`, `/api/publish_rule`, etc.) and + schema (`rules` + `version_history` tables, soft-delete via + `deleted` column) **do not overlap** with the new app. No data + migration. +- The Flask chatbot at + [`CommunityRule/CommunityRuleChatBot`](https://git.medlab.host/CommunityRule/CommunityRuleChatBot) + is currently crash-looping inside the LAMP container and is **not + being migrated** — confirmed with admin. It dies when the LAMP + container is uninstalled in [CR-101](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-expressmysql-backend). + +## 8. Follow-up tickets + +All filed in Linear, titled `[Backend] …`, assigned to me, in the +**Community-rule** team, **Backlog** state. + +1. [**CR-96**](https://linear.app/community-rule/issue/CR-96/backend-bridge-cloudron-env-vars-to-canonical-names) + — `[Backend] Bridge CLOUDRON_* env vars to canonical names`. No + blockers; can land now. +2. [**CR-97**](https://linear.app/community-rule/issue/CR-97/backend-container-image-registry-choose-build-push) + — `[Backend] Container image registry: choose, build, push`. + Blocked by registry decision (§6.3). +3. [**CR-98**](https://linear.app/community-rule/issue/CR-98/backend-cloudron-staging-install-smoke) + — `[Backend] Cloudron staging install + smoke` at + `staging.communityrule.info`. Blocked by CR-96 + CR-97. +4. [**CR-99**](https://linear.app/community-rule/issue/CR-99/backend-cloudron-production-install-apex-cutover) + — `[Backend] Cloudron production install + apex cutover`. + Side-by-side cutover at scheduled low-traffic window per §5. + Blocked by CR-98 green + CR-102 resolved. +5. [**CR-100**](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook) + — `[Backend] Steady-state operator runbook`. Blocked by CR-98 + (write what we actually did). +6. [**CR-101**](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-communityrule-lamp-app) + — `[Backend] Decommission legacy CommunityRule LAMP app`. + Uninstall the entire LAMP slot (marketing + Express backend + + chatbot in one go); preserve final backup ≥ 90 days. Blocked by + CR-99 + sign-off window. Priority: Low. +7. [**CR-102**](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export) + — `[Backend] Decide fate of legacy rules table (read-only export?)`. + Count rows + decide whether to publish a static archive before + CR-99 uninstalls the legacy MySQL. Priority: Low. + +## 9. Related docs + +- [`docs/guides/backend-roadmap.md`](backend-roadmap.md) §11 + (environments) and §8 (Prisma migrations policy). +- [`docs/guides/backend-linear-tickets.md`](backend-linear-tickets.md) + Ticket 12 / CR-83 — this doc satisfies it. +- [`CONTRIBUTING.md`](../../CONTRIBUTING.md) — local dev setup + (Postgres, magic-link, draft sync). diff --git a/docs/relaunch-brief.md b/docs/relaunch-brief.md new file mode 100644 index 0000000..5e296a9 --- /dev/null +++ b/docs/relaunch-brief.md @@ -0,0 +1,57 @@ +# CommunityRule relaunch + +A short high-level summary of what's being built, what it replaces, and how the cutover will work. + +## What gets replaced + +The existing `CommunityRule` Cloudron app currently hosts three things in one container: + +- The static marketing site. +- The Express + MySQL backend (rule drafting, publishing, OTP sign-in). +- A Flask chatbot. + +All three retire together when the new app goes live. The chatbot is **not** being migrated or replaced in this stage. + +## What the new app is + +- A Next.js application with a Postgres database, packaged as a Cloudron app (Docker image + `CloudronManifest.json`). +- Uses Cloudron's **postgresql + sendmail + localstorage** addons. +- Cloudron's built-in container supervisor keeps it running. +- Sign-in changes from 4-digit email **codes** to email **links** ("magic link" authentication). Users click a link in their inbox instead of typing a code. +- One visible process, one port (3000), one health check (`/api/health`), ~512 MiB memory the same footprint as the existing app. + +## What does NOT carry over + +- **No user accounts.** New sign-ins start fresh. +- **No published rules from the old database.** We'll count the existing `rules` table before cutover and decide whether to publish a read-only archive (CSV/JSON) somewhere for anyone looking for their old work. +- **No chatbot.** + +## How the cutover will work + +Side-by-side, the legacy app keeps running untouched +until the new one is verified. + +1. **Staging phase.** New app installed at + `staging.communityrule.info` (auto-provisioned by Cloudron). Legacy app at the apex is not touched. Quiet testing within MEDLab/stakeholders. +2. **Cutover phase.** When staging is green and we're ready, schedule a low-traffic window. During the window (roughly 5–15 minutes of apex downtime): + - Take a final backup of the legacy app (Cloudron one-click). + - Pull a copy of the legacy `rules` table if we decided to publish an archive. + - Uninstall the legacy app at the apex `communityrule.info`. + - Move the new app to the apex. + - Smoke-test, confirm backups are on, done. +3. **Post-cutover.** Legacy backup retained ≥ 90 days as a safety net. Legacy source repos get README pointers to the new app and are archived. + +Rollback plan during the window: restore the legacy backup to a scratch Cloudron slot and point DNS back. Realistic only if we discover something genuinely broken in the first few minutes. + +## Rough timeline + +Roughly this order: + +1. **Code prep** — small local change so the app reads Cloudron's injected `CLOUDRON_*` env vars natively. No infra impact. +2. **Build and push the app image** to a container registry. +3. **Install at staging** subdomain, smoke test, soft launch. +4. **Apex cutover window** — the brief downtime above. +5. **Uninstall legacy**, archive legacy repos. +6. **Write the steady-state runbook** based on what actually worked. + +Staging should be ready to deploy in 1-2 weeks, and we can go from there. diff --git a/docs/testing-guide.md b/docs/testing-guide.md index f00b24e..1ae818e 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -3,6 +3,34 @@ This is the **why** of testing in CommunityRule. For file layout, helper APIs, and required imports see `.cursor/rules/testing.mdc`. +## Running tests + +```bash +npx tsc --noEmit +npm test +npx next build +npm run e2e # when routes, auth, or critical flows change +npm run storybook:build # when stories/ change +npm run test:component # components only, faster inner loop +npm run visual:update # after UI changes to visual regression tests +``` + +**Prisma** (`prisma/**` changes): **requires Docker.** + +```bash +npm run migrate:smoke +``` + +Starts a throwaway Postgres on `127.0.0.1:5433`, runs `prisma migrate +deploy`, checks the connection, then removes the container. Port **5433** +avoids clashing with `docker compose` on **5432**. If you already use +Compose on 5432: `docker compose up -d postgres` then +`DATABASE_URL=postgresql://communityrule:communityrule@127.0.0.1:5432/communityrule npm run db:deploy`. + +Do not rewrite migrations already applied to shared DBs — see +[CONTRIBUTING.md](../CONTRIBUTING.md) and +[guides/backend-roadmap.md](guides/backend-roadmap.md) §8. + ## Philosophy - **Test behaviour, not implementation.** Assert on what a user can see and @@ -45,15 +73,6 @@ APIs, and required imports see `.cursor/rules/testing.mdc`. - Hook internals or memoization specifics. - Responsive visibility in JSDOM — use Playwright instead. -## Running tests - -```bash -npm test # vitest run with coverage -npm run test:component # vitest, components only (faster inner loop) -npm run e2e # playwright (alias: test:e2e) -npm run visual:update # refresh playwright screenshots after UI changes -``` - ## Adding tests for a new component 1. Create `app/components//`. diff --git a/lib/server/apiRoute.ts b/lib/server/apiRoute.ts new file mode 100644 index 0000000..3c68be3 --- /dev/null +++ b/lib/server/apiRoute.ts @@ -0,0 +1,45 @@ +import type { NextRequest, NextResponse } from "next/server"; +import { internalError } from "./responses"; +import { + getOrCreateRequestId, + logRouteError, + withRequestId, +} from "./requestId"; + +export interface ApiRouteMeta { + requestId: string; +} + +export type ApiHandler = ( + request: NextRequest, + ctx: Ctx, + meta: ApiRouteMeta, +) => Promise | NextResponse; + +/** + * Minimal wrapper around a Route Handler that: + * + * - generates or forwards an `x-request-id`, + * - attaches that id to every response (success and error), + * - catches uncaught throws, logs them with the id via `lib/logger`, and + * returns the canonical 500 `internal_error` body. + * + * Pass a `scope` like `"auth.magicLink.request"` so logs are filterable per + * route. Handlers that don't need request-id correlation can skip the + * wrapper. + */ +export function apiRoute( + scope: string, + handler: ApiHandler, +): (request: NextRequest, ctx: Ctx) => Promise { + return async (request, ctx) => { + const requestId = getOrCreateRequestId(request); + try { + const res = await handler(request, ctx, { requestId }); + return withRequestId(res, requestId); + } catch (err) { + logRouteError(scope, requestId, err); + return withRequestId(internalError(), requestId); + } + }; +} diff --git a/lib/server/publishedRules.ts b/lib/server/publishedRules.ts new file mode 100644 index 0000000..e458a64 --- /dev/null +++ b/lib/server/publishedRules.ts @@ -0,0 +1,48 @@ +import { prisma } from "./db"; +import { isDatabaseConfigured } from "./env"; + +/** + * Public fields safe to expose via the unauthenticated rule detail surfaces + * (`GET /api/rules/[id]` and `/rules/[id]`). `userId` is intentionally omitted. + */ +const PUBLISHED_RULE_PUBLIC_SELECT = { + id: true, + title: true, + summary: true, + document: true, + createdAt: true, + updatedAt: true, +} as const; + +export type PublicPublishedRule = { + id: string; + title: string; + summary: string | null; + document: unknown; + createdAt: Date; + updatedAt: Date; +}; + +/** + * Fetch a single published rule by id for public read surfaces. + * + * Returns `null` when the database is not configured, the id does not match + * any row, or the query throws — callers render a 404 in all missing cases + * and are expected to surface the "DB not configured" state separately if + * they care about distinguishing it (the API route does; the page does not). + */ +export async function getPublicPublishedRuleById( + id: string, +): Promise { + if (!isDatabaseConfigured()) return null; + if (typeof id !== "string" || id.trim() === "") return null; + try { + const rule = await prisma.publishedRule.findUnique({ + where: { id }, + select: PUBLISHED_RULE_PUBLIC_SELECT, + }); + return rule; + } catch { + return null; + } +} diff --git a/lib/server/requestId.ts b/lib/server/requestId.ts new file mode 100644 index 0000000..8f865e1 --- /dev/null +++ b/lib/server/requestId.ts @@ -0,0 +1,70 @@ +import type { NextResponse } from "next/server"; +import { logger } from "../logger"; + +export const REQUEST_ID_HEADER = "x-request-id"; + +const MAX_REQUEST_ID_LENGTH = 128; +const REQUEST_ID_PATTERN = /^[A-Za-z0-9_.-]+$/; + +/** + * Returns the incoming `x-request-id` header (sanitized) when present and + * well-formed, otherwise generates a fresh UUID. Sanitization rejects + * oversized values and characters outside `[A-Za-z0-9_.-]` so log lines + * cannot be poisoned by client-controlled input. + */ +export function getOrCreateRequestId(request: Request): string { + const raw = request.headers.get(REQUEST_ID_HEADER); + if (raw) { + const trimmed = raw.trim(); + if ( + trimmed.length > 0 && + trimmed.length <= MAX_REQUEST_ID_LENGTH && + REQUEST_ID_PATTERN.test(trimmed) + ) { + return trimmed; + } + } + return crypto.randomUUID(); +} + +/** + * Attach the request id to a response so callers (and log drains) can + * correlate logs with the response. Returns the same response for chaining. + */ +export function withRequestId( + res: T, + requestId: string, +): T { + res.headers.set(REQUEST_ID_HEADER, requestId); + return res; +} + +interface ErrorLogPayload { + scope: string; + requestId: string; + message: string; + stack?: string; + [key: string]: unknown; +} + +/** + * Structured error log including the request id. Use from route handlers + * (and the `apiRoute` wrapper) so support can tie a 5xx back to log lines. + */ +export function logRouteError( + scope: string, + requestId: string, + err: unknown, + extra?: Record, +): void { + const payload: ErrorLogPayload = { + scope, + requestId, + message: err instanceof Error ? err.message : String(err), + ...(extra ?? {}), + }; + if (err instanceof Error && err.stack) { + payload.stack = err.stack; + } + logger.error(payload); +} diff --git a/lib/server/responses.ts b/lib/server/responses.ts index 0ccfe6a..9eed8f2 100644 --- a/lib/server/responses.ts +++ b/lib/server/responses.ts @@ -1,8 +1,83 @@ import { NextResponse } from "next/server"; +/** + * Canonical API error contract for `app/api/**`. + * + * Response body shape: `{ error: { code, message }, details? }`. + * + * Codes are kept intentionally small. Add a new code only when an existing + * one cannot describe the failure; route handlers should not invent codes + * inline (use `errorJson(code, …)` here so the union stays the source of + * truth). + */ +export type ApiErrorCode = + | "db_unavailable" + | "unauthorized" + | "forbidden" + | "not_found" + | "validation_error" + | "invalid_json" + | "payload_too_large" + | "rate_limited" + | "server_misconfigured" + | "mail_failed" + | "internal_error"; + +export interface ApiErrorBody { + error: { code: ApiErrorCode; message: string }; + details?: unknown; +} + +interface ErrorOpts { + details?: unknown; + headers?: HeadersInit; +} + +export function errorJson( + code: ApiErrorCode, + message: string, + status: number, + opts: ErrorOpts = {}, +): NextResponse { + const body: ApiErrorBody = { error: { code, message } }; + if (opts.details !== undefined) { + body.details = opts.details; + } + return NextResponse.json(body, { status, headers: opts.headers }); +} + export function dbUnavailable(): NextResponse { - return NextResponse.json( - { error: "Database is not configured (DATABASE_URL)." }, - { status: 503 }, + return errorJson( + "db_unavailable", + "Database is not configured (DATABASE_URL).", + 503, ); } + +export function unauthorized(message = "Unauthorized"): NextResponse { + return errorJson("unauthorized", message, 401); +} + +export function notFound(message = "Not found"): NextResponse { + return errorJson("not_found", message, 404); +} + +export function rateLimited(retryAfterMs: number): NextResponse { + const retryAfterSec = Math.max(1, Math.ceil(retryAfterMs / 1000)); + return errorJson("rate_limited", "Too many requests", 429, { + details: { retryAfterMs }, + headers: { "retry-after": String(retryAfterSec) }, + }); +} + +export function serverMisconfigured( + message = "Server misconfiguration", +): NextResponse { + return errorJson("server_misconfigured", message, 500); +} + +export function internalError( + message = "Internal server error", +): NextResponse { + return errorJson("internal_error", message, 500); +} diff --git a/lib/server/session.ts b/lib/server/session.ts index a62bc51..9bc73cb 100644 --- a/lib/server/session.ts +++ b/lib/server/session.ts @@ -1,11 +1,36 @@ import { cookies } from "next/headers"; import type { User } from "@prisma/client"; +import { logger } from "../logger"; import { prisma } from "./db"; import { getSessionPepper } from "./env"; import { hashSessionToken, newSessionToken } from "./hash"; +/** + * Custom session lifecycle (CR-85). + * + * Decisions documented here so the implementation below is the canonical + * source of truth (referenced from `docs/guides/backend-roadmap.md` §4–5). + * + * 1. **Policy: multi-device.** A new sign-in (`createSessionForUser`) does + * NOT delete the user's other still-valid sessions. Users routinely use + * phone + laptop and there is no v1 security argument for forcing a + * single active session — pre-publish state lives in `localStorage` + * until "Save & Exit", and `/api/auth/logout` only revokes the current + * cookie by design. + * 2. **Rotation: deferred.** No token rotation on privilege-sensitive + * actions in v1. Revisit if/when product requires it (ticket calls + * this v1.1). + * 3. **Cleanup: lazy, two-tier, no cron.** Every sign-in prunes the + * signing user's own expired rows (cheap — uses `@@index([userId])`). + * A small fraction of sign-ins (`SESSION_GLOBAL_PRUNE_PROB`) also runs + * a global sweep so rows from users who never return are still bounded + * over months. Cleanup is best-effort: a prune failure never fails the + * sign-in itself. + */ + export const SESSION_COOKIE_NAME = "cr_session"; const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 30; +const SESSION_GLOBAL_PRUNE_PROB = 0.05; export async function getSessionUser(): Promise { const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value; @@ -31,6 +56,24 @@ export async function getSessionUser(): Promise { return session.user; } +/** + * Delete expired `Session` rows. Scoped to a single user when `userId` is + * provided (uses the `@@index([userId])` lookup); otherwise sweeps the + * whole table. Returns the number of rows deleted. + */ +export async function pruneExpiredSessions( + opts: { userId?: string } = {}, +): Promise { + const where: { expiresAt: { lt: Date }; userId?: string } = { + expiresAt: { lt: new Date() }, + }; + if (opts.userId) { + where.userId = opts.userId; + } + const { count } = await prisma.session.deleteMany({ where }); + return count; +} + export async function createSessionForUser( userId: string, ): Promise<{ token: string; expiresAt: Date }> { @@ -47,6 +90,15 @@ export async function createSessionForUser( }, }); + try { + await pruneExpiredSessions({ userId }); + if (Math.random() < SESSION_GLOBAL_PRUNE_PROB) { + await pruneExpiredSessions(); + } + } catch (err) { + logger.warn("[session] expired-row cleanup failed", err); + } + return { token, expiresAt }; } diff --git a/lib/server/validation/webVitalsSchema.ts b/lib/server/validation/webVitalsSchema.ts new file mode 100644 index 0000000..34a4f11 --- /dev/null +++ b/lib/server/validation/webVitalsSchema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +/** POST /api/web-vitals — browser ingest payload */ +export const webVitalIngestSchema = z.object({ + metric: z.string().min(1).max(64), + data: z.object({ + value: z.number().finite(), + rating: z.string().min(1).max(32), + }), + url: z.string().min(1).max(8192), + userAgent: z.string().max(512).optional().default(""), + timestamp: z.union([z.string(), z.number()]), +}); + +export type WebVitalIngestInput = z.infer; diff --git a/lib/server/webVitals/localFileStore.ts b/lib/server/webVitals/localFileStore.ts new file mode 100644 index 0000000..26352f2 --- /dev/null +++ b/lib/server/webVitals/localFileStore.ts @@ -0,0 +1,115 @@ +import fs from "fs"; +import path from "path"; +import { logger } from "../../logger"; + +const WEB_VITALS_DIR = path.join(process.cwd(), ".next", "web-vitals"); + +export interface WebVitalData { + metric: string; + data: { + value: number; + rating: string; + }; + url: string; + userAgent: string; + timestamp: string; + receivedAt: string; +} + +export interface WebVitalMetrics { + [metric: string]: { + count: number; + average: number; + min: number; + max: number; + goodCount: number; + needsImprovementCount: number; + poorCount: number; + lastUpdated: string; + }; +} + +function ensureWebVitalsDir(): void { + if (!fs.existsSync(WEB_VITALS_DIR)) { + fs.mkdirSync(WEB_VITALS_DIR, { recursive: true }); + } +} + +export function appendLocalWebVital(vitalsData: WebVitalData): void { + ensureWebVitalsDir(); + const filePath = path.join(WEB_VITALS_DIR, `${vitalsData.metric}.json`); + let existingData: WebVitalData[] = []; + + if (fs.existsSync(filePath)) { + try { + const fileContent = fs.readFileSync(filePath, "utf8"); + existingData = JSON.parse(fileContent) as WebVitalData[]; + } catch (error) { + const err = error as Error; + logger.warn("Could not parse existing vitals data:", err.message); + } + } + + existingData.push(vitalsData); + + if (existingData.length > 100) { + existingData = existingData.slice(-100); + } + + fs.writeFileSync(filePath, JSON.stringify(existingData, null, 2)); +} + +export function readLocalAggregatedMetrics(): WebVitalMetrics { + const metrics: WebVitalMetrics = {}; + + if (!fs.existsSync(WEB_VITALS_DIR)) { + return metrics; + } + + const files = fs.readdirSync(WEB_VITALS_DIR); + + files.forEach((file) => { + if (!file.endsWith(".json")) return; + const metric = file.replace(".json", ""); + let data: WebVitalData[]; + try { + const fileContent = fs.readFileSync( + path.join(WEB_VITALS_DIR, file), + "utf8", + ); + data = JSON.parse(fileContent) as WebVitalData[]; + } catch (error) { + logger.warn( + `Skipping corrupt web vitals file ${file}:`, + (error as Error).message, + ); + return; + } + + if (data.length === 0) return; + + const values = data + .map((d) => d.data.value) + .filter((v) => v !== undefined); + const ratings = data + .map((d) => d.data.rating) + .filter((r) => r !== undefined); + + metrics[metric] = { + count: data.length, + average: + values.length > 0 + ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) + : 0, + min: values.length > 0 ? Math.min(...values) : 0, + max: values.length > 0 ? Math.max(...values) : 0, + goodCount: ratings.filter((r) => r === "good").length, + needsImprovementCount: ratings.filter((r) => r === "needs-improvement") + .length, + poorCount: ratings.filter((r) => r === "poor").length, + lastUpdated: data[data.length - 1]?.receivedAt || "", + }; + }); + + return metrics; +} diff --git a/lib/server/webVitals/mode.ts b/lib/server/webVitals/mode.ts new file mode 100644 index 0000000..1ab50be --- /dev/null +++ b/lib/server/webVitals/mode.ts @@ -0,0 +1,31 @@ +/** + * Web vitals persistence mode (CR-80). Default: external in production, local in dev. + * Does not require a specific observability vendor — ops can pipe structured logs to any stack. + */ +export type WebVitalsStorageMode = "external" | "local"; + +const VALID: WebVitalsStorageMode[] = ["external", "local"]; + +function normalizeEnv( + raw: string | undefined, +): WebVitalsStorageMode | undefined { + const v = raw?.trim().toLowerCase(); + if (v === "external" || v === "local") return v; + if (v === "database") { + // Reserved for Ticket 9 option C — not implemented yet; safe default. + return "external"; + } + return undefined; +} + +/** + * Resolves storage mode from `WEB_VITALS_STORAGE` or defaults: + * - `production` → `external` (no `.next` writes; ingest logs only) + * - otherwise → `local` (file-based under `.next/web-vitals` for dev/admin) + */ +export function getWebVitalsStorageMode(): WebVitalsStorageMode { + const fromEnv = normalizeEnv(process.env.WEB_VITALS_STORAGE); + if (fromEnv && VALID.includes(fromEnv)) return fromEnv; + + return process.env.NODE_ENV === "production" ? "external" : "local"; +} diff --git a/messages/en/components/webVitalsDashboard.json b/messages/en/components/webVitalsDashboard.json new file mode 100644 index 0000000..d93b589 --- /dev/null +++ b/messages/en/components/webVitalsDashboard.json @@ -0,0 +1,24 @@ +{ + "_comment": "Admin Web Vitals dashboard copy", + "title": "Web Vitals Dashboard", + "historicalMetricsTitle": "Historical Metrics", + "performanceGuidelinesTitle": "Performance Guidelines", + "valueLabel": "Value", + "ratingLabel": "Rating", + "countLabel": "Count", + "averageLabel": "Average", + "rangeLabel": "Range", + "goodLabel": "Good", + "needsImprovementLabel": "Needs Improvement", + "poorLabel": "Poor", + "externalNoticeTitle": "Server-side aggregates", + "externalNoticeBody": "Production uses external storage for web vitals. Historical totals are not kept in this app; use your log pipeline or metrics dashboard. Live values below still reflect this browser session.", + "externalDashboardLinkLabel": "Open metrics dashboard", + "guidelines": { + "lcp": "LCP: Good < 2.5s, Needs Improvement 2.5–4s, Poor > 4s", + "fid": "FID: Good < 100ms, Needs Improvement 100–300ms, Poor > 300ms", + "cls": "CLS: Good < 0.1, Needs Improvement 0.1–0.25, Poor > 0.25", + "fcp": "FCP: Good < 1.8s, Needs Improvement 1.8–3s, Poor > 3s", + "ttfb": "TTFB: Good < 800ms, Needs Improvement 800–1800ms, Poor > 1800ms" + } +} diff --git a/messages/en/index.ts b/messages/en/index.ts index 2a44d79..0c54a8b 100644 --- a/messages/en/index.ts +++ b/messages/en/index.ts @@ -11,9 +11,11 @@ import menuBar from "./components/menuBar.json"; import quoteBlock from "./components/quoteBlock.json"; import ruleCard from "./components/ruleCard.json"; import ruleStack from "./components/ruleStack.json"; +import webVitalsDashboard from "./components/webVitalsDashboard.json"; import home from "./pages/home.json"; import templates from "./pages/templates.json"; import learn from "./pages/learn.json"; +import monitor from "./pages/monitor.json"; import login from "./pages/login.json"; import profile from "./pages/profile.json"; import navigation from "./navigation.json"; @@ -62,10 +64,12 @@ export default { quoteBlock, ruleCard, ruleStack, + webVitalsDashboard, pages: { home, templates, learn, + monitor, login, profile, }, diff --git a/messages/en/pages/monitor.json b/messages/en/pages/monitor.json new file mode 100644 index 0000000..5508bb6 --- /dev/null +++ b/messages/en/pages/monitor.json @@ -0,0 +1,46 @@ +{ + "_comment": "Admin /monitor performance page", + "title": "Performance Monitoring", + "description": "Real-time monitoring of Core Web Vitals and performance metrics for Community Rule 3.0", + "performanceTargets": { + "title": "Performance Targets", + "loadTime": "Load Time", + "loadTimeTarget": "< 2 seconds", + "lcp": "LCP", + "lcpTarget": "< 2.5s", + "fid": "FID", + "fidTarget": "< 100ms", + "cls": "CLS", + "clsTarget": "< 0.1", + "lighthouse": "Lighthouse Score", + "lighthouseTarget": "> 90" + }, + "optimizationStatus": { + "title": "Optimization Status", + "codeSplitting": "Code Splitting & Dynamic Imports", + "reactMemo": "React.memo Optimizations", + "imageOptimization": "Image Optimization", + "fontPreloading": "Font Preloading", + "bundleAnalysis": "Bundle Analysis", + "errorBoundaries": "Error Boundaries" + }, + "monitoringCommands": { + "title": "Monitoring Commands", + "bundleAnalyze": { + "title": "Bundle analysis", + "command": "npm run bundle:analyze" + }, + "e2ePerformance": { + "title": "E2E performance (Core Web Vitals)", + "command": "npm run e2e:performance" + }, + "lhciDesktop": { + "title": "Lighthouse CI (desktop preset)", + "command": "npm run lhci:desktop" + }, + "performanceBudget": { + "title": "Lighthouse CI with performance budgets", + "command": "npm run performance:budget" + } + } +} diff --git a/package.json b/package.json index 7d7b75c..7fb55b4 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "analyze:server": "ANALYZE=true npm run build", "analyze:browser": "BUNDLE_ANALYZE=true npm run build", "bundle:analyze": "node scripts/bundle-analyzer.js", - "db:deploy": "prisma migrate deploy" + "db:deploy": "prisma migrate deploy", + "migrate:smoke": "./scripts/migrate-smoke-local.sh" }, "dependencies": { "@mdx-js/loader": "^3.1.1", diff --git a/scripts/migrate-smoke-local.sh b/scripts/migrate-smoke-local.sh new file mode 100755 index 0000000..970ce3d --- /dev/null +++ b/scripts/migrate-smoke-local.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Ephemeral Postgres on host 5433, prisma migrate deploy, verify, teardown. +set -euo pipefail + +PG_HOST_PORT="${PG_HOST_PORT:-5433}" +POSTGRES_USER="${POSTGRES_USER:-communityrule}" +POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-communityrule}" +POSTGRES_DB="${POSTGRES_DB:-communityrule}" +CONTAINER_NAME="${CONTAINER_NAME:-migrate-smoke-pg}" +export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${PG_HOST_PORT}/${POSTGRES_DB}" + +cleanup() { + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +echo "→ Starting throwaway Postgres on 127.0.0.1:${PG_HOST_PORT} (container: ${CONTAINER_NAME})" +docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true +docker run -d --name "$CONTAINER_NAME" \ + -e "POSTGRES_USER=$POSTGRES_USER" \ + -e "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" \ + -e "POSTGRES_DB=$POSTGRES_DB" \ + -p "${PG_HOST_PORT}:5432" \ + postgres:16-alpine + +echo "→ Waiting for Postgres..." +for i in {1..30}; do + if docker exec "$CONTAINER_NAME" pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" >/dev/null 2>&1; then + echo "→ Postgres ready after ${i}s" + break + fi + if [ "$i" -eq 30 ]; then + echo "Postgres did not become ready in 30s" >&2 + docker logs "$CONTAINER_NAME" || true + exit 1 + fi + sleep 1 +done + +echo "→ prisma migrate deploy" +npm run db:deploy + +echo "→ Verifying connection (SELECT 1)" +echo "SELECT 1;" | npx --no-install prisma db execute --stdin --url "$DATABASE_URL" + +echo "→ migrate smoke OK" diff --git a/scripts/prune-sessions.ts b/scripts/prune-sessions.ts new file mode 100644 index 0000000..517c933 --- /dev/null +++ b/scripts/prune-sessions.ts @@ -0,0 +1,47 @@ +/** + * Manual prune-expired-sessions script for CR-85 verification. + * + * Usage (from repo root): + * node --env-file=.env --import tsx scripts/prune-sessions.ts # global sweep + * node --env-file=.env --import tsx scripts/prune-sessions.ts # per-user + * + * Intentionally does NOT import `lib/server/session` — that module pulls in + * `next/headers` which requires a Next request context. For ad-hoc DB + * surgery we talk to Prisma directly. + */ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +async function main() { + const userId = process.argv[2]; + const now = new Date(); + const before = await prisma.session.count(); + const { count } = await prisma.session.deleteMany({ + where: { + expiresAt: { lt: now }, + ...(userId ? { userId } : {}), + }, + }); + const after = await prisma.session.count(); + console.log( + JSON.stringify( + { + scope: userId ?? "global", + now: now.toISOString(), + sessionsBefore: before, + pruned: count, + sessionsAfter: after, + }, + null, + 2, + ), + ); +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); diff --git a/tests/unit/apiRoute.test.ts b/tests/unit/apiRoute.test.ts new file mode 100644 index 0000000..429e2b8 --- /dev/null +++ b/tests/unit/apiRoute.test.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const errorMock = vi.fn(); +vi.mock("../../lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: (...args: unknown[]) => errorMock(...args), + }, +})); + +import { apiRoute } from "../../lib/server/apiRoute"; +import { REQUEST_ID_HEADER } from "../../lib/server/requestId"; + +afterEach(() => { + errorMock.mockReset(); +}); + +function makeReq(headers: Record = {}): NextRequest { + return new NextRequest("https://x.test/api/x", { headers }); +} + +describe("lib/server/apiRoute", () => { + it("attaches a generated x-request-id to a successful response", async () => { + const handler = apiRoute("test.scope", () => + NextResponse.json({ ok: true }), + ); + const res = await handler(makeReq(), undefined); + expect(res.status).toBe(200); + const id = res.headers.get(REQUEST_ID_HEADER); + expect(id).toBeTruthy(); + expect(id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); + + it("forwards an incoming x-request-id and exposes it to the handler", async () => { + const incoming = "req_forwarded-1"; + let seen: string | undefined; + const handler = apiRoute("test.scope", (_req, _ctx, { requestId }) => { + seen = requestId; + return NextResponse.json({ ok: true }); + }); + const res = await handler( + makeReq({ [REQUEST_ID_HEADER]: incoming }), + undefined, + ); + expect(seen).toBe(incoming); + expect(res.headers.get(REQUEST_ID_HEADER)).toBe(incoming); + }); + + it("returns canonical 500 + logs when the handler throws", async () => { + const handler = apiRoute("test.scope", () => { + throw new Error("boom"); + }); + const res = await handler(makeReq(), undefined); + expect(res.status).toBe(500); + const body = (await res.json()) as { + error: { code: string; message: string }; + }; + expect(body.error.code).toBe("internal_error"); + expect(res.headers.get(REQUEST_ID_HEADER)).toBeTruthy(); + + expect(errorMock).toHaveBeenCalledTimes(1); + const payload = errorMock.mock.calls[0][0] as Record; + expect(payload.scope).toBe("test.scope"); + expect(payload.requestId).toBe(res.headers.get(REQUEST_ID_HEADER)); + expect(payload.message).toBe("boom"); + }); + + it("passes the route ctx through to the handler", async () => { + type Ctx = { params: Promise<{ id: string }> }; + const handler = apiRoute("test.scope", async (_req, ctx) => { + const { id } = await ctx.params; + return NextResponse.json({ id }); + }); + const res = await handler(makeReq(), { + params: Promise.resolve({ id: "abc" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { id: string }; + expect(body.id).toBe("abc"); + }); +}); diff --git a/tests/unit/draftsMeRoute.test.ts b/tests/unit/draftsMeRoute.test.ts new file mode 100644 index 0000000..456c680 --- /dev/null +++ b/tests/unit/draftsMeRoute.test.ts @@ -0,0 +1,100 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const isDatabaseConfiguredMock = vi.fn(); +const getSessionUserMock = vi.fn(); +const findUniqueMock = vi.fn(); + +vi.mock("../../lib/server/env", () => ({ + isDatabaseConfigured: () => isDatabaseConfiguredMock(), +})); + +vi.mock("../../lib/server/db", () => ({ + prisma: { + ruleDraft: { + findUnique: (...args: unknown[]) => findUniqueMock(...args), + upsert: vi.fn(), + deleteMany: vi.fn(), + }, + }, +})); + +vi.mock("../../lib/server/session", () => ({ + getSessionUser: () => getSessionUserMock(), +})); + +import { GET } from "../../app/api/drafts/me/route"; + +beforeEach(() => { + isDatabaseConfiguredMock.mockReset(); + getSessionUserMock.mockReset(); + findUniqueMock.mockReset(); +}); + +describe("GET /api/drafts/me", () => { + it("returns 503 with the canonical shape when the database is not configured", async () => { + isDatabaseConfiguredMock.mockReturnValue(false); + const res = await GET( + new NextRequest("https://x.test/api/drafts/me"), + undefined, + ); + expect(res.status).toBe(503); + const body = (await res.json()) as { + error: { code: string; message: string }; + }; + expect(body.error.code).toBe("db_unavailable"); + expect(res.headers.get("x-request-id")).toBeTruthy(); + }); + + it("returns 401 unauthorized with the canonical shape when no session user", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + getSessionUserMock.mockResolvedValueOnce(null); + const res = await GET( + new NextRequest("https://x.test/api/drafts/me"), + undefined, + ); + expect(res.status).toBe(401); + const body = (await res.json()) as { + error: { code: string; message: string }; + }; + expect(body.error).toEqual({ + code: "unauthorized", + message: "Unauthorized", + }); + expect(res.headers.get("x-request-id")).toBeTruthy(); + expect(findUniqueMock).not.toHaveBeenCalled(); + }); + + it("forwards an incoming x-request-id on the response", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + getSessionUserMock.mockResolvedValueOnce(null); + const res = await GET( + new NextRequest("https://x.test/api/drafts/me", { + headers: { "x-request-id": "req_drafts-1" }, + }), + undefined, + ); + expect(res.headers.get("x-request-id")).toBe("req_drafts-1"); + }); + + it("returns the draft when present", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + getSessionUserMock.mockResolvedValueOnce({ + id: "u1", + email: "x@y.test", + }); + findUniqueMock.mockResolvedValueOnce({ + payload: { foo: 1 }, + updatedAt: new Date("2026-01-01T00:00:00Z"), + }); + const res = await GET( + new NextRequest("https://x.test/api/drafts/me"), + undefined, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + draft: { payload: { foo: number } } | null; + }; + expect(body.draft?.payload).toEqual({ foo: 1 }); + }); +}); diff --git a/tests/unit/requestId.test.ts b/tests/unit/requestId.test.ts new file mode 100644 index 0000000..6df6ff9 --- /dev/null +++ b/tests/unit/requestId.test.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; +import { describe, expect, it } from "vitest"; +import { + REQUEST_ID_HEADER, + getOrCreateRequestId, + withRequestId, +} from "../../lib/server/requestId"; + +function reqWith(headers: Record): Request { + return new Request("https://x.test/api/x", { headers }); +} + +describe("lib/server/requestId", () => { + it("generates a UUID when no header is present", () => { + const id = getOrCreateRequestId(reqWith({})); + expect(id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); + + it("preserves a well-formed incoming x-request-id", () => { + const incoming = "req_abc-123.456"; + const id = getOrCreateRequestId(reqWith({ [REQUEST_ID_HEADER]: incoming })); + expect(id).toBe(incoming); + }); + + it("trims surrounding whitespace from a well-formed id", () => { + const id = getOrCreateRequestId( + reqWith({ [REQUEST_ID_HEADER]: " abc-123 " }), + ); + expect(id).toBe("abc-123"); + }); + + it("rejects oversized ids and falls back to a UUID", () => { + const huge = "a".repeat(200); + const id = getOrCreateRequestId(reqWith({ [REQUEST_ID_HEADER]: huge })); + expect(id).not.toBe(huge); + expect(id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); + + it("rejects ids with disallowed characters", () => { + // Spaces, semicolons, slashes are valid HTTP header bytes but disallowed + // by our `[A-Za-z0-9_.-]` pattern. + const bad = "abc def;