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