deploy: clean Jessica chat and manual catalog plumbing

This commit is contained in:
DMleadgen 2026-03-26 15:31:20 -06:00
parent 42c9400e6d
commit 207e6194b2
Signed by: matt
GPG key ID: C2720CF8CD701894
10 changed files with 3408 additions and 89 deletions

View file

@ -1,17 +1,18 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps
COPY package.json package-lock.json pnpm-lock.yaml ./
RUN npm install -g pnpm
RUN pnpm install --no-frozen-lockfile
COPY . .
RUN npm run build
RUN pnpm build
FROM node:20-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app
RUN apk add --no-cache curl
RUN apk add --no-cache curl wget
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

View file

@ -1,5 +1,6 @@
import { Metadata } from 'next'
import { ManualsDashboardClient } from '@/components/manuals-dashboard-client'
import { getConvexManualDashboard } from '@/lib/convex-service'
import { readFileSync } from 'fs'
import { join } from 'path'
@ -74,7 +75,7 @@ async function loadDashboardData(): Promise<DashboardData> {
}
export default async function ManualsDashboardPage() {
const data = await loadDashboardData()
const data = (await getConvexManualDashboard()) || (await loadDashboardData())
return (
<div className="container mx-auto px-4 py-8 md:py-12">
@ -96,4 +97,3 @@ export default async function ManualsDashboardPage() {

View file

@ -1,6 +1,7 @@
import { Metadata } from 'next'
import { businessConfig } from '@/lib/seo-config'
import { ManualsPageClient } from '@/components/manuals-page-client'
import { ManualsPageShell } from '@/components/manuals-page-shell'
import { listConvexManuals } from '@/lib/convex-service'
import {
scanManuals,
groupManuals,
@ -86,8 +87,9 @@ export default async function ManualsPage() {
const manufacturerAliases = getManufacturerAliases()
const includePaymentComponents = shouldIncludePaymentComponents(siteDomain)
// Scan manuals directory
const allManuals = await scanManuals()
// Prefer Convex-backed metadata, but keep filesystem fallback in place until the shared catalog is fully populated.
const convexManuals = await listConvexManuals()
const allManuals = convexManuals.length > 0 ? convexManuals : await scanManuals()
// Filtering chain:
// 1. Filter by MDB (existing)
@ -168,7 +170,7 @@ export default async function ManualsPage() {
</div>
</header>
<ManualsPageClient
<ManualsPageShell
manuals={manuals}
groupedManuals={groupedManuals}
manufacturers={manufacturers}
@ -178,6 +180,3 @@ export default async function ManualsPage() {
</>
)
}

View file

@ -0,0 +1,27 @@
"use client"
import dynamic from "next/dynamic"
import type { Manual, ManualGroup } from "@/lib/manuals-types"
const ManualsPageClient = dynamic(
() => import("@/components/manuals-page-client").then((mod) => mod.ManualsPageClient),
{
ssr: false,
loading: () => (
<div className="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground">
Loading manual browser...
</div>
),
}
)
interface ManualsPageShellProps {
manuals: Manual[]
groupedManuals: ManualGroup[]
manufacturers: string[]
categories: string[]
}
export function ManualsPageShell(props: ManualsPageShellProps) {
return <ManualsPageClient {...props} />
}

7
lib/convex-config.ts Normal file
View file

@ -0,0 +1,7 @@
export function getConvexUrl() {
return process.env.NEXT_PUBLIC_CONVEX_URL ?? "";
}
export function hasConvexUrl() {
return Boolean(getConvexUrl());
}

160
lib/convex-service.ts Normal file
View file

@ -0,0 +1,160 @@
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { hasConvexUrl } from "@/lib/convex-config"
import type { Product } from "@/lib/products/types"
import type { Manual } from "@/lib/manuals-types"
type ConvexProductDoc = {
_id: string
name: string
description?: string
price: number
currency: string
images: string[]
metadata?: Record<string, string>
stripeProductId?: string
stripePriceId?: string
active: boolean
}
type ConvexManualDoc = {
filename: string
path: string
manufacturer: string
category: string
size?: number
lastModified?: number
searchTerms?: string[]
commonNames?: string[]
thumbnailUrl?: string
manualUrl?: string
}
type ConvexOrderItem = {
productId?: string | null
productName: string
price: number
quantity: number
priceId: string
image?: string
}
export type ConvexAdminOrder = {
id: string
customerEmail: string
customerName?: string
totalAmount: number
currency: string
status: "pending" | "paid" | "fulfilled" | "cancelled" | "refunded"
stripeSessionId: string | null
paymentIntentId: string | null
createdAt: string
updatedAt: string
shippingAddress?: {
name?: string
address?: string
city?: string
state?: string
zipCode?: string
country?: string
}
items: ConvexOrderItem[]
}
function mapConvexProduct(product: ConvexProductDoc): Product {
return {
id: product._id,
stripeId: product.stripeProductId ?? product._id,
name: product.name,
description: product.description ?? null,
price: product.price,
currency: product.currency,
images: product.images ?? [],
metadata: product.metadata,
priceId: product.stripePriceId,
}
}
function mapConvexManual(manual: ConvexManualDoc): Manual {
return {
filename: manual.filename,
path: manual.manualUrl || manual.path,
manufacturer: manual.manufacturer,
category: manual.category,
size: manual.size,
lastModified: manual.lastModified ? new Date(manual.lastModified) : undefined,
searchTerms: manual.searchTerms,
commonNames: manual.commonNames,
thumbnailUrl: manual.thumbnailUrl,
}
}
export async function listConvexProducts(): Promise<Product[]> {
if (!hasConvexUrl()) {
return []
}
const products = await fetchQuery(api.products.listActive, {})
return (products as ConvexProductDoc[]).map(mapConvexProduct)
}
export async function listConvexAdminProducts(search?: string): Promise<Product[]> {
if (!hasConvexUrl()) {
return []
}
const products = await fetchQuery(api.products.listAdmin, { search })
return (products as ConvexProductDoc[]).map(mapConvexProduct)
}
export async function getConvexProduct(id: string): Promise<Product | null> {
if (!hasConvexUrl()) {
return null
}
const products = await fetchQuery(api.products.listAdmin, {})
const match = (products as ConvexProductDoc[]).find((product) => {
return product._id === id || product.stripeProductId === id
})
return match ? mapConvexProduct(match) : null
}
export async function listConvexManuals(): Promise<Manual[]> {
if (!hasConvexUrl()) {
return []
}
const manuals = await fetchQuery(api.manuals.list, {})
return (manuals as ConvexManualDoc[]).map(mapConvexManual)
}
export async function getConvexManualDashboard() {
if (!hasConvexUrl()) {
return null
}
return await fetchQuery(api.manuals.dashboard, {})
}
export async function getConvexOrderMetrics() {
if (!hasConvexUrl()) {
return null
}
return await fetchQuery(api.orders.getMetrics, {})
}
export async function listConvexOrders(options?: {
status?: ConvexAdminOrder["status"]
search?: string
}): Promise<ConvexAdminOrder[]> {
if (!hasConvexUrl()) {
return []
}
return await fetchQuery(api.orders.listAdmin, {
status: options?.status,
search: options?.search,
})
}

53
lib/manuals-storage.ts Normal file
View file

@ -0,0 +1,53 @@
import { businessConfig } from "@/lib/seo-config"
function cleanBaseUrl(value: string) {
return value.replace(/\/$/, "")
}
function getSiteBaseUrl() {
return cleanBaseUrl(
process.env.NEXT_PUBLIC_SITE_URL ||
process.env.VOICE_ASSISTANT_SITE_URL ||
businessConfig.website,
)
}
export function getManualsAssetBaseUrl() {
return cleanBaseUrl(process.env.NEXT_PUBLIC_MANUALS_BASE_URL || `${getSiteBaseUrl()}/manuals`)
}
export function getThumbnailsAssetBaseUrl() {
return cleanBaseUrl(process.env.NEXT_PUBLIC_THUMBNAILS_BASE_URL || `${getSiteBaseUrl()}/thumbnails`)
}
export function getManualsAssetSource() {
return process.env.NEXT_PUBLIC_MANUALS_BASE_URL && process.env.NEXT_PUBLIC_THUMBNAILS_BASE_URL
? "external-object-storage"
: "site-static"
}
export function buildManualAssetUrl(relativePath: string) {
const encodedPath = relativePath
.split("/")
.map((segment) => encodeURIComponent(decodeURIComponentSafe(segment)))
.join("/")
return `${getManualsAssetBaseUrl()}/${encodedPath}`
}
export function buildThumbnailAssetUrl(relativePath: string) {
const encodedPath = relativePath
.split("/")
.map((segment) => encodeURIComponent(decodeURIComponentSafe(segment)))
.join("/")
return `${getThumbnailsAssetBaseUrl()}/${encodedPath}`
}
function decodeURIComponentSafe(value: string) {
try {
return decodeURIComponent(value)
} catch {
return value
}
}

View file

@ -35,6 +35,7 @@
"seo:interactive": "node scripts/seo-internal-link-tool.js interactive"
},
"dependencies": {
"@aws-sdk/client-sesv2": "^3.1017.0",
"@aws-sdk/client-s3": "^3.0.0",
"@hookform/resolvers": "^3.10.0",
"@livekit/agents": "^1.2.1",
@ -96,6 +97,7 @@
"stripe": "^17.0.0",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"usesend-js": "^1.6.3",
"vaul": "^0.9.9",
"zod": "3.25.76"
},

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,8 @@ import { ConvexHttpClient } from "convex/browser"
import { makeFunctionReference } from "convex/server"
import { scanManuals } from "../lib/manuals"
import { buildManualAssetUrl, buildThumbnailAssetUrl, getManualsAssetSource } from "../lib/manuals-storage"
import { businessConfig } from "../lib/seo-config"
import { getSiteDomain } from "../lib/site-config"
loadEnv({ path: ".env.local" })
loadEnv({ path: ".env.staging", override: false })
@ -20,6 +22,12 @@ type ManualUpsertInput = {
thumbnailUrl?: string
manualUrl?: string
hasParts?: boolean
assetSource?: string
sourcePath?: string
sourceSite?: string
sourceDomain?: string
siteVisibility?: string[]
importBatch?: string
}
const UPSERT_MANUALS = makeFunctionReference<"mutation">("manuals:upsertMany")
@ -45,7 +53,16 @@ function readConvexUrl() {
}
function shouldUploadAssets() {
return Boolean(process.env.CLOUDFLARE_R2_ACCESS_KEY_ID && process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY)
const accessKey =
process.env.CLOUDFLARE_R2_ACCESS_KEY_ID ||
process.env.AWS_ACCESS_KEY_ID ||
process.env.AWS_ACCESS_KEY
const secret =
process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY ||
process.env.AWS_SECRET_ACCESS_KEY ||
process.env.AWS_SECRET_KEY
return Boolean(accessKey && secret)
}
function runAssetUpload() {
@ -59,7 +76,11 @@ function runAssetUpload() {
}
}
function normalizeManualForConvex(manual: Awaited<ReturnType<typeof scanManuals>>[number]) {
function normalizeManualForConvex(
manual: Awaited<ReturnType<typeof scanManuals>>[number],
importBatch: string,
) {
const siteDomain = getSiteDomain()
const manualUrl = buildManualAssetUrl(manual.path)
const thumbnailUrl = manual.thumbnailUrl ? buildThumbnailAssetUrl(manual.thumbnailUrl) : undefined
@ -75,6 +96,12 @@ function normalizeManualForConvex(manual: Awaited<ReturnType<typeof scanManuals>
thumbnailUrl,
manualUrl,
hasParts: Boolean(manual.searchTerms?.some((term) => term.toLowerCase().includes("parts"))),
assetSource: getManualsAssetSource(),
sourcePath: manual.path,
sourceSite: businessConfig.name,
sourceDomain: siteDomain,
siteVisibility: [siteDomain],
importBatch,
} satisfies ManualUpsertInput
}
@ -91,7 +118,7 @@ async function main() {
const manuals = await scanManuals()
const selectedManuals = args.limit ? manuals.slice(0, args.limit) : manuals
const importBatch = new Date().toISOString()
const payload = selectedManuals.map((manual) => normalizeManualForConvex(manual))
const payload = selectedManuals.map((manual) => normalizeManualForConvex(manual, importBatch))
console.log("[manuals-sync] scanned manuals", {
total: manuals.length,