diff --git a/STAGING_DEPLOYMENT.md b/STAGING_DEPLOYMENT.md new file mode 100644 index 00000000..2cf7f8a7 --- /dev/null +++ b/STAGING_DEPLOYMENT.md @@ -0,0 +1,150 @@ +# Rocky Mountain Vending Staging Deployment + +This is the canonical staging deployment runbook for Rocky Mountain Vending. + +## Repo And Build Source + +- Deploy from the `code/` repository root, not the parent workspace folder. +- Forgejo remote: `https://git.abundancepartners.app/matt/Rocky_Mountain_Vending.git` +- Branch: `main` +- Coolify build source: repository root Dockerfile at `/Dockerfile` +- Container port: `3001` +- Health check port: `3001` + +The parent workspace contains older migration notes and deployment docs that do not reflect the current Coolify app layout. Treat this file as the source of truth for staging execution. + +## Staging Targets + +- Staging host: `https://rmv.abundancepartners.app` +- Preview URL: `https://bsowk840kccg08coocwwc44c.85.239.237.247.sslip.io` +- Coolify project: `Rocky Mountain Vending` +- Coolify app UUID: `bsowk840kccg08coocwwc44c` +- Coolify environment UUID: `ew8k8og0gw48swck4ckk84kk` + +## Required Environment Contract + +Use the environment variables the app reads today. + +### Core Site + +- `NEXT_PUBLIC_SITE_URL` +- `NEXT_PUBLIC_SITE_DOMAIN` +- `NEXT_PUBLIC_CONVEX_URL` + +### Voice And Chat + +- `LIVEKIT_URL` +- `LIVEKIT_API_KEY` +- `LIVEKIT_API_SECRET` +- `XAI_API_KEY` +- `VOICE_ASSISTANT_SITE_URL` + +Optional: + +- `XAI_REALTIME_MODEL` +- `NEXT_PUBLIC_CALL_PHONE_DISPLAY` +- `NEXT_PUBLIC_CALL_PHONE_E164` +- `NEXT_PUBLIC_SMS_PHONE_DISPLAY` +- `NEXT_PUBLIC_SMS_PHONE_E164` +- `NEXT_PUBLIC_MANUALS_BASE_URL` +- `NEXT_PUBLIC_THUMBNAILS_BASE_URL` +- `VOICE_RECORDING_ENABLED` +- `VOICE_RECORDING_BUCKET` +- `VOICE_RECORDING_ENDPOINT` +- `VOICE_RECORDING_PUBLIC_BASE_URL` +- `VOICE_RECORDING_ACCESS_KEY_ID` +- `VOICE_RECORDING_SECRET_ACCESS_KEY` +- `VOICE_RECORDING_REGION` + +### Admin And Auth + +- `ADMIN_EMAIL` +- `ADMIN_PASSWORD` +- `NEXT_PUBLIC_SUPABASE_URL` +- `NEXT_PUBLIC_SUPABASE_ANON_KEY` + +### Stripe + +- `STRIPE_SECRET_KEY` +- `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` +- `STRIPE_WEBHOOK_SECRET` + +### Current Non-Contract Handoff Vars + +Do not rely on these names for current code behavior without a separate alignment change: + +- `GHL_API_TOKEN` +- `ADMIN_API_TOKEN` +- `ADMIN_UI_ENABLED` +- `USESEND_API_KEY` +- `USESEND_BASE_URL` +- `CONVEX_URL` +- `CONVEX_SELF_HOSTED_URL` +- `CONVEX_SELF_HOSTED_ADMIN_KEY` + +## Lead Routing Notes + +- `/api/contact` and `/api/request-machine` prefer Convex whenever `NEXT_PUBLIC_CONVEX_URL` is present. +- The legacy webhook fallback still expects `GHL_CONTACT_WEBHOOK_URL` and `GHL_REQUEST_MACHINE_WEBHOOK_URL`. +- Do not treat `GHL_API_TOKEN` as a fallback for staging readiness. + +## Pre-Deploy Checklist + +1. Start from a clean reviewed commit on `main`. +2. Confirm required staging env coverage: + +```bash +pnpm deploy:staging:env +``` + +3. Run the full local preflight: + +```bash +pnpm deploy:staging:preflight +``` + +4. Push the reviewed release commit to `origin/main`. +5. Verify Coolify is building from the repo root Dockerfile and exposing port `3001`. + +## Post-Deploy Smoke Tests + +Run the smoke test suite against either the preview URL or the staging host: + +```bash +pnpm deploy:staging:smoke --base-url https://rmv.abundancepartners.app +``` + +If you want to verify real admin sign-in, pass credentials without committing them: + +```bash +pnpm deploy:staging:smoke --base-url https://rmv.abundancepartners.app --admin-email "$ADMIN_EMAIL" --admin-password "$ADMIN_PASSWORD" +``` + +If you need to exercise valid contact or request-machine submissions, provide JSON payload files explicitly. This is opt-in because those requests create real records: + +```bash +pnpm deploy:staging:smoke \ + --base-url https://rmv.abundancepartners.app \ + --contact-payload-file ./tmp/contact.json \ + --request-machine-payload-file ./tmp/request-machine.json +``` + +## Acceptance Criteria + +Staging is ready only when all of the following pass on the deployed host: + +- `GET /` +- `GET /contact-us` +- `GET /products` +- `GET /manuals/dashboard` +- `POST /api/contact` with `{}` returns `400` +- `POST /api/request-machine` with `{}` returns `400` +- `POST /api/chat` succeeds +- `POST /api/livekit/token` succeeds +- Admin auth is configured and sign-in works +- Stripe product fetch works +- Stripe checkout route is configured + +## Known Non-Blocking Issue + +- `app/manuals/dashboard/page.tsx` still logs a missing parent-directory JSON file during builds when local manual-report artifacts are absent. The build currently completes successfully, so treat this as follow-up work rather than a staging blocker. diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 83eff21a..b8d130b0 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -57,14 +57,9 @@ type ChatVisitorProfile = { serviceTextConsent: boolean } -function readRequiredEnv(name: string) { +function getOptionalEnv(name: string) { const value = process.env[name] - - if (!value) { - throw new Error(`Missing required site chat environment variable: ${name}`) - } - - return value + return typeof value === "string" && value.trim() ? value.trim() : "" } function getClientIp(request: NextRequest) { @@ -259,7 +254,22 @@ export async function POST(request: NextRequest) { consumeChatRequest({ ip, requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS, sessionId }) - const xaiApiKey = readRequiredEnv("XAI_API_KEY") + const xaiApiKey = getOptionalEnv("XAI_API_KEY") + if (!xaiApiKey) { + console.warn("[site-chat] missing XAI_API_KEY", { + pathname, + sessionId, + }) + + return NextResponse.json( + { + error: "Jessica is temporarily unavailable right now. Please call us or use the contact form.", + sessionId, + }, + { status: 503, headers: responseHeaders }, + ) + } + const completionResponse = await fetch("https://api.x.ai/v1/chat/completions", { method: "POST", headers: { @@ -344,9 +354,16 @@ export async function POST(request: NextRequest) { } catch (error) { console.error("[site-chat] request failed", error) + const safeError = + error instanceof Error && error.message.startsWith("Missing required site chat environment variable:") + ? "Jessica is temporarily unavailable right now. Please call us or use the contact form." + : error instanceof Error + ? error.message + : "Chat failed unexpectedly." + return NextResponse.json( { - error: error instanceof Error ? error.message : "Chat failed unexpectedly.", + error: safeError, }, { status: 500, headers: responseHeaders }, ) diff --git a/app/api/livekit/token/route.ts b/app/api/livekit/token/route.ts new file mode 100644 index 00000000..7e002c4f --- /dev/null +++ b/app/api/livekit/token/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server" +import { createVoiceAssistantTokenResponse } from "@/lib/voice-assistant/server" +import { isVoiceAssistantSuppressedRoute } from "@/lib/voice-assistant/shared" + +export const runtime = "nodejs" + +type TokenRequestBody = { + pathname?: string +} + +export async function POST(request: NextRequest) { + try { + const body = (await request.json().catch(() => ({}))) as TokenRequestBody + const pathname = typeof body.pathname === "string" && body.pathname.trim() ? body.pathname.trim() : "/" + + if (isVoiceAssistantSuppressedRoute(pathname)) { + console.info("[voice-assistant/token] blocked on suppressed route", { pathname }) + return NextResponse.json({ error: "Voice assistant is not available on this route." }, { status: 403 }) + } + + const tokenResponse = await createVoiceAssistantTokenResponse(pathname) + console.info("[voice-assistant/token] issued token", { + pathname, + roomName: tokenResponse.roomName, + participantIdentity: tokenResponse.participantIdentity, + }) + return NextResponse.json(tokenResponse) + } catch (error) { + console.error("Failed to create LiveKit voice assistant token:", error) + + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Failed to create voice assistant token", + }, + { status: 500 }, + ) + } +} diff --git a/app/layout.tsx b/app/layout.tsx index ab8cef77..31efce42 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -56,19 +56,18 @@ export const metadata: Metadata = { icons: { icon: [ { - url: "/icon-light-32x32.png", - media: "(prefers-color-scheme: light)", + url: "/favicon-16x16.png", + sizes: "16x16", + type: "image/png", }, { - url: "/icon-dark-32x32.png", - media: "(prefers-color-scheme: dark)", - }, - { - url: "/icon.svg", - type: "image/svg+xml", + url: "/favicon-32x32.png", + sizes: "32x32", + type: "image/png", }, ], - apple: "/apple-icon.png", + shortcut: "/favicon.ico", + apple: "/apple-touch-icon.png", }, openGraph: { type: "website", diff --git a/components/forms/sms-consent-fields.tsx b/components/forms/sms-consent-fields.tsx index f12344a5..13312191 100644 --- a/components/forms/sms-consent-fields.tsx +++ b/components/forms/sms-consent-fields.tsx @@ -34,6 +34,7 @@ type SmsConsentFieldsProps = { idPrefix: string marketingChecked: boolean marketingError?: string + mode?: "chat" | "forms" onMarketingChange: (checked: boolean) => void onServiceChange: (checked: boolean) => void serviceChecked: boolean @@ -44,6 +45,7 @@ export function SmsConsentFields({ idPrefix, marketingChecked, marketingError, + mode = "forms", onMarketingChange, onServiceChange, serviceChecked, @@ -70,24 +72,28 @@ export function SmsConsentFields({ {serviceError ?

{serviceError}

