fix: enforce tenant-scoped manuals and deployment gates
This commit is contained in:
parent
c5e40c5caf
commit
f077966bb2
12 changed files with 636 additions and 18 deletions
|
|
@ -1,8 +1,10 @@
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
import { Metadata } from "next"
|
import { Metadata } from "next"
|
||||||
|
import { headers } from "next/headers"
|
||||||
import { businessConfig } from "@/lib/seo-config"
|
import { businessConfig } from "@/lib/seo-config"
|
||||||
import { ManualsPageExperience } from "@/components/manuals-page-experience"
|
import { ManualsPageExperience } from "@/components/manuals-page-experience"
|
||||||
import { listConvexManuals } from "@/lib/convex-service"
|
import { listConvexManuals } from "@/lib/convex-service"
|
||||||
|
|
@ -10,6 +12,7 @@ import { scanManuals } from "@/lib/manuals"
|
||||||
import { selectManualsForSite } from "@/lib/manuals-site-selection"
|
import { selectManualsForSite } from "@/lib/manuals-site-selection"
|
||||||
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
|
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
|
||||||
import { getManualsThumbnailsRoot } from "@/lib/manuals-paths"
|
import { getManualsThumbnailsRoot } from "@/lib/manuals-paths"
|
||||||
|
import { resolveManualsTenantDomain } from "@/lib/manuals-tenant"
|
||||||
|
|
||||||
export const metadata: Metadata = generateSEOMetadata({
|
export const metadata: Metadata = generateSEOMetadata({
|
||||||
title: "Vending Machine Manuals | Rocky Mountain Vending",
|
title: "Vending Machine Manuals | Rocky Mountain Vending",
|
||||||
|
|
@ -31,14 +34,54 @@ export const metadata: Metadata = generateSEOMetadata({
|
||||||
})
|
})
|
||||||
|
|
||||||
export default async function ManualsPage() {
|
export default async function ManualsPage() {
|
||||||
// Prefer Convex-backed metadata, but keep filesystem fallback in place until the shared catalog is fully populated.
|
const requestHeaders = await headers()
|
||||||
const convexManuals = await listConvexManuals()
|
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 =
|
const allManuals =
|
||||||
convexManuals.length > 0 ? convexManuals : await scanManuals()
|
convexManuals.length > 0 || !shouldUseFilesystemFallback
|
||||||
|
? convexManuals
|
||||||
|
: await scanManuals()
|
||||||
let manuals =
|
let manuals =
|
||||||
convexManuals.length > 0
|
convexManuals.length > 0
|
||||||
? convexManuals
|
? 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.
|
// Hide broken local thumbnails so the public manuals page doesn't spam 404s.
|
||||||
const thumbnailsRoot = getManualsThumbnailsRoot()
|
const thumbnailsRoot = getManualsThumbnailsRoot()
|
||||||
|
|
@ -99,8 +142,22 @@ export default async function ManualsPage() {
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }}
|
||||||
/>
|
/>
|
||||||
<div className="public-page">
|
<div className="public-page" data-manuals-domain={manualsDomain}>
|
||||||
<ManualsPageExperience initialManuals={manuals} />
|
{shouldShowDegradedState ? (
|
||||||
|
<div className="mx-auto max-w-[var(--public-shell-max)] px-4 py-10 sm:px-5 lg:px-6">
|
||||||
|
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-6">
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">
|
||||||
|
Manuals Library Temporarily Unavailable
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
We are restoring tenant catalog data for this domain. Please
|
||||||
|
refresh shortly or contact support if this persists.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ManualsPageExperience initialManuals={manuals} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { mutation, query } from "./_generated/server"
|
import { mutation, query } from "./_generated/server"
|
||||||
import { v } from "convex/values"
|
import { v } from "convex/values"
|
||||||
|
import {
|
||||||
|
canonicalizeTenantDomain,
|
||||||
|
manualVisibleForTenant,
|
||||||
|
tenantDomainVariants,
|
||||||
|
} from "../lib/manuals-tenant"
|
||||||
|
|
||||||
const manualInput = v.object({
|
const manualInput = v.object({
|
||||||
filename: v.string(),
|
filename: v.string(),
|
||||||
|
|
@ -23,10 +28,19 @@ const manualInput = v.object({
|
||||||
})
|
})
|
||||||
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
args: {},
|
args: {
|
||||||
handler: async (ctx) => {
|
domain: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const tenantDomain = canonicalizeTenantDomain(args.domain)
|
||||||
|
if (!tenantDomain) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
const manuals = await ctx.db.query("manuals").collect()
|
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
|
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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
45
docs/manuals-tenant-recovery.md
Normal file
45
docs/manuals-tenant-recovery.md
Normal file
|
|
@ -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
|
||||||
|
|
@ -3,6 +3,7 @@ import { fetchQuery } from "convex/nextjs"
|
||||||
import { makeFunctionReference } from "convex/server"
|
import { makeFunctionReference } from "convex/server"
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { hasConvexUrl } from "@/lib/convex-config"
|
import { hasConvexUrl } from "@/lib/convex-config"
|
||||||
|
import { resolveManualsTenantDomain } from "@/lib/manuals-tenant"
|
||||||
import type { Product } from "@/lib/products/types"
|
import type { Product } from "@/lib/products/types"
|
||||||
import type { Manual } from "@/lib/manuals-types"
|
import type { Manual } from "@/lib/manuals-types"
|
||||||
|
|
||||||
|
|
@ -80,14 +81,15 @@ function getServerConvexClient(useAdminAuth: boolean) {
|
||||||
async function queryManualsWithAuthFallback<TData>(
|
async function queryManualsWithAuthFallback<TData>(
|
||||||
label: string,
|
label: string,
|
||||||
queryRef: ReturnType<typeof makeFunctionReference<"query">>,
|
queryRef: ReturnType<typeof makeFunctionReference<"query">>,
|
||||||
fallback: TData
|
fallback: TData,
|
||||||
|
args: Record<string, unknown> = {}
|
||||||
): Promise<TData> {
|
): Promise<TData> {
|
||||||
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY
|
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY
|
||||||
const adminClient = getServerConvexClient(true)
|
const adminClient = getServerConvexClient(true)
|
||||||
|
|
||||||
if (adminClient) {
|
if (adminClient) {
|
||||||
try {
|
try {
|
||||||
return (await adminClient.query(queryRef, {})) as TData
|
return (await adminClient.query(queryRef, args)) as TData
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[convex-service] ${label} admin query failed`, error)
|
console.error(`[convex-service] ${label} admin query failed`, error)
|
||||||
if (!adminKey) {
|
if (!adminKey) {
|
||||||
|
|
@ -103,7 +105,7 @@ async function queryManualsWithAuthFallback<TData>(
|
||||||
|
|
||||||
return await safeFetchQuery(
|
return await safeFetchQuery(
|
||||||
`${label}.public`,
|
`${label}.public`,
|
||||||
publicClient.query(queryRef, {}),
|
publicClient.query(queryRef, args),
|
||||||
fallback
|
fallback
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -227,15 +229,26 @@ export async function getConvexProduct(id: string): Promise<Product | null> {
|
||||||
return match ? mapConvexProduct(match) : null
|
return match ? mapConvexProduct(match) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listConvexManuals(): Promise<Manual[]> {
|
export async function listConvexManuals(domain?: string): Promise<Manual[]> {
|
||||||
if (!hasConvexUrl()) {
|
if (!hasConvexUrl()) {
|
||||||
return []
|
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(
|
const manuals = await queryManualsWithAuthFallback(
|
||||||
"manuals.list",
|
"manuals.list",
|
||||||
LIST_MANUALS,
|
LIST_MANUALS,
|
||||||
[] as ConvexManualDoc[]
|
[] as ConvexManualDoc[],
|
||||||
|
{ domain: tenantDomain }
|
||||||
)
|
)
|
||||||
return (manuals as ConvexManualDoc[]).map(mapConvexManual)
|
return (manuals as ConvexManualDoc[]).map(mapConvexManual)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
90
lib/manuals-tenant.test.ts
Normal file
90
lib/manuals-tenant.test.ts
Normal file
|
|
@ -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"
|
||||||
|
)
|
||||||
|
})
|
||||||
79
lib/manuals-tenant.ts
Normal file
79
lib/manuals-tenant.ts
Normal file
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -8,14 +8,18 @@
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"copy:check": "node scripts/check-public-copy.mjs",
|
"copy:check": "node scripts/check-public-copy.mjs",
|
||||||
"diagnose:ebay": "node scripts/staging-smoke.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: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",
|
"deploy:staging:smoke": "node scripts/staging-smoke.mjs",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
||||||
"manuals:qdrant:build": "tsx scripts/build-manuals-qdrant-corpus.ts",
|
"manuals:qdrant:build": "tsx scripts/build-manuals-qdrant-corpus.ts",
|
||||||
"manuals:qdrant:eval": "tsx scripts/evaluate-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": "tsx scripts/sync-manuals-to-convex.ts",
|
||||||
"manuals:sync:convex:dry": "tsx scripts/sync-manuals-to-convex.ts --dry-run",
|
"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.')\"",
|
"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",
|
"dev": "next dev",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
|
|
||||||
60
scripts/backfill-convex-manuals-tenant.ts
Normal file
60
scripts/backfill-convex-manuals-tenant.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
82
scripts/check-convex-manuals-gate.mjs
Normal file
82
scripts/check-convex-manuals-gate.mjs
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
|
@ -112,6 +112,35 @@ function readValue(name) {
|
||||||
return String(process.env[name] ?? "").trim()
|
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() {
|
function hasVoiceRecordingConfig() {
|
||||||
return [
|
return [
|
||||||
readValue("VOICE_RECORDING_ACCESS_KEY_ID") ||
|
readValue("VOICE_RECORDING_ACCESS_KEY_ID") ||
|
||||||
|
|
@ -234,6 +263,21 @@ function main() {
|
||||||
console.log(`${group.label}: fallback in use`)
|
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()) {
|
if (!hasManualStorageCredentials()) {
|
||||||
failures.push(
|
failures.push(
|
||||||
"Manual asset storage credentials are incomplete. Set R2/S3 access key and secret env vars before release."
|
"Manual asset storage credentials are incomplete. Set R2/S3 access key and secret env vars before release."
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,17 @@ function normalizeBaseUrl(value) {
|
||||||
return value.replace(/\/+$/, "")
|
return value.replace(/\/+$/, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canonicalizeDomain(input) {
|
||||||
|
return String(input || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^https?:\/\//, "")
|
||||||
|
.replace(/^\/\//, "")
|
||||||
|
.replace(/\/.*$/, "")
|
||||||
|
.replace(/:\d+$/, "")
|
||||||
|
.replace(/\.$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
function envPresence(name) {
|
function envPresence(name) {
|
||||||
return Boolean(String(process.env[name] ?? "").trim())
|
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) {
|
async function checkEbaySearch(baseUrl, failures) {
|
||||||
heading("eBay Cache Search")
|
heading("eBay Cache Search")
|
||||||
const url = new URL(`${baseUrl}/api/ebay/search`)
|
const url = new URL(`${baseUrl}/api/ebay/search`)
|
||||||
|
|
@ -499,6 +545,7 @@ async function main() {
|
||||||
report("Base URL", baseUrl)
|
report("Base URL", baseUrl)
|
||||||
|
|
||||||
await checkPages(baseUrl, failures)
|
await checkPages(baseUrl, failures)
|
||||||
|
await checkManualsPayload(baseUrl, failures)
|
||||||
await checkEbaySearch(baseUrl, failures)
|
await checkEbaySearch(baseUrl, failures)
|
||||||
await checkManualParts(
|
await checkManualParts(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@ import {
|
||||||
import { businessConfig } from "../lib/seo-config"
|
import { businessConfig } from "../lib/seo-config"
|
||||||
import { getSiteDomain } from "../lib/site-config"
|
import { getSiteDomain } from "../lib/site-config"
|
||||||
import { selectManualsForSite } from "../lib/manuals-site-selection"
|
import { selectManualsForSite } from "../lib/manuals-site-selection"
|
||||||
|
import {
|
||||||
|
canonicalizeTenantDomain,
|
||||||
|
resolveManualsTenantDomain,
|
||||||
|
tenantDomainVariants,
|
||||||
|
} from "../lib/manuals-tenant"
|
||||||
import {
|
import {
|
||||||
getManualsFilesRoot,
|
getManualsFilesRoot,
|
||||||
getManualsThumbnailsRoot,
|
getManualsThumbnailsRoot,
|
||||||
|
|
@ -73,8 +78,15 @@ function readConvexUrl() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalSiteVisibility(siteDomain: string) {
|
function canonicalSiteVisibility(siteDomain: string) {
|
||||||
const canonicalHost = new URL(businessConfig.website).hostname
|
const canonicalHost = canonicalizeTenantDomain(
|
||||||
return Array.from(new Set([siteDomain, canonicalHost]))
|
new URL(businessConfig.website).hostname
|
||||||
|
)
|
||||||
|
return Array.from(
|
||||||
|
new Set([
|
||||||
|
...tenantDomainVariants(siteDomain),
|
||||||
|
...tenantDomainVariants(canonicalHost),
|
||||||
|
])
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadSelectedAssets(
|
async function uploadSelectedAssets(
|
||||||
|
|
@ -169,7 +181,17 @@ function normalizeManualForConvex(
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const args = parseArgs(process.argv.slice(2))
|
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 allManuals = await scanManuals()
|
||||||
const selection = selectManualsForSite(allManuals, siteDomain)
|
const selection = selectManualsForSite(allManuals, siteDomain)
|
||||||
const selectedManuals = args.limit
|
const selectedManuals = args.limit
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue