Session lifecycle, public rules API, web vitals RUM, ops docs, and project guidelines #47
@@ -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
|
4. **Prisma access** via `import { prisma } from "lib/server/db"`. Do not
|
||||||
instantiate `PrismaClient` directly.
|
instantiate `PrismaClient` directly.
|
||||||
|
|
||||||
5. **Responses** via `NextResponse.json(...)`. Shared shapes (`dbUnavailable`)
|
5. **Responses** via `NextResponse.json(...)`. Shared shapes
|
||||||
live in `lib/server/responses.ts`; add new shared responses there when a
|
(`dbUnavailable`, `unauthorized`, `notFound`, `rateLimited`,
|
||||||
pattern repeats in two routes.
|
`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
|
# Server-only isolation
|
||||||
|
|
||||||
@@ -68,9 +78,8 @@ instead of introducing new patterns:
|
|||||||
|
|
||||||
- **Rate limiting.** `lib/server/rateLimit.ts` is an in-memory stopgap marked
|
- **Rate limiting.** `lib/server/rateLimit.ts` is an in-memory stopgap marked
|
||||||
for replacement. Reuse `rateLimitKey()` where limiting is needed; don't
|
for replacement. Reuse `rateLimitKey()` where limiting is needed; don't
|
||||||
design a new limiter.
|
design a new limiter. When returning 429, prefer `rateLimited(retryAfterMs)`
|
||||||
- **Error response shape.** Currently `{ error: string }` + HTTP status. No
|
from `responses.ts` so the body and `Retry-After` header stay uniform.
|
||||||
error codes yet — don't add a taxonomy until one is designed.
|
|
||||||
- **Pagination / filtering.** Only `rules/route.ts` paginates (`take` capped
|
- **Pagination / filtering.** Only `rules/route.ts` paginates (`take` capped
|
||||||
at 100). Mirror it if you add list endpoints; don't invent cursors or
|
at 100). Mirror it if you add list endpoints; don't invent cursors or
|
||||||
offset contracts unilaterally.
|
offset contracts unilaterally.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -13,3 +13,10 @@ SMTP_FROM="Community Rule <noreply@localhost>"
|
|||||||
|
|
||||||
# Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in.
|
# Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in.
|
||||||
NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
|
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=
|
||||||
|
|||||||
@@ -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:-<empty>}"
|
|
||||||
|
|
||||||
# 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:-<empty>}"
|
|
||||||
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
|
|
||||||
@@ -60,7 +60,7 @@ removal trigger.
|
|||||||
|
|
||||||
## Verification recipe
|
## Verification recipe
|
||||||
|
|
||||||
Run these (in order) before declaring a change done. They mirror CI:
|
Run these (in order) before declaring a change done:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rm -rf .next # only if you moved/renamed routes or layouts
|
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 UI-only changes, also: `npm run storybook` and visually confirm.
|
||||||
For E2E-relevant changes: `npm run e2e`.
|
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
|
## Where else to look
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -15,12 +15,20 @@
|
|||||||
|
|
||||||
Use `npx prisma studio` to inspect the database.
|
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
|
### Prisma migrations
|
||||||
|
|
||||||
- **Never edit** a migration that has already been applied to staging,
|
- **Never edit** a migration that has already been applied to staging,
|
||||||
production, or any shared database. Add a **new** migration that
|
production, or any shared database. Add a **new** migration that
|
||||||
corrects the schema instead. Full policy:
|
corrects the schema instead. Full policy:
|
||||||
[docs/guides/backend-roadmap.md](docs/guides/backend-roadmap.md) §8.
|
[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
|
### API routes
|
||||||
|
|
||||||
@@ -35,7 +43,7 @@ Use `npx prisma studio` to inspect the database.
|
|||||||
| GET / POST | `/api/rules` | List or publish rules. |
|
| GET / POST | `/api/rules` | List or publish rules. |
|
||||||
| GET | `/api/templates` | List curated templates. Optional repeatable `facet.<group>=<value>` 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/templates` | List curated templates. Optional repeatable `facet.<group>=<value>` 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. |
|
| 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
|
### Magic-link sign-in
|
||||||
|
|
||||||
@@ -72,7 +80,6 @@ Ticket 17.
|
|||||||
|
|
||||||
1. Branch from `main`: `git checkout -b feature/<short-name>`.
|
1. Branch from `main`: `git checkout -b feature/<short-name>`.
|
||||||
2. Make the change and add/update tests.
|
2. Make the change and add/update tests.
|
||||||
3. `npm test && npm run e2e` (and `npm run storybook:build` if you touched
|
3. Before merging, run [docs/testing-guide.md](docs/testing-guide.md#running-tests) *Running tests*.
|
||||||
stories).
|
|
||||||
4. Commit using a clear message (`feat:`, `fix:`, `chore:`, …).
|
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.
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
|
||||||
|
<TopNav folderTop={false} />
|
||||||
|
|
||||||
|
<main className="container mx-auto px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)]">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="mb-[var(--spacing-scale-032)]">
|
||||||
|
<h1 className="text-4xl font-bold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-016)]">
|
||||||
|
{p.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[var(--font-size-body-large)] text-[var(--color-content-default-secondary)]">
|
||||||
|
{p.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[var(--spacing-scale-032)] mb-[var(--spacing-scale-032)]">
|
||||||
|
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||||
|
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||||
|
{p.performanceTargets.title}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-[var(--spacing-scale-012)]">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.performanceTargets.loadTime}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-green-600">
|
||||||
|
{p.performanceTargets.loadTimeTarget}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.performanceTargets.lcp}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-green-600">
|
||||||
|
{p.performanceTargets.lcpTarget}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.performanceTargets.fid}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-green-600">
|
||||||
|
{p.performanceTargets.fidTarget}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.performanceTargets.cls}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-green-600">
|
||||||
|
{p.performanceTargets.clsTarget}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.performanceTargets.lighthouse}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-green-600">
|
||||||
|
{p.performanceTargets.lighthouseTarget}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||||
|
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||||
|
{p.optimizationStatus.title}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-[var(--spacing-scale-012)]">
|
||||||
|
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||||
|
<span className="text-green-600">✅</span>
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.optimizationStatus.codeSplitting}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||||
|
<span className="text-green-600">✅</span>
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.optimizationStatus.reactMemo}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||||
|
<span className="text-green-600">✅</span>
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.optimizationStatus.imageOptimization}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||||
|
<span className="text-green-600">✅</span>
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.optimizationStatus.fontPreloading}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||||
|
<span className="text-green-600">✅</span>
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.optimizationStatus.bundleAnalysis}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||||
|
<span className="text-green-600">✅</span>
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.optimizationStatus.errorBoundaries}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WebVitalsDashboard />
|
||||||
|
|
||||||
|
<div className="mt-[var(--spacing-scale-032)] p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||||
|
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||||
|
{p.monitoringCommands.title}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-[var(--spacing-scale-016)]">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||||
|
{p.monitoringCommands.bundleAnalyze.title}
|
||||||
|
</h3>
|
||||||
|
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||||
|
{p.monitoringCommands.bundleAnalyze.command}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||||
|
{p.monitoringCommands.e2ePerformance.title}
|
||||||
|
</h3>
|
||||||
|
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||||
|
{p.monitoringCommands.e2ePerformance.command}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||||
|
{p.monitoringCommands.lhciDesktop.title}
|
||||||
|
</h3>
|
||||||
|
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||||
|
{p.monitoringCommands.lhciDesktop.command}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||||
|
{p.monitoringCommands.performanceBudget.title}
|
||||||
|
</h3>
|
||||||
|
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||||
|
{p.monitoringCommands.performanceBudget.command}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,159 +1,5 @@
|
|||||||
import WebVitalsDashboard from "../../components/sections/WebVitalsDashboard";
|
import MonitorPageContent from "./MonitorPageContent";
|
||||||
import TopNav from "../../components/navigation/TopNav";
|
|
||||||
import Footer from "../../components/navigation/Footer";
|
|
||||||
|
|
||||||
export default function MonitorPage() {
|
export default function MonitorPage() {
|
||||||
return (
|
return <MonitorPageContent />;
|
||||||
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
|
|
||||||
<TopNav folderTop={false} />
|
|
||||||
|
|
||||||
<main className="container mx-auto px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)]">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<div className="mb-[var(--spacing-scale-032)]">
|
|
||||||
<h1 className="text-4xl font-bold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-016)]">
|
|
||||||
Performance Monitoring
|
|
||||||
</h1>
|
|
||||||
<p className="text-[var(--font-size-body-large)] text-[var(--color-content-default-secondary)]">
|
|
||||||
Real-time monitoring of Core Web Vitals and performance metrics
|
|
||||||
for Community Rule 3.0
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[var(--spacing-scale-032)] mb-[var(--spacing-scale-032)]">
|
|
||||||
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
|
||||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
|
||||||
Performance Targets
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-[var(--spacing-scale-012)]">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-[var(--font-size-body-medium)]">
|
|
||||||
Load Time
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-green-600">
|
|
||||||
< 2 seconds
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-[var(--font-size-body-medium)]">
|
|
||||||
LCP
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-green-600">
|
|
||||||
< 2.5s
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-[var(--font-size-body-medium)]">
|
|
||||||
FID
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-green-600">
|
|
||||||
< 100ms
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-[var(--font-size-body-medium)]">
|
|
||||||
CLS
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-green-600">< 0.1</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-[var(--font-size-body-medium)]">
|
|
||||||
Lighthouse Score
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-green-600">> 90</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
|
||||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
|
||||||
Optimization Status
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-[var(--spacing-scale-012)]">
|
|
||||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
|
||||||
<span className="text-green-600">✅</span>
|
|
||||||
<span className="text-[var(--font-size-body-medium)]">
|
|
||||||
Code Splitting & Dynamic Imports
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
|
||||||
<span className="text-green-600">✅</span>
|
|
||||||
<span className="text-[var(--font-size-body-medium)]">
|
|
||||||
React.memo Optimizations
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
|
||||||
<span className="text-green-600">✅</span>
|
|
||||||
<span className="text-[var(--font-size-body-medium)]">
|
|
||||||
Image Optimization
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
|
||||||
<span className="text-green-600">✅</span>
|
|
||||||
<span className="text-[var(--font-size-body-medium)]">
|
|
||||||
Font Preloading
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
|
||||||
<span className="text-green-600">✅</span>
|
|
||||||
<span className="text-[var(--font-size-body-medium)]">
|
|
||||||
Bundle Analysis
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
|
||||||
<span className="text-green-600">✅</span>
|
|
||||||
<span className="text-[var(--font-size-body-medium)]">
|
|
||||||
Error Boundaries
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<WebVitalsDashboard />
|
|
||||||
|
|
||||||
<div className="mt-[var(--spacing-scale-032)] p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
|
||||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
|
||||||
Monitoring Commands
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-[var(--spacing-scale-016)]">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
|
||||||
Bundle Analysis
|
|
||||||
</h3>
|
|
||||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
|
||||||
npm run bundle:analyze
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
|
||||||
Performance Monitor
|
|
||||||
</h3>
|
|
||||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
|
||||||
npm run performance:monitor
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
|
||||||
Web Vitals Tracking
|
|
||||||
</h3>
|
|
||||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
|
||||||
npm run web-vitals:track
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
|
||||||
All Monitoring
|
|
||||||
</h3>
|
|
||||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
|
||||||
npm run monitor:all
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Metadata> {
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-[var(--color-teal-teal50,#c9fef9)]">
|
||||||
|
<div className="mx-auto flex max-w-[1200px] flex-col gap-[var(--measures-spacing-1200,48px)] px-5 py-[var(--spacing-scale-048,48px)] md:px-12 md:py-[var(--spacing-scale-064,64px)]">
|
||||||
|
<HeaderLockup
|
||||||
|
title={rule.title}
|
||||||
|
description={description}
|
||||||
|
justification="left"
|
||||||
|
size="L"
|
||||||
|
palette="inverse"
|
||||||
|
/>
|
||||||
|
<CommunityRuleDocument sections={sections} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,12 +2,13 @@ import { NextResponse } from "next/server";
|
|||||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||||
import { dbUnavailable } from "../../../../lib/server/responses";
|
import { dbUnavailable } from "../../../../lib/server/responses";
|
||||||
import { destroySessionFromRequest } from "../../../../lib/server/session";
|
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()) {
|
if (!isDatabaseConfigured()) {
|
||||||
return dbUnavailable();
|
return dbUnavailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
await destroySessionFromRequest();
|
await destroySessionFromRequest();
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -10,13 +10,20 @@ import {
|
|||||||
} from "../../../../../lib/server/hash";
|
} from "../../../../../lib/server/hash";
|
||||||
import { sendMagicLinkEmail } from "../../../../../lib/server/mail";
|
import { sendMagicLinkEmail } from "../../../../../lib/server/mail";
|
||||||
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
|
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
|
||||||
import { dbUnavailable } from "../../../../../lib/server/responses";
|
import {
|
||||||
import { logger } from "../../../../../lib/logger";
|
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";
|
import { safeInternalPath } from "../../../../../lib/safeInternalPath";
|
||||||
|
|
||||||
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
|
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
|
||||||
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
|
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
|
||||||
const IP_MIN_INTERVAL_MS = 20 * 1000;
|
const IP_MIN_INTERVAL_MS = 20 * 1000;
|
||||||
|
const SCOPE = "auth.magicLink.request";
|
||||||
|
|
||||||
function normalizeEmail(raw: unknown): string | null {
|
function normalizeEmail(raw: unknown): string | null {
|
||||||
if (typeof raw !== "string") return null;
|
if (typeof raw !== "string") return null;
|
||||||
@@ -32,7 +39,7 @@ function readNextPath(body: unknown): string | null {
|
|||||||
return safeInternalPath(n);
|
return safeInternalPath(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { requestId }) => {
|
||||||
if (!isDatabaseConfigured()) {
|
if (!isDatabaseConfigured()) {
|
||||||
return dbUnavailable();
|
return dbUnavailable();
|
||||||
}
|
}
|
||||||
@@ -41,7 +48,7 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
return errorJson("invalid_json", "Invalid JSON", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = normalizeEmail(
|
const email = normalizeEmail(
|
||||||
@@ -50,10 +57,7 @@ export async function POST(request: NextRequest) {
|
|||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return NextResponse.json(
|
return errorJson("validation_error", "Valid email required", 400);
|
||||||
{ error: "Valid email required" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ip =
|
const ip =
|
||||||
@@ -63,28 +67,19 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const rlEmail = rateLimitKey(`magic-email:${email}`, EMAIL_MIN_INTERVAL_MS);
|
const rlEmail = rateLimitKey(`magic-email:${email}`, EMAIL_MIN_INTERVAL_MS);
|
||||||
if (rlEmail.ok === false) {
|
if (rlEmail.ok === false) {
|
||||||
return NextResponse.json(
|
return rateLimited(rlEmail.retryAfterMs);
|
||||||
{ error: "Too many requests", retryAfterMs: rlEmail.retryAfterMs },
|
|
||||||
{ status: 429 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rlIp = rateLimitKey(`magic-ip:${ip}`, IP_MIN_INTERVAL_MS);
|
const rlIp = rateLimitKey(`magic-ip:${ip}`, IP_MIN_INTERVAL_MS);
|
||||||
if (rlIp.ok === false) {
|
if (rlIp.ok === false) {
|
||||||
return NextResponse.json(
|
return rateLimited(rlIp.retryAfterMs);
|
||||||
{ error: "Too many requests", retryAfterMs: rlIp.retryAfterMs },
|
|
||||||
{ status: 429 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let pepper: string;
|
let pepper: string;
|
||||||
try {
|
try {
|
||||||
pepper = getSessionPepper();
|
pepper = getSessionPepper();
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json(
|
return serverMisconfigured();
|
||||||
{ error: "Server misconfiguration" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = newSessionToken();
|
const token = newSessionToken();
|
||||||
@@ -108,13 +103,10 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
await sendMagicLinkEmail(email, verifyUrl);
|
await sendMagicLinkEmail(email, verifyUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("sendMagicLinkEmail failed:", err);
|
logRouteError(SCOPE, requestId, err, { phase: "sendMagicLinkEmail", email });
|
||||||
await prisma.magicLinkToken.deleteMany({ where: { email } });
|
await prisma.magicLinkToken.deleteMany({ where: { email } });
|
||||||
return NextResponse.json(
|
return errorJson("mail_failed", "Could not send email", 502);
|
||||||
{ error: "Could not send email" },
|
|
||||||
{ status: 502 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -10,52 +10,83 @@ import {
|
|||||||
setSessionCookie,
|
setSessionCookie,
|
||||||
} from "../../../../../lib/server/session";
|
} from "../../../../../lib/server/session";
|
||||||
import { dbUnavailable } from "../../../../../lib/server/responses";
|
import { dbUnavailable } from "../../../../../lib/server/responses";
|
||||||
|
import {
|
||||||
|
REQUEST_ID_HEADER,
|
||||||
|
getOrCreateRequestId,
|
||||||
|
logRouteError,
|
||||||
|
} from "../../../../../lib/server/requestId";
|
||||||
import { safeInternalPath } from "../../../../../lib/safeInternalPath";
|
import { safeInternalPath } from "../../../../../lib/safeInternalPath";
|
||||||
|
|
||||||
|
const SCOPE = "auth.magicLink.verify";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
const requestId = getOrCreateRequestId(request);
|
||||||
|
|
||||||
if (!isDatabaseConfigured()) {
|
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 {
|
try {
|
||||||
pepper = getSessionPepper();
|
const token = request.nextUrl.searchParams.get("token");
|
||||||
} catch {
|
if (!token || token.length < 10) {
|
||||||
return NextResponse.redirect(new URL("/login?error=server", request.url));
|
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({
|
const tokenHash = hashSessionToken(token, pepper);
|
||||||
where: { tokenHash },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!row || row.expiresAt < new Date()) {
|
const row = await prisma.magicLinkToken.findUnique({
|
||||||
return NextResponse.redirect(
|
where: { tokenHash },
|
||||||
new URL("/login?error=expired_link", request.url),
|
});
|
||||||
|
|
||||||
|
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 } });
|
|
||||||
|
function redirectWithRequestId(
|
||||||
const user = await prisma.user.upsert({
|
request: NextRequest,
|
||||||
where: { email: row.email },
|
path: string,
|
||||||
create: { email: row.email },
|
requestId: string,
|
||||||
update: {},
|
): NextResponse {
|
||||||
});
|
const res = NextResponse.redirect(new URL(path, request.url));
|
||||||
|
res.headers.set(REQUEST_ID_HEADER, requestId);
|
||||||
const { token: sessionToken, expiresAt } = await createSessionForUser(
|
return res;
|
||||||
user.id,
|
|
||||||
);
|
|
||||||
await setSessionCookie(sessionToken, expiresAt);
|
|
||||||
|
|
||||||
const dest = safeInternalPath(row.nextPath);
|
|
||||||
return NextResponse.redirect(new URL(dest, request.url));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { NextResponse } from "next/server";
|
|||||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||||
import { dbUnavailable } from "../../../../lib/server/responses";
|
import { dbUnavailable } from "../../../../lib/server/responses";
|
||||||
import { getSessionUser } from "../../../../lib/server/session";
|
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()) {
|
if (!isDatabaseConfigured()) {
|
||||||
return dbUnavailable();
|
return dbUnavailable();
|
||||||
}
|
}
|
||||||
@@ -16,4 +17,4 @@ export async function GET() {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
user: { id: user.id, email: user.email },
|
user: { id: user.id, email: user.email },
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|||||||
+14
-10
@@ -2,20 +2,24 @@ import type { Prisma } from "@prisma/client";
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "../../../../lib/server/db";
|
import { prisma } from "../../../../lib/server/db";
|
||||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
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 { getSessionUser } from "../../../../lib/server/session";
|
||||||
|
import { apiRoute } from "../../../../lib/server/apiRoute";
|
||||||
import { putDraftBodySchema } from "../../../../lib/server/validation/createFlowSchemas";
|
import { putDraftBodySchema } from "../../../../lib/server/validation/createFlowSchemas";
|
||||||
import { readLimitedJson } from "../../../../lib/server/validation/requestBody";
|
import { readLimitedJson } from "../../../../lib/server/validation/requestBody";
|
||||||
import { jsonFromZodError } from "../../../../lib/server/validation/zodHttp";
|
import { jsonFromZodError } from "../../../../lib/server/validation/zodHttp";
|
||||||
|
|
||||||
export async function GET() {
|
export const GET = apiRoute("drafts.me.get", async () => {
|
||||||
if (!isDatabaseConfigured()) {
|
if (!isDatabaseConfigured()) {
|
||||||
return dbUnavailable();
|
return dbUnavailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const draft = await prisma.ruleDraft.findUnique({
|
const draft = await prisma.ruleDraft.findUnique({
|
||||||
@@ -27,16 +31,16 @@ export async function GET() {
|
|||||||
? { payload: draft.payload, updatedAt: draft.updatedAt }
|
? { payload: draft.payload, updatedAt: draft.updatedAt }
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
export const PUT = apiRoute("drafts.me.put", async (request: NextRequest) => {
|
||||||
if (!isDatabaseConfigured()) {
|
if (!isDatabaseConfigured()) {
|
||||||
return dbUnavailable();
|
return dbUnavailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedBody = await readLimitedJson(request);
|
const parsedBody = await readLimitedJson(request);
|
||||||
@@ -67,16 +71,16 @@ export async function PUT(request: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
draft: { payload: draft.payload, updatedAt: draft.updatedAt },
|
draft: { payload: draft.payload, updatedAt: draft.updatedAt },
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
export async function DELETE() {
|
export const DELETE = apiRoute("drafts.me.delete", async () => {
|
||||||
if (!isDatabaseConfigured()) {
|
if (!isDatabaseConfigured()) {
|
||||||
return dbUnavailable();
|
return dbUnavailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) {
|
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
|
// 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 } });
|
await prisma.ruleDraft.deleteMany({ where: { userId: user.id } });
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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<RouteContext>(
|
||||||
|
"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 });
|
||||||
|
},
|
||||||
|
);
|
||||||
+10
-6
@@ -2,13 +2,17 @@ import type { Prisma } from "@prisma/client";
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "../../../lib/server/db";
|
import { prisma } from "../../../lib/server/db";
|
||||||
import { isDatabaseConfigured } from "../../../lib/server/env";
|
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 { getSessionUser } from "../../../lib/server/session";
|
||||||
|
import { apiRoute } from "../../../lib/server/apiRoute";
|
||||||
import { publishRuleBodySchema } from "../../../lib/server/validation/createFlowSchemas";
|
import { publishRuleBodySchema } from "../../../lib/server/validation/createFlowSchemas";
|
||||||
import { readLimitedJson } from "../../../lib/server/validation/requestBody";
|
import { readLimitedJson } from "../../../lib/server/validation/requestBody";
|
||||||
import { jsonFromZodError } from "../../../lib/server/validation/zodHttp";
|
import { jsonFromZodError } from "../../../lib/server/validation/zodHttp";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export const GET = apiRoute("rules.list", async (request: NextRequest) => {
|
||||||
if (!isDatabaseConfigured()) {
|
if (!isDatabaseConfigured()) {
|
||||||
return dbUnavailable();
|
return dbUnavailable();
|
||||||
}
|
}
|
||||||
@@ -29,16 +33,16 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ rules });
|
return NextResponse.json({ rules });
|
||||||
}
|
});
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export const POST = apiRoute("rules.publish", async (request: NextRequest) => {
|
||||||
if (!isDatabaseConfigured()) {
|
if (!isDatabaseConfigured()) {
|
||||||
return dbUnavailable();
|
return dbUnavailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedBody = await readLimitedJson(request);
|
const parsedBody = await readLimitedJson(request);
|
||||||
@@ -70,4 +74,4 @@ export async function POST(request: NextRequest) {
|
|||||||
createdAt: rule.createdAt,
|
createdAt: rule.createdAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|||||||
+55
-108
@@ -1,90 +1,71 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import { logger } from "../../../lib/logger";
|
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");
|
function normalizeTimestamp(raw: string | number): string {
|
||||||
|
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||||
interface WebVitalData {
|
return new Date(raw).toISOString();
|
||||||
metric: string;
|
}
|
||||||
data: {
|
return new Date(raw).toISOString();
|
||||||
value: number;
|
|
||||||
rating: string;
|
|
||||||
};
|
|
||||||
url: string;
|
|
||||||
userAgent: string;
|
|
||||||
timestamp: string;
|
|
||||||
receivedAt: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebVitalMetrics {
|
function logExternalIngest(body: WebVitalData): void {
|
||||||
[metric: string]: {
|
const line = JSON.stringify({
|
||||||
count: number;
|
kind: "web_vital_ingest",
|
||||||
average: number;
|
metric: body.metric,
|
||||||
min: number;
|
value: body.data.value,
|
||||||
max: number;
|
rating: body.data.rating,
|
||||||
goodCount: number;
|
url: body.url,
|
||||||
needsImprovementCount: number;
|
receivedAt: body.receivedAt,
|
||||||
poorCount: number;
|
});
|
||||||
lastUpdated: string;
|
logger.info(line);
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure web-vitals directory exists
|
|
||||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
|
||||||
fs.mkdirSync(WEB_VITALS_DIR, { recursive: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const limited = await readLimitedJson(request);
|
||||||
const { metric, data, url, userAgent, timestamp } = body as {
|
if (limited.ok === false) {
|
||||||
metric: string;
|
return limited.response;
|
||||||
data: { value: number; rating: string };
|
}
|
||||||
url: string;
|
|
||||||
userAgent: string;
|
const parsed = webVitalIngestSchema.safeParse(limited.value);
|
||||||
timestamp: string;
|
if (!parsed.success) return jsonFromZodError(parsed.error);
|
||||||
};
|
|
||||||
|
const body = parsed.data;
|
||||||
|
|
||||||
// Store the metric data
|
|
||||||
const vitalsData: WebVitalData = {
|
const vitalsData: WebVitalData = {
|
||||||
metric,
|
metric: body.metric,
|
||||||
data,
|
data: {
|
||||||
url,
|
value: body.data.value,
|
||||||
userAgent,
|
rating: body.data.rating,
|
||||||
timestamp: new Date(timestamp).toISOString(),
|
},
|
||||||
|
url: body.url,
|
||||||
|
userAgent: body.userAgent,
|
||||||
|
timestamp: normalizeTimestamp(body.timestamp),
|
||||||
receivedAt: new Date().toISOString(),
|
receivedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save to file (in production, you would save to a database)
|
const mode = getWebVitalsStorageMode();
|
||||||
const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`);
|
|
||||||
let existingData: WebVitalData[] = [];
|
|
||||||
|
|
||||||
if (fs.existsSync(filePath)) {
|
if (mode === "external") {
|
||||||
try {
|
logExternalIngest(vitalsData);
|
||||||
const fileContent = fs.readFileSync(filePath, "utf8");
|
return NextResponse.json({ success: true, storage: "external" });
|
||||||
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);
|
appendLocalWebVital(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
|
|
||||||
logger.info(
|
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) {
|
} catch (error) {
|
||||||
logger.error("Error processing web vital:", error);
|
logger.error("Error processing web vital:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -96,51 +77,17 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const metrics: WebVitalMetrics = {};
|
const mode = getWebVitalsStorageMode();
|
||||||
|
|
||||||
if (fs.existsSync(WEB_VITALS_DIR)) {
|
if (mode === "external") {
|
||||||
const files = fs.readdirSync(WEB_VITALS_DIR);
|
return NextResponse.json({
|
||||||
|
metrics: {},
|
||||||
files.forEach((file) => {
|
storage: "external" as const,
|
||||||
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 || "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ metrics });
|
const metrics = readLocalAggregatedMetrics();
|
||||||
|
return NextResponse.json({ metrics, storage: "local" as const });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error fetching web vitals:", error);
|
logger.error("Error fetching web vitals:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo, useEffect, useState } from "react";
|
import { memo, useEffect, useState } from "react";
|
||||||
|
import { useMessages } from "../../../contexts/MessagesContext";
|
||||||
import { logger } from "../../../../lib/logger";
|
import { logger } from "../../../../lib/logger";
|
||||||
import WebVitalsDashboardView from "./WebVitalsDashboard.view";
|
import WebVitalsDashboardView from "./WebVitalsDashboard.view";
|
||||||
import type { Metrics, Vitals, VitalData } from "./WebVitalsDashboard.types";
|
import type { Metrics, Vitals, VitalData } from "./WebVitalsDashboard.types";
|
||||||
@@ -18,17 +19,55 @@ const createInitialVitals = (): Vitals => ({
|
|||||||
ttfb: createInitialVital(),
|
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 WebVitalsDashboardContainer = memo(() => {
|
||||||
|
const m = useMessages();
|
||||||
|
const copy = m.webVitalsDashboard;
|
||||||
const [vitals, setVitals] = useState<Vitals>(createInitialVitals);
|
const [vitals, setVitals] = useState<Vitals>(createInitialVitals);
|
||||||
const [metrics, setMetrics] = useState<Metrics>({});
|
const [metrics, setMetrics] = useState<Metrics>({});
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
const fetchVitals = async () => {
|
const fetchVitals = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/web-vitals");
|
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 || {});
|
setMetrics(data.metrics || {});
|
||||||
|
setStorage(data.storage === "external" ? "external" : "local");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error fetching web vitals:", error);
|
logger.error("Error fetching web vitals:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -39,77 +78,71 @@ const WebVitalsDashboardContainer = memo(() => {
|
|||||||
fetchVitals();
|
fetchVitals();
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
import("web-vitals").then((webVitals) => {
|
// web-vitals v4+ exposes onLCP / onCLS / … — legacy getLCP was removed.
|
||||||
// web-vitals v4 typings don't expose legacy get* names the same way; runtime bundle still provides them for this dashboard.
|
import("web-vitals").then(
|
||||||
const { getCLS, getFID, getFCP, getLCP, getTTFB } =
|
({ onCLS, onFID, onFCP, onLCP, onTTFB }) => {
|
||||||
webVitals as unknown as {
|
onLCP((metric) => {
|
||||||
getCLS: (
|
const rating = metric.rating as VitalData["rating"];
|
||||||
_fn: (_m: { value: number; rating: string }) => void,
|
setVitals((prev) => ({
|
||||||
) => void;
|
...prev,
|
||||||
getFID: (
|
lcp: {
|
||||||
_fn: (_m: { value: number; rating: string }) => void,
|
value: Math.round(metric.value),
|
||||||
) => void;
|
rating,
|
||||||
getFCP: (
|
},
|
||||||
_fn: (_m: { value: number; rating: string }) => void,
|
}));
|
||||||
) => void;
|
reportWebVitalToApi("lcp", Math.round(metric.value), rating);
|
||||||
getLCP: (
|
});
|
||||||
_fn: (_m: { value: number; rating: string }) => void,
|
|
||||||
) => void;
|
|
||||||
getTTFB: (
|
|
||||||
_fn: (_m: { value: number; rating: string }) => void,
|
|
||||||
) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
getLCP((metric: { value: number; rating: VitalData["rating"] }) => {
|
onFID((metric) => {
|
||||||
setVitals((prev) => ({
|
const rating = metric.rating as VitalData["rating"];
|
||||||
...prev,
|
setVitals((prev) => ({
|
||||||
lcp: {
|
...prev,
|
||||||
value: Math.round(metric.value),
|
fid: {
|
||||||
rating: metric.rating,
|
value: Math.round(metric.value),
|
||||||
},
|
rating,
|
||||||
}));
|
},
|
||||||
});
|
}));
|
||||||
|
reportWebVitalToApi("fid", Math.round(metric.value), rating);
|
||||||
|
});
|
||||||
|
|
||||||
getFID((metric: { value: number; rating: VitalData["rating"] }) => {
|
onCLS((metric) => {
|
||||||
setVitals((prev) => ({
|
const rounded = Math.round(metric.value * 1000) / 1000;
|
||||||
...prev,
|
const rating = metric.rating as VitalData["rating"];
|
||||||
fid: {
|
setVitals((prev) => ({
|
||||||
value: Math.round(metric.value),
|
...prev,
|
||||||
rating: metric.rating,
|
cls: {
|
||||||
},
|
value: rounded,
|
||||||
}));
|
rating,
|
||||||
});
|
},
|
||||||
|
}));
|
||||||
|
reportWebVitalToApi("cls", rounded, rating);
|
||||||
|
});
|
||||||
|
|
||||||
getCLS((metric: { value: number; rating: VitalData["rating"] }) => {
|
onFCP((metric) => {
|
||||||
setVitals((prev) => ({
|
const rating = metric.rating as VitalData["rating"];
|
||||||
...prev,
|
setVitals((prev) => ({
|
||||||
cls: {
|
...prev,
|
||||||
value: Math.round(metric.value * 1000) / 1000,
|
fcp: {
|
||||||
rating: metric.rating,
|
value: Math.round(metric.value),
|
||||||
},
|
rating,
|
||||||
}));
|
},
|
||||||
});
|
}));
|
||||||
|
reportWebVitalToApi("fcp", Math.round(metric.value), rating);
|
||||||
|
});
|
||||||
|
|
||||||
getFCP((metric: { value: number; rating: VitalData["rating"] }) => {
|
onTTFB((metric) => {
|
||||||
setVitals((prev) => ({
|
const rating = metric.rating as VitalData["rating"];
|
||||||
...prev,
|
setVitals((prev) => ({
|
||||||
fcp: {
|
...prev,
|
||||||
value: Math.round(metric.value),
|
ttfb: {
|
||||||
rating: metric.rating,
|
value: Math.round(metric.value),
|
||||||
},
|
rating,
|
||||||
}));
|
},
|
||||||
});
|
}));
|
||||||
|
reportWebVitalToApi("ttfb", Math.round(metric.value), rating);
|
||||||
getTTFB((metric: { value: number; rating: VitalData["rating"] }) => {
|
});
|
||||||
setVitals((prev) => ({
|
},
|
||||||
...prev,
|
);
|
||||||
ttfb: {
|
|
||||||
value: Math.round(metric.value),
|
|
||||||
rating: metric.rating,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -118,6 +151,9 @@ const WebVitalsDashboardContainer = memo(() => {
|
|||||||
vitals={vitals}
|
vitals={vitals}
|
||||||
metrics={metrics}
|
metrics={metrics}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
storage={storage}
|
||||||
|
copy={copy}
|
||||||
|
rumDashboardUrl={rumDashboardUrl}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type messages from "../../../../messages/en/index";
|
||||||
|
|
||||||
export interface VitalData {
|
export interface VitalData {
|
||||||
value: number;
|
value: number;
|
||||||
rating: "good" | "needs-improvement" | "poor" | "unknown";
|
rating: "good" | "needs-improvement" | "poor" | "unknown";
|
||||||
@@ -26,8 +28,13 @@ export interface Metrics {
|
|||||||
[key: string]: MetricData;
|
[key: string]: MetricData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WebVitalsDashboardCopy = typeof messages.webVitalsDashboard;
|
||||||
|
|
||||||
export interface WebVitalsDashboardViewProps {
|
export interface WebVitalsDashboardViewProps {
|
||||||
vitals: Vitals;
|
vitals: Vitals;
|
||||||
metrics: Metrics;
|
metrics: Metrics;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
storage: "external" | "local";
|
||||||
|
copy: WebVitalsDashboardCopy;
|
||||||
|
rumDashboardUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") {
|
if (metric === "cls") {
|
||||||
return value.toFixed(3);
|
return value.toFixed(3);
|
||||||
}
|
}
|
||||||
return `${value}ms`;
|
return `${value}ms`;
|
||||||
};
|
}
|
||||||
|
|
||||||
function WebVitalsDashboardView({
|
function WebVitalsDashboardView({
|
||||||
vitals,
|
vitals,
|
||||||
metrics,
|
metrics,
|
||||||
loading,
|
loading,
|
||||||
|
storage,
|
||||||
|
copy,
|
||||||
|
rumDashboardUrl,
|
||||||
}: WebVitalsDashboardViewProps) {
|
}: WebVitalsDashboardViewProps) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -59,9 +62,31 @@ function WebVitalsDashboardView({
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 bg-white rounded-lg shadow-lg">
|
<div className="p-6 bg-white rounded-lg shadow-lg">
|
||||||
<h2 className="text-2xl font-bold mb-6 text-[var(--color-content-default-primary)]">
|
<h2 className="text-2xl font-bold mb-6 text-[var(--color-content-default-primary)]">
|
||||||
Web Vitals Dashboard
|
{copy.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
{storage === "external" && (
|
||||||
|
<div
|
||||||
|
className="mb-6 p-4 rounded-lg border border-[var(--color-border-default-primary)] bg-[var(--color-surface-default-secondary)] text-[var(--font-size-body-medium)] text-[var(--color-content-default-secondary)]"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<p className="font-semibold text-[var(--color-content-default-primary)] mb-2">
|
||||||
|
{copy.externalNoticeTitle}
|
||||||
|
</p>
|
||||||
|
<p className="mb-3">{copy.externalNoticeBody}</p>
|
||||||
|
{rumDashboardUrl ? (
|
||||||
|
<a
|
||||||
|
href={rumDashboardUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-[var(--color-content-default-primary)] underline font-medium"
|
||||||
|
>
|
||||||
|
{copy.externalDashboardLinkLabel}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||||
{Object.entries(vitals).map(([metric, data]) => (
|
{Object.entries(vitals).map(([metric, data]) => (
|
||||||
<div
|
<div
|
||||||
@@ -74,21 +99,20 @@ function WebVitalsDashboardView({
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
Value: {formatValue(metric, data.value)}
|
{copy.valueLabel}: {formatValue(metric, data.value)}
|
||||||
</div>
|
</div>
|
||||||
<div className="capitalize">
|
<div className="capitalize">
|
||||||
Rating: {data.rating.replace("-", " ")}
|
{copy.ratingLabel}: {data.rating.replace("-", " ")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Historical Metrics */}
|
|
||||||
{Object.keys(metrics).length > 0 && (
|
{Object.keys(metrics).length > 0 && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h3 className="text-lg font-semibold mb-4 text-[var(--color-content-default-primary)]">
|
<h3 className="text-lg font-semibold mb-4 text-[var(--color-content-default-primary)]">
|
||||||
Historical Metrics
|
{copy.historicalMetricsTitle}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{Object.entries(metrics).map(([metric, data]) => (
|
{Object.entries(metrics).map(([metric, data]) => (
|
||||||
@@ -98,20 +122,26 @@ function WebVitalsDashboardView({
|
|||||||
>
|
>
|
||||||
<h4 className="font-semibold mb-2">{metric.toUpperCase()}</h4>
|
<h4 className="font-semibold mb-2">{metric.toUpperCase()}</h4>
|
||||||
<div className="text-sm space-y-1">
|
<div className="text-sm space-y-1">
|
||||||
<div>Count: {data.count}</div>
|
|
||||||
<div>Average: {formatValue(metric, data.average)}</div>
|
|
||||||
<div>
|
<div>
|
||||||
Range: {formatValue(metric, data.min)} -{" "}
|
{copy.countLabel}: {data.count}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{copy.averageLabel}: {formatValue(metric, data.average)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{copy.rangeLabel}: {formatValue(metric, data.min)} -{" "}
|
||||||
{formatValue(metric, data.max)}
|
{formatValue(metric, data.max)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 text-xs">
|
<div className="flex gap-2 text-xs">
|
||||||
<span className="text-green-600">
|
<span className="text-green-600">
|
||||||
Good: {data.goodCount}
|
{copy.goodLabel}: {data.goodCount}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-yellow-600">
|
<span className="text-yellow-600">
|
||||||
Needs Improvement: {data.needsImprovementCount}
|
{copy.needsImprovementLabel}: {data.needsImprovementCount}
|
||||||
|
</span>
|
||||||
|
<span className="text-red-600">
|
||||||
|
{copy.poorLabel}: {data.poorCount}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-red-600">Poor: {data.poorCount}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,32 +150,16 @@ function WebVitalsDashboardView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Performance Guidelines */}
|
|
||||||
<div className="p-4 bg-[var(--color-surface-default-secondary)] rounded-lg">
|
<div className="p-4 bg-[var(--color-surface-default-secondary)] rounded-lg">
|
||||||
<h3 className="font-semibold mb-2 text-[var(--color-content-default-primary)]">
|
<h3 className="font-semibold mb-2 text-[var(--color-content-default-primary)]">
|
||||||
Performance Guidelines
|
{copy.performanceGuidelinesTitle}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="text-sm space-y-1 text-[var(--color-content-default-secondary)]">
|
<ul className="text-sm space-y-1 text-[var(--color-content-default-secondary)]">
|
||||||
<li>
|
<li>• {copy.guidelines.lcp}</li>
|
||||||
• <strong>LCP:</strong> Good < 2.5s, Needs Improvement 2.5-4s,
|
<li>• {copy.guidelines.fid}</li>
|
||||||
Poor > 4s
|
<li>• {copy.guidelines.cls}</li>
|
||||||
</li>
|
<li>• {copy.guidelines.fcp}</li>
|
||||||
<li>
|
<li>• {copy.guidelines.ttfb}</li>
|
||||||
• <strong>FID:</strong> Good < 100ms, Needs Improvement
|
|
||||||
100-300ms, Poor > 300ms
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• <strong>CLS:</strong> Good < 0.1, Needs Improvement 0.1-0.25,
|
|
||||||
Poor > 0.25
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• <strong>FCP:</strong> Good < 1.8s, Needs Improvement 1.8-3s,
|
|
||||||
Poor > 3s
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• <strong>TTFB:</strong> Good < 800ms, Needs Improvement
|
|
||||||
800-1800ms, Poor > 1800ms
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-2
@@ -6,8 +6,8 @@ User-facing docs. Implementation conventions live in `.cursor/rules/`.
|
|||||||
|
|
||||||
- [create-flow.md](./create-flow.md) — Custom create-rule wizard: stages,
|
- [create-flow.md](./create-flow.md) — Custom create-rule wizard: stages,
|
||||||
URLs, persistence. Source of truth for product/eng alignment.
|
URLs, persistence. Source of truth for product/eng alignment.
|
||||||
- [testing-guide.md](./testing-guide.md) — Testing philosophy and what to
|
- [testing-guide.md](./testing-guide.md) — Testing philosophy, layer
|
||||||
cover at each layer.
|
coverage, and Prisma migration smoke before merge.
|
||||||
|
|
||||||
## Author guides (`guides/`)
|
## 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:
|
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-roadmap.md](./guides/backend-roadmap.md)
|
||||||
- [guides/backend-linear-tickets.md](./guides/backend-linear-tickets.md)
|
- [guides/backend-linear-tickets.md](./guides/backend-linear-tickets.md)
|
||||||
- [guides/template-recommendation-matrix.md](./guides/template-recommendation-matrix.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
|
## Cursor rules
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ Copy each block into Linear (or your tracker) as a separate issue, **in order**.
|
|||||||
|
|
||||||
### Review sync (relevant feedback only)
|
### 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)
|
### 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.
|
- **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.
|
- **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.
|
- **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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
### 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).
|
**Depends on:** nothing in production; **Docker** on the developer machine.
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
**Goal:** Catch broken SQL migrations before merge.
|
**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 (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.
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
**Acceptance criteria:**
|
**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.
|
**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:
|
**Implementation (shipped):**
|
||||||
- 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.
|
1. [`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md):
|
||||||
- Reverse proxy: `GET /api/health` for LB health.
|
- **§1 Context** — what the legacy LAMP slot actually contains and why side-by-side cutover is the safe path.
|
||||||
- Backups and restore drill for Postgres.
|
- **§2 Access** — what Cloudron admin already grants self-serve; only outstanding admin-side step is generating a CLI token.
|
||||||
- SMTP DNS (SPF/DKIM).
|
- **§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.
|
||||||
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.
|
- **§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:**
|
**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 <registry>/communityrule:<tag>` works from a Cloudron-reachable network. CI builds and pushes on merge to `main` (stretch).
|
||||||
|
3. **Cloudron staging install + smoke.** Acceptance: `curl https://<staging>/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:**
|
**Acceptance criteria:**
|
||||||
|
|
||||||
- [ ] At least auth + draft + rules routes return the agreed shape for new code paths.
|
- [x] At least auth + draft + rules routes return the agreed shape for new code paths.
|
||||||
- [ ] Errors in logs include request id when available.
|
- [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).
|
**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 |
|
| 8 | 8 | Templates in UI |
|
||||||
| 9 | 9 | Web vitals persistence |
|
| 9 | 9 | Web vitals persistence |
|
||||||
| 10 | 10 | Public rule detail (optional) |
|
| 10 | 10 | Public rule detail (optional) |
|
||||||
| 11 | 11 | CI migrate smoke (optional) |
|
| 11 | 11 | Migrate smoke (local) **Done in repo** |
|
||||||
| 12 | 12 | Ops runbook |
|
| 12 | 12 | Ops admin handoff (Cloudron) **Done** |
|
||||||
| 13 | 13 | API errors + request-id logging |
|
| 13 | 13 | API errors + request-id logging |
|
||||||
| 14 | 14 | Session lifecycle + cleanup |
|
| 14 | 14 | Session lifecycle + cleanup |
|
||||||
| 15 | 15 | Profile + account (Figma profile) |
|
| 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).
|
**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)
|
## 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) |
|
| 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 |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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 runbook / admin handoff |
|
| 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 |
|
| 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) |
|
| 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) |
|
| 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** |
|
| 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** |
|
||||||
|
|||||||
@@ -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).
|
- **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.).
|
- **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).
|
- **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).
|
- **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`).
|
||||||
- **CI:** [`.gitea/workflows/ci.yaml`](.gitea/workflows/ci.yaml) (build, test, lint, `prisma validate`); no in-repo production deploy definition.
|
- **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)
|
### 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 / 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/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.*`) |
|
| 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/*`).
|
**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.
|
**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.
|
**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.
|
- **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.
|
- **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.
|
- 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
|
## 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.
|
**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`.
|
**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).
|
**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 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.
|
**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):**
|
**Admin / infra (coordinate with whoever runs the server):**
|
||||||
|
|
||||||
1. TLS certificates and hostnames.
|
1. TLS certificates and hostnames. _On Cloudron: handled by the platform per chosen subdomain._
|
||||||
2. PostgreSQL backups and restore drill.
|
2. PostgreSQL backups and restore drill. _On Cloudron: daily snapshots; configure retention in admin UI._
|
||||||
3. SMTP DNS (SPF, DKIM).
|
3. SMTP DNS (SPF, DKIM). _On Cloudron: handled for the platform-managed domain._
|
||||||
4. Health check URL for reverse proxy (`/api/health`).
|
4. Health check URL for reverse proxy (`/api/health`). _On Cloudron: set `healthCheckPath` in `CloudronManifest.json`._
|
||||||
5. Log retention and alerts for 5xx errors.
|
5. Log retention and alerts for 5xx errors. _On Cloudron: app log viewer; export off-platform if longer retention is needed._
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 <id> --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 <our-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).
|
||||||
@@ -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.
|
||||||
+28
-9
@@ -3,6 +3,34 @@
|
|||||||
This is the **why** of testing in CommunityRule. For file layout, helper
|
This is the **why** of testing in CommunityRule. For file layout, helper
|
||||||
APIs, and required imports see `.cursor/rules/testing.mdc`.
|
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
|
## Philosophy
|
||||||
|
|
||||||
- **Test behaviour, not implementation.** Assert on what a user can see and
|
- **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.
|
- Hook internals or memoization specifics.
|
||||||
- Responsive visibility in JSDOM — use Playwright instead.
|
- 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
|
## Adding tests for a new component
|
||||||
|
|
||||||
1. Create `app/components/<Name>/`.
|
1. Create `app/components/<Name>/`.
|
||||||
|
|||||||
@@ -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<Ctx> = (
|
||||||
|
request: NextRequest,
|
||||||
|
ctx: Ctx,
|
||||||
|
meta: ApiRouteMeta,
|
||||||
|
) => Promise<NextResponse> | 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<Ctx = undefined>(
|
||||||
|
scope: string,
|
||||||
|
handler: ApiHandler<Ctx>,
|
||||||
|
): (request: NextRequest, ctx: Ctx) => Promise<NextResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<PublicPublishedRule | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T extends NextResponse>(
|
||||||
|
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<string, unknown>,
|
||||||
|
): 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);
|
||||||
|
}
|
||||||
+78
-3
@@ -1,8 +1,83 @@
|
|||||||
import { NextResponse } from "next/server";
|
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 {
|
export function dbUnavailable(): NextResponse {
|
||||||
return NextResponse.json(
|
return errorJson(
|
||||||
{ error: "Database is not configured (DATABASE_URL)." },
|
"db_unavailable",
|
||||||
{ status: 503 },
|
"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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,36 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import type { User } from "@prisma/client";
|
import type { User } from "@prisma/client";
|
||||||
|
import { logger } from "../logger";
|
||||||
import { prisma } from "./db";
|
import { prisma } from "./db";
|
||||||
import { getSessionPepper } from "./env";
|
import { getSessionPepper } from "./env";
|
||||||
import { hashSessionToken, newSessionToken } from "./hash";
|
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";
|
export const SESSION_COOKIE_NAME = "cr_session";
|
||||||
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 30;
|
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 30;
|
||||||
|
const SESSION_GLOBAL_PRUNE_PROB = 0.05;
|
||||||
|
|
||||||
export async function getSessionUser(): Promise<User | null> {
|
export async function getSessionUser(): Promise<User | null> {
|
||||||
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
|
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
|
||||||
@@ -31,6 +56,24 @@ export async function getSessionUser(): Promise<User | null> {
|
|||||||
return session.user;
|
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<number> {
|
||||||
|
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(
|
export async function createSessionForUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ token: string; expiresAt: Date }> {
|
): 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 };
|
return { token, expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<typeof webVitalIngestSchema>;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,9 +11,11 @@ import menuBar from "./components/menuBar.json";
|
|||||||
import quoteBlock from "./components/quoteBlock.json";
|
import quoteBlock from "./components/quoteBlock.json";
|
||||||
import ruleCard from "./components/ruleCard.json";
|
import ruleCard from "./components/ruleCard.json";
|
||||||
import ruleStack from "./components/ruleStack.json";
|
import ruleStack from "./components/ruleStack.json";
|
||||||
|
import webVitalsDashboard from "./components/webVitalsDashboard.json";
|
||||||
import home from "./pages/home.json";
|
import home from "./pages/home.json";
|
||||||
import templates from "./pages/templates.json";
|
import templates from "./pages/templates.json";
|
||||||
import learn from "./pages/learn.json";
|
import learn from "./pages/learn.json";
|
||||||
|
import monitor from "./pages/monitor.json";
|
||||||
import login from "./pages/login.json";
|
import login from "./pages/login.json";
|
||||||
import profile from "./pages/profile.json";
|
import profile from "./pages/profile.json";
|
||||||
import navigation from "./navigation.json";
|
import navigation from "./navigation.json";
|
||||||
@@ -62,10 +64,12 @@ export default {
|
|||||||
quoteBlock,
|
quoteBlock,
|
||||||
ruleCard,
|
ruleCard,
|
||||||
ruleStack,
|
ruleStack,
|
||||||
|
webVitalsDashboard,
|
||||||
pages: {
|
pages: {
|
||||||
home,
|
home,
|
||||||
templates,
|
templates,
|
||||||
learn,
|
learn,
|
||||||
|
monitor,
|
||||||
login,
|
login,
|
||||||
profile,
|
profile,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -44,7 +44,8 @@
|
|||||||
"analyze:server": "ANALYZE=true npm run build",
|
"analyze:server": "ANALYZE=true npm run build",
|
||||||
"analyze:browser": "BUNDLE_ANALYZE=true npm run build",
|
"analyze:browser": "BUNDLE_ANALYZE=true npm run build",
|
||||||
"bundle:analyze": "node scripts/bundle-analyzer.js",
|
"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": {
|
"dependencies": {
|
||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
|
|||||||
Executable
+46
@@ -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"
|
||||||
@@ -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 <userId> # 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());
|
||||||
@@ -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<string, string> = {}): 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<string, unknown>;
|
||||||
|
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<Ctx>("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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>): 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;<script>";
|
||||||
|
const id = getOrCreateRequestId(reqWith({ [REQUEST_ID_HEADER]: bad }));
|
||||||
|
expect(id).not.toBe(bad);
|
||||||
|
expect(id).toMatch(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("withRequestId attaches the header to a NextResponse", () => {
|
||||||
|
const res = NextResponse.json({ ok: true });
|
||||||
|
const out = withRequestId(res, "req_xyz");
|
||||||
|
expect(out).toBe(res);
|
||||||
|
expect(out.headers.get(REQUEST_ID_HEADER)).toBe("req_xyz");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
dbUnavailable,
|
||||||
|
errorJson,
|
||||||
|
internalError,
|
||||||
|
notFound,
|
||||||
|
rateLimited,
|
||||||
|
serverMisconfigured,
|
||||||
|
unauthorized,
|
||||||
|
} from "../../lib/server/responses";
|
||||||
|
|
||||||
|
async function readBody(res: Response): Promise<{
|
||||||
|
error: { code: string; message: string };
|
||||||
|
details?: unknown;
|
||||||
|
}> {
|
||||||
|
return (await res.json()) as {
|
||||||
|
error: { code: string; message: string };
|
||||||
|
details?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("lib/server/responses", () => {
|
||||||
|
it("errorJson returns the canonical shape, status, and details", async () => {
|
||||||
|
const res = errorJson("validation_error", "Bad input", 400, {
|
||||||
|
details: { field: "email" },
|
||||||
|
headers: { "x-custom": "1" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.headers.get("x-custom")).toBe("1");
|
||||||
|
const body = await readBody(res);
|
||||||
|
expect(body).toEqual({
|
||||||
|
error: { code: "validation_error", message: "Bad input" },
|
||||||
|
details: { field: "email" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errorJson omits details when not provided", async () => {
|
||||||
|
const res = errorJson("internal_error", "Boom", 500);
|
||||||
|
const body = await readBody(res);
|
||||||
|
expect(body.details).toBeUndefined();
|
||||||
|
expect(body.error).toEqual({ code: "internal_error", message: "Boom" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dbUnavailable → 503 db_unavailable", async () => {
|
||||||
|
const res = dbUnavailable();
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
const body = await readBody(res);
|
||||||
|
expect(body.error.code).toBe("db_unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unauthorized → 401 unauthorized", async () => {
|
||||||
|
const res = unauthorized();
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = await readBody(res);
|
||||||
|
expect(body.error).toEqual({
|
||||||
|
code: "unauthorized",
|
||||||
|
message: "Unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notFound → 404 not_found with optional message", async () => {
|
||||||
|
const res = notFound("Rule not found");
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const body = await readBody(res);
|
||||||
|
expect(body.error).toEqual({
|
||||||
|
code: "not_found",
|
||||||
|
message: "Rule not found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rateLimited → 429 with Retry-After header (seconds, ceil) and details", async () => {
|
||||||
|
const res = rateLimited(2500);
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
expect(res.headers.get("retry-after")).toBe("3");
|
||||||
|
const body = await readBody(res);
|
||||||
|
expect(body.error.code).toBe("rate_limited");
|
||||||
|
expect(body.details).toEqual({ retryAfterMs: 2500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rateLimited clamps Retry-After to at least 1 second", () => {
|
||||||
|
const res = rateLimited(0);
|
||||||
|
expect(res.headers.get("retry-after")).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serverMisconfigured → 500 server_misconfigured", async () => {
|
||||||
|
const res = serverMisconfigured();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await readBody(res);
|
||||||
|
expect(body.error.code).toBe("server_misconfigured");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("internalError → 500 internal_error", async () => {
|
||||||
|
const res = internalError();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await readBody(res);
|
||||||
|
expect(body.error.code).toBe("internal_error");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const isDatabaseConfiguredMock = vi.fn();
|
||||||
|
const findUniqueMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../lib/server/env", () => ({
|
||||||
|
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/server/db", () => ({
|
||||||
|
prisma: {
|
||||||
|
publishedRule: {
|
||||||
|
findUnique: (...args: unknown[]) => findUniqueMock(...args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { GET } from "../../app/api/rules/[id]/route";
|
||||||
|
|
||||||
|
function makeContext(id: string) {
|
||||||
|
return { params: Promise.resolve({ id }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
isDatabaseConfiguredMock.mockReset();
|
||||||
|
findUniqueMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/rules/[id]", () => {
|
||||||
|
it("returns 503 when the database is not configured", async () => {
|
||||||
|
isDatabaseConfiguredMock.mockReturnValue(false);
|
||||||
|
const res = await GET(
|
||||||
|
new NextRequest("https://x.test/api/rules/abc"),
|
||||||
|
makeContext("abc"),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(findUniqueMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 with the canonical error shape when no published rule matches the id", async () => {
|
||||||
|
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||||
|
findUniqueMock.mockResolvedValueOnce(null);
|
||||||
|
const res = await GET(
|
||||||
|
new NextRequest("https://x.test/api/rules/missing"),
|
||||||
|
makeContext("missing"),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.headers.get("x-request-id")).toBeTruthy();
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
error: { code: string; message: string };
|
||||||
|
};
|
||||||
|
expect(body.error.code).toBe("not_found");
|
||||||
|
expect(typeof body.error.message).toBe("string");
|
||||||
|
expect(findUniqueMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { id: "missing" } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards an incoming x-request-id on the response", async () => {
|
||||||
|
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||||
|
findUniqueMock.mockResolvedValueOnce(null);
|
||||||
|
const res = await GET(
|
||||||
|
new NextRequest("https://x.test/api/rules/missing", {
|
||||||
|
headers: { "x-request-id": "req_test-1" },
|
||||||
|
}),
|
||||||
|
makeContext("missing"),
|
||||||
|
);
|
||||||
|
expect(res.headers.get("x-request-id")).toBe("req_test-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when the query throws (swallowed by helper)", async () => {
|
||||||
|
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||||
|
findUniqueMock.mockRejectedValueOnce(new Error("db down"));
|
||||||
|
const res = await GET(
|
||||||
|
new NextRequest("https://x.test/api/rules/broken"),
|
||||||
|
makeContext("broken"),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with { rule } when a published rule exists", async () => {
|
||||||
|
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||||
|
const row = {
|
||||||
|
id: "rule-1",
|
||||||
|
title: "Mutual Aid Mondays",
|
||||||
|
summary: "A grassroots community in Denver.",
|
||||||
|
document: { sections: [] },
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-01-02T00:00:00Z"),
|
||||||
|
};
|
||||||
|
findUniqueMock.mockResolvedValueOnce(row);
|
||||||
|
const res = await GET(
|
||||||
|
new NextRequest("https://x.test/api/rules/rule-1"),
|
||||||
|
makeContext("rule-1"),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
rule: { id: string; title: string; summary: string | null };
|
||||||
|
};
|
||||||
|
expect(body.rule.id).toBe("rule-1");
|
||||||
|
expect(body.rule.title).toBe("Mutual Aid Mondays");
|
||||||
|
expect(body.rule.summary).toBe("A grassroots community in Denver.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const sessionCreateMock = vi.fn();
|
||||||
|
const sessionDeleteManyMock = vi.fn();
|
||||||
|
const getSessionPepperMock = vi.fn();
|
||||||
|
const newSessionTokenMock = vi.fn();
|
||||||
|
const hashSessionTokenMock = vi.fn();
|
||||||
|
const loggerWarnMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../lib/server/db", () => ({
|
||||||
|
prisma: {
|
||||||
|
session: {
|
||||||
|
create: (...args: unknown[]) => sessionCreateMock(...args),
|
||||||
|
deleteMany: (...args: unknown[]) => sessionDeleteManyMock(...args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/server/env", () => ({
|
||||||
|
getSessionPepper: () => getSessionPepperMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/server/hash", () => ({
|
||||||
|
hashSessionToken: (...args: unknown[]) => hashSessionTokenMock(...args),
|
||||||
|
newSessionToken: () => newSessionTokenMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/logger", () => ({
|
||||||
|
logger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: (...args: unknown[]) => loggerWarnMock(...args),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/headers", () => ({
|
||||||
|
cookies: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createSessionForUser,
|
||||||
|
pruneExpiredSessions,
|
||||||
|
} from "../../lib/server/session";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionCreateMock.mockReset();
|
||||||
|
sessionDeleteManyMock.mockReset();
|
||||||
|
getSessionPepperMock.mockReset();
|
||||||
|
newSessionTokenMock.mockReset();
|
||||||
|
hashSessionTokenMock.mockReset();
|
||||||
|
loggerWarnMock.mockReset();
|
||||||
|
|
||||||
|
getSessionPepperMock.mockReturnValue("test-pepper");
|
||||||
|
newSessionTokenMock.mockReturnValue("token-raw");
|
||||||
|
hashSessionTokenMock.mockReturnValue("token-hash");
|
||||||
|
sessionCreateMock.mockResolvedValue({});
|
||||||
|
sessionDeleteManyMock.mockResolvedValue({ count: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pruneExpiredSessions", () => {
|
||||||
|
it("deletes globally expired rows when no userId is supplied", async () => {
|
||||||
|
sessionDeleteManyMock.mockResolvedValueOnce({ count: 7 });
|
||||||
|
|
||||||
|
const count = await pruneExpiredSessions();
|
||||||
|
|
||||||
|
expect(count).toBe(7);
|
||||||
|
expect(sessionDeleteManyMock).toHaveBeenCalledTimes(1);
|
||||||
|
const arg = sessionDeleteManyMock.mock.calls[0]?.[0] as {
|
||||||
|
where: { expiresAt: { lt: Date }; userId?: string };
|
||||||
|
};
|
||||||
|
expect(arg.where.userId).toBeUndefined();
|
||||||
|
expect(arg.where.expiresAt.lt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes the prune to a single user when userId is supplied", async () => {
|
||||||
|
sessionDeleteManyMock.mockResolvedValueOnce({ count: 2 });
|
||||||
|
|
||||||
|
const count = await pruneExpiredSessions({ userId: "user-1" });
|
||||||
|
|
||||||
|
expect(count).toBe(2);
|
||||||
|
const arg = sessionDeleteManyMock.mock.calls[0]?.[0] as {
|
||||||
|
where: { expiresAt: { lt: Date }; userId?: string };
|
||||||
|
};
|
||||||
|
expect(arg.where.userId).toBe("user-1");
|
||||||
|
expect(arg.where.expiresAt.lt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never matches non-expired rows (multi-device safety)", async () => {
|
||||||
|
await pruneExpiredSessions({ userId: "user-1" });
|
||||||
|
|
||||||
|
const arg = sessionDeleteManyMock.mock.calls[0]?.[0] as {
|
||||||
|
where: { expiresAt: { lt: Date } };
|
||||||
|
};
|
||||||
|
expect(Object.keys(arg.where.expiresAt)).toEqual(["lt"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createSessionForUser cleanup behaviour", () => {
|
||||||
|
it("creates the new session and prunes the same user's expired rows", async () => {
|
||||||
|
vi.spyOn(Math, "random").mockReturnValue(0.99);
|
||||||
|
|
||||||
|
const result = await createSessionForUser("user-1");
|
||||||
|
|
||||||
|
expect(result.token).toBe("token-raw");
|
||||||
|
expect(result.expiresAt).toBeInstanceOf(Date);
|
||||||
|
expect(sessionCreateMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sessionDeleteManyMock).toHaveBeenCalledTimes(1);
|
||||||
|
const arg = sessionDeleteManyMock.mock.calls[0]?.[0] as {
|
||||||
|
where: { userId?: string; expiresAt: { lt: Date } };
|
||||||
|
};
|
||||||
|
expect(arg.where.userId).toBe("user-1");
|
||||||
|
expect(arg.where.expiresAt.lt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs an additional global sweep when the probability roll succeeds", async () => {
|
||||||
|
vi.spyOn(Math, "random").mockReturnValue(0.01);
|
||||||
|
|
||||||
|
await createSessionForUser("user-1");
|
||||||
|
|
||||||
|
expect(sessionDeleteManyMock).toHaveBeenCalledTimes(2);
|
||||||
|
const userScoped = sessionDeleteManyMock.mock.calls[0]?.[0] as {
|
||||||
|
where: { userId?: string };
|
||||||
|
};
|
||||||
|
const globalSweep = sessionDeleteManyMock.mock.calls[1]?.[0] as {
|
||||||
|
where: { userId?: string };
|
||||||
|
};
|
||||||
|
expect(userScoped.where.userId).toBe("user-1");
|
||||||
|
expect(globalSweep.where.userId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the global sweep when the probability roll fails", async () => {
|
||||||
|
vi.spyOn(Math, "random").mockReturnValue(0.06);
|
||||||
|
|
||||||
|
await createSessionForUser("user-1");
|
||||||
|
|
||||||
|
expect(sessionDeleteManyMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw out of sign-in when cleanup fails", async () => {
|
||||||
|
vi.spyOn(Math, "random").mockReturnValue(0.01);
|
||||||
|
sessionDeleteManyMock.mockRejectedValue(new Error("db down"));
|
||||||
|
|
||||||
|
const result = await createSessionForUser("user-1");
|
||||||
|
|
||||||
|
expect(result.token).toBe("token-raw");
|
||||||
|
expect(sessionCreateMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(loggerWarnMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never deletes the user's other still-valid sessions (multi-device policy)", async () => {
|
||||||
|
vi.spyOn(Math, "random").mockReturnValue(0.99);
|
||||||
|
|
||||||
|
await createSessionForUser("user-1");
|
||||||
|
|
||||||
|
for (const call of sessionDeleteManyMock.mock.calls) {
|
||||||
|
const arg = call[0] as { where: { expiresAt?: { lt: Date } } };
|
||||||
|
expect(arg.where.expiresAt?.lt).toBeInstanceOf(Date);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { getWebVitalsStorageMode } from "../../lib/server/webVitals/mode";
|
||||||
|
|
||||||
|
describe("getWebVitalsStorageMode", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns external when WEB_VITALS_STORAGE=external", () => {
|
||||||
|
vi.stubEnv("WEB_VITALS_STORAGE", "external");
|
||||||
|
vi.stubEnv("NODE_ENV", "development");
|
||||||
|
expect(getWebVitalsStorageMode()).toBe("external");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns local when WEB_VITALS_STORAGE=local", () => {
|
||||||
|
vi.stubEnv("WEB_VITALS_STORAGE", "local");
|
||||||
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
|
expect(getWebVitalsStorageMode()).toBe("local");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to external in production when unset", () => {
|
||||||
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
|
expect(getWebVitalsStorageMode()).toBe("external");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to local in development when unset", () => {
|
||||||
|
vi.stubEnv("NODE_ENV", "development");
|
||||||
|
expect(getWebVitalsStorageMode()).toBe("local");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps database to external until implemented", () => {
|
||||||
|
vi.stubEnv("WEB_VITALS_STORAGE", "database");
|
||||||
|
vi.stubEnv("NODE_ENV", "development");
|
||||||
|
expect(getWebVitalsStorageMode()).toBe("external");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { webVitalIngestSchema } from "../../lib/server/validation/webVitalsSchema";
|
||||||
|
|
||||||
|
describe("webVitalIngestSchema", () => {
|
||||||
|
it("accepts a valid payload", () => {
|
||||||
|
const parsed = webVitalIngestSchema.safeParse({
|
||||||
|
metric: "lcp",
|
||||||
|
data: { value: 1200, rating: "good" },
|
||||||
|
url: "https://example.com/path",
|
||||||
|
userAgent: "jest",
|
||||||
|
timestamp: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts numeric timestamp", () => {
|
||||||
|
const parsed = webVitalIngestSchema.safeParse({
|
||||||
|
metric: "cls",
|
||||||
|
data: { value: 0.05, rating: "good" },
|
||||||
|
url: "https://example.com/",
|
||||||
|
timestamp: 1_700_000_000_000,
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty metric", () => {
|
||||||
|
const parsed = webVitalIngestSchema.safeParse({
|
||||||
|
metric: "",
|
||||||
|
data: { value: 1, rating: "good" },
|
||||||
|
url: "https://example.com/",
|
||||||
|
timestamp: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty url", () => {
|
||||||
|
const parsed = webVitalIngestSchema.safeParse({
|
||||||
|
metric: "lcp",
|
||||||
|
data: { value: 1, rating: "good" },
|
||||||
|
url: "",
|
||||||
|
timestamp: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user