From f077966bb2b7f8e5c4822fc3c61b73a9b6d77a54 Mon Sep 17 00:00:00 2001 From: DMleadgen Date: Thu, 16 Apr 2026 15:09:26 -0600 Subject: [PATCH] fix: enforce tenant-scoped manuals and deployment gates --- app/manuals/page.tsx | 69 +++++++++++++++-- convex/manuals.ts | 81 +++++++++++++++++++- docs/manuals-tenant-recovery.md | 45 ++++++++++++ lib/convex-service.ts | 23 ++++-- lib/manuals-tenant.test.ts | 90 +++++++++++++++++++++++ lib/manuals-tenant.ts | 79 ++++++++++++++++++++ package.json | 6 +- scripts/backfill-convex-manuals-tenant.ts | 60 +++++++++++++++ scripts/check-convex-manuals-gate.mjs | 82 +++++++++++++++++++++ scripts/deploy-readiness.mjs | 44 +++++++++++ scripts/staging-smoke.mjs | 47 ++++++++++++ scripts/sync-manuals-to-convex.ts | 28 ++++++- 12 files changed, 636 insertions(+), 18 deletions(-) create mode 100644 docs/manuals-tenant-recovery.md create mode 100644 lib/manuals-tenant.test.ts create mode 100644 lib/manuals-tenant.ts create mode 100644 scripts/backfill-convex-manuals-tenant.ts create mode 100644 scripts/check-convex-manuals-gate.mjs diff --git a/app/manuals/page.tsx b/app/manuals/page.tsx index dfbd5b79..994ea64e 100644 --- a/app/manuals/page.tsx +++ b/app/manuals/page.tsx @@ -1,8 +1,10 @@ export const dynamic = "force-dynamic" +import { createHash } from "node:crypto" import { existsSync } from "fs" import { join } from "path" import { Metadata } from "next" +import { headers } from "next/headers" import { businessConfig } from "@/lib/seo-config" import { ManualsPageExperience } from "@/components/manuals-page-experience" import { listConvexManuals } from "@/lib/convex-service" @@ -10,6 +12,7 @@ import { scanManuals } from "@/lib/manuals" import { selectManualsForSite } from "@/lib/manuals-site-selection" import { generateSEOMetadata, generateStructuredData } from "@/lib/seo" import { getManualsThumbnailsRoot } from "@/lib/manuals-paths" +import { resolveManualsTenantDomain } from "@/lib/manuals-tenant" export const metadata: Metadata = generateSEOMetadata({ title: "Vending Machine Manuals | Rocky Mountain Vending", @@ -31,14 +34,54 @@ export const metadata: Metadata = generateSEOMetadata({ }) export default async function ManualsPage() { - // Prefer Convex-backed metadata, but keep filesystem fallback in place until the shared catalog is fully populated. - const convexManuals = await listConvexManuals() + const requestHeaders = await headers() + const requestHost = + requestHeaders.get("x-forwarded-host") || requestHeaders.get("host") + const manualsDomain = resolveManualsTenantDomain({ + requestHost, + envTenantDomain: process.env.MANUALS_TENANT_DOMAIN, + envSiteDomain: process.env.NEXT_PUBLIC_SITE_DOMAIN, + }) + + const convexManuals = manualsDomain + ? await listConvexManuals(manualsDomain) + : [] + + const isLocalDevelopment = process.env.NODE_ENV === "development" + const shouldUseFilesystemFallback = isLocalDevelopment + const allManuals = - convexManuals.length > 0 ? convexManuals : await scanManuals() + convexManuals.length > 0 || !shouldUseFilesystemFallback + ? convexManuals + : await scanManuals() let manuals = convexManuals.length > 0 ? convexManuals - : selectManualsForSite(allManuals).manuals + : shouldUseFilesystemFallback + ? selectManualsForSite(allManuals, manualsDomain).manuals + : [] + + const shouldShowDegradedState = + !shouldUseFilesystemFallback && manuals.length === 0 + + if (shouldShowDegradedState) { + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || "" + const convexUrlHash = convexUrl + ? createHash("sha256").update(convexUrl).digest("hex").slice(0, 12) + : "missing" + + console.error( + JSON.stringify({ + event: "manuals.degraded_empty_tenant", + severity: "error", + domain: manualsDomain || "missing", + host: requestHost || "missing", + manualCount: manuals.length, + convexManualCount: convexManuals.length, + convexUrlHash, + }) + ) + } // Hide broken local thumbnails so the public manuals page doesn't spam 404s. const thumbnailsRoot = getManualsThumbnailsRoot() @@ -99,8 +142,22 @@ export default async function ManualsPage() { type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }} /> -
- +
+ {shouldShowDegradedState ? ( +
+
+

+ Manuals Library Temporarily Unavailable +

+

+ We are restoring tenant catalog data for this domain. Please + refresh shortly or contact support if this persists. +

+
+
+ ) : ( + + )}
) diff --git a/convex/manuals.ts b/convex/manuals.ts index e219405f..12969786 100644 --- a/convex/manuals.ts +++ b/convex/manuals.ts @@ -1,6 +1,11 @@ // @ts-nocheck import { mutation, query } from "./_generated/server" import { v } from "convex/values" +import { + canonicalizeTenantDomain, + manualVisibleForTenant, + tenantDomainVariants, +} from "../lib/manuals-tenant" const manualInput = v.object({ filename: v.string(), @@ -23,10 +28,19 @@ const manualInput = v.object({ }) export const list = query({ - args: {}, - handler: async (ctx) => { + args: { + domain: v.string(), + }, + handler: async (ctx, args) => { + const tenantDomain = canonicalizeTenantDomain(args.domain) + if (!tenantDomain) { + return [] + } + const manuals = await ctx.db.query("manuals").collect() - return manuals.sort((a, b) => a.filename.localeCompare(b.filename)) + return manuals + .filter((manual) => manualVisibleForTenant(manual, tenantDomain)) + .sort((a, b) => a.filename.localeCompare(b.filename)) }, }) @@ -106,3 +120,64 @@ export const upsertMany = mutation({ return results }, }) + +export const backfillTenantVisibility = mutation({ + args: { + domain: v.string(), + dryRun: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const tenantDomain = canonicalizeTenantDomain(args.domain) + if (!tenantDomain) { + throw new Error("A valid tenant domain is required.") + } + + const aliases = tenantDomainVariants(tenantDomain) + const dryRun = Boolean(args.dryRun) + const now = Date.now() + const manuals = await ctx.db.query("manuals").collect() + + let patched = 0 + let alreadyCovered = 0 + + for (const manual of manuals) { + const visibilitySet = new Set( + (manual.siteVisibility || []) + .map((entry) => canonicalizeTenantDomain(entry)) + .filter(Boolean) + ) + + const sourceDomain = canonicalizeTenantDomain(manual.sourceDomain) + const hasDomain = + aliases.some((alias) => visibilitySet.has(alias)) || + (sourceDomain ? aliases.includes(sourceDomain) : false) + + if (hasDomain) { + alreadyCovered += 1 + continue + } + + const nextVisibility = Array.from( + new Set([...visibilitySet, ...aliases]) + ).sort() + + if (!dryRun) { + await ctx.db.patch(manual._id, { + sourceDomain: sourceDomain || tenantDomain, + siteVisibility: nextVisibility, + updatedAt: now, + }) + } + + patched += 1 + } + + return { + domain: tenantDomain, + total: manuals.length, + patched, + alreadyCovered, + dryRun, + } + }, +}) diff --git a/docs/manuals-tenant-recovery.md b/docs/manuals-tenant-recovery.md new file mode 100644 index 00000000..92b48bd6 --- /dev/null +++ b/docs/manuals-tenant-recovery.md @@ -0,0 +1,45 @@ +# Manuals Tenant Recovery Runbook + +## 1) Verify runtime env on active app + +Confirm these variables on the live Coolify app/container: + +- `NEXT_PUBLIC_CONVEX_URL` (full `https://...` URL) +- `NEXT_PUBLIC_SITE_DOMAIN=rmv.abundancepartners.app` +- Optional override: `MANUALS_TENANT_DOMAIN=rmv.abundancepartners.app` + +## 2) Verify Convex tenant data gate + +Run: + +```bash +pnpm deploy:staging:convex-gate +``` + +This fails if Convex returns fewer than one manual for the active domain. + +## 3) Backfill existing manuals rows for tenant visibility + +Dry run first: + +```bash +pnpm manuals:backfill:tenant -- --domain rmv.abundancepartners.app --dry-run +``` + +Apply: + +```bash +pnpm manuals:backfill:tenant -- --domain rmv.abundancepartners.app +``` + +## 4) Re-run smoke checks + +```bash +pnpm deploy:staging:smoke -- --base-url https://rmv.abundancepartners.app --skip-browser +``` + +Manuals checks will fail if: + +- `/manuals` renders with `initialManuals: []` +- tenant domain marker mismatches the host +- degraded manuals state is shown diff --git a/lib/convex-service.ts b/lib/convex-service.ts index 692a0eb0..d156d639 100644 --- a/lib/convex-service.ts +++ b/lib/convex-service.ts @@ -3,6 +3,7 @@ import { fetchQuery } from "convex/nextjs" import { makeFunctionReference } from "convex/server" import { api } from "@/convex/_generated/api" import { hasConvexUrl } from "@/lib/convex-config" +import { resolveManualsTenantDomain } from "@/lib/manuals-tenant" import type { Product } from "@/lib/products/types" import type { Manual } from "@/lib/manuals-types" @@ -80,14 +81,15 @@ function getServerConvexClient(useAdminAuth: boolean) { async function queryManualsWithAuthFallback( label: string, queryRef: ReturnType>, - fallback: TData + fallback: TData, + args: Record = {} ): Promise { const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY const adminClient = getServerConvexClient(true) if (adminClient) { try { - return (await adminClient.query(queryRef, {})) as TData + return (await adminClient.query(queryRef, args)) as TData } catch (error) { console.error(`[convex-service] ${label} admin query failed`, error) if (!adminKey) { @@ -103,7 +105,7 @@ async function queryManualsWithAuthFallback( return await safeFetchQuery( `${label}.public`, - publicClient.query(queryRef, {}), + publicClient.query(queryRef, args), fallback ) } @@ -227,15 +229,26 @@ export async function getConvexProduct(id: string): Promise { return match ? mapConvexProduct(match) : null } -export async function listConvexManuals(): Promise { +export async function listConvexManuals(domain?: string): Promise { if (!hasConvexUrl()) { return [] } + const tenantDomain = resolveManualsTenantDomain({ + requestHost: domain, + envTenantDomain: process.env.MANUALS_TENANT_DOMAIN, + envSiteDomain: process.env.NEXT_PUBLIC_SITE_DOMAIN, + }) + + if (!tenantDomain) { + return [] + } + const manuals = await queryManualsWithAuthFallback( "manuals.list", LIST_MANUALS, - [] as ConvexManualDoc[] + [] as ConvexManualDoc[], + { domain: tenantDomain } ) return (manuals as ConvexManualDoc[]).map(mapConvexManual) } diff --git a/lib/manuals-tenant.test.ts b/lib/manuals-tenant.test.ts new file mode 100644 index 00000000..ffa3a3ca --- /dev/null +++ b/lib/manuals-tenant.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { + canonicalizeTenantDomain, + manualVisibleForTenant, + resolveManualsTenantDomain, + tenantDomainVariants, +} from "@/lib/manuals-tenant" + +test("canonicalizeTenantDomain strips protocol, port, path, and casing", () => { + assert.equal( + canonicalizeTenantDomain("HTTPS://RMV.AbundancePartners.App:443/manuals"), + "rmv.abundancepartners.app" + ) + assert.equal(canonicalizeTenantDomain(""), "") + assert.equal(canonicalizeTenantDomain(undefined), "") +}) + +test("tenantDomainVariants includes root and www aliases", () => { + assert.deepEqual(tenantDomainVariants("rmv.abundancepartners.app"), [ + "rmv.abundancepartners.app", + "www.rmv.abundancepartners.app", + ]) + assert.deepEqual(tenantDomainVariants("www.rockymountainvending.com"), [ + "www.rockymountainvending.com", + "rockymountainvending.com", + ]) +}) + +test("manualVisibleForTenant matches sourceDomain and siteVisibility aliases", () => { + assert.equal( + manualVisibleForTenant( + { + sourceDomain: "rmv.abundancepartners.app", + }, + "https://rmv.abundancepartners.app/manuals" + ), + true + ) + + assert.equal( + manualVisibleForTenant( + { + siteVisibility: ["www.rockymountainvending.com"], + }, + "rockymountainvending.com" + ), + true + ) + + assert.equal( + manualVisibleForTenant( + { + sourceDomain: "quickfreshvending.com", + siteVisibility: ["quickfreshvending.com"], + }, + "rmv.abundancepartners.app" + ), + false + ) +}) + +test("resolveManualsTenantDomain prioritizes request host then env overrides", () => { + assert.equal( + resolveManualsTenantDomain({ + requestHost: "rmv.abundancepartners.app", + envTenantDomain: "fallback.example", + envSiteDomain: "another.example", + }), + "rmv.abundancepartners.app" + ) + + assert.equal( + resolveManualsTenantDomain({ + requestHost: "", + envTenantDomain: "tenant.example", + envSiteDomain: "site.example", + }), + "tenant.example" + ) + + assert.equal( + resolveManualsTenantDomain({ + requestHost: "", + envTenantDomain: "", + envSiteDomain: "site.example", + }), + "site.example" + ) +}) diff --git a/lib/manuals-tenant.ts b/lib/manuals-tenant.ts new file mode 100644 index 00000000..39cb29ea --- /dev/null +++ b/lib/manuals-tenant.ts @@ -0,0 +1,79 @@ +export type TenantScopedManual = { + sourceDomain?: string + siteVisibility?: string[] +} + +function stripProtocolAndPath(input: string) { + const firstHost = input.split(",")[0] || "" + + return firstHost + .trim() + .toLowerCase() + .replace(/^https?:\/\//, "") + .replace(/^\/\//, "") + .replace(/\/.*$/, "") + .replace(/:\d+$/, "") + .replace(/\.$/, "") +} + +export function canonicalizeTenantDomain( + input: string | null | undefined +): string { + if (!input) { + return "" + } + + return stripProtocolAndPath(input) +} + +export function tenantDomainVariants(domain: string): string[] { + const canonical = canonicalizeTenantDomain(domain) + if (!canonical) { + return [] + } + + if (canonical.startsWith("www.")) { + return [canonical, canonical.replace(/^www\./, "")] + } + + return [canonical, `www.${canonical}`] +} + +export function manualVisibleForTenant( + manual: TenantScopedManual, + domain: string +): boolean { + const variants = new Set(tenantDomainVariants(domain)) + if (variants.size === 0) { + return false + } + + const sourceDomain = canonicalizeTenantDomain(manual.sourceDomain) + if (sourceDomain && variants.has(sourceDomain)) { + return true + } + + const visibility = Array.isArray(manual.siteVisibility) + ? manual.siteVisibility + .map((entry) => canonicalizeTenantDomain(entry)) + .filter(Boolean) + : [] + + if (visibility.some((entry) => variants.has(entry))) { + return true + } + + return false +} + +export function resolveManualsTenantDomain(params: { + requestHost?: string | null + envSiteDomain?: string | null + envTenantDomain?: string | null +}) { + return ( + canonicalizeTenantDomain(params.requestHost) || + canonicalizeTenantDomain(params.envTenantDomain) || + canonicalizeTenantDomain(params.envSiteDomain) + ) +} diff --git a/package.json b/package.json index 1afd925b..c2efdccf 100644 --- a/package.json +++ b/package.json @@ -8,14 +8,18 @@ "build": "next build", "copy:check": "node scripts/check-public-copy.mjs", "diagnose:ebay": "node scripts/staging-smoke.mjs", + "deploy:staging:convex-gate": "node scripts/check-convex-manuals-gate.mjs", "deploy:staging:env": "node scripts/deploy-readiness.mjs", - "deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build", + "deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build && pnpm deploy:staging:convex-gate", "deploy:staging:smoke": "node scripts/staging-smoke.mjs", "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "manuals:qdrant:build": "tsx scripts/build-manuals-qdrant-corpus.ts", "manuals:qdrant:eval": "tsx scripts/evaluate-manuals-qdrant-corpus.ts", "manuals:sync:convex": "tsx scripts/sync-manuals-to-convex.ts", "manuals:sync:convex:dry": "tsx scripts/sync-manuals-to-convex.ts --dry-run", + "manuals:backfill:tenant": "tsx scripts/backfill-convex-manuals-tenant.ts", + "ghl:export:call-transcripts": "tsx scripts/export-ghl-call-transcripts.ts", + "ghl:export:outbound-call-transcripts": "tsx scripts/export-ghl-outbound-call-transcripts.ts", "convex:codegen": "node -e \"console.log('Convex generated stubs are committed. Run `pnpm exec convex dev` or `pnpm exec convex codegen` after configuring a deployment to replace them with typed output.')\"", "dev": "next dev", "lint": "eslint .", diff --git a/scripts/backfill-convex-manuals-tenant.ts b/scripts/backfill-convex-manuals-tenant.ts new file mode 100644 index 00000000..ede93c71 --- /dev/null +++ b/scripts/backfill-convex-manuals-tenant.ts @@ -0,0 +1,60 @@ +import { config as loadEnv } from "dotenv" +import { ConvexHttpClient } from "convex/browser" +import { makeFunctionReference } from "convex/server" +import { resolveManualsTenantDomain } from "../lib/manuals-tenant" + +loadEnv({ path: ".env.local" }) +loadEnv({ path: ".env.staging", override: false }) + +const BACKFILL_MUTATION = makeFunctionReference<"mutation">( + "manuals:backfillTenantVisibility" +) + +function parseArgs(argv: string[]) { + const domainFlagIndex = argv.indexOf("--domain") + const domain = + domainFlagIndex >= 0 ? (argv[domainFlagIndex + 1] || "").trim() : "" + + return { + domain, + dryRun: argv.includes("--dry-run"), + } +} + +function readConvexUrl() { + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_URL + if (!convexUrl) { + throw new Error( + "NEXT_PUBLIC_CONVEX_URL (or CONVEX_URL) is required for Convex backfill." + ) + } + + return convexUrl +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + const domain = resolveManualsTenantDomain({ + envTenantDomain: args.domain || process.env.MANUALS_TENANT_DOMAIN, + envSiteDomain: process.env.NEXT_PUBLIC_SITE_DOMAIN, + }) + + if (!domain) { + throw new Error( + "Could not resolve tenant domain. Pass --domain or set MANUALS_TENANT_DOMAIN / NEXT_PUBLIC_SITE_DOMAIN." + ) + } + + const convex = new ConvexHttpClient(readConvexUrl()) + const result = await convex.mutation(BACKFILL_MUTATION, { + domain, + dryRun: args.dryRun, + }) + + console.log("[manuals-tenant-backfill] result", result) +} + +main().catch((error) => { + console.error("[manuals-tenant-backfill] failed", error) + process.exit(1) +}) diff --git a/scripts/check-convex-manuals-gate.mjs b/scripts/check-convex-manuals-gate.mjs new file mode 100644 index 00000000..97cd4831 --- /dev/null +++ b/scripts/check-convex-manuals-gate.mjs @@ -0,0 +1,82 @@ +import process from "node:process" +import dotenv from "dotenv" +import { ConvexHttpClient } from "convex/browser" +import { makeFunctionReference } from "convex/server" + +const LIST_MANUALS = makeFunctionReference("manuals:list") + +function canonicalizeDomain(input) { + return String(input || "") + .trim() + .toLowerCase() + .replace(/^https?:\/\//, "") + .replace(/^\/\//, "") + .replace(/\/.*$/, "") + .replace(/:\d+$/, "") + .replace(/\.$/, "") +} + +function parseArgs(argv) { + const domainIndex = argv.indexOf("--domain") + const domain = domainIndex >= 0 ? argv[domainIndex + 1] : "" + const minCountIndex = argv.indexOf("--min-count") + const minCount = + minCountIndex >= 0 + ? Number.parseInt(argv[minCountIndex + 1] || "", 10) + : 1 + + return { + domain, + minCount: Number.isFinite(minCount) && minCount > 0 ? minCount : 1, + } +} + +function readConvexUrl() { + const value = + process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_URL || "" + return value.trim() +} + +async function main() { + dotenv.config({ path: ".env.local", override: false }) + dotenv.config({ path: ".env.staging", override: false }) + + const args = parseArgs(process.argv.slice(2)) + const domain = canonicalizeDomain( + args.domain || + process.env.MANUALS_TENANT_DOMAIN || + process.env.NEXT_PUBLIC_SITE_DOMAIN + ) + const convexUrl = readConvexUrl() + + if (!convexUrl) { + throw new Error( + "NEXT_PUBLIC_CONVEX_URL (or CONVEX_URL) is required for Convex manuals gate." + ) + } + + if (!domain) { + throw new Error( + "A tenant domain is required. Set NEXT_PUBLIC_SITE_DOMAIN / MANUALS_TENANT_DOMAIN or pass --domain." + ) + } + + const convex = new ConvexHttpClient(convexUrl) + const manuals = await convex.query(LIST_MANUALS, { domain }) + const count = Array.isArray(manuals) ? manuals.length : 0 + + console.log( + `[convex-manuals-gate] domain=${domain} count=${count} min=${args.minCount}` + ) + + if (count < args.minCount) { + throw new Error( + `Convex manuals gate failed for ${domain}: expected at least ${args.minCount} manuals, got ${count}.` + ) + } +} + +main().catch((error) => { + console.error("[convex-manuals-gate] failed", error) + process.exit(1) +}) diff --git a/scripts/deploy-readiness.mjs b/scripts/deploy-readiness.mjs index baed4b77..f45eea12 100644 --- a/scripts/deploy-readiness.mjs +++ b/scripts/deploy-readiness.mjs @@ -112,6 +112,35 @@ function readValue(name) { return String(process.env[name] ?? "").trim() } +function canonicalizeDomain(input) { + return String(input || "") + .trim() + .toLowerCase() + .replace(/^https?:\/\//, "") + .replace(/^\/\//, "") + .replace(/\/.*$/, "") + .replace(/:\d+$/, "") + .replace(/\.$/, "") +} + +function isValidHttpUrl(value) { + if (!value) { + return false + } + + try { + const url = new URL(value) + return url.protocol === "https:" || url.protocol === "http:" + } catch { + return false + } +} + +function isValidHostname(value) { + const host = canonicalizeDomain(value) + return /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(host) +} + function hasVoiceRecordingConfig() { return [ readValue("VOICE_RECORDING_ACCESS_KEY_ID") || @@ -234,6 +263,21 @@ function main() { console.log(`${group.label}: fallback in use`) } + const convexUrl = readValue("NEXT_PUBLIC_CONVEX_URL") + if (!isValidHttpUrl(convexUrl)) { + failures.push( + "NEXT_PUBLIC_CONVEX_URL is malformed. It must be a full http(s) URL." + ) + } + + const siteDomain = + readValue("MANUALS_TENANT_DOMAIN") || readValue("NEXT_PUBLIC_SITE_DOMAIN") + if (!isValidHostname(siteDomain)) { + failures.push( + "NEXT_PUBLIC_SITE_DOMAIN (or MANUALS_TENANT_DOMAIN) is malformed. It must be a valid hostname." + ) + } + if (!hasManualStorageCredentials()) { failures.push( "Manual asset storage credentials are incomplete. Set R2/S3 access key and secret env vars before release." diff --git a/scripts/staging-smoke.mjs b/scripts/staging-smoke.mjs index 5c819990..0b8ba94c 100644 --- a/scripts/staging-smoke.mjs +++ b/scripts/staging-smoke.mjs @@ -78,6 +78,17 @@ function normalizeBaseUrl(value) { return value.replace(/\/+$/, "") } +function canonicalizeDomain(input) { + return String(input || "") + .trim() + .toLowerCase() + .replace(/^https?:\/\//, "") + .replace(/^\/\//, "") + .replace(/\/.*$/, "") + .replace(/:\d+$/, "") + .replace(/\.$/, "") +} + function envPresence(name) { return Boolean(String(process.env[name] ?? "").trim()) } @@ -198,6 +209,41 @@ async function checkPages(baseUrl, failures) { } } +async function checkManualsPayload(baseUrl, failures) { + heading("Manuals Payload") + const { response, text } = await requestJson(`${baseUrl}/manuals`) + const ok = response.status === 200 + + report("GET /manuals payload", ok ? "ok" : "fail", `status=${response.status}`) + if (!ok) { + failures.push(`GET /manuals returned ${response.status}`) + return + } + + if (text.includes("Manuals Library Temporarily Unavailable")) { + failures.push("Manuals page is in degraded mode.") + } + + if (text.includes('"initialManuals":[]')) { + failures.push("Manuals page rendered with zero initial manuals.") + } + + const expectedHost = canonicalizeDomain(new URL(baseUrl).host) + const domainMatch = text.match(/data-manuals-domain=\"([^\"]+)\"/) + const runtimeDomain = canonicalizeDomain(domainMatch?.[1] || "") + + console.log(` expectedDomain: ${expectedHost}`) + console.log(` runtimeDomain: ${runtimeDomain || "missing"}`) + + if (!runtimeDomain) { + failures.push("Manuals page is missing runtime tenant domain marker.") + } else if (runtimeDomain !== expectedHost) { + failures.push( + `Manuals runtime domain mismatch. Expected ${expectedHost}, got ${runtimeDomain}.` + ) + } +} + async function checkEbaySearch(baseUrl, failures) { heading("eBay Cache Search") const url = new URL(`${baseUrl}/api/ebay/search`) @@ -499,6 +545,7 @@ async function main() { report("Base URL", baseUrl) await checkPages(baseUrl, failures) + await checkManualsPayload(baseUrl, failures) await checkEbaySearch(baseUrl, failures) await checkManualParts( baseUrl, diff --git a/scripts/sync-manuals-to-convex.ts b/scripts/sync-manuals-to-convex.ts index 0e5aa721..2a665e8c 100644 --- a/scripts/sync-manuals-to-convex.ts +++ b/scripts/sync-manuals-to-convex.ts @@ -12,6 +12,11 @@ import { import { businessConfig } from "../lib/seo-config" import { getSiteDomain } from "../lib/site-config" import { selectManualsForSite } from "../lib/manuals-site-selection" +import { + canonicalizeTenantDomain, + resolveManualsTenantDomain, + tenantDomainVariants, +} from "../lib/manuals-tenant" import { getManualsFilesRoot, getManualsThumbnailsRoot, @@ -73,8 +78,15 @@ function readConvexUrl() { } function canonicalSiteVisibility(siteDomain: string) { - const canonicalHost = new URL(businessConfig.website).hostname - return Array.from(new Set([siteDomain, canonicalHost])) + const canonicalHost = canonicalizeTenantDomain( + new URL(businessConfig.website).hostname + ) + return Array.from( + new Set([ + ...tenantDomainVariants(siteDomain), + ...tenantDomainVariants(canonicalHost), + ]) + ) } async function uploadSelectedAssets( @@ -169,7 +181,17 @@ function normalizeManualForConvex( async function main() { const args = parseArgs(process.argv.slice(2)) - const siteDomain = getSiteDomain() + const siteDomain = resolveManualsTenantDomain({ + envTenantDomain: process.env.MANUALS_TENANT_DOMAIN, + envSiteDomain: getSiteDomain(), + }) + + if (!siteDomain) { + throw new Error( + "Could not resolve manuals tenant domain. Set MANUALS_TENANT_DOMAIN or NEXT_PUBLIC_SITE_DOMAIN." + ) + } + const allManuals = await scanManuals() const selection = selectManualsForSite(allManuals, siteDomain) const selectedManuals = args.limit