Initial project commit for Coolify staging
CI / Quality gate (push) Has been cancelled

This commit is contained in:
Andres Resiri
2026-08-03 10:45:41 -04:00
commit 70daeb214a
1199 changed files with 243503 additions and 0 deletions
+73
View File
@@ -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.
+279
View File
@@ -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.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,219 @@
# Phase 30: Site Tag Detection Registry Expansion
**Status:** Session 2 complete (commit in progress)
**Effort:** 6 hours across 3 sessions (3h + 2h + 1h)
**Risk:** Medium. Touches Site Tag JavaScript running on every client site.
> Full spec content lives in Claude conversation dated May 22-25, 2026.
> This file is a structured summary derived from that conversation.
---
## Problem Statement
The meSEO Site Tag detected approximately 20 plugins across two categories
(booking widgets and chat widgets). A modern WordPress or CMS site typically
has 15-30 trackable integrations. Coverage was roughly 25% of what is
actually present on a typical brand site.
Confirmed via production data on May 22, 2026:
- modernheartandvascular.com (cardiology): 4 plugins detected out of ~15-25 present
- lifeimagingfla.com (imaging): 1 plugin detected out of ~8-15 present
After Phase 30 Session 1: cardiology brand shows 11 detected plugins.
---
## Goal
1. Site Tag detects 50-80 known plugins, widgets, pixels, and integrations
2. Each detection fires a `plugin_detected` event with consistent metadata
including `category`, `tag_v`, and `confidence`
3. Integrations tab on Site Tag Analytics shows comprehensive per-brand inventories
4. Adding new detection patterns is a small registry edit
---
## Architecture
Detection happens entirely in `public/t.js` (served from Next.js static directory).
The unminified source lives at `public/t.source.js` (gitignored).
**Edit workflow:**
```bash
cp public/t.source.js public/t.js # copy source to served file
# edit public/t.js
npm run build:site-tag # minifies in place, backs up to t.source.js
```
**Three registries in `public/t.source.js`:**
| Registry | Location | Category | Count | Event shape |
|---|---|---|---|---|
| `BOOKING_PLUGINS` | ~line 5832 | booking, forms, crm | 15 entries | `{plugin, plugin_key, category, tag_v, confidence}` |
| `CHAT_WIDGETS` | ~line 6416 | chat | 15 entries | `{plugin, plugin_key: "chat_"+key, category: "chat", tag_v, confidence}` |
| `ANALYTICS_PLUGINS` | ~line 6659 | analytics, pixel, seo, page_builder, ecommerce, crm, reviews, cache, security, booking, lead_gen | ~47 entries after Session 2 | `{plugin, plugin_key, category, tag_v, confidence}` |
**Event metadata shape (all registries after Phase 30 Session 1 fix):**
```js
sendEvent('plugin_detected', {
plugin: 'Google Analytics 4', // display name
plugin_key: 'ga4', // machine key
category: 'analytics', // category string
tag_v: '2026.05.31', // Site Tag version (bump on each deploy)
confidence: 'high' | 'medium', // detection confidence
});
```
---
## Existing Registries (do NOT modify these in Phase 30)
### BOOKING_PLUGINS keys
amelia, calendly, nexhealth, acuity, jotform, typeform, wpforms,
gravityforms, contactform7, hubspot, deardoc, birdeye, podium, weave
### CHAT_WIDGETS keys
intercom, drift, tidio, tawkto, livechat, crisp, olark, hubspot_chat,
zendesk, fb_messenger, smartsupp, jivochat, purechat, userlike, frontchat
---
## ANALYTICS_PLUGINS Entries
### Session 1 (20 entries, commit cdb096f)
**Analytics and pixels (12):**
ga4, gtm, ua_legacy, fb_pixel, linkedin_insight, tiktok_pixel,
twitter_pixel, pinterest_tag, reddit_pixel, clarity, hotjar, amplitude
**SEO (3):** wordpress, yoast, rankmath
**Page builders (1):** elementor
**E-commerce (1):** woocommerce
**CRM (3):** hubspot_track, klaviyo, mailchimp
### Session 2 (~27 additional entries)
**Forms (2):** formidable, fluent_forms
**Page builders (4):** divi, beaver_builder, brizy, bricks
**Booking (4):** bookly, bookingpress, simplybook, setmore
**Reviews (3):** trustpilot, google_reviews_widget, nicejob
**Cache (2):** wp_rocket, litespeed_cache
**Security (2):** wordfence, sucuri
**Healthcare-specific (2):** patientpop, doctible
**SEO (2):** aioseo, seopress
**E-commerce (2):** shopify, edd
**CRM (2):** activecampaign, convertkit
**Skipped (already covered):**
- mailchimp: already in ANALYTICS_PLUGINS
- olark: already in CHAT_WIDGETS (fires as "chat_olark")
- zendesk_chat: already in CHAT_WIDGETS (fires as "chat_zendesk")
- hs_conversations: already in CHAT_WIDGETS (fires as "chat_hubspot_chat")
---
## Detection Strategies
### Window global
```js
detect: () => typeof window.fbq === 'function'
```
### Script src match (uses cached getScriptSrcs())
```js
detect: () => /googletagmanager\.com\/gtm\.js/.test(getScriptSrcs())
```
### DOM selector
```js
detect: () => !!document.querySelector('.gform_wrapper')
```
### Meta tag
```js
detect: () => {
const meta = document.querySelector('meta[name="generator"]');
return !!(meta && /Yoast SEO/.test(meta.getAttribute('content') || ''));
}
```
### Cookie name (uses cached getCookies())
```js
detect: () => /_fbp=/.test(getCookies())
```
### Composite (high confidence)
```js
detect: () => {
const hasGlobal = typeof window.fbq === 'function';
const hasCookie = /_fbp=/.test(getCookies());
if (hasGlobal && hasCookie) return { matched: true, confidence: 'high' };
if (hasGlobal || hasCookie) return { matched: true, confidence: 'medium' };
return false;
}
```
---
## Performance Constraints
- Detection total work: target under 5ms on mid-range mobile
- `requestIdleCallback` for post-DOMContentLoaded run (5000ms fallback)
- Skip detection if `document.hidden` is true
- `getScriptSrcs()` iterates `document.scripts` once per detection pass (cached)
- `getCookies()` reads `document.cookie` once per detection pass (cached)
- `window.__meseoDetectedKeys` Set prevents any plugin_key firing more than once per page load
- Detection runs at most twice: DOMContentLoaded + requestIdleCallback(timeout: 5000)
---
## Backend: ENRICHMENT_REGISTRY
Location: `src/lib/site-tag/integration-signatures.ts`
Provides vendor info, descriptions, and category defaults for backfill.
Keyed by `plugin_key`. Consulted by:
1. `backfill-detected-integrations` route when resolving category/vendor
2. `site-tag-analytics/integrations` GET route when merging description into response
---
## Phase Sessions
| Session | Status | Commit | Description |
|---|---|---|---|
| Session 1 | Complete | cdb096f | 20 ANALYTICS_PLUGINS rules, BOOKING_PLUGINS category fix, tag_v + confidence fields |
| Session 2 | Complete | (current) | ~27 additional rules, backend enrichment, docs |
| Session 3 | Pending | - | UI polish, category icons, vendor logos |
---
## Acceptance Criteria
- Site Tag detects at least 8 distinct plugins on modernheartandvascular.com (was 4)
- Site Tag detects at least 5 distinct plugins on an active lawn care brand site
- No false positives on a clean test site
- All `plugin_detected` events include `category`, `tag_v`, and `confidence`
- Detection adds less than 10ms to page load p95
- Integrations tab shows expanded set per brand within 24 hours of deploy
---
## Dependencies
- Phase 28A Session 1.5 (backend backfill fix): SHIPPED at commit 058272e
- Phase 28A Session 1.6 (SQL aggregation backfill): SHIPPED at commit 30363ed
- Phase 28A Session 2 (real-time write hook to /api/site-tag/event): PENDING