This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# Architecture Conventions
|
||||
|
||||
## Deployment Conventions
|
||||
|
||||
### Multi-brand testing requirement
|
||||
|
||||
Any code change to shared services (audit pipeline, site-tag-analytics,
|
||||
synthesizers, categorizers, conversion type registry) must be verified
|
||||
against at least:
|
||||
|
||||
- One medical brand (cardiology or imaging) for dense events, multi-language
|
||||
data shapes, and HIPAA-relevant surfaces
|
||||
- One service brand (lawn care portfolio) for high-volume events and
|
||||
multi-domain reconciliation
|
||||
- One B2B brand (TPAction / AiGrowth360) for low-event-volume edge cases
|
||||
and lead gen flows
|
||||
- One ecommerce brand if available for product schema and checkout flows
|
||||
|
||||
Verification recipes must specify at least two brands of different verticals.
|
||||
"Verify on cardiology" alone is insufficient.
|
||||
|
||||
Single-brand verification was the root cause of the Phase 19A.16 missed
|
||||
form_filled crash on Fairway Lawns (May 27, 2026).
|
||||
|
||||
### Conversion type registration requirement
|
||||
|
||||
Before merging any change to shared event or conversion categorization code,
|
||||
run the comprehensive type discovery SQL:
|
||||
|
||||
```sql
|
||||
SELECT "brandId", "conversionType", COUNT(*)
|
||||
FROM "SiteConversion"
|
||||
WHERE timestamp >= NOW() - INTERVAL '30 days'
|
||||
AND "conversionType" NOT IN (
|
||||
'appointment_booked','form_submitted','phone_call','email_contact',
|
||||
'sms_contact','purchase','appointment_attempted','form_started',
|
||||
'appointment_intent','chat_initiated','newsletter_signup','add_to_cart',
|
||||
'checkout_started','file_download','waitlist_signup','form_filled',
|
||||
'form_submit','form_submission','phone_number_click','click_to_call',
|
||||
'calendly','booking_confirmed','booking_attempt','email_click',
|
||||
'sms_click','phone_copy','phone_input','form_iframe_present',
|
||||
'error_count_update'
|
||||
)
|
||||
GROUP BY "brandId", "conversionType"
|
||||
ORDER BY count DESC;
|
||||
```
|
||||
|
||||
Confirm zero rows returned, or explicitly handle every type returned before
|
||||
shipping.
|
||||
|
||||
New types must be registered in:
|
||||
1. `SITE_CONVERSION_TYPES` in `src/lib/services/site-tag-analytics.ts`
|
||||
(query allowlist -- unregistered types are silently dropped)
|
||||
2. `CONVERSION_TIERS` in `src/lib/conversions/tiers.ts`
|
||||
(tier mapping -- unregistered types crash route.ts via undefined key lookup)
|
||||
|
||||
The defensive guard at `src/app/api/site-tag/analytics/route.ts` around
|
||||
the `tierTotals[cfg.tier]` access will log a warning and skip rather than
|
||||
crash for any future type that slips through, but the correct fix is always
|
||||
to register the type proactively.
|
||||
|
||||
### Tier assignment guidelines
|
||||
|
||||
| Tier | Description | isCounted | Examples |
|
||||
|------|-------------|-----------|---------|
|
||||
| completed | Definitive conversion action | true | form_submit, phone_call, appointment_booked, purchase |
|
||||
| intent | Started a conversion flow | false | form_filled, appointment_attempted, checkout_started |
|
||||
| signal | Engagement only (no conversion flow) | false | phone_copy, phone_input, form_iframe_present |
|
||||
|
||||
The `DEFAULT_COUNTED_TIERS` constant controls which tiers roll up into the
|
||||
headline conversion count and conversion rate. Only "completed" is counted
|
||||
by default. Brand-level overrides via `BrandConversionConfig.isCounted` can
|
||||
promote intent signals to counted status for specific brands.
|
||||
@@ -0,0 +1,241 @@
|
||||
# Audit Pipeline: Current State (Phase 19A.1 Discovery)
|
||||
|
||||
**Date:** 2026-05-26
|
||||
**Scope:** Read-only architectural discovery of the full audit pipeline as it exists post-Phase 17.
|
||||
|
||||
---
|
||||
|
||||
## 1. Three Distinct Audit Runners
|
||||
|
||||
The codebase contains three independent audit runners with separate entry points, separate Prisma write targets, and separate triggering surfaces. They share no code.
|
||||
|
||||
### 1.1 Main SEO Audit Runner
|
||||
|
||||
**File:** `src/lib/audit/audit-runner.ts` (1594 lines)
|
||||
**Export:** `runAudit(auditId: string): Promise<void>`
|
||||
**Triggered by:** `POST /api/admin/audits/trigger` via Vercel `waitUntil`
|
||||
**Prisma write target:** `Audit.seoSection` (JSONB)
|
||||
**Model:** central to all Phase 11A-17 dimension output
|
||||
|
||||
This is the primary audit pipeline. It orchestrates all dimension data sources, the two LLM synthesis calls, and the final DB write. All output lands in a single JSONB field (`Audit.seoSection`) on the `Audit` record.
|
||||
|
||||
### 1.2 Technical Audit Runner
|
||||
|
||||
**File:** `src/lib/services/audit-runner.ts`
|
||||
**Export:** `runTechnicalAudit(brandId: string, domain: string): Promise<AuditResult>`
|
||||
**Triggered by:** `POST /api/admin/audits/[id]/retry` and brand-health scheduler
|
||||
**Prisma write targets:** `TechnicalAudit`, `TechnicalIssue` (row-per-issue table)
|
||||
**Model:** independent crawler with headless fallback via Puppeteer/Chromium
|
||||
|
||||
Crawls up to 100 pages, checks each for HTTP errors, broken links, redirect chains, missing meta, etc., then bulk-inserts findings as `TechnicalIssue` rows.
|
||||
|
||||
### 1.3 Performance Audit Runner
|
||||
|
||||
**File:** `src/lib/services/performance-audit-runner.ts`
|
||||
**Export:** `runPerformanceAudit(brandId: string, domain: string): Promise<PerfAuditResult>`
|
||||
**Triggered by:** `POST /api/performance-audit/run`
|
||||
**Prisma write targets:** `PerformanceAudit`, `PerformancePageScore` (row-per-URL)
|
||||
**Model:** PageSpeed Insights API calls per URL, scored per strategy (mobile/desktop)
|
||||
|
||||
Reads from `GscPage` and `TrackedEvent` to pick the top pages to test, then calls PageSpeed Insights for each, writing per-URL scores to `PerformancePageScore`.
|
||||
|
||||
### 1.4 Content Audit
|
||||
|
||||
**API route:** `POST /api/content-audit/run`
|
||||
**Prisma write targets:** `ContentAuditResult`, `AuditScoreHistory`
|
||||
**Note:** Not a standalone runner file -- logic lives inline in the route handler.
|
||||
|
||||
---
|
||||
|
||||
## 2. Main Audit Runner: Phase Sequence
|
||||
|
||||
The `runAudit` function orchestrates work in five labeled phases plus a degraded-mode path.
|
||||
|
||||
```
|
||||
Phase 1 DFS site crawl + first-party data fetch + (3rd-party) backlink fetch [parallel]
|
||||
Phase 2 Firecrawl key-page content fetch + site analysis [parallel]
|
||||
Phase 3 Industry classification (Haiku, ~5 s) [sequential]
|
||||
Phase 4 SERP analysis + bilingual audit (3rd-party) [parallel]
|
||||
Phase 5 All dimension aggregators + two LLM synthesis calls [parallel where possible]
|
||||
Phase 12 Live visual annotations + competitor visual comparison [parallel, 180 s budget]
|
||||
```
|
||||
|
||||
**Degraded mode** activates when the crawl returns 0 pages. It skips SERP-dependent dimensions (snippet capture) and runs a subset: Phase 12 (homepage-only), 13, and 15 in parallel using fallback data.
|
||||
|
||||
---
|
||||
|
||||
## 3. industryContext: Lifecycle and Constraints
|
||||
|
||||
`industryContext` is computed in Phase 3 by `classifyIndustry()` and passed through the pipeline as an in-memory value. It is **never written to Prisma**.
|
||||
|
||||
**Type (as of Phase 19.0):**
|
||||
```typescript
|
||||
interface IndustryContext {
|
||||
industry: string; // backward-compat alias for industries[0]
|
||||
industries: string[]; // all applicable industries, primary first (max 3)
|
||||
subVertical: string;
|
||||
audienceFraming: string;
|
||||
relevantFrameworks: string[];
|
||||
aiVisibilityBenchmark: string;
|
||||
schemaPriorities: string[];
|
||||
suggestedQueries: string[]; // 5-7 queries; fed to Phase 4 SERP
|
||||
conversionPathExpectation: string[];
|
||||
}
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
1. Haiku classifies the brand using homepage markdown (first 1200 chars).
|
||||
2. Result stored on `AuditInput.industryContext`.
|
||||
3. `suggestedQueries` fed to `fetchSerpAnalysis` in Phase 4.
|
||||
4. `industryContext` passed to all Phase 5 dimension aggregators that need it.
|
||||
5. `inferredIndustry` (= `industries[0]`) stored in `seoSection.inferredIndustry` -- the only persisted trace of classification.
|
||||
|
||||
**Models used:**
|
||||
- Industry classifier: `claude-haiku-4-5-20251001`
|
||||
- SEO narrative: `claude-sonnet-4-6` (with prompt caching)
|
||||
- Structured synthesis: `claude-haiku-4-5-20251001`
|
||||
|
||||
---
|
||||
|
||||
## 4. AuditInput: The Pipeline Accumulator
|
||||
|
||||
`AuditInput` (defined in `src/lib/audit/types.ts`) is a mutable object assembled incrementally across Phases 1-5. Each phase appends its output to the relevant optional field. The synthesizer receives the fully-populated input at Phase 5.
|
||||
|
||||
All `*Context` fields are optional. Absent fields signal "not available" -- dimension aggregators and the synthesizer each have fallback behavior when their input is null.
|
||||
|
||||
**First-party-only fields** (absent on 3rd-party audits):
|
||||
`brandContext`, `behavioralContext`, `aiAttributionContext`, `webVitalsContext`,
|
||||
`crawlStatsContext`, `signalCenterContext`
|
||||
|
||||
**Conditionally-present fields** (both audit types, depend on crawl success):
|
||||
All remaining `*Context` fields.
|
||||
|
||||
---
|
||||
|
||||
## 5. Dimension Data Sources: Pure Functions
|
||||
|
||||
Every Phase 5 aggregator in `src/lib/audit/data-sources/` is a **pure function** -- no Prisma reads or writes. They receive `AuditInput` slices and return typed structs that get merged into `enrichedSeoSection` before the single final DB write.
|
||||
|
||||
| Import name | File | Activates when |
|
||||
|---|---|---|
|
||||
| `aggregateSignalCenter` | data-sources/signal-center | signalCenterContext present |
|
||||
| `aggregateBilingualAudit` | data-sources/bilingual-audit | bilingualAuditContext present |
|
||||
| `aggregateCompetitorAnalysis` | data-sources/competitor-analysis | serpAnalysis present |
|
||||
| `aggregateSchemaOpportunityForecast` | data-sources/schema-opportunity-forecast | industryContext present |
|
||||
| `aggregateIndustryBenchmarkComparison` | data-sources/industry-benchmark-comparison | industryContext present |
|
||||
| `aggregatePageStrategicPass` | data-sources/page-strategic-pass | pages available |
|
||||
| `aggregateAiEnginePersonalityAnalysis` | data-sources/ai-engine-personality-analysis | always |
|
||||
| `aggregateAiSourceAttributionComparison` | data-sources/ai-source-attribution-comparison | aiAttributionContext present |
|
||||
| `aggregateBehavioralActionBridge` | data-sources/behavioral-action-bridge | behavioralContext present |
|
||||
| `aggregateEeatAudit` | data-sources/eeat-audit | pages + industryContext |
|
||||
| `runVoiceSearchEligibility` | data-sources/voice-search-eligibility | pages + contentSamples |
|
||||
| `aggregateAiSnippetEligibility` | data-sources/ai-snippet-eligibility | pages present |
|
||||
| `aggregateMultiTouchAiPath` | data-sources/multi-touch-ai-path | aiAttributionContext present |
|
||||
| `aggregateRevenueAttribution` | data-sources/revenue-attribution | first-party data |
|
||||
| `aggregatePredictiveConversionScoring` | data-sources/predictive-conversion-scoring | first-party data |
|
||||
| `aggregateConversionHeatMap` | data-sources/conversion-heat-map | behavioral data |
|
||||
| `aggregateAiSearchVerification` | data-sources/ai-search-verification | always (async agent) |
|
||||
| `aggregateLostOpportunity` | data-sources/lost-opportunity-calculator | findings present |
|
||||
| `aggregateLiveVisualAnnotations` | data-sources/live-visual-annotations | Phase 12, parallel |
|
||||
| `aggregateCompetitorVisualComparison` | data-sources/competitor-visual-comparison | Phase 12, parallel |
|
||||
| `aggregateBrandAuthorityScore` | data-sources/brand-authority | eeat + schema data |
|
||||
| `aggregateSnippetCaptureStrategy` | data-sources/snippet-capture-strategy | serpAnalysis present |
|
||||
| `aggregateMobileFirstAudit` | data-sources/mobile-first-audit | pages present |
|
||||
| `aggregatePageSpeedInsights` | data-sources/page-speed-deep-dive | webVitalsContext |
|
||||
|
||||
---
|
||||
|
||||
## 6. SeoSection: The Single JSONB Output
|
||||
|
||||
`SeoSection` (defined in `src/lib/audit/types.ts`, ~155 lines) is the TypeScript shape of `Audit.seoSection` in Prisma. It is a wide JSON blob -- every dimension output lives as an optional field on this single object.
|
||||
|
||||
**Core fields (always present on completed audits):**
|
||||
```
|
||||
healthScore, narrative, keyFindings, priorityActions, quickWins, scoringBreakdown
|
||||
```
|
||||
|
||||
**Additive consulting fields:**
|
||||
```
|
||||
prioritizedFindings, additionalFindings, summaryThemes, inferredIndustry, displayName
|
||||
```
|
||||
|
||||
**Conditional enrichments (field absent when not applicable):**
|
||||
```
|
||||
aiSearchPerformance, brandEcho, dimensionScores, siteTagContext, methodology,
|
||||
industryFramework, competitiveLandscape, backlinkProfile, publicAiVisibility,
|
||||
organicSearchSummary, aiConversionAttribution, signalCenter, bilingualAudit,
|
||||
competitorAnalysis, schemaOpportunityForecast, strategicRoadmap,
|
||||
industryBenchmarkComparison, pageStrategicPass, aiEnginePersonalityAnalysis,
|
||||
aiSourceAttributionComparison, behavioralActionBridge, eeatAudit,
|
||||
voiceSearchEligibility, aiSnippetEligibility, multiTouchAiPath, revenueAttribution,
|
||||
predictiveConversionScoring, conversionHeatMap, aiSearchVerification,
|
||||
lostOpportunityCalculation, liveVisualAnnotations, competitorVisualComparison,
|
||||
brandAuthorityScore, snippetCaptureStrategy, mobileFirstAudit, pageSpeedInsights,
|
||||
executiveBrief, lockedFirstPartyTeaser
|
||||
```
|
||||
|
||||
**Engineering-only internal field:**
|
||||
```
|
||||
internal.dataSourcesUsed, internal.costLog, internal.auditType
|
||||
```
|
||||
Must not be rendered by dashboard components or PDF templates.
|
||||
|
||||
**Single Prisma write pattern:**
|
||||
```typescript
|
||||
await prisma.audit.update({
|
||||
where: { id: auditId },
|
||||
data: { seoSection: enrichedSeoSection as object },
|
||||
});
|
||||
```
|
||||
All 30+ dimension outputs are assembled in memory then written in one round trip.
|
||||
|
||||
---
|
||||
|
||||
## 7. Persistence: Write Targets by Runner
|
||||
|
||||
| Runner | Table(s) written | Pattern |
|
||||
|---|---|---|
|
||||
| Main SEO (`audit-runner.ts`) | `Audit.seoSection` (JSONB) | Single update at end of run |
|
||||
| Main SEO (progress) | `Audit` (status, progress fields) | Multiple incremental updates during run |
|
||||
| Technical (`services/audit-runner.ts`) | `TechnicalAudit`, `TechnicalIssue` | Create + bulk createMany |
|
||||
| Performance (`performance-audit-runner.ts`) | `PerformanceAudit`, `PerformancePageScore` | Create + createMany per URL |
|
||||
| Content audit (`/api/content-audit/run`) | `ContentAuditResult`, `AuditScoreHistory` | Create per run |
|
||||
|
||||
**Connection-leak mitigation (two-layer):**
|
||||
- Client-side: `boundedFireAndForget` uses `Promise.race` with a hard timeout so the serverless function is never blocked waiting on a Prisma query past its useful lifetime.
|
||||
- Server-side: `prisma.$transaction({ timeout })` enforces a server-enforced maximum on any transaction, releasing the connection-pool slot even if the JS promise chain is abandoned.
|
||||
|
||||
---
|
||||
|
||||
## 8. UI Rendering: Audit Report Surfaces
|
||||
|
||||
The `seoSection` JSONB is read (never mutated) by three display surfaces:
|
||||
|
||||
| Surface | Route | Notes |
|
||||
|---|---|---|
|
||||
| Public audit page | `src/app/audit/[id]/page.tsx` | Gated by `publicSlug`; shows subset of sections |
|
||||
| Admin audit page | `src/app/admin/audits/[id]/page.tsx` | Full section visibility |
|
||||
| Client portal | `src/app/client/[brandId]/audit/page.tsx` | Brand-linked first-party view |
|
||||
| PDF report | `src/lib/audit/pdf/AuditPdf.tsx` | Generated via `@react-pdf/renderer`; served from `/api/admin/audits/[id]/pdf` |
|
||||
|
||||
**Section rendering convention:**
|
||||
Each dashboard section component checks `hasData` (or a null check on the parent object) and returns `null` when the dimension output is absent. The TOC is constructed by the page from the same boolean flags, so suppressed sections disappear from the table of contents automatically.
|
||||
|
||||
**Separate performance/technical UIs:**
|
||||
`/technical-audit`, `/site-performance-audit`, and `/content-audit` are independent pages that read from their respective tables (`TechnicalAudit`, `PerformanceAudit`, `ContentAuditResult`) -- they do not read `Audit.seoSection`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Key Architectural Constraints for Phase 19 Work
|
||||
|
||||
1. **All new dimension outputs must be appended to `SeoSection` as optional fields.** There is no separate table for Phase 11A-17 dimension data. The JSONB approach means zero migrations for new dimensions.
|
||||
|
||||
2. **Dimension aggregators must remain pure functions.** No Prisma reads or writes inside `src/lib/audit/data-sources/`. Data flows in via `AuditInput` slices; output is returned as a typed struct.
|
||||
|
||||
3. **industryContext is ephemeral.** If a dimension needs to activate conditionally on industry (e.g., HIPAA compliance when `industries.some(i => i.startsWith('healthcare-'))`), the check must happen inside the aggregator or inside the runner at Phase 5 gate -- not via a DB lookup.
|
||||
|
||||
4. **Any new DB write in Phase 19 (e.g., ComplianceAuditResult) requires both layers:** `boundedFireAndForget` at the call site and `$transaction({ timeout })` inside the write operation. The connection pool has 14 slots shared across all serverless function instances.
|
||||
|
||||
5. **Model names must never appear in user-facing output.** `SeoSection.internal` is the only field where model/vendor names are permitted. All other fields, dashboard copy, and PDF copy must use generic language.
|
||||
|
||||
6. **Primary industry stability.** The `industries[0]` value must equal what would have been returned in single-industry mode. Secondary industries are additive-only. Downstream gate logic checking `industryContext.industry` (backward-compat alias) remains correct without changes.
|
||||
@@ -0,0 +1,279 @@
|
||||
# Phase 16: Validation and Auth Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
Before Phase 16, auth and validation were duplicated inline across API routes. Each handler called `getCurrentUser()` directly, repeated the null check, re-fetched the brand, re-verified membership, and manually returned `NextResponse.json({ error: "..." }, { status: 401 })` in its own way. Zod parse errors had no standard shape. The result was inconsistent error responses and auth logic that could drift per route.
|
||||
|
||||
Phase 16 centralised this into four layers:
|
||||
|
||||
1. **Error types** (`src/lib/api/errors.ts`): typed exceptions with HTTP status codes attached
|
||||
2. **`withErrorHandler`** (`src/lib/api/with-error-handler.ts`): catches those exceptions and converts them to uniform JSON responses
|
||||
3. **Auth guards** (`src/lib/auth/require.ts`, `src/lib/admin-auth.ts`): throw the typed exceptions instead of returning responses, so they compose cleanly inside `withErrorHandler`
|
||||
4. **Validation helpers** (`src/lib/validation/request.ts`): parse and throw `ValidationError` on bad input
|
||||
|
||||
---
|
||||
|
||||
## Error Types
|
||||
|
||||
`src/lib/api/errors.ts`
|
||||
|
||||
All errors extend `APIError`, which carries a `statusCode`. Route handlers and helpers throw these; `withErrorHandler` catches them.
|
||||
|
||||
| Class | Status | When to use |
|
||||
|---|---|---|
|
||||
| `AuthError` | 401 | User is not authenticated |
|
||||
| `ForbiddenError` | 403 | User is authenticated but lacks access |
|
||||
| `NotFoundError` | 404 | Resource does not exist |
|
||||
| `ValidationError` | 400 | Request body or query params failed schema validation |
|
||||
| `RateLimitError` | 429 | Rate limit exceeded |
|
||||
| `ServerError` | 500 | Explicit internal error (prefer letting unexpected errors bubble) |
|
||||
|
||||
---
|
||||
|
||||
## `withErrorHandler`
|
||||
|
||||
`src/lib/api/with-error-handler.ts`
|
||||
|
||||
Wraps a route handler. Catches `APIError` subclasses and `ZodError` (as a backstop) and converts them to a consistent JSON shape. Logs unhandled errors to `console.error`.
|
||||
|
||||
```typescript
|
||||
export const GET = withErrorHandler(async (req, ...args) => {
|
||||
// throw AuthError, ForbiddenError, ValidationError, etc. freely here
|
||||
return NextResponse.json({ ... });
|
||||
});
|
||||
```
|
||||
|
||||
**Response shapes on error:**
|
||||
|
||||
```json
|
||||
// APIError
|
||||
{ "error": "message" }
|
||||
{ "error": "message", "details": { ... } } // when details are present
|
||||
|
||||
// ZodError (converted to ValidationError internally)
|
||||
{ "error": "Invalid request body", "details": { "code": "VALIDATION_ERROR", "fieldErrors": { ... } } }
|
||||
```
|
||||
|
||||
All routes that use `requireUser` or `requireUserAndBrand` must be wrapped with `withErrorHandler`. Without the wrapper, thrown `AuthError` / `ForbiddenError` instances are uncaught and produce a 500.
|
||||
|
||||
---
|
||||
|
||||
## Auth Helpers
|
||||
|
||||
### `requireUser`
|
||||
|
||||
`src/lib/auth/require.ts`
|
||||
|
||||
```typescript
|
||||
async function requireUser(): Promise<User>
|
||||
```
|
||||
|
||||
Calls `getCurrentUser()` (Clerk session resolution + Prisma upsert). Throws `AuthError("Unauthorized")` if there is no active session.
|
||||
|
||||
Returns the Prisma `User` record including `memberships` (with nested `organization`).
|
||||
|
||||
### `requireUserAndBrand`
|
||||
|
||||
```typescript
|
||||
async function requireUserAndBrand(
|
||||
brandId: string
|
||||
): Promise<{ user: User; brand: Brand }>
|
||||
```
|
||||
|
||||
Calls `requireUser`, then:
|
||||
1. Calls `verifyBrandAccess(user.id, brandId)`, which checks `BrandMembership` rows and org-owner role. Throws `ForbiddenError("Forbidden")` if the user has no access.
|
||||
2. Fetches the brand with `prisma.brand.findUnique`. Throws `NotFoundError("Brand not found")` if the row does not exist.
|
||||
|
||||
Returns `{ user, brand }`. The caller almost never needs to use the return value since the primary purpose is the guard, but the brand object is available when the handler needs it immediately without a second query.
|
||||
|
||||
```typescript
|
||||
// Guard only
|
||||
export const GET = withErrorHandler(async (req, ...args) => {
|
||||
const { brandId } = (args[0] as { params: { brandId: string } }).params;
|
||||
await requireUserAndBrand(brandId);
|
||||
// ... handler logic
|
||||
});
|
||||
|
||||
// Using the return value
|
||||
export const POST = withErrorHandler(async (req, ...args) => {
|
||||
const { brandId } = (args[0] as { params: { brandId: string } }).params;
|
||||
const { brand } = await requireUserAndBrand(brandId);
|
||||
// brand.domain, brand.plan, etc. available without a second query
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `requireAdmin`
|
||||
|
||||
`src/lib/admin-auth.ts`
|
||||
|
||||
```typescript
|
||||
async function requireAdmin(): Promise<
|
||||
| { ok: true; user: AdminUser }
|
||||
| { ok: false; status: number }
|
||||
>
|
||||
```
|
||||
|
||||
**Legacy helper.** Returns a discriminated union rather than throwing. Used by pre-existing `/api/admin-legacy` routes and a handful of older admin endpoints.
|
||||
|
||||
Checks the `ADMIN_EMAILS` environment variable (comma-separated list). If the env var is empty, denies all requests (fail-safe default; no misconfigured deployment accidentally grants access).
|
||||
|
||||
**Callers must check `ok` and return early:**
|
||||
|
||||
```typescript
|
||||
const auth = await requireAdmin();
|
||||
if (!auth.ok) return NextResponse.json({ error: "Forbidden" }, { status: auth.status });
|
||||
```
|
||||
|
||||
Do not use `requireAdmin` for new routes. Use `requireSuperadmin` instead.
|
||||
|
||||
### `requireSuperadmin`
|
||||
|
||||
```typescript
|
||||
async function requireSuperadmin(): Promise<Response | null>
|
||||
```
|
||||
|
||||
**Current admin guard.** Returns `null` when the caller is allowed to proceed, or a `NextResponse` (401/403) when denied. This is the opposite of the throwing pattern; callers return early on non-null.
|
||||
|
||||
```typescript
|
||||
export const GET = withErrorHandler(async (req) => {
|
||||
const denied = await requireSuperadmin();
|
||||
if (denied) return denied;
|
||||
// ... handler logic
|
||||
});
|
||||
```
|
||||
|
||||
Checks (in order): Clerk session claims `metadata.role === "superadmin"`, `publicMetadata.role === "superadmin"`, `metadata.isSuperadmin === true`, `publicMetadata.isSuperadmin === true`, then falls back to `ADMIN_EMAILS` email match.
|
||||
|
||||
Does NOT throw (it returns a response), so `withErrorHandler` is not strictly required around it, but wrapping is still preferred for consistent unhandled-error behaviour.
|
||||
|
||||
### `getSuperadmin`
|
||||
|
||||
```typescript
|
||||
async function getSuperadmin(): Promise<SuperadminIdentity | null>
|
||||
```
|
||||
|
||||
Pure boolean check. Returns `{ userId, email }` when the session is a superadmin, `null` otherwise. Does not return a response. Use this when you need to branch on admin status within a route that also serves non-admin users. `requireSuperadmin` is the right choice when the entire route is admin-only.
|
||||
|
||||
### `withAdminTiming`
|
||||
|
||||
```typescript
|
||||
async function withAdminTiming<T>(name: string, handler: () => Promise<T>): Promise<T>
|
||||
```
|
||||
|
||||
Wraps a block with `console.info` / `console.error` timing output tagged `[admin:name]`. Result array length is logged when the result is an array. Use in admin routes where query time is worth tracking in Vercel logs.
|
||||
|
||||
```typescript
|
||||
const result = await withAdminTiming("platform-metrics", async () => {
|
||||
return await prisma.metricSnapshot.findMany({ ... });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation Helpers
|
||||
|
||||
`src/lib/validation/request.ts`
|
||||
|
||||
### `validateBody`
|
||||
|
||||
```typescript
|
||||
async function validateBody<T>(req: Request, schema: ZodSchema<T>): Promise<T>
|
||||
```
|
||||
|
||||
Parses `req.json()`. Throws `ValidationError("Request body must be valid JSON")` on JSON parse failure and `ValidationError("Invalid request body")` with `fieldErrors` on schema failure. Both are caught by `withErrorHandler`.
|
||||
|
||||
### `validateQuery`
|
||||
|
||||
```typescript
|
||||
function validateQuery<T>(req: Request, schema: ZodSchema<T>): T
|
||||
```
|
||||
|
||||
Parses `new URL(req.url).searchParams` as a flat string object. Throws `ValidationError("Invalid query parameters")` on schema failure. Note: all values are strings from the URL; Zod coercion (`z.coerce.number()`) is required to parse numerics.
|
||||
|
||||
---
|
||||
|
||||
## Full Route Example
|
||||
|
||||
```typescript
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { withErrorHandler } from "@/lib/api/with-error-handler";
|
||||
import { requireUserAndBrand } from "@/lib/auth/require";
|
||||
import { validateBody, validateQuery } from "@/lib/validation/request";
|
||||
|
||||
const QuerySchema = z.object({
|
||||
range: z.enum(["7d", "30d", "90d"]).default("30d"),
|
||||
});
|
||||
|
||||
const BodySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
value: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export const GET = withErrorHandler(async (req, ...args) => {
|
||||
const { brandId } = (args[0] as { params: { brandId: string } }).params;
|
||||
await requireUserAndBrand(brandId);
|
||||
const { range } = validateQuery(req, QuerySchema);
|
||||
return NextResponse.json({ range });
|
||||
});
|
||||
|
||||
export const POST = withErrorHandler(async (req, ...args) => {
|
||||
const { brandId } = (args[0] as { params: { brandId: string } }).params;
|
||||
await requireUserAndBrand(brandId);
|
||||
const body = await validateBody(req, BodySchema);
|
||||
// ... use body.name, body.value
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
Older routes (pre-Phase-16) do auth inline. The migration is mechanical:
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
export async function GET(req: Request) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const brandId = new URL(req.url).searchParams.get("brandId");
|
||||
const brand = await prisma.brand.findUnique({
|
||||
where: { id: brandId! },
|
||||
include: { organization: { include: { memberships: true } } },
|
||||
});
|
||||
if (!brand || !brand.organization.memberships.some((m) => m.userId === user.id)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// ... handler
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
export const GET = withErrorHandler(async (req, ...args) => {
|
||||
const { brandId } = (args[0] as { params: { brandId: string } }).params;
|
||||
await requireUserAndBrand(brandId);
|
||||
// ... handler
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
**`requireUserAndBrand` vs. manual membership check.** The manual pre-Phase-16 pattern fetched the brand and checked `memberships.some(m => m.userId === user.id)`. `requireUserAndBrand` calls `verifyBrandAccess`, which also grants access to org owners regardless of an explicit `BrandMembership` row. Converting a route to use `requireUserAndBrand` may grant access to org owners who previously could not reach that route. This is almost always the correct behaviour but worth noting.
|
||||
|
||||
**`withErrorHandler` is required around throwing guards.** `requireUser` and `requireUserAndBrand` throw exceptions. If you call them inside a handler that is not wrapped with `withErrorHandler`, the exception propagates and Next.js returns a 500. Always pair them.
|
||||
|
||||
**`requireSuperadmin` returns a response, not an exception.** It cannot be placed inside `withErrorHandler` and be handled automatically. The `if (denied) return denied` idiom is intentional; the return type `Response | null` makes the pattern explicit at the call site.
|
||||
|
||||
**`requireAdmin` is legacy.** New admin routes use `requireSuperadmin`. Do not add more callers of `requireAdmin`. The two helpers are not interchangeable; `requireAdmin` has no Clerk role awareness.
|
||||
|
||||
**Query params are always strings.** `validateQuery` uses `new URL(req.url).searchParams`, which returns strings for all values. Use `z.coerce.number()` or `z.coerce.boolean()` in the schema to parse non-string types from query strings.
|
||||
|
||||
**`validateBody` is async, `validateQuery` is not.** `validateBody` must be awaited; `validateQuery` is synchronous. Mixing them up is a TypeScript error but worth being aware of when reading unfamiliar routes.
|
||||
Reference in New Issue
Block a user