: null} -
- onMarketingChange(Boolean(checked))} - className="mt-0.5" - /> - -
- {marketingError ?

{marketingError}

: null} + {mode === "forms" ? ( + <> +
+ onMarketingChange(Boolean(checked))} + className="mt-0.5" + /> + +
+ {marketingError ?

{marketingError}

: null} + + ) : null} ) } diff --git a/components/site-chat-widget.tsx b/components/site-chat-widget.tsx index 7198aec6..d17683ba 100644 --- a/components/site-chat-widget.tsx +++ b/components/site-chat-widget.tsx @@ -59,6 +59,8 @@ type ChatApiResponse = { limits?: ChatLimitStatus } +const CHAT_UNAVAILABLE_MESSAGE = "Jessica is temporarily unavailable right now. Please call or use the contact form." + const SESSION_STORAGE_KEY = "rmv-site-chat-session" const PROFILE_STORAGE_KEY = "rmv-site-chat-profile" const PANEL_MAX_HEIGHT = "min(40rem, calc(100vh - 7rem))" @@ -348,16 +350,16 @@ export function SiteChatWidget() { } if (!response.ok || !data.reply) { - throw new Error(data.error || "Jessica could not reply right now.") + throw new Error(data.error || CHAT_UNAVAILABLE_MESSAGE) } setMessages((current) => [...current, createMessage("assistant", data.reply || "")].slice(-12)) } catch (chatError) { - const message = chatError instanceof Error ? chatError.message : "Jessica could not reply right now." + const message = chatError instanceof Error ? chatError.message : CHAT_UNAVAILABLE_MESSAGE setError(message) setMessages((current) => [ ...current, - createMessage("assistant", "I'm having trouble right now. Please try again or call us."), + createMessage("assistant", "I'm temporarily unavailable right now. Please call us or use the contact form."), ].slice(-12)) } finally { setIsSending(false) @@ -457,6 +459,7 @@ export function SiteChatWidget() {
@@ -467,14 +470,7 @@ export function SiteChatWidget() { consentSourcePage: pathname || "/", })) } - onMarketingChange={(checked) => - setProfileDraft((current) => ({ - ...current, - marketingTextConsent: checked, - consentVersion: SMS_CONSENT_VERSION, - consentSourcePage: pathname || "/", - })) - } + onMarketingChange={() => undefined} serviceError={profileError && !profileDraft.serviceTextConsent ? profileError : undefined} />
diff --git a/convex/_generated/api.ts b/convex/_generated/api.ts new file mode 100644 index 00000000..ccc0e103 --- /dev/null +++ b/convex/_generated/api.ts @@ -0,0 +1,4 @@ +import { anyApi } from "convex/server"; + +export const api = anyApi; +export const internal = anyApi; diff --git a/convex/_generated/server.ts b/convex/_generated/server.ts new file mode 100644 index 00000000..ee8faebf --- /dev/null +++ b/convex/_generated/server.ts @@ -0,0 +1,9 @@ +export { + actionGeneric as action, + httpActionGeneric as httpAction, + internalActionGeneric as internalAction, + internalMutationGeneric as internalMutation, + internalQueryGeneric as internalQuery, + mutationGeneric as mutation, + queryGeneric as query, +} from "convex/server"; diff --git a/convex/admin.ts b/convex/admin.ts new file mode 100644 index 00000000..2d92ac19 --- /dev/null +++ b/convex/admin.ts @@ -0,0 +1,108 @@ +// @ts-nocheck +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; + +export const ensureAdminUser = mutation({ + args: { + email: v.string(), + name: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("adminUsers") + .withIndex("by_email", (q) => q.eq("email", args.email)) + .unique(); + const now = Date.now(); + + if (existing) { + await ctx.db.patch(existing._id, { + active: true, + name: args.name ?? existing.name, + updatedAt: now, + lastLoginAt: now, + }); + return await ctx.db.get(existing._id); + } + + const id = await ctx.db.insert("adminUsers", { + email: args.email, + name: args.name, + role: "admin", + active: true, + createdAt: now, + updatedAt: now, + lastLoginAt: now, + }); + return await ctx.db.get(id); + }, +}); + +export const createSession = mutation({ + args: { + email: v.string(), + tokenHash: v.string(), + expiresAt: v.number(), + }, + handler: async (ctx, args) => { + const user = await ctx.db + .query("adminUsers") + .withIndex("by_email", (q) => q.eq("email", args.email)) + .unique(); + if (!user) { + throw new Error("Admin user not found"); + } + + const id = await ctx.db.insert("adminSessions", { + adminUserId: user._id, + tokenHash: args.tokenHash, + expiresAt: args.expiresAt, + createdAt: Date.now(), + }); + return { sessionId: id, user }; + }, +}); + +export const destroySession = mutation({ + args: { + tokenHash: v.string(), + }, + handler: async (ctx, args) => { + const session = await ctx.db + .query("adminSessions") + .withIndex("by_tokenHash", (q) => q.eq("tokenHash", args.tokenHash)) + .unique(); + if (session) { + await ctx.db.delete(session._id); + } + return { success: true }; + }, +}); + +export const validateSession = query({ + args: { + tokenHash: v.string(), + }, + handler: async (ctx, args) => { + const session = await ctx.db + .query("adminSessions") + .withIndex("by_tokenHash", (q) => q.eq("tokenHash", args.tokenHash)) + .unique(); + if (!session || session.expiresAt < Date.now()) { + return null; + } + const user = await ctx.db.get(session.adminUserId); + if (!user || !user.active) { + return null; + } + return { + user: { + id: user._id, + email: user.email, + name: user.name, + role: user.role, + }, + sessionId: session._id, + expiresAt: session.expiresAt, + }; + }, +}); diff --git a/convex/leads.ts b/convex/leads.ts new file mode 100644 index 00000000..80ffba8f --- /dev/null +++ b/convex/leads.ts @@ -0,0 +1,158 @@ +// @ts-nocheck +import { action, mutation } from "./_generated/server"; +import { v } from "convex/values"; + +const baseLead = { + firstName: v.string(), + lastName: v.string(), + email: v.string(), + phone: v.string(), + company: v.optional(v.string()), + intent: v.optional(v.string()), + message: v.optional(v.string()), + source: v.optional(v.string()), + page: v.optional(v.string()), + url: v.optional(v.string()), + serviceTextConsent: v.optional(v.boolean()), + marketingTextConsent: v.optional(v.boolean()), + consentVersion: v.optional(v.string()), + consentCapturedAt: v.optional(v.string()), + consentSourcePage: v.optional(v.string()), +}; + +async function sendWebhook(webhookUrl: string | undefined, payload: Record) { + if (!webhookUrl) { + return { success: false, error: "Webhook URL not configured" }; + } + + const response = await fetch(webhookUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + return { + success: false, + error: `${response.status} ${response.statusText}`, + }; + } + + return { success: true }; +} + +export const createLead = mutation({ + args: { + type: v.union(v.literal("contact"), v.literal("requestMachine")), + firstName: v.string(), + lastName: v.string(), + email: v.string(), + phone: v.string(), + company: v.optional(v.string()), + intent: v.optional(v.string()), + message: v.optional(v.string()), + source: v.optional(v.string()), + page: v.optional(v.string()), + url: v.optional(v.string()), + employeeCount: v.optional(v.string()), + machineType: v.optional(v.string()), + machineCount: v.optional(v.string()), + serviceTextConsent: v.optional(v.boolean()), + marketingTextConsent: v.optional(v.boolean()), + consentVersion: v.optional(v.string()), + consentCapturedAt: v.optional(v.string()), + consentSourcePage: v.optional(v.string()), + marketingConsent: v.optional(v.boolean()), + termsAgreement: v.optional(v.boolean()), + status: v.union(v.literal("pending"), v.literal("delivered"), v.literal("failed")), + error: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const now = Date.now(); + return await ctx.db.insert("leadSubmissions", { + ...args, + createdAt: now, + updatedAt: now, + deliveredAt: args.status === "delivered" ? now : undefined, + }); + }, +}); + +export const submitContact = action({ + args: { + ...baseLead, + }, + handler: async (ctx, args) => { + const payload = { + firstName: args.firstName, + lastName: args.lastName, + email: args.email, + phone: args.phone, + company: args.company ?? "", + custom1: args.message ?? "", + custom2: args.source ?? "website", + custom3: args.page ?? "", + custom4: new Date().toISOString(), + custom5: args.url ?? "", + custom6: args.intent ?? "", + custom7: args.serviceTextConsent ? "Consented" : "Not Consented", + custom8: args.marketingTextConsent ? "Consented" : "Not Consented", + custom9: args.consentVersion ?? "", + custom10: args.consentCapturedAt ?? "", + custom11: args.consentSourcePage ?? "", + }; + + const result = await sendWebhook(process.env.GHL_CONTACT_WEBHOOK_URL, payload); + await ctx.runMutation("leads:createLead", { + type: "contact", + ...args, + status: result.success ? "delivered" : "failed", + error: result.error, + }); + return result; + }, +}); + +export const submitRequestMachine = action({ + args: { + ...baseLead, + employeeCount: v.string(), + machineType: v.string(), + machineCount: v.string(), + }, + handler: async (ctx, args) => { + const payload = { + firstName: args.firstName, + lastName: args.lastName, + email: args.email, + phone: args.phone, + company: args.company ?? "", + custom1: args.employeeCount, + custom2: args.machineType, + custom3: args.machineCount, + custom4: args.message ?? "", + custom5: args.source ?? "website", + custom6: args.page ?? "", + custom7: new Date().toISOString(), + custom8: args.url ?? "", + custom9: args.serviceTextConsent ? "Consented" : "Not Consented", + custom10: args.marketingTextConsent ? "Consented" : "Not Consented", + custom11: args.consentVersion ?? "", + custom12: args.consentCapturedAt ?? "", + custom13: args.consentSourcePage ?? "", + custom14: "Free Consultation Request", + tags: ["Machine Request"], + }; + + const result = await sendWebhook(process.env.GHL_REQUEST_MACHINE_WEBHOOK_URL, payload); + await ctx.runMutation("leads:createLead", { + type: "requestMachine", + ...args, + status: result.success ? "delivered" : "failed", + error: result.error, + }); + return result; + }, +}); diff --git a/convex/manuals.ts b/convex/manuals.ts new file mode 100644 index 00000000..e150afa1 --- /dev/null +++ b/convex/manuals.ts @@ -0,0 +1,108 @@ +// @ts-nocheck +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; + +const manualInput = v.object({ + filename: v.string(), + path: v.string(), + manufacturer: v.string(), + category: v.string(), + size: v.optional(v.number()), + lastModified: v.optional(v.number()), + searchTerms: v.optional(v.array(v.string())), + commonNames: v.optional(v.array(v.string())), + thumbnailUrl: v.optional(v.string()), + manualUrl: v.optional(v.string()), + hasParts: v.optional(v.boolean()), + assetSource: v.optional(v.string()), + sourcePath: v.optional(v.string()), + sourceSite: v.optional(v.string()), + sourceDomain: v.optional(v.string()), + siteVisibility: v.optional(v.array(v.string())), + importBatch: v.optional(v.string()), +}); + +export const list = query({ + args: {}, + handler: async (ctx) => { + const manuals = await ctx.db.query("manuals").collect(); + return manuals.sort((a, b) => a.filename.localeCompare(b.filename)); + }, +}); + +export const dashboard = query({ + args: {}, + handler: async (ctx) => { + const manuals = await ctx.db.query("manuals").collect(); + const categories = await ctx.db.query("manualCategories").collect(); + + const manufacturerMap = new Map(); + const categoryMap = new Map(); + for (const manual of manuals) { + manufacturerMap.set( + manual.manufacturer, + (manufacturerMap.get(manual.manufacturer) ?? 0) + 1, + ); + categoryMap.set( + manual.category, + (categoryMap.get(manual.category) ?? 0) + 1, + ); + } + + return { + missingManuals: { + summary: { + total_expected_models: manuals.length, + models_missing_all: 0, + models_partial: 0, + }, + }, + qaData: [], + metadata: manuals, + structuredData: [], + semanticIndex: { + total_chunks: manuals.length, + }, + acquisitionList: { + total_items: 0, + high_priority: 0, + medium_priority: 0, + low_priority: 0, + acquisition_list: [], + }, + nameMapping: categories, + }; + }, +}); + +export const upsertMany = mutation({ + args: { + manuals: v.array(manualInput), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const results = []; + for (const manual of args.manuals) { + const existing = await ctx.db + .query("manuals") + .withIndex("by_path", (q) => q.eq("path", manual.path)) + .unique(); + + if (existing) { + await ctx.db.patch(existing._id, { + ...manual, + updatedAt: now, + }); + results.push(await ctx.db.get(existing._id)); + } else { + const id = await ctx.db.insert("manuals", { + ...manual, + createdAt: now, + updatedAt: now, + }); + results.push(await ctx.db.get(id)); + } + } + return results; + }, +}); diff --git a/convex/orders.ts b/convex/orders.ts new file mode 100644 index 00000000..911d6795 --- /dev/null +++ b/convex/orders.ts @@ -0,0 +1,200 @@ +// @ts-nocheck +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; + +const orderStatus = v.union( + v.literal("pending"), + v.literal("paid"), + v.literal("fulfilled"), + v.literal("cancelled"), + v.literal("refunded"), +); + +const orderItem = v.object({ + productId: v.optional(v.id("products")), + stripeProductId: v.optional(v.string()), + stripePriceId: v.string(), + productName: v.string(), + image: v.optional(v.string()), + price: v.number(), + quantity: v.number(), +}); + +const shippingAddress = v.object({ + name: v.optional(v.string()), + address: v.optional(v.string()), + city: v.optional(v.string()), + state: v.optional(v.string()), + zipCode: v.optional(v.string()), + country: v.optional(v.string()), +}); + +async function attachItems(ctx: any, order: any) { + const items = await ctx.db + .query("orderItems") + .withIndex("by_orderId", (q: any) => q.eq("orderId", order._id)) + .collect(); + + return { + id: order._id, + customerEmail: order.customerEmail, + customerName: order.customerName, + totalAmount: order.totalAmount, + currency: order.currency, + status: order.status, + stripeSessionId: order.stripeSessionId ?? null, + paymentIntentId: order.stripePaymentIntentId ?? null, + createdAt: new Date(order.createdAt).toISOString(), + updatedAt: new Date(order.updatedAt).toISOString(), + shippingAddress: order.shippingAddress, + items: items.map((item: any) => ({ + productId: item.productId ?? null, + productName: item.productName, + price: item.price, + quantity: item.quantity, + priceId: item.stripePriceId, + image: item.image, + })), + }; +} + +export const listAdmin = query({ + args: { + status: v.optional(orderStatus), + search: v.optional(v.string()), + }, + handler: async (ctx, args) => { + let orders = await ctx.db + .query("orders") + .withIndex("by_createdAt") + .collect(); + orders = orders.sort((a, b) => b.createdAt - a.createdAt); + + if (args.status) { + orders = orders.filter((order) => order.status === args.status); + } + + const search = args.search?.trim().toLowerCase(); + if (search) { + orders = orders.filter((order) => { + return ( + order.customerEmail.toLowerCase().includes(search) || + order.customerName?.toLowerCase().includes(search) || + order._id.toLowerCase().includes(search) + ); + }); + } + + return await Promise.all(orders.map((order) => attachItems(ctx, order))); + }, +}); + +export const getMetrics = query({ + args: {}, + handler: async (ctx) => { + const orders = await ctx.db.query("orders").collect(); + const paidOrders = orders.filter((order) => order.status === "paid" || order.status === "fulfilled"); + const revenue = paidOrders.reduce((sum, order) => sum + order.totalAmount, 0); + + return { + totalOrders: orders.length, + totalRevenue: revenue, + pendingOrders: orders.filter((order) => order.status === "pending").length, + completedOrders: orders.filter((order) => order.status === "fulfilled").length, + paidOrders: orders.filter((order) => order.status === "paid").length, + refundedOrders: orders.filter((order) => order.status === "refunded").length, + }; + }, +}); + +export const upsertStripeOrder = mutation({ + args: { + customerEmail: v.string(), + customerName: v.optional(v.string()), + status: orderStatus, + totalAmount: v.number(), + currency: v.string(), + stripeSessionId: v.optional(v.string()), + stripePaymentIntentId: v.optional(v.string()), + shippingAddress: v.optional(shippingAddress), + items: v.array(orderItem), + }, + handler: async (ctx, args) => { + const now = Date.now(); + let existing = + args.stripeSessionId + ? await ctx.db + .query("orders") + .withIndex("by_stripeSessionId", (q) => + q.eq("stripeSessionId", args.stripeSessionId), + ) + .unique() + : null; + + let orderId; + if (existing) { + orderId = existing._id; + await ctx.db.patch(orderId, { + customerEmail: args.customerEmail, + customerName: args.customerName, + status: args.status, + totalAmount: args.totalAmount, + currency: args.currency, + stripePaymentIntentId: args.stripePaymentIntentId, + shippingAddress: args.shippingAddress, + updatedAt: now, + }); + + const existingItems = await ctx.db + .query("orderItems") + .withIndex("by_orderId", (q) => q.eq("orderId", orderId)) + .collect(); + for (const item of existingItems) { + await ctx.db.delete(item._id); + } + } else { + orderId = await ctx.db.insert("orders", { + customerEmail: args.customerEmail, + customerName: args.customerName, + status: args.status, + totalAmount: args.totalAmount, + currency: args.currency, + stripeSessionId: args.stripeSessionId, + stripePaymentIntentId: args.stripePaymentIntentId, + shippingAddress: args.shippingAddress, + createdAt: now, + updatedAt: now, + }); + } + + for (const item of args.items) { + await ctx.db.insert("orderItems", { + orderId, + productId: item.productId, + stripeProductId: item.stripeProductId, + stripePriceId: item.stripePriceId, + productName: item.productName, + image: item.image, + price: item.price, + quantity: item.quantity, + createdAt: now, + }); + } + + return await attachItems(ctx, (await ctx.db.get(orderId))!); + }, +}); + +export const updateStatus = mutation({ + args: { + id: v.id("orders"), + status: orderStatus, + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.id, { + status: args.status, + updatedAt: Date.now(), + }); + return await attachItems(ctx, (await ctx.db.get(args.id))!); + }, +}); diff --git a/convex/products.ts b/convex/products.ts new file mode 100644 index 00000000..4eb53b68 --- /dev/null +++ b/convex/products.ts @@ -0,0 +1,124 @@ +// @ts-nocheck +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; + +const productArgs = { + id: v.optional(v.id("products")), + name: v.string(), + description: v.optional(v.string()), + price: v.number(), + currency: v.string(), + images: v.array(v.string()), + metadata: v.optional(v.record(v.string(), v.string())), + stripeProductId: v.optional(v.string()), + stripePriceId: v.optional(v.string()), + active: v.boolean(), + featured: v.optional(v.boolean()), +}; + +export const listActive = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db + .query("products") + .withIndex("by_active", (q) => q.eq("active", true)) + .collect(); + }, +}); + +export const listAdmin = query({ + args: { + search: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const products = await ctx.db.query("products").collect(); + const search = args.search?.trim().toLowerCase(); + if (!search) { + return products.sort((a, b) => a.name.localeCompare(b.name)); + } + return products + .filter((product) => { + return ( + product.name.toLowerCase().includes(search) || + product.description?.toLowerCase().includes(search) + ); + }) + .sort((a, b) => a.name.localeCompare(b.name)); + }, +}); + +export const getById = query({ + args: { + id: v.id("products"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.id); + }, +}); + +export const upsert = mutation({ + args: productArgs, + handler: async (ctx, args) => { + const now = Date.now(); + if (args.id) { + await ctx.db.patch(args.id, { + name: args.name, + description: args.description, + price: args.price, + currency: args.currency, + images: args.images, + metadata: args.metadata, + stripeProductId: args.stripeProductId, + stripePriceId: args.stripePriceId, + active: args.active, + featured: args.featured, + updatedAt: now, + }); + return await ctx.db.get(args.id); + } + + const id = await ctx.db.insert("products", { + name: args.name, + description: args.description, + price: args.price, + currency: args.currency, + images: args.images, + metadata: args.metadata, + stripeProductId: args.stripeProductId, + stripePriceId: args.stripePriceId, + active: args.active, + featured: args.featured, + createdAt: now, + updatedAt: now, + }); + return await ctx.db.get(id); + }, +}); + +export const deactivate = mutation({ + args: { + id: v.id("products"), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.id, { + active: false, + updatedAt: Date.now(), + }); + return await ctx.db.get(args.id); + }, +}); + +export const bulkDeactivate = mutation({ + args: { + ids: v.array(v.id("products")), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const results = []; + for (const id of args.ids) { + await ctx.db.patch(id, { active: false, updatedAt: now }); + results.push(await ctx.db.get(id)); + } + return results; + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts new file mode 100644 index 00000000..2b11b065 --- /dev/null +++ b/convex/schema.ts @@ -0,0 +1,223 @@ +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +const orderStatus = v.union( + v.literal("pending"), + v.literal("paid"), + v.literal("fulfilled"), + v.literal("cancelled"), + v.literal("refunded"), +); + +export default defineSchema({ + products: defineTable({ + name: v.string(), + description: v.optional(v.string()), + price: v.number(), + currency: v.string(), + images: v.array(v.string()), + metadata: v.optional(v.record(v.string(), v.string())), + stripeProductId: v.optional(v.string()), + stripePriceId: v.optional(v.string()), + active: v.boolean(), + featured: v.optional(v.boolean()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_active", ["active"]) + .index("by_stripeProductId", ["stripeProductId"]), + + orders: defineTable({ + customerEmail: v.string(), + customerName: v.optional(v.string()), + status: orderStatus, + totalAmount: v.number(), + currency: v.string(), + stripeSessionId: v.optional(v.string()), + stripePaymentIntentId: v.optional(v.string()), + shippingAddress: v.optional( + v.object({ + name: v.optional(v.string()), + address: v.optional(v.string()), + city: v.optional(v.string()), + state: v.optional(v.string()), + zipCode: v.optional(v.string()), + country: v.optional(v.string()), + }), + ), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_createdAt", ["createdAt"]) + .index("by_status", ["status"]) + .index("by_stripeSessionId", ["stripeSessionId"]) + .index("by_customerEmail", ["customerEmail"]), + + orderItems: defineTable({ + orderId: v.id("orders"), + productId: v.optional(v.id("products")), + stripeProductId: v.optional(v.string()), + stripePriceId: v.string(), + productName: v.string(), + image: v.optional(v.string()), + price: v.number(), + quantity: v.number(), + createdAt: v.number(), + }).index("by_orderId", ["orderId"]), + + manuals: defineTable({ + filename: v.string(), + path: v.string(), + manufacturer: v.string(), + category: v.string(), + size: v.optional(v.number()), + lastModified: v.optional(v.number()), + searchTerms: v.optional(v.array(v.string())), + commonNames: v.optional(v.array(v.string())), + thumbnailUrl: v.optional(v.string()), + manualUrl: v.optional(v.string()), + hasParts: v.optional(v.boolean()), + assetSource: v.optional(v.string()), + sourcePath: v.optional(v.string()), + sourceSite: v.optional(v.string()), + sourceDomain: v.optional(v.string()), + siteVisibility: v.optional(v.array(v.string())), + importBatch: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_manufacturer", ["manufacturer"]) + .index("by_category", ["category"]) + .index("by_path", ["path"]), + + manualCategories: defineTable({ + name: v.string(), + slug: v.string(), + description: v.optional(v.string()), + icon: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), + }).index("by_slug", ["slug"]), + + leadSubmissions: defineTable({ + type: v.union(v.literal("contact"), v.literal("requestMachine")), + status: v.union(v.literal("pending"), v.literal("delivered"), v.literal("failed")), + firstName: v.string(), + lastName: v.string(), + email: v.string(), + phone: v.string(), + company: v.optional(v.string()), + intent: v.optional(v.string()), + message: v.optional(v.string()), + source: v.optional(v.string()), + page: v.optional(v.string()), + url: v.optional(v.string()), + employeeCount: v.optional(v.string()), + machineType: v.optional(v.string()), + machineCount: v.optional(v.string()), + serviceTextConsent: v.optional(v.boolean()), + marketingTextConsent: v.optional(v.boolean()), + consentVersion: v.optional(v.string()), + consentCapturedAt: v.optional(v.string()), + consentSourcePage: v.optional(v.string()), + marketingConsent: v.optional(v.boolean()), + termsAgreement: v.optional(v.boolean()), + error: v.optional(v.string()), + deliveredAt: v.optional(v.number()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_type", ["type"]) + .index("by_status", ["status"]) + .index("by_createdAt", ["createdAt"]), + + adminUsers: defineTable({ + email: v.string(), + name: v.optional(v.string()), + role: v.union(v.literal("admin")), + active: v.boolean(), + createdAt: v.number(), + updatedAt: v.number(), + lastLoginAt: v.optional(v.number()), + }).index("by_email", ["email"]), + + adminSessions: defineTable({ + adminUserId: v.id("adminUsers"), + tokenHash: v.string(), + expiresAt: v.number(), + createdAt: v.number(), + }) + .index("by_tokenHash", ["tokenHash"]) + .index("by_adminUserId", ["adminUserId"]), + + siteSettings: defineTable({ + key: v.string(), + value: v.string(), + description: v.optional(v.string()), + updatedAt: v.number(), + }).index("by_key", ["key"]), + + syncJobs: defineTable({ + kind: v.string(), + status: v.union( + v.literal("pending"), + v.literal("running"), + v.literal("completed"), + v.literal("failed"), + ), + message: v.optional(v.string()), + metadata: v.optional(v.string()), + startedAt: v.optional(v.number()), + completedAt: v.optional(v.number()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_kind", ["kind"]) + .index("by_status", ["status"]), + + voiceSessions: defineTable({ + roomName: v.string(), + participantIdentity: v.string(), + siteUrl: v.optional(v.string()), + pathname: v.optional(v.string()), + pageUrl: v.optional(v.string()), + source: v.optional(v.string()), + startedAt: v.number(), + endedAt: v.optional(v.number()), + recordingDisclosureAt: v.optional(v.number()), + recordingStatus: v.optional( + v.union( + v.literal("pending"), + v.literal("starting"), + v.literal("recording"), + v.literal("completed"), + v.literal("failed"), + ), + ), + recordingId: v.optional(v.string()), + recordingUrl: v.optional(v.string()), + recordingError: v.optional(v.string()), + metadata: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_roomName", ["roomName"]) + .index("by_participantIdentity", ["participantIdentity"]) + .index("by_startedAt", ["startedAt"]), + + voiceTranscriptTurns: defineTable({ + sessionId: v.id("voiceSessions"), + roomName: v.string(), + participantIdentity: v.string(), + role: v.union(v.literal("user"), v.literal("assistant"), v.literal("system")), + kind: v.optional(v.string()), + text: v.string(), + isFinal: v.optional(v.boolean()), + language: v.optional(v.string()), + source: v.optional(v.string()), + createdAt: v.number(), + }) + .index("by_sessionId", ["sessionId"]) + .index("by_roomName", ["roomName"]) + .index("by_createdAt", ["createdAt"]), +}); diff --git a/convex/tsconfig.json b/convex/tsconfig.json new file mode 100644 index 00000000..41bfbb9b --- /dev/null +++ b/convex/tsconfig.json @@ -0,0 +1,25 @@ +{ + /* This TypeScript project config describes the environment that + * Convex functions run in and is used to typecheck them. + * You can modify it, but some settings are required to use Convex. + */ + "compilerOptions": { + /* These settings are not required by Convex and can be modified. */ + "allowJs": true, + "strict": true, + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + + /* These compiler options are required by Convex */ + "target": "ESNext", + "lib": ["ES2023", "dom"], + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["./**/*"], + "exclude": ["./_generated"] +} diff --git a/convex/voiceSessions.ts b/convex/voiceSessions.ts new file mode 100644 index 00000000..bd9eed73 --- /dev/null +++ b/convex/voiceSessions.ts @@ -0,0 +1,129 @@ +// @ts-nocheck +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; + +export const getByRoom = query({ + args: { + roomName: v.string(), + }, + handler: async (ctx, args) => { + return await ctx.db + .query("voiceSessions") + .withIndex("by_roomName", (q) => q.eq("roomName", args.roomName)) + .unique(); + }, +}); + +export const createSession = mutation({ + args: { + roomName: v.string(), + participantIdentity: v.string(), + siteUrl: v.optional(v.string()), + pathname: v.optional(v.string()), + pageUrl: v.optional(v.string()), + source: v.optional(v.string()), + metadata: v.optional(v.string()), + startedAt: v.optional(v.number()), + recordingDisclosureAt: v.optional(v.number()), + recordingStatus: v.optional( + v.union( + v.literal("pending"), + v.literal("starting"), + v.literal("recording"), + v.literal("completed"), + v.literal("failed"), + ), + ), + }, + handler: async (ctx, args) => { + const now = args.startedAt ?? Date.now(); + return await ctx.db.insert("voiceSessions", { + ...args, + startedAt: now, + createdAt: now, + updatedAt: now, + }); + }, +}); + +export const addTranscriptTurn = mutation({ + args: { + sessionId: v.id("voiceSessions"), + roomName: v.string(), + participantIdentity: v.string(), + role: v.union(v.literal("user"), v.literal("assistant"), v.literal("system")), + text: v.string(), + kind: v.optional(v.string()), + isFinal: v.optional(v.boolean()), + language: v.optional(v.string()), + source: v.optional(v.string()), + createdAt: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const createdAt = args.createdAt ?? Date.now(); + return await ctx.db.insert("voiceTranscriptTurns", { + ...args, + text: args.text.trim(), + createdAt, + }); + }, +}); + +export const updateRecording = mutation({ + args: { + sessionId: v.id("voiceSessions"), + recordingStatus: v.optional( + v.union( + v.literal("pending"), + v.literal("starting"), + v.literal("recording"), + v.literal("completed"), + v.literal("failed"), + ), + ), + recordingId: v.optional(v.string()), + recordingUrl: v.optional(v.string()), + recordingError: v.optional(v.string()), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.sessionId, { + recordingStatus: args.recordingStatus, + recordingId: args.recordingId, + recordingUrl: args.recordingUrl, + recordingError: args.recordingError, + updatedAt: Date.now(), + }); + return await ctx.db.get(args.sessionId); + }, +}); + +export const completeSession = mutation({ + args: { + sessionId: v.id("voiceSessions"), + endedAt: v.optional(v.number()), + recordingStatus: v.optional( + v.union( + v.literal("pending"), + v.literal("starting"), + v.literal("recording"), + v.literal("completed"), + v.literal("failed"), + ), + ), + recordingId: v.optional(v.string()), + recordingUrl: v.optional(v.string()), + recordingError: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const endedAt = args.endedAt ?? Date.now(); + await ctx.db.patch(args.sessionId, { + endedAt, + recordingStatus: args.recordingStatus, + recordingId: args.recordingId, + recordingUrl: args.recordingUrl, + recordingError: args.recordingError, + updatedAt: endedAt, + }); + return await ctx.db.get(args.sessionId); + }, +}); diff --git a/lib/manuals-types.ts b/lib/manuals-types.ts index 1dea372e..4f299a9d 100644 --- a/lib/manuals-types.ts +++ b/lib/manuals-types.ts @@ -17,6 +17,10 @@ export interface ManualGroup { } } +function isAbsoluteUrl(value: string) { + return /^https?:\/\//i.test(value) +} + /** * Get public URL for a manual file * Supports both local static files and external hosting (e.g., Cloudflare) @@ -26,6 +30,10 @@ export interface ManualGroup { * - If not set: Uses local static path "/manuals/" */ export function getManualUrl(manual: Manual): string { + if (isAbsoluteUrl(manual.path)) { + return manual.path + } + // Handle both absolute and relative paths // If path is absolute (contains /manuals/), extract relative portion // Otherwise, assume it's already relative @@ -82,6 +90,10 @@ export function getThumbnailUrl(manual: Manual): string | null { if (!manual.thumbnailUrl) { return null } + + if (isAbsoluteUrl(manual.thumbnailUrl)) { + return manual.thumbnailUrl + } // Handle both absolute and relative paths let relativePath: string @@ -119,4 +131,3 @@ export function getThumbnailUrl(manual: Manual): string | null { // Use local static path for GHL static hosting return `/thumbnails/${encodedPath}` } - diff --git a/lib/voice-assistant/persistence.ts b/lib/voice-assistant/persistence.ts new file mode 100644 index 00000000..c9265e22 --- /dev/null +++ b/lib/voice-assistant/persistence.ts @@ -0,0 +1,266 @@ +import { ConvexHttpClient } from "convex/browser" +import { makeFunctionReference } from "convex/server" +import { + EgressClient, + EncodedFileOutput, + EncodedFileType, + S3Upload, +} from "livekit-server-sdk" +import { VOICE_ASSISTANT_SOURCE } from "@/lib/voice-assistant/types" + +type VoiceRecordingStatus = "pending" | "starting" | "recording" | "completed" | "failed" + +type CreateVoiceSessionArgs = { + roomName: string + participantIdentity: string + siteUrl?: string + pathname?: string + pageUrl?: string + source?: string + metadata?: string + startedAt?: number + recordingDisclosureAt?: number + recordingStatus?: VoiceRecordingStatus +} + +type AddTranscriptTurnArgs = { + sessionId: string + roomName: string + participantIdentity: string + role: "user" | "assistant" | "system" + text: string + kind?: string + isFinal?: boolean + language?: string + source?: string + createdAt?: number +} + +type UpdateVoiceRecordingArgs = { + sessionId: string + recordingStatus?: VoiceRecordingStatus + recordingId?: string + recordingUrl?: string + recordingError?: string +} + +type CompleteVoiceSessionArgs = UpdateVoiceRecordingArgs & { + endedAt?: number +} + +type VoiceRecordingConfig = { + bucket: string + egressClient: EgressClient + endpoint: string + forcePathStyle: boolean + pathPrefix: string + publicBaseUrl?: string + region: string + secret: string + accessKey: string +} + +type VoicePersistenceServices = { + convexClient: ConvexHttpClient | null + recording: VoiceRecordingConfig | null +} + +const CREATE_VOICE_SESSION = makeFunctionReference<"mutation">("voiceSessions:createSession") +const ADD_VOICE_TRANSCRIPT_TURN = makeFunctionReference<"mutation">("voiceSessions:addTranscriptTurn") +const UPDATE_VOICE_RECORDING = makeFunctionReference<"mutation">("voiceSessions:updateRecording") +const COMPLETE_VOICE_SESSION = makeFunctionReference<"mutation">("voiceSessions:completeSession") + +function readOptionalEnv(name: string) { + const value = process.env[name] + return typeof value === "string" && value.trim() ? value.trim() : "" +} + +function readBooleanEnv(name: string) { + const value = readOptionalEnv(name).toLowerCase() + if (!value) { + return undefined + } + + return value === "1" || value === "true" || value === "yes" +} + +function sanitizePathSegment(value: string) { + return value + .replace(/[^a-zA-Z0-9/_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/\/+/g, "/") + .replace(/^-|-$/g, "") +} + +function buildRecordingFilepath(roomName: string) { + const prefix = readOptionalEnv("VOICE_RECORDING_PATH_PREFIX") || "livekit-recordings" + const date = new Date() + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1).padStart(2, "0") + const day = String(date.getUTCDate()).padStart(2, "0") + const stamp = date.toISOString().replace(/[:.]/g, "-") + const safeRoom = sanitizePathSegment(roomName) + return `${sanitizePathSegment(prefix)}/${year}/${month}/${day}/${safeRoom}-${stamp}.mp3` +} + +function buildRecordingUrl(publicBaseUrl: string | undefined, bucket: string, filepath: string) { + const encodedPath = filepath + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/") + + if (publicBaseUrl) { + return `${publicBaseUrl.replace(/\/$/, "")}/${encodedPath}` + } + + return `s3://${bucket}/${filepath}` +} + +export function getVoicePersistenceServices(args: { + livekitUrl: string + livekitApiKey: string + livekitApiSecret: string +}) { + const convexUrl = readOptionalEnv("NEXT_PUBLIC_CONVEX_URL") || readOptionalEnv("CONVEX_URL") + const convexClient = convexUrl ? new ConvexHttpClient(convexUrl) : null + + const accessKey = + readOptionalEnv("VOICE_RECORDING_ACCESS_KEY_ID") || + readOptionalEnv("CLOUDFLARE_R2_ACCESS_KEY_ID") || + readOptionalEnv("AWS_ACCESS_KEY_ID") || + readOptionalEnv("AWS_ACCESS_KEY") + const secret = + readOptionalEnv("VOICE_RECORDING_SECRET_ACCESS_KEY") || + readOptionalEnv("CLOUDFLARE_R2_SECRET_ACCESS_KEY") || + readOptionalEnv("AWS_SECRET_ACCESS_KEY") || + readOptionalEnv("AWS_SECRET_KEY") + const endpoint = + readOptionalEnv("VOICE_RECORDING_ENDPOINT") || + readOptionalEnv("CLOUDFLARE_R2_ENDPOINT") + const bucket = readOptionalEnv("VOICE_RECORDING_BUCKET") + const region = readOptionalEnv("VOICE_RECORDING_REGION") || readOptionalEnv("AWS_DEFAULT_REGION") || "auto" + const publicBaseUrl = readOptionalEnv("VOICE_RECORDING_PUBLIC_BASE_URL") || undefined + const forcePathStyle = readBooleanEnv("VOICE_RECORDING_FORCE_PATH_STYLE") ?? true + const requested = readBooleanEnv("VOICE_RECORDING_ENABLED") + const hasRecordingEnv = Boolean(accessKey && secret && endpoint && bucket) + const recordingEnabled = requested ?? hasRecordingEnv + + const recording = + recordingEnabled && hasRecordingEnv + ? { + accessKey, + bucket, + egressClient: new EgressClient(args.livekitUrl, args.livekitApiKey, args.livekitApiSecret), + endpoint, + forcePathStyle, + pathPrefix: readOptionalEnv("VOICE_RECORDING_PATH_PREFIX") || "livekit-recordings", + publicBaseUrl, + region, + secret, + } + : null + + return { + convexClient, + recording, + } satisfies VoicePersistenceServices +} + +async function safeMutation( + client: ConvexHttpClient | null, + reference: ReturnType>, + args: TArgs, +) { + if (!client) { + return null + } + + return await client.mutation(reference, args) +} + +export async function createVoiceSessionRecord( + services: VoicePersistenceServices, + args: CreateVoiceSessionArgs, +) { + return await safeMutation(services.convexClient, CREATE_VOICE_SESSION, { + ...args, + source: args.source || VOICE_ASSISTANT_SOURCE, + }) +} + +export async function addVoiceTranscriptTurn( + services: VoicePersistenceServices, + args: AddTranscriptTurnArgs, +) { + const text = args.text.replace(/\s+/g, " ").trim() + if (!text) { + return null + } + + return await safeMutation(services.convexClient, ADD_VOICE_TRANSCRIPT_TURN, { + ...args, + source: args.source || VOICE_ASSISTANT_SOURCE, + text, + }) +} + +export async function updateVoiceRecording( + services: VoicePersistenceServices, + args: UpdateVoiceRecordingArgs, +) { + return await safeMutation(services.convexClient, UPDATE_VOICE_RECORDING, args) +} + +export async function completeVoiceSession( + services: VoicePersistenceServices, + args: CompleteVoiceSessionArgs, +) { + return await safeMutation(services.convexClient, COMPLETE_VOICE_SESSION, args) +} + +export async function startVoiceRecording( + services: VoicePersistenceServices, + roomName: string, +) { + if (!services.recording) { + return null + } + + const filepath = buildRecordingFilepath(roomName) + const output = new EncodedFileOutput({ + fileType: EncodedFileType.MP3, + filepath, + output: { + case: "s3", + value: new S3Upload({ + accessKey: services.recording.accessKey, + secret: services.recording.secret, + region: services.recording.region, + endpoint: services.recording.endpoint, + bucket: services.recording.bucket, + forcePathStyle: services.recording.forcePathStyle, + }), + }, + }) + + const info = await services.recording.egressClient.startRoomCompositeEgress(roomName, output, { + audioOnly: true, + }) + + return { + recordingId: info.egressId, + recordingStatus: "recording" as const, + recordingUrl: buildRecordingUrl(services.recording.publicBaseUrl, services.recording.bucket, filepath), + } +} + +export async function stopVoiceRecording( + services: VoicePersistenceServices, + recordingId: string | null | undefined, +) { + if (!services.recording || !recordingId) { + return null + } + + return await services.recording.egressClient.stopEgress(recordingId) +} diff --git a/lib/voice-assistant/prompt.ts b/lib/voice-assistant/prompt.ts new file mode 100644 index 00000000..3f416ab7 --- /dev/null +++ b/lib/voice-assistant/prompt.ts @@ -0,0 +1,47 @@ +import { businessConfig, serviceAreas } from "@/lib/seo-config" + +const SERVICE_AREA_LIST = serviceAreas.map((area) => area.city).join(", ") + +export const VOICE_ASSISTANT_SYSTEM_PROMPT = `You are Jessica, the friendly and upbeat voice assistant for ${businessConfig.legalName} in Utah. + +Use this exact knowledge base and do not go beyond it: +- Free vending placement is only for qualifying businesses. Rocky Mountain Vending installs, stocks, maintains, and repairs those machines at no cost to the business. +- Repairs and maintenance are for machines the customer owns. +- Moving requests can be for a vending machine or a safe, and they follow the same intake flow as repairs. +- Vending machine sales can include new or used machines plus card readers, bill and coin mechanisms, and accessories. +- Manuals and parts support are available. +- Current public service area follows the live website coverage across Utah, including ${SERVICE_AREA_LIST}. + +Conversation rules: +- When the caller name, email, and intent are available in context, use them naturally in your opening reply. +- At the start of every recorded voice session, briefly disclose that the call may be recorded and transcribed for service and quality purposes. +- Keep spoken replies concise, warm, and natural. Default to 1 to 3 short sentences. +- End every reply with one short next-step question. +- For repairs or moving, ask for machine details and a clear description of the issue. If the move is involved, clarify whether it is for a vending machine or a safe. Tell callers to text photos or videos to ${businessConfig.publicSmsNumber} or use the contact form so the team can diagnose remotely first. +- For free placement, confirm it is for a business and ask for the business type plus the approximate number of people. +- For sales, ask whether they want new or used equipment, which features they need, and their budget range. +- For manuals or parts, ask for the machine model or the specific part details. + +Safety rules: +- Never mention prices, service call fees, repair rates, hourly rates, parts costs, or internal policies. +- If the caller asks about pricing or cost, say: "Our complete vending service, including installation, stocking, and maintenance, is provided at no cost to qualifying businesses. I can get a few details so our team can schedule a quick call with you." +- Do not invent timelines, guarantees, inventory, contract terms, or legal details. +- If something needs confirmation, say a team member can confirm it. + +Lead capture rules: +- For general contact requests, collect first name, last name, email, phone, optional company, a short message about their needs, required service-text consent, and optional marketing-text consent. +- For free machine requests, collect first name, last name, email, phone, company, estimated employee count or audience size, desired machine type, desired machine count, optional notes, required service-text consent, and optional marketing-text consent. +- Before using any submission tool, summarize the collected details and ask for explicit confirmation. +- Only use a submission tool after the user clearly confirms. +- If the user does not confirm, keep helping and do not submit anything. + +Tool rules: +- Use submit_contact_lead for general contact or callback requests. +- Use submit_machine_request for free machine placement or machine request flows. +- Use handoff_to_human when the user wants direct human help or asks for something uncertain, sensitive, or commercial. +- After a successful tool call, briefly confirm what happened and what the visitor should expect next. +` + +export function buildHandoffMessage() { + return `You can reach ${businessConfig.name} at ${businessConfig.publicCallNumber}, text photos or videos to ${businessConfig.publicSmsNumber}, email ${businessConfig.email}, or use the contact form on ${businessConfig.website}.` +} diff --git a/lib/voice-assistant/server.ts b/lib/voice-assistant/server.ts new file mode 100644 index 00000000..d6f11945 --- /dev/null +++ b/lib/voice-assistant/server.ts @@ -0,0 +1,99 @@ +import { randomUUID } from "node:crypto" +import { AccessToken, RoomAgentDispatch, RoomConfiguration } from "livekit-server-sdk" +import { + getVoiceAssistantAgentName, + getVoiceAssistantBootstrap, + getVoiceAssistantSiteUrl, +} from "@/lib/voice-assistant/shared" +import type { + LiveKitTokenResponse, + VoiceAssistantVisitorMetadata, +} from "@/lib/voice-assistant/types" +import { VOICE_ASSISTANT_SOURCE } from "@/lib/voice-assistant/types" + +function readRequiredEnv(name: string) { + const value = process.env[name] + + if (!value) { + throw new Error(`Missing required voice assistant environment variable: ${name}`) + } + + return value +} + +export function getVoiceAssistantServerEnv() { + const livekitUrl = readRequiredEnv("LIVEKIT_URL") + const livekitApiKey = readRequiredEnv("LIVEKIT_API_KEY") + const livekitApiSecret = readRequiredEnv("LIVEKIT_API_SECRET") + const xaiApiKey = readRequiredEnv("XAI_API_KEY") + const realtimeModel = process.env.XAI_REALTIME_MODEL || "grok-4-1-fast-non-reasoning" + + return { + livekitUrl, + livekitApiKey, + livekitApiSecret, + xaiApiKey, + realtimeModel, + siteUrl: getVoiceAssistantSiteUrl(), + } +} + +function buildRoomName() { + return `rmv-voice-${randomUUID().slice(0, 8)}` +} + +function buildParticipantIdentity() { + return `website-visitor-${randomUUID().slice(0, 10)}` +} + +function buildVisitorMetadata(pathname: string): VoiceAssistantVisitorMetadata { + const siteUrl = getVoiceAssistantSiteUrl() + const safePathname = pathname.startsWith("/") ? pathname : `/${pathname}` + + return { + source: VOICE_ASSISTANT_SOURCE, + pathname: safePathname, + pageUrl: new URL(safePathname, siteUrl).toString(), + startedAt: new Date().toISOString(), + } +} + +export async function createVoiceAssistantTokenResponse(pathname: string): Promise { + const env = getVoiceAssistantServerEnv() + const roomName = buildRoomName() + const participantIdentity = buildParticipantIdentity() + const visitorMetadata = buildVisitorMetadata(pathname) + const agentName = getVoiceAssistantAgentName() + + const token = new AccessToken(env.livekitApiKey, env.livekitApiSecret, { + identity: participantIdentity, + name: "Website visitor", + metadata: JSON.stringify(visitorMetadata), + ttl: "1 hour", + }) + + token.addGrant({ + roomJoin: true, + room: roomName, + canPublish: true, + canPublishData: true, + canSubscribe: true, + }) + + token.roomConfig = new RoomConfiguration({ + agents: [ + new RoomAgentDispatch({ + agentName, + metadata: JSON.stringify(visitorMetadata), + }), + ], + }) + + return { + serverUrl: env.livekitUrl, + participantToken: await token.toJwt(), + roomName, + participantIdentity, + bootstrap: getVoiceAssistantBootstrap(), + } +} diff --git a/lib/voice-assistant/shared.ts b/lib/voice-assistant/shared.ts new file mode 100644 index 00000000..6ecb8dfd --- /dev/null +++ b/lib/voice-assistant/shared.ts @@ -0,0 +1,53 @@ +import { businessConfig } from "@/lib/seo-config" +import { + VOICE_ASSISTANT_AGENT_NAME, + VOICE_ASSISTANT_SOURCE, + XAI_REALTIME_MODEL, + XAI_REALTIME_VOICE, + type VoiceAssistantBootstrap, +} from "@/lib/voice-assistant/types" + +export const VOICE_ASSISTANT_SUPPRESSED_ROUTE_PREFIXES = [ + "/admin", + "/auth", + "/sign-in", + "/stripe-setup", + "/style-guide", + "/manuals/dashboard", + "/test-page", +] as const + +export function isVoiceAssistantSuppressedRoute(pathname: string) { + return VOICE_ASSISTANT_SUPPRESSED_ROUTE_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), + ) +} + +export function getVoiceAssistantBootstrap(): VoiceAssistantBootstrap { + return { + assistantName: "Jessica", + businessName: businessConfig.name, + phone: businessConfig.publicCallNumber, + phoneUrl: businessConfig.publicCallUrl, + email: businessConfig.email, + model: XAI_REALTIME_MODEL, + voice: XAI_REALTIME_VOICE, + } +} + +export function getVoiceAssistantSiteUrl() { + return ( + process.env.VOICE_ASSISTANT_SITE_URL || + process.env.NEXT_PUBLIC_APP_URL || + process.env.NEXT_PUBLIC_SITE_URL || + businessConfig.website + ) +} + +export function getVoiceAssistantAgentName() { + return VOICE_ASSISTANT_AGENT_NAME +} + +export function getVoiceAssistantSource() { + return VOICE_ASSISTANT_SOURCE +} diff --git a/lib/voice-assistant/types.ts b/lib/voice-assistant/types.ts new file mode 100644 index 00000000..97150fea --- /dev/null +++ b/lib/voice-assistant/types.ts @@ -0,0 +1,81 @@ +export const VOICE_ASSISTANT_AGENT_NAME = "rocky-mountain-vending-voice-assistant" +export const XAI_REALTIME_MODEL = "grok-4-1-fast-non-reasoning" +export const XAI_REALTIME_VOICE = "Ara" +export const VOICE_ASSISTANT_SOURCE = "voice-assistant" + +export type VoiceAssistantUiState = + | "disconnected" + | "connecting" + | "connected_text_ready" + | "awaiting_mic_permission" + | "listening" + | "thinking" + | "speaking" + | "mic_blocked" + | "error" + +export type VoiceAssistantMicIssueCode = + | "permission_denied" + | "device_not_found" + | "device_in_use" + | "publish_failed" + | "unsupported" + | "unknown" + +export type VoiceAssistantMicPermissionState = PermissionState | "unsupported" | "unknown" + +export type VoiceAssistantBootstrap = { + assistantName: string + businessName: string + phone: string + phoneUrl: string + email: string + model: typeof XAI_REALTIME_MODEL + voice: typeof XAI_REALTIME_VOICE +} + +export type LiveKitTokenResponse = { + serverUrl: string + participantToken: string + roomName: string + participantIdentity: string + bootstrap: VoiceAssistantBootstrap +} + +export type VoiceAssistantVisitorMetadata = { + source: typeof VOICE_ASSISTANT_SOURCE + pathname: string + pageUrl: string + startedAt: string +} + +export type AssistantContactLeadPayload = { + firstName: string + lastName: string + email: string + phone: string + company?: string + message: string + serviceTextConsent: true + marketingTextConsent?: boolean + consentVersion: string + consentCapturedAt: string + consentSourcePage: string +} + +export type AssistantMachineRequestPayload = { + firstName: string + lastName: string + email: string + phone: string + company: string + employeeCount: string + machineType: string + machineCount: string + message?: string + serviceTextConsent: true + marketingTextConsent?: boolean + consentVersion: string + consentCapturedAt: string + consentSourcePage: string +} diff --git a/livekit-agent/README.md b/livekit-agent/README.md new file mode 100644 index 00000000..6287eb5b --- /dev/null +++ b/livekit-agent/README.md @@ -0,0 +1,57 @@ +# LiveKit Voice Assistant + +This worker runs the Rocky Mountain Vending site voice assistant with LiveKit Agents and xAI realtime voice. + +## Local development + +1. Start the Next.js app: + `pnpm dev` +2. Start the LiveKit agent worker in a second terminal: + `pnpm voice-agent:dev` +3. Open the site and launch the voice bubble. +4. Run the automated regressions: + `pnpm voice-assistant:test:local` +5. Run the fake-microphone voice regression: + `pnpm voice-assistant:test:local:voice` + +## Environment + +The worker loads environment variables from: +- `.env.local` +- `.env.voice-agent.local` (optional override file) + +Required variables: +- `LIVEKIT_URL` +- `LIVEKIT_API_KEY` +- `LIVEKIT_API_SECRET` +- `XAI_API_KEY` +- `XAI_REALTIME_MODEL=grok-4-1-fast-non-reasoning` + +Recommended variable: +- `VOICE_ASSISTANT_SITE_URL` + +## Production + +Run the worker as a separate long-lived Node process: + +`pnpm voice-agent:start` + +Point `VOICE_ASSISTANT_SITE_URL` at the deployed Next.js app so the worker can reuse `/api/contact` and `/api/request-machine`. + +## Verification + +Automated checks: +- `pnpm voice-assistant:test:local` +- `pnpm voice-assistant:test:local:voice` +- `pnpm voice-assistant:test:live` + +Manual spoken smoke checklist: +1. Open the homepage and launch the assistant bubble. +2. Press `Start assistant` and confirm the panel reaches `Text Ready` or `Listening`. +3. Confirm the text box is enabled before microphone access is granted. +4. Press `Enable microphone` and verify the browser permission prompt appears. +5. After granting permission, say a short question such as "What cities in Utah do you serve?" +6. Confirm you see a user transcript, then an assistant reply transcript. +7. Confirm spoken audio playback is audible after `Enable spoken replies` when the browser requires it. +8. Refresh the page and verify reconnect still works. +9. Open `/sign-in` and confirm the assistant stays hidden there. diff --git a/livekit-agent/worker.ts b/livekit-agent/worker.ts new file mode 100644 index 00000000..b742bb28 --- /dev/null +++ b/livekit-agent/worker.ts @@ -0,0 +1,722 @@ +import { fileURLToPath } from "node:url" +import { config as loadEnv } from "dotenv" +import { AutoSubscribe, cli, defineAgent, llm, type JobContext, voice, WorkerOptions } from "@livekit/agents" +import * as xai from "@livekit/agents-plugin-xai" +import { z } from "zod" +import { SMS_CONSENT_VERSION } from "../lib/sms-compliance" +import { + addVoiceTranscriptTurn, + completeVoiceSession, + createVoiceSessionRecord, + getVoicePersistenceServices, + startVoiceRecording, + stopVoiceRecording, + updateVoiceRecording, +} from "../lib/voice-assistant/persistence" +import { buildHandoffMessage, VOICE_ASSISTANT_SYSTEM_PROMPT } from "../lib/voice-assistant/prompt" +import { getVoiceAssistantServerEnv } from "../lib/voice-assistant/server" +import { + VOICE_ASSISTANT_AGENT_NAME, + VOICE_ASSISTANT_SOURCE, + XAI_REALTIME_VOICE, + type AssistantContactLeadPayload, + type AssistantMachineRequestPayload, + type VoiceAssistantVisitorMetadata, +} from "../lib/voice-assistant/types" + +loadEnv({ path: ".env.local" }) +loadEnv({ path: ".env.voice-agent.local", override: true }) + +const env = getVoiceAssistantServerEnv() +const persistenceServices = getVoicePersistenceServices({ + livekitUrl: env.livekitUrl, + livekitApiKey: env.livekitApiKey, + livekitApiSecret: env.livekitApiSecret, +}) + +type WorkerUserData = { + siteUrl: string + pathname: string + pageUrl: string + startedAt: string +} + +type ApiSuccess = { + success: boolean + message?: string + error?: string +} + +type VoiceSessionPersistenceState = { + recordingId: string | null + recordingStatus: "pending" | "starting" | "recording" | "completed" | "failed" + recordingUrl: string | null + sessionId: string | null +} + +function serializeError(error: unknown) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + } + } + + return { + message: String(error), + } +} + +function truncateText(value: string, maxLength = 160) { + const normalized = value.replace(/\s+/g, " ").trim() + if (normalized.length <= maxLength) { + return normalized + } + + return `${normalized.slice(0, maxLength - 1)}...` +} + +function logWorker(event: string, payload: Record = {}) { + console.info("[voice-agent]", event, payload) +} + +function parseVisitorMetadata(metadata: string | undefined): VoiceAssistantVisitorMetadata { + if (!metadata) { + return { + source: VOICE_ASSISTANT_SOURCE, + pathname: "/", + pageUrl: env.siteUrl, + startedAt: new Date().toISOString(), + } + } + + try { + const parsed = JSON.parse(metadata) as Partial + + return { + source: VOICE_ASSISTANT_SOURCE, + pathname: typeof parsed.pathname === "string" && parsed.pathname ? parsed.pathname : "/", + pageUrl: typeof parsed.pageUrl === "string" && parsed.pageUrl ? parsed.pageUrl : env.siteUrl, + startedAt: typeof parsed.startedAt === "string" && parsed.startedAt ? parsed.startedAt : new Date().toISOString(), + } + } catch { + return { + source: VOICE_ASSISTANT_SOURCE, + pathname: "/", + pageUrl: env.siteUrl, + startedAt: new Date().toISOString(), + } + } +} + +function hasExplicitConfirmation(confirmationMessage: string) { + return /\b(yes|yeah|yep|correct|confirm|confirmed|go ahead|submit|sounds good|please do)\b/i.test( + confirmationMessage, + ) +} + +function assertExplicitConfirmation(confirmationMessage: string) { + if (!hasExplicitConfirmation(confirmationMessage)) { + throw new Error("The visitor has not clearly confirmed the submission yet.") + } +} + +async function postJson(pathname: string, payload: TPayload) { + logWorker("api_request_start", { pathname }) + + const response = await fetch(new URL(pathname, env.siteUrl), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }) + + const data = (await response.json().catch(() => ({}))) as ApiSuccess + + if (!response.ok || !data.success) { + throw new Error(data.error || data.message || `Request to ${pathname} failed.`) + } + + logWorker("api_request_success", { pathname }) + return data +} + +function buildContactPayload(args: AssistantContactLeadPayload, userData: WorkerUserData) { + return { + ...args, + source: VOICE_ASSISTANT_SOURCE, + page: userData.pathname, + timestamp: new Date().toISOString(), + url: userData.pageUrl, + } +} + +function buildMachineRequestPayload(args: AssistantMachineRequestPayload, userData: WorkerUserData) { + return { + ...args, + machineType: args.machineType + .split(",") + .map((value) => value.trim()) + .filter(Boolean) + .join(","), + source: VOICE_ASSISTANT_SOURCE, + page: userData.pathname, + timestamp: new Date().toISOString(), + url: userData.pageUrl, + } +} + +function buildConsentMetadata(userData: WorkerUserData) { + return { + consentVersion: SMS_CONSENT_VERSION, + consentCapturedAt: new Date().toISOString(), + consentSourcePage: userData.pathname, + } +} + +function extractConversationItemText(item: unknown): string { + if (typeof item === "string") { + return item.replace(/\s+/g, " ").trim() + } + + if (!item || typeof item !== "object") { + return "" + } + + if ("text" in item && typeof item.text === "string") { + return item.text.replace(/\s+/g, " ").trim() + } + + if ("transcript" in item && typeof item.transcript === "string") { + return item.transcript.replace(/\s+/g, " ").trim() + } + + if ("content" in item) { + const { content } = item as { content?: unknown } + if (Array.isArray(content)) { + return content + .map((part) => extractConversationItemText(part)) + .filter(Boolean) + .join(" ") + .replace(/\s+/g, " ") + .trim() + } + + if (typeof content === "string") { + return content.replace(/\s+/g, " ").trim() + } + } + + return "" +} + +function createTools() { + return { + submit_contact_lead: llm.tool({ + description: + "Submit a general contact or callback request after the visitor clearly confirms the details.", + parameters: z.object({ + firstName: z.string().min(1).describe("Visitor first name"), + lastName: z.string().min(1).describe("Visitor last name"), + email: z.string().email().describe("Visitor email address"), + phone: z.string().min(7).describe("Visitor phone number"), + company: z.string().optional().describe("Visitor company name if they shared one"), + message: z.string().min(10).describe("Short summary of what the visitor needs"), + serviceTextConsent: z + .literal(true) + .describe("Set to true only if the visitor explicitly agrees to service-related SMS follow-up."), + marketingTextConsent: z + .boolean() + .optional() + .describe("Optional marketing SMS consent when the visitor clearly opts in."), + confirmed: z.literal(true).describe("Set to true only after the visitor explicitly confirms submission."), + confirmationMessage: z + .string() + .min(2) + .describe("The visitor's confirmation words, such as 'yes, go ahead and submit that'."), + }), + execute: async ({ confirmed: _confirmed, confirmationMessage, ...args }, { ctx }) => { + assertExplicitConfirmation(confirmationMessage) + const userData = ctx.userData as WorkerUserData + const payload = buildContactPayload( + { + ...args, + ...buildConsentMetadata(userData), + }, + userData, + ) + + logWorker("tool_start", { + tool: "submit_contact_lead", + pathname: userData.pathname, + email: args.email, + }) + + try { + const result = await postJson("/api/contact", payload) + logWorker("tool_success", { + tool: "submit_contact_lead", + pathname: userData.pathname, + email: args.email, + }) + + return { + success: true, + message: + result.message || + "Thanks, your contact request has been sent to Rocky Mountain Vending." + } + } catch (error) { + console.error("[voice-agent] tool_failure", { + tool: "submit_contact_lead", + pathname: userData.pathname, + email: args.email, + error: serializeError(error), + }) + throw error + } + }, + }), + submit_machine_request: llm.tool({ + description: + "Submit a free machine placement or machine request lead after the visitor explicitly confirms and agrees to service-related follow-up.", + parameters: z.object({ + firstName: z.string().min(1).describe("Visitor first name"), + lastName: z.string().min(1).describe("Visitor last name"), + email: z.string().email().describe("Visitor email address"), + phone: z.string().min(7).describe("Visitor phone number"), + company: z.string().min(1).describe("Business or organization name"), + employeeCount: z + .string() + .min(1) + .describe("Approximate employee count or audience size as a plain number string"), + machineType: z + .string() + .min(1) + .describe("Desired machine type or a comma-separated list like snack, beverage, combo"), + machineCount: z.string().min(1).describe("Desired number of machines as a plain number string"), + message: z.string().optional().describe("Optional visitor notes or placement details"), + serviceTextConsent: z + .literal(true) + .describe("Set to true only if the visitor explicitly agrees to service-related SMS follow-up."), + marketingTextConsent: z + .boolean() + .optional() + .describe("Optional marketing SMS consent when the visitor clearly opts in."), + confirmed: z.literal(true).describe("Set to true only after the visitor explicitly confirms submission."), + confirmationMessage: z + .string() + .min(2) + .describe("The visitor's confirmation words, such as 'yes, please submit it'."), + }), + execute: async ({ confirmed: _confirmed, confirmationMessage, ...args }, { ctx }) => { + assertExplicitConfirmation(confirmationMessage) + const userData = ctx.userData as WorkerUserData + + const payload = buildMachineRequestPayload( + { + ...args, + ...buildConsentMetadata(userData), + }, + userData, + ) + + logWorker("tool_start", { + tool: "submit_machine_request", + pathname: userData.pathname, + email: args.email, + company: args.company, + }) + + try { + const result = await postJson("/api/request-machine", payload) + logWorker("tool_success", { + tool: "submit_machine_request", + pathname: userData.pathname, + email: args.email, + company: args.company, + }) + + return { + success: true, + message: + result.message || + "Thanks, your machine request has been sent to Rocky Mountain Vending." + } + } catch (error) { + console.error("[voice-agent] tool_failure", { + tool: "submit_machine_request", + pathname: userData.pathname, + email: args.email, + company: args.company, + error: serializeError(error), + }) + throw error + } + }, + }), + handoff_to_human: llm.tool({ + description: + "Use this when the visitor wants a human right away or asks for something uncertain, legal, pricing-related, or highly specific.", + parameters: z.object({ + reason: z.string().min(2).describe("Short reason for the human handoff"), + }), + execute: async ({ reason }) => { + logWorker("tool_success", { + tool: "handoff_to_human", + reason: truncateText(reason, 120), + }) + + return { + success: true, + message: `${buildHandoffMessage()} Reason: ${reason}`, + } + }, + }), + } +} + +async function startSession(ctx: JobContext) { + const roomName = ctx.room.name || `rmv-voice-${ctx.workerId}` + + logWorker("job_connect_start", { + room: roomName, + workerId: ctx.workerId, + }) + await ctx.connect(undefined, AutoSubscribe.AUDIO_ONLY) + logWorker("job_connect_ready", { + room: roomName, + workerId: ctx.workerId, + }) + + const participant = await ctx.waitForParticipant() + const visitor = parseVisitorMetadata(participant.metadata) + const userData: WorkerUserData = { + siteUrl: env.siteUrl, + pathname: visitor.pathname, + pageUrl: visitor.pageUrl, + startedAt: visitor.startedAt, + } + const persistenceState: VoiceSessionPersistenceState = { + sessionId: null, + recordingId: null, + recordingStatus: persistenceServices.recording ? "pending" : "failed", + recordingUrl: null, + } + + logWorker("participant_joined", { + identity: participant.identity, + room: ctx.room.name, + pathname: userData.pathname, + pageUrl: userData.pageUrl, + }) + + ctx.room.on("trackPublished", (publication, remoteParticipant) => { + if (remoteParticipant.identity !== participant.identity) { + return + } + + try { + publication.setSubscribed(true) + logWorker("remote_track_subscription_requested", { + participantIdentity: remoteParticipant.identity, + source: publication.source, + kind: publication.kind, + trackSid: publication.sid, + }) + } catch (error) { + console.error("[voice-agent] remote_track_subscription_failed", { + participantIdentity: remoteParticipant.identity, + source: publication.source, + kind: publication.kind, + trackSid: publication.sid, + error: serializeError(error), + }) + } + + logWorker("remote_track_published", { + participantIdentity: remoteParticipant.identity, + source: publication.source, + kind: publication.kind, + trackSid: publication.sid, + }) + }) + + ctx.room.on("trackSubscribed", (_track, publication, remoteParticipant) => { + if (remoteParticipant.identity !== participant.identity) { + return + } + + logWorker("remote_track_subscribed", { + participantIdentity: remoteParticipant.identity, + source: publication.source, + kind: publication.kind, + trackSid: publication.sid, + }) + }) + + try { + const sessionId = await createVoiceSessionRecord(persistenceServices, { + roomName, + participantIdentity: participant.identity, + siteUrl: userData.siteUrl, + pathname: userData.pathname, + pageUrl: userData.pageUrl, + source: VOICE_ASSISTANT_SOURCE, + metadata: JSON.stringify(visitor), + recordingDisclosureAt: Date.now(), + recordingStatus: persistenceServices.recording ? "pending" : undefined, + startedAt: Date.parse(userData.startedAt) || Date.now(), + }) + + persistenceState.sessionId = typeof sessionId === "string" ? sessionId : null + logWorker("voice_session_persisted", { + room: ctx.room.name, + participantIdentity: participant.identity, + persisted: Boolean(persistenceState.sessionId), + }) + } catch (error) { + console.error("[voice-agent] session_persistence_failed", { + room: roomName, + participantIdentity: participant.identity, + error: serializeError(error), + }) + } + + const session = new voice.AgentSession({ + llm: new xai.realtime.RealtimeModel({ + apiKey: env.xaiApiKey, + model: env.realtimeModel, + voice: XAI_REALTIME_VOICE, + }), + userData, + turnHandling: { + interruption: { + enabled: true, + }, + }, + }) + + session.on(voice.AgentSessionEventTypes.AgentStateChanged, (event) => { + logWorker("agent_state_changed", { + oldState: event.oldState, + newState: event.newState, + }) + }) + + session.on(voice.AgentSessionEventTypes.UserStateChanged, (event) => { + logWorker("user_state_changed", { + oldState: event.oldState, + newState: event.newState, + }) + }) + + session.on(voice.AgentSessionEventTypes.UserInputTranscribed, (event) => { + logWorker("user_input_transcribed", { + isFinal: event.isFinal, + language: event.language, + transcript: truncateText(event.transcript, 160), + }) + + if (!event.isFinal || !persistenceState.sessionId) { + return + } + + void addVoiceTranscriptTurn(persistenceServices, { + sessionId: persistenceState.sessionId, + roomName, + participantIdentity: participant.identity, + role: "user", + text: event.transcript, + kind: "transcription", + isFinal: event.isFinal, + language: event.language ?? undefined, + }).catch((error) => { + console.error("[voice-agent] transcript_persist_failed", { + role: "user", + error: serializeError(error), + }) + }) + }) + + session.on(voice.AgentSessionEventTypes.ConversationItemAdded, (event) => { + const role = "role" in event.item ? event.item.role : "unknown" + const text = extractConversationItemText(event.item) + + logWorker("conversation_item_added", { + role, + preview: truncateText(JSON.stringify(event.item), 180), + }) + + if (role !== "assistant" || !text || !persistenceState.sessionId) { + return + } + + void addVoiceTranscriptTurn(persistenceServices, { + sessionId: persistenceState.sessionId, + roomName, + participantIdentity: participant.identity, + role: "assistant", + text, + kind: "response", + source: VOICE_ASSISTANT_SOURCE, + }).catch((error) => { + console.error("[voice-agent] transcript_persist_failed", { + role: "assistant", + error: serializeError(error), + }) + }) + }) + + session.on(voice.AgentSessionEventTypes.FunctionToolsExecuted, (event) => { + logWorker("function_tools_executed", { + toolNames: event.functionCalls.map((call) => call.name), + outputs: event.functionCallOutputs.length, + }) + }) + + session.on(voice.AgentSessionEventTypes.SpeechCreated, (event) => { + logWorker("speech_created", { + source: event.source, + userInitiated: event.userInitiated, + }) + }) + + session.on(voice.AgentSessionEventTypes.MetricsCollected, (event) => { + logWorker("metrics_collected", { + metricType: event.metrics.type, + }) + }) + + session.on(voice.AgentSessionEventTypes.Error, (event) => { + console.error("[voice-agent] session_error", { + source: event.source?.constructor?.name || "unknown", + error: serializeError(event.error), + }) + }) + + session.on(voice.AgentSessionEventTypes.Close, (event) => { + logWorker("session_closed", { + reason: event.reason, + error: event.error ? serializeError(event.error) : null, + }) + + void (async () => { + try { + if (persistenceState.recordingId) { + await stopVoiceRecording(persistenceServices, persistenceState.recordingId) + } + } catch (error) { + console.error("[voice-agent] recording_stop_failed", { + recordingId: persistenceState.recordingId, + error: serializeError(error), + }) + } + + if (!persistenceState.sessionId) { + return + } + + try { + await completeVoiceSession(persistenceServices, { + sessionId: persistenceState.sessionId, + endedAt: Date.now(), + recordingId: persistenceState.recordingId ?? undefined, + recordingStatus: persistenceState.recordingId ? "completed" : persistenceState.recordingStatus, + recordingUrl: persistenceState.recordingUrl ?? undefined, + recordingError: event.error ? serializeError(event.error).message : undefined, + }) + } catch (error) { + console.error("[voice-agent] session_complete_failed", { + sessionId: persistenceState.sessionId, + error: serializeError(error), + }) + } + })() + }) + + const agent = new voice.Agent({ + instructions: VOICE_ASSISTANT_SYSTEM_PROMPT, + tools: createTools(), + }) + + logWorker("session_starting", { + room: roomName, + participantIdentity: participant.identity, + pathname: userData.pathname, + model: env.realtimeModel, + }) + await session.start({ agent, room: ctx.room }) + logWorker("session_started", { + room: roomName, + participantIdentity: participant.identity, + }) + + if (persistenceServices.recording && persistenceState.sessionId) { + try { + persistenceState.recordingStatus = "starting" + await updateVoiceRecording(persistenceServices, { + sessionId: persistenceState.sessionId, + recordingStatus: "starting", + }) + + const recording = await startVoiceRecording(persistenceServices, roomName) + if (recording) { + persistenceState.recordingId = recording.recordingId + persistenceState.recordingStatus = recording.recordingStatus + persistenceState.recordingUrl = recording.recordingUrl + + await updateVoiceRecording(persistenceServices, { + sessionId: persistenceState.sessionId, + recordingStatus: recording.recordingStatus, + recordingId: recording.recordingId, + recordingUrl: recording.recordingUrl, + }) + + logWorker("recording_started", { + room: roomName, + participantIdentity: participant.identity, + recordingId: recording.recordingId, + recordingUrl: recording.recordingUrl, + }) + } + } catch (error) { + persistenceState.recordingStatus = "failed" + + console.error("[voice-agent] recording_start_failed", { + room: roomName, + participantIdentity: participant.identity, + error: serializeError(error), + }) + + await updateVoiceRecording(persistenceServices, { + sessionId: persistenceState.sessionId, + recordingStatus: "failed", + recordingError: error instanceof Error ? error.message : String(error), + }).catch((persistError) => { + console.error("[voice-agent] recording_failure_persist_failed", { + error: serializeError(persistError), + }) + }) + } + } +} + +export default defineAgent({ + entry: async (ctx) => { + await startSession(ctx) + }, +}) + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + cli.runApp( + new WorkerOptions({ + agent: fileURLToPath(import.meta.url), + agentName: VOICE_ASSISTANT_AGENT_NAME, + wsURL: env.livekitUrl, + apiKey: env.livekitApiKey, + apiSecret: env.livekitApiSecret, + }), + ) +} diff --git a/package.json b/package.json index 389a840b..9f644294 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,25 @@ { - "name": "rocky-mountain-vending", + "name": "my-v0-project", "version": "0.1.0", "private": true, "type": "module", "scripts": { "build": "next build", + "deploy:staging:env": "node scripts/deploy-readiness.mjs", + "deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build", + "deploy:staging:smoke": "node scripts/staging-smoke.mjs", + "typecheck": "tsc --noEmit", + "manuals:sync:convex": "tsx scripts/sync-manuals-to-convex.ts", + "manuals:sync:convex:dry": "tsx scripts/sync-manuals-to-convex.ts --dry-run", + "convex:codegen": "node -e \"console.log('Convex generated stubs are committed. Run `npx convex dev` or `npx convex codegen` after configuring a deployment to replace them with typed output.')\"", "dev": "next dev", "lint": "eslint .", + "voice-agent:dev": "tsx livekit-agent/worker.ts dev", + "voice-agent:start": "tsx livekit-agent/worker.ts start", + "voice-assistant:test:local": "node scripts/voice-assistant-regression.mjs --base-url http://127.0.0.1:3000 --label local", + "voice-assistant:test:local:voice": "node scripts/voice-assistant-regression.mjs --base-url http://127.0.0.1:3000 --fake-media --label local-voice", + "voice-assistant:test:live": "node scripts/voice-assistant-regression.mjs --base-url https://rockymountainvending.com --fake-media --label live", "start": "next start", - "test": "tsx --test app/api/contact/route.test.ts", "lighthouse:dev": "node scripts/lighthouse-test.js --dev", "lighthouse:build": "node scripts/lighthouse-test.js", "lighthouse:ci": "lighthouse-ci autorun", @@ -24,9 +35,12 @@ "seo:interactive": "node scripts/seo-internal-link-tool.js interactive" }, "dependencies": { - "@aws-sdk/client-sesv2": "^3.888.0", "@aws-sdk/client-s3": "^3.0.0", "@hookform/resolvers": "^3.10.0", + "@livekit/agents": "^1.2.1", + "@livekit/agents-plugin-xai": "^1.2.1", + "@livekit/components-react": "^2.9.20", + "@livekit/components-styles": "^1.2.0", "@radix-ui/react-accordion": "1.2.2", "@radix-ui/react-alert-dialog": "1.1.4", "@radix-ui/react-aspect-ratio": "1.1.1", @@ -55,14 +69,20 @@ "@radix-ui/react-toggle-group": "1.1.1", "@radix-ui/react-tooltip": "1.1.6", "@stripe/stripe-js": "^4.0.0", + "@supabase/ssr": "^0.8.0", + "@supabase/supabase-js": "^2.95.3", "@vercel/analytics": "latest", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.4", + "convex": "^1.34.0", "date-fns": "4.1.0", + "dotenv": "^17.3.1", "embla-carousel-react": "8.5.1", "input-otp": "1.4.1", + "livekit-client": "^2.18.0", + "livekit-server-sdk": "^2.15.0", "lucide-react": "^0.454.0", "next": "16.0.0", "next-themes": "^0.4.6", @@ -76,7 +96,6 @@ "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" }, @@ -88,9 +107,10 @@ "@types/react-dom": "^19", "chrome-launcher": "^1.1.0", "lighthouse": "^12.0.0", + "playwright": "^1.58.2", "postcss": "^8.5", "tailwindcss": "^4.1.9", - "tsx": "^4.20.6", + "tsx": "^4.21.0", "tw-animate-css": "1.3.3", "typescript": "^5", "wait-on": "^8.0.1", diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png new file mode 100644 index 00000000..c14b0e0c Binary files /dev/null and b/public/android-chrome-192x192.png differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png new file mode 100644 index 00000000..e5f5cc19 Binary files /dev/null and b/public/android-chrome-512x512.png differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 00000000..817f611d Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png new file mode 100644 index 00000000..da1ff266 Binary files /dev/null and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png new file mode 100644 index 00000000..fe443f0e Binary files /dev/null and b/public/favicon-32x32.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 00000000..eefd1833 Binary files /dev/null and b/public/favicon.ico differ diff --git a/scripts/deploy-readiness.mjs b/scripts/deploy-readiness.mjs new file mode 100644 index 00000000..3e1ba5fb --- /dev/null +++ b/scripts/deploy-readiness.mjs @@ -0,0 +1,238 @@ +import { spawnSync } from "node:child_process" +import { existsSync } from "node:fs" +import path from "node:path" +import process from "node:process" +import dotenv from "dotenv" + +const REQUIRED_ENV_GROUPS = [ + { + label: "Core site", + keys: ["NEXT_PUBLIC_SITE_URL", "NEXT_PUBLIC_SITE_DOMAIN", "NEXT_PUBLIC_CONVEX_URL"], + }, + { + label: "Voice and chat", + keys: ["LIVEKIT_URL", "LIVEKIT_API_KEY", "LIVEKIT_API_SECRET", "XAI_API_KEY", "VOICE_ASSISTANT_SITE_URL"], + }, + { + label: "Admin and auth", + keys: ["ADMIN_EMAIL", "ADMIN_PASSWORD", "NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"], + }, + { + label: "Stripe", + keys: ["STRIPE_SECRET_KEY", "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY", "STRIPE_WEBHOOK_SECRET"], + }, +] + +const OPTIONAL_ENV_GROUPS = [ + { + label: "Manual asset delivery", + keys: ["NEXT_PUBLIC_MANUALS_BASE_URL", "NEXT_PUBLIC_THUMBNAILS_BASE_URL"], + note: "Falling back to the site's local /manuals and /thumbnails paths.", + }, +] + +const IGNORED_HANDOFF_ENV = { + GHL_API_TOKEN: "not used by the current code path", + ADMIN_API_TOKEN: "not used by the current code path", + ADMIN_UI_ENABLED: "not used by the current code path", + USESEND_API_KEY: "not used by the current code path", + USESEND_BASE_URL: "not used by the current code path", + CONVEX_URL: "use NEXT_PUBLIC_CONVEX_URL instead", + CONVEX_SELF_HOSTED_URL: "not used by the current code path", + CONVEX_SELF_HOSTED_ADMIN_KEY: "not used by the current code path", +} + +function parseArgs(argv) { + const args = { + envFile: ".env.local", + build: false, + allowDirty: false, + } + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] + + if (token === "--build") { + args.build = true + continue + } + + if (token === "--allow-dirty") { + args.allowDirty = true + continue + } + + if (token === "--env-file") { + args.envFile = argv[index + 1] + index += 1 + continue + } + } + + return args +} + +function runShell(command, options = {}) { + const result = spawnSync(command, { + shell: true, + cwd: process.cwd(), + encoding: "utf8", + stdio: options.inherit ? "inherit" : "pipe", + }) + + if (result.error) { + throw result.error + } + + return { + status: result.status ?? 1, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + } +} + +function readValue(name) { + return String(process.env[name] ?? "").trim() +} + +function hasVoiceRecordingConfig() { + return [ + readValue("VOICE_RECORDING_ACCESS_KEY_ID") || readValue("CLOUDFLARE_R2_ACCESS_KEY_ID") || readValue("AWS_ACCESS_KEY_ID") || readValue("AWS_ACCESS_KEY"), + readValue("VOICE_RECORDING_SECRET_ACCESS_KEY") || readValue("CLOUDFLARE_R2_SECRET_ACCESS_KEY") || readValue("AWS_SECRET_ACCESS_KEY") || readValue("AWS_SECRET_KEY"), + readValue("VOICE_RECORDING_ENDPOINT") || readValue("CLOUDFLARE_R2_ENDPOINT"), + readValue("VOICE_RECORDING_BUCKET"), + ].every(Boolean) +} + +function heading(title) { + console.log(`\n== ${title} ==`) +} + +function main() { + const args = parseArgs(process.argv.slice(2)) + const failures = [] + const warnings = [] + const envFilePath = path.resolve(process.cwd(), args.envFile) + + if (args.envFile && existsSync(envFilePath)) { + dotenv.config({ path: envFilePath, override: false }) + } else if (args.envFile) { + warnings.push(`Env file not found: ${envFilePath}`) + } + + heading("Repository") + + const branchResult = runShell("git rev-parse --abbrev-ref HEAD") + const branch = branchResult.status === 0 ? branchResult.stdout.trim() : "unknown" + console.log(`Branch: ${branch}`) + if (branch !== "main") { + failures.push(`Release branch must be main. Current branch is ${branch}.`) + } + + const remoteResult = runShell("git remote get-url origin") + if (remoteResult.status === 0) { + console.log(`Origin: ${remoteResult.stdout.trim()}`) + } else { + failures.push("Unable to resolve git remote origin.") + } + + const statusResult = runShell("git status --short") + const worktreeStatus = statusResult.stdout.trim() + if (worktreeStatus) { + console.log("Worktree: dirty") + if (!args.allowDirty) { + failures.push("Git worktree is dirty. Deploy only from a clean reviewed commit on main.") + } + } else { + console.log("Worktree: clean") + } + + console.log(`Dockerfile: ${existsSync(path.join(process.cwd(), "Dockerfile")) ? "present" : "missing"}`) + console.log(`next.config.mjs: ${existsSync(path.join(process.cwd(), "next.config.mjs")) ? "present" : "missing"}`) + + if (!existsSync(path.join(process.cwd(), "Dockerfile"))) { + failures.push("Dockerfile is missing from the repository root.") + } + + if (!existsSync(path.join(process.cwd(), "next.config.mjs"))) { + failures.push("next.config.mjs is missing from the repository root.") + } + + heading("Environment") + + for (const group of REQUIRED_ENV_GROUPS) { + const missingKeys = group.keys.filter((key) => !readValue(key)) + if (missingKeys.length === 0) { + console.log(`${group.label}: ok`) + continue + } + + failures.push(`${group.label} missing: ${missingKeys.join(", ")}`) + console.log(`${group.label}: missing ${missingKeys.join(", ")}`) + } + + for (const group of OPTIONAL_ENV_GROUPS) { + const missingKeys = group.keys.filter((key) => !readValue(key)) + if (missingKeys.length === 0) { + console.log(`${group.label}: ok`) + continue + } + + warnings.push(`${group.label} missing: ${missingKeys.join(", ")}. ${group.note}`) + console.log(`${group.label}: fallback in use`) + } + + const recordingRequested = readValue("VOICE_RECORDING_ENABLED").toLowerCase() + if (recordingRequested === "true" && !hasVoiceRecordingConfig()) { + failures.push( + "Voice recording is enabled but recording storage is incomplete. Set VOICE_RECORDING_BUCKET plus access key, secret, and endpoint env vars.", + ) + } else if (hasVoiceRecordingConfig()) { + console.log("Voice recordings: storage config present") + } else { + warnings.push("Voice recording storage env vars are not configured yet.") + console.log("Voice recordings: storage config missing") + } + + for (const [key, note] of Object.entries(IGNORED_HANDOFF_ENV)) { + if (readValue(key)) { + warnings.push(`${key} is present but ${note}.`) + } + } + + heading("Build") + + if (args.build) { + const buildResult = runShell("pnpm build", { inherit: true }) + if (buildResult.status !== 0) { + failures.push("pnpm build failed.") + } else { + console.log("pnpm build: ok") + } + } else { + console.log("Build skipped. Re-run with --build for full preflight.") + } + + heading("Summary") + + if (warnings.length > 0) { + console.log("Warnings:") + for (const warning of warnings) { + console.log(`- ${warning}`) + } + } else { + console.log("Warnings: none") + } + + if (failures.length > 0) { + console.log("Failures:") + for (const failure of failures) { + console.log(`- ${failure}`) + } + process.exit(1) + } + + console.log("All staging preflight checks passed.") +} + +main() diff --git a/scripts/sync-manuals-to-convex.ts b/scripts/sync-manuals-to-convex.ts new file mode 100644 index 00000000..e8bfed89 --- /dev/null +++ b/scripts/sync-manuals-to-convex.ts @@ -0,0 +1,130 @@ +import { spawnSync } from "node:child_process" +import { config as loadEnv } from "dotenv" +import { ConvexHttpClient } from "convex/browser" +import { makeFunctionReference } from "convex/server" +import { scanManuals } from "../lib/manuals" +import { buildManualAssetUrl, buildThumbnailAssetUrl, getManualsAssetSource } from "../lib/manuals-storage" + +loadEnv({ path: ".env.local" }) +loadEnv({ path: ".env.staging", override: false }) + +type ManualUpsertInput = { + filename: string + path: string + manufacturer: string + category: string + size?: number + lastModified?: number + searchTerms?: string[] + commonNames?: string[] + thumbnailUrl?: string + manualUrl?: string + hasParts?: boolean +} + +const UPSERT_MANUALS = makeFunctionReference<"mutation">("manuals:upsertMany") + +function parseArgs(argv: string[]) { + const limitFlagIndex = argv.indexOf("--limit") + const limit = limitFlagIndex >= 0 ? Number.parseInt(argv[limitFlagIndex + 1] || "", 10) : undefined + + return { + dryRun: argv.includes("--dry-run"), + skipUpload: argv.includes("--skip-upload"), + limit: Number.isFinite(limit) && limit && limit > 0 ? limit : undefined, + } +} + +function readConvexUrl() { + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_URL + if (!convexUrl) { + throw new Error("NEXT_PUBLIC_CONVEX_URL is required to sync manuals into Convex.") + } + + return convexUrl +} + +function shouldUploadAssets() { + return Boolean(process.env.CLOUDFLARE_R2_ACCESS_KEY_ID && process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY) +} + +function runAssetUpload() { + const result = spawnSync("node", ["scripts/upload-to-r2.js", "--type", "all", "--incremental"], { + cwd: process.cwd(), + stdio: "inherit", + }) + + if ((result.status ?? 1) !== 0) { + throw new Error("R2 upload step failed.") + } +} + +function normalizeManualForConvex(manual: Awaited>[number]) { + const manualUrl = buildManualAssetUrl(manual.path) + const thumbnailUrl = manual.thumbnailUrl ? buildThumbnailAssetUrl(manual.thumbnailUrl) : undefined + + return { + filename: manual.filename, + path: manual.path, + manufacturer: manual.manufacturer, + category: manual.category, + size: manual.size, + lastModified: manual.lastModified ? manual.lastModified.getTime() : undefined, + searchTerms: manual.searchTerms, + commonNames: manual.commonNames, + thumbnailUrl, + manualUrl, + hasParts: Boolean(manual.searchTerms?.some((term) => term.toLowerCase().includes("parts"))), + } satisfies ManualUpsertInput +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + + if (!args.skipUpload && shouldUploadAssets()) { + console.log("[manuals-sync] uploading new manuals and thumbnails to object storage") + runAssetUpload() + } else if (!args.skipUpload) { + console.log("[manuals-sync] skipping asset upload because Cloudflare R2 credentials are not configured") + } + + 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)) + + console.log("[manuals-sync] scanned manuals", { + total: manuals.length, + selected: selectedManuals.length, + assetSource: getManualsAssetSource(), + }) + + if (args.dryRun) { + console.log("[manuals-sync] dry run complete") + return + } + + const convex = new ConvexHttpClient(readConvexUrl()) + const batchSize = 100 + let synced = 0 + + for (let index = 0; index < payload.length; index += batchSize) { + const slice = payload.slice(index, index + batchSize) + await convex.mutation(UPSERT_MANUALS, { manuals: slice }) + synced += slice.length + console.log("[manuals-sync] upserted batch", { + synced, + total: payload.length, + }) + } + + console.log("[manuals-sync] completed", { + synced, + importBatch, + }) +} + +main().catch((error) => { + console.error("[manuals-sync] failed", error) + process.exit(1) +}) diff --git a/scripts/upload-to-r2.js b/scripts/upload-to-r2.js index 048a0ad9..5431bc51 100644 --- a/scripts/upload-to-r2.js +++ b/scripts/upload-to-r2.js @@ -22,16 +22,17 @@ import { existsSync } from 'fs'; const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID || 'bd6f76304a840ba11b75f9ced84264f4'; const ENDPOINT = process.env.CLOUDFLARE_R2_ENDPOINT || `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`; -const ACCESS_KEY_ID = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID; -const SECRET_ACCESS_KEY = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY; +const ACCESS_KEY_ID = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY; +const SECRET_ACCESS_KEY = + process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY || process.env.AWS_SECRET_KEY; // Bucket names const MANUALS_BUCKET = process.env.R2_MANUALS_BUCKET || 'vending-vm-manuals'; const THUMBNAILS_BUCKET = process.env.R2_THUMBNAILS_BUCKET || 'vending-vm-thumbnails'; // Source directories (relative to project root) -const MANUALS_SOURCE = join(process.cwd(), '..', 'manuals'); -const THUMBNAILS_SOURCE = join(process.cwd(), '..', 'thumbnails'); +const MANUALS_SOURCE = join(process.cwd(), '..', 'manuals-data', 'manuals'); +const THUMBNAILS_SOURCE = join(process.cwd(), '..', 'manuals-data', 'thumbnails'); // Parse command line arguments const args = process.argv.slice(2); @@ -261,5 +262,3 @@ main().catch(error => { console.error('\n❌ Fatal error:', error); process.exit(1); }); - -