deploy: clean Jessica chat and add manuals sync

This commit is contained in:
DMleadgen 2026-03-26 15:21:43 -06:00
parent 0ff33f82fb
commit 42c9400e6d
Signed by: matt
GPG key ID: C2720CF8CD701894
34 changed files with 3076 additions and 59 deletions

150
STAGING_DEPLOYMENT.md Normal file
View file

@ -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.

View file

@ -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 },
)

View file

@ -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 },
)
}
}

View file

@ -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",

View file

@ -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,6 +72,8 @@ export function SmsConsentFields({
</div>
{serviceError ? <p className="text-xs text-destructive">{serviceError}</p> : null}
{mode === "forms" ? (
<>
<div className="flex items-start gap-3 rounded-2xl border border-border/60 bg-background/90 px-4 py-3">
<Checkbox
id={`${idPrefix}-marketing-consent`}
@ -88,6 +92,8 @@ export function SmsConsentFields({
</Label>
</div>
{marketingError ? <p className="text-xs text-destructive">{marketingError}</p> : null}
</>
) : null}
</div>
)
}

View file

@ -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() {
<div className="mt-4">
<SmsConsentFields
idPrefix="site-chat"
mode="chat"
serviceChecked={profileDraft.serviceTextConsent}
marketingChecked={profileDraft.marketingTextConsent}
onServiceChange={(checked) =>
@ -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}
/>
</div>

4
convex/_generated/api.ts Normal file
View file

@ -0,0 +1,4 @@
import { anyApi } from "convex/server";
export const api = anyApi;
export const internal = anyApi;

View file

@ -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";

108
convex/admin.ts Normal file
View file

@ -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,
};
},
});

158
convex/leads.ts Normal file
View file

@ -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<string, unknown>) {
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;
},
});

108
convex/manuals.ts Normal file
View file

@ -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<string, number>();
const categoryMap = new Map<string, number>();
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;
},
});

200
convex/orders.ts Normal file
View file

@ -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))!);
},
});

124
convex/products.ts Normal file
View file

@ -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;
},
});

223
convex/schema.ts Normal file
View file

@ -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"]),
});

25
convex/tsconfig.json Normal file
View file

@ -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"]
}

129
convex/voiceSessions.ts Normal file
View file

@ -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);
},
});

View file

@ -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
@ -83,6 +91,10 @@ export function getThumbnailUrl(manual: Manual): string | null {
return null
}
if (isAbsoluteUrl(manual.thumbnailUrl)) {
return manual.thumbnailUrl
}
// Handle both absolute and relative paths
let relativePath: string
if (manual.thumbnailUrl.includes('/thumbnails/')) {
@ -119,4 +131,3 @@ export function getThumbnailUrl(manual: Manual): string | null {
// Use local static path for GHL static hosting
return `/thumbnails/${encodedPath}`
}

View file

@ -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<TArgs extends object>(
client: ConvexHttpClient | null,
reference: ReturnType<typeof makeFunctionReference<"mutation">>,
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)
}

View file

@ -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}.`
}

View file

@ -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<LiveKitTokenResponse> {
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(),
}
}

View file

@ -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
}

View file

@ -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
}

57
livekit-agent/README.md Normal file
View file

@ -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.

722
livekit-agent/worker.ts Normal file
View file

@ -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<string, unknown> = {}) {
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<VoiceAssistantVisitorMetadata>
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<TPayload extends object>(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<WorkerUserData>({
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<WorkerUserData>({
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,
}),
)
}

View file

@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -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()

View file

@ -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<ReturnType<typeof scanManuals>>[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)
})

View file

@ -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);
});