deploy: clean Jessica chat and add manuals sync
This commit is contained in:
parent
0ff33f82fb
commit
42c9400e6d
34 changed files with 3076 additions and 59 deletions
150
STAGING_DEPLOYMENT.md
Normal file
150
STAGING_DEPLOYMENT.md
Normal 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.
|
||||||
|
|
@ -57,14 +57,9 @@ type ChatVisitorProfile = {
|
||||||
serviceTextConsent: boolean
|
serviceTextConsent: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function readRequiredEnv(name: string) {
|
function getOptionalEnv(name: string) {
|
||||||
const value = process.env[name]
|
const value = process.env[name]
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : ""
|
||||||
if (!value) {
|
|
||||||
throw new Error(`Missing required site chat environment variable: ${name}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClientIp(request: NextRequest) {
|
function getClientIp(request: NextRequest) {
|
||||||
|
|
@ -259,7 +254,22 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
consumeChatRequest({ ip, requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS, sessionId })
|
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", {
|
const completionResponse = await fetch("https://api.x.ai/v1/chat/completions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -344,9 +354,16 @@ export async function POST(request: NextRequest) {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[site-chat] request failed", 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(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: error instanceof Error ? error.message : "Chat failed unexpectedly.",
|
error: safeError,
|
||||||
},
|
},
|
||||||
{ status: 500, headers: responseHeaders },
|
{ status: 500, headers: responseHeaders },
|
||||||
)
|
)
|
||||||
|
|
|
||||||
38
app/api/livekit/token/route.ts
Normal file
38
app/api/livekit/token/route.ts
Normal 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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -56,19 +56,18 @@ export const metadata: Metadata = {
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{
|
{
|
||||||
url: "/icon-light-32x32.png",
|
url: "/favicon-16x16.png",
|
||||||
media: "(prefers-color-scheme: light)",
|
sizes: "16x16",
|
||||||
|
type: "image/png",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: "/icon-dark-32x32.png",
|
url: "/favicon-32x32.png",
|
||||||
media: "(prefers-color-scheme: dark)",
|
sizes: "32x32",
|
||||||
},
|
type: "image/png",
|
||||||
{
|
|
||||||
url: "/icon.svg",
|
|
||||||
type: "image/svg+xml",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
apple: "/apple-icon.png",
|
shortcut: "/favicon.ico",
|
||||||
|
apple: "/apple-touch-icon.png",
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: "website",
|
type: "website",
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ type SmsConsentFieldsProps = {
|
||||||
idPrefix: string
|
idPrefix: string
|
||||||
marketingChecked: boolean
|
marketingChecked: boolean
|
||||||
marketingError?: string
|
marketingError?: string
|
||||||
|
mode?: "chat" | "forms"
|
||||||
onMarketingChange: (checked: boolean) => void
|
onMarketingChange: (checked: boolean) => void
|
||||||
onServiceChange: (checked: boolean) => void
|
onServiceChange: (checked: boolean) => void
|
||||||
serviceChecked: boolean
|
serviceChecked: boolean
|
||||||
|
|
@ -44,6 +45,7 @@ export function SmsConsentFields({
|
||||||
idPrefix,
|
idPrefix,
|
||||||
marketingChecked,
|
marketingChecked,
|
||||||
marketingError,
|
marketingError,
|
||||||
|
mode = "forms",
|
||||||
onMarketingChange,
|
onMarketingChange,
|
||||||
onServiceChange,
|
onServiceChange,
|
||||||
serviceChecked,
|
serviceChecked,
|
||||||
|
|
@ -70,24 +72,28 @@ export function SmsConsentFields({
|
||||||
</div>
|
</div>
|
||||||
{serviceError ? <p className="text-xs text-destructive">{serviceError}</p> : null}
|
{serviceError ? <p className="text-xs text-destructive">{serviceError}</p> : null}
|
||||||
|
|
||||||
<div className="flex items-start gap-3 rounded-2xl border border-border/60 bg-background/90 px-4 py-3">
|
{mode === "forms" ? (
|
||||||
<Checkbox
|
<>
|
||||||
id={`${idPrefix}-marketing-consent`}
|
<div className="flex items-start gap-3 rounded-2xl border border-border/60 bg-background/90 px-4 py-3">
|
||||||
checked={marketingChecked}
|
<Checkbox
|
||||||
onCheckedChange={(checked) => onMarketingChange(Boolean(checked))}
|
id={`${idPrefix}-marketing-consent`}
|
||||||
className="mt-0.5"
|
checked={marketingChecked}
|
||||||
/>
|
onCheckedChange={(checked) => onMarketingChange(Boolean(checked))}
|
||||||
<Label
|
className="mt-0.5"
|
||||||
htmlFor={`${idPrefix}-marketing-consent`}
|
/>
|
||||||
className="text-xs leading-relaxed text-muted-foreground"
|
<Label
|
||||||
>
|
htmlFor={`${idPrefix}-marketing-consent`}
|
||||||
I agree to receive promotional and marketing SMS from {businessConfig.legalName}. Message frequency
|
className="text-xs leading-relaxed text-muted-foreground"
|
||||||
varies. Message and data rates may apply. Reply STOP to opt out and HELP for help. Consent is not a
|
>
|
||||||
condition of purchase.
|
I agree to receive promotional and marketing SMS from {businessConfig.legalName}. Message frequency
|
||||||
<PolicyLinks />
|
varies. Message and data rates may apply. Reply STOP to opt out and HELP for help. Consent is not a
|
||||||
</Label>
|
condition of purchase.
|
||||||
</div>
|
<PolicyLinks />
|
||||||
{marketingError ? <p className="text-xs text-destructive">{marketingError}</p> : null}
|
</Label>
|
||||||
|
</div>
|
||||||
|
{marketingError ? <p className="text-xs text-destructive">{marketingError}</p> : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ type ChatApiResponse = {
|
||||||
limits?: ChatLimitStatus
|
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 SESSION_STORAGE_KEY = "rmv-site-chat-session"
|
||||||
const PROFILE_STORAGE_KEY = "rmv-site-chat-profile"
|
const PROFILE_STORAGE_KEY = "rmv-site-chat-profile"
|
||||||
const PANEL_MAX_HEIGHT = "min(40rem, calc(100vh - 7rem))"
|
const PANEL_MAX_HEIGHT = "min(40rem, calc(100vh - 7rem))"
|
||||||
|
|
@ -348,16 +350,16 @@ export function SiteChatWidget() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok || !data.reply) {
|
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))
|
setMessages((current) => [...current, createMessage("assistant", data.reply || "")].slice(-12))
|
||||||
} catch (chatError) {
|
} 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)
|
setError(message)
|
||||||
setMessages((current) => [
|
setMessages((current) => [
|
||||||
...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))
|
].slice(-12))
|
||||||
} finally {
|
} finally {
|
||||||
setIsSending(false)
|
setIsSending(false)
|
||||||
|
|
@ -457,6 +459,7 @@ export function SiteChatWidget() {
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<SmsConsentFields
|
<SmsConsentFields
|
||||||
idPrefix="site-chat"
|
idPrefix="site-chat"
|
||||||
|
mode="chat"
|
||||||
serviceChecked={profileDraft.serviceTextConsent}
|
serviceChecked={profileDraft.serviceTextConsent}
|
||||||
marketingChecked={profileDraft.marketingTextConsent}
|
marketingChecked={profileDraft.marketingTextConsent}
|
||||||
onServiceChange={(checked) =>
|
onServiceChange={(checked) =>
|
||||||
|
|
@ -467,14 +470,7 @@ export function SiteChatWidget() {
|
||||||
consentSourcePage: pathname || "/",
|
consentSourcePage: pathname || "/",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
onMarketingChange={(checked) =>
|
onMarketingChange={() => undefined}
|
||||||
setProfileDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
marketingTextConsent: checked,
|
|
||||||
consentVersion: SMS_CONSENT_VERSION,
|
|
||||||
consentSourcePage: pathname || "/",
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
serviceError={profileError && !profileDraft.serviceTextConsent ? profileError : undefined}
|
serviceError={profileError && !profileDraft.serviceTextConsent ? profileError : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
4
convex/_generated/api.ts
Normal file
4
convex/_generated/api.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { anyApi } from "convex/server";
|
||||||
|
|
||||||
|
export const api = anyApi;
|
||||||
|
export const internal = anyApi;
|
||||||
9
convex/_generated/server.ts
Normal file
9
convex/_generated/server.ts
Normal 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
108
convex/admin.ts
Normal 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
158
convex/leads.ts
Normal 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
108
convex/manuals.ts
Normal 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
200
convex/orders.ts
Normal 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
124
convex/products.ts
Normal 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
223
convex/schema.ts
Normal 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
25
convex/tsconfig.json
Normal 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
129
convex/voiceSessions.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -17,6 +17,10 @@ export interface ManualGroup {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAbsoluteUrl(value: string) {
|
||||||
|
return /^https?:\/\//i.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get public URL for a manual file
|
* Get public URL for a manual file
|
||||||
* Supports both local static files and external hosting (e.g., Cloudflare)
|
* 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/"
|
* - If not set: Uses local static path "/manuals/"
|
||||||
*/
|
*/
|
||||||
export function getManualUrl(manual: Manual): string {
|
export function getManualUrl(manual: Manual): string {
|
||||||
|
if (isAbsoluteUrl(manual.path)) {
|
||||||
|
return manual.path
|
||||||
|
}
|
||||||
|
|
||||||
// Handle both absolute and relative paths
|
// Handle both absolute and relative paths
|
||||||
// If path is absolute (contains /manuals/), extract relative portion
|
// If path is absolute (contains /manuals/), extract relative portion
|
||||||
// Otherwise, assume it's already relative
|
// Otherwise, assume it's already relative
|
||||||
|
|
@ -82,6 +90,10 @@ export function getThumbnailUrl(manual: Manual): string | null {
|
||||||
if (!manual.thumbnailUrl) {
|
if (!manual.thumbnailUrl) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAbsoluteUrl(manual.thumbnailUrl)) {
|
||||||
|
return manual.thumbnailUrl
|
||||||
|
}
|
||||||
|
|
||||||
// Handle both absolute and relative paths
|
// Handle both absolute and relative paths
|
||||||
let relativePath: string
|
let relativePath: string
|
||||||
|
|
@ -119,4 +131,3 @@ export function getThumbnailUrl(manual: Manual): string | null {
|
||||||
// Use local static path for GHL static hosting
|
// Use local static path for GHL static hosting
|
||||||
return `/thumbnails/${encodedPath}`
|
return `/thumbnails/${encodedPath}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
266
lib/voice-assistant/persistence.ts
Normal file
266
lib/voice-assistant/persistence.ts
Normal 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)
|
||||||
|
}
|
||||||
47
lib/voice-assistant/prompt.ts
Normal file
47
lib/voice-assistant/prompt.ts
Normal 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}.`
|
||||||
|
}
|
||||||
99
lib/voice-assistant/server.ts
Normal file
99
lib/voice-assistant/server.ts
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
53
lib/voice-assistant/shared.ts
Normal file
53
lib/voice-assistant/shared.ts
Normal 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
|
||||||
|
}
|
||||||
81
lib/voice-assistant/types.ts
Normal file
81
lib/voice-assistant/types.ts
Normal 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
57
livekit-agent/README.md
Normal 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
722
livekit-agent/worker.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
30
package.json
30
package.json
|
|
@ -1,14 +1,25 @@
|
||||||
{
|
{
|
||||||
"name": "rocky-mountain-vending",
|
"name": "my-v0-project",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"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",
|
"dev": "next dev",
|
||||||
"lint": "eslint .",
|
"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",
|
"start": "next start",
|
||||||
"test": "tsx --test app/api/contact/route.test.ts",
|
|
||||||
"lighthouse:dev": "node scripts/lighthouse-test.js --dev",
|
"lighthouse:dev": "node scripts/lighthouse-test.js --dev",
|
||||||
"lighthouse:build": "node scripts/lighthouse-test.js",
|
"lighthouse:build": "node scripts/lighthouse-test.js",
|
||||||
"lighthouse:ci": "lighthouse-ci autorun",
|
"lighthouse:ci": "lighthouse-ci autorun",
|
||||||
|
|
@ -24,9 +35,12 @@
|
||||||
"seo:interactive": "node scripts/seo-internal-link-tool.js interactive"
|
"seo:interactive": "node scripts/seo-internal-link-tool.js interactive"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sesv2": "^3.888.0",
|
|
||||||
"@aws-sdk/client-s3": "^3.0.0",
|
"@aws-sdk/client-s3": "^3.0.0",
|
||||||
"@hookform/resolvers": "^3.10.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-accordion": "1.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "1.1.4",
|
"@radix-ui/react-alert-dialog": "1.1.4",
|
||||||
"@radix-ui/react-aspect-ratio": "1.1.1",
|
"@radix-ui/react-aspect-ratio": "1.1.1",
|
||||||
|
|
@ -55,14 +69,20 @@
|
||||||
"@radix-ui/react-toggle-group": "1.1.1",
|
"@radix-ui/react-toggle-group": "1.1.1",
|
||||||
"@radix-ui/react-tooltip": "1.1.6",
|
"@radix-ui/react-tooltip": "1.1.6",
|
||||||
"@stripe/stripe-js": "^4.0.0",
|
"@stripe/stripe-js": "^4.0.0",
|
||||||
|
"@supabase/ssr": "^0.8.0",
|
||||||
|
"@supabase/supabase-js": "^2.95.3",
|
||||||
"@vercel/analytics": "latest",
|
"@vercel/analytics": "latest",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "1.0.4",
|
"cmdk": "1.0.4",
|
||||||
|
"convex": "^1.34.0",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
"embla-carousel-react": "8.5.1",
|
"embla-carousel-react": "8.5.1",
|
||||||
"input-otp": "1.4.1",
|
"input-otp": "1.4.1",
|
||||||
|
"livekit-client": "^2.18.0",
|
||||||
|
"livekit-server-sdk": "^2.15.0",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"next": "16.0.0",
|
"next": "16.0.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|
@ -76,7 +96,6 @@
|
||||||
"stripe": "^17.0.0",
|
"stripe": "^17.0.0",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"usesend-js": "^1.6.3",
|
|
||||||
"vaul": "^0.9.9",
|
"vaul": "^0.9.9",
|
||||||
"zod": "3.25.76"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
|
|
@ -88,9 +107,10 @@
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"chrome-launcher": "^1.1.0",
|
"chrome-launcher": "^1.1.0",
|
||||||
"lighthouse": "^12.0.0",
|
"lighthouse": "^12.0.0",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
"tailwindcss": "^4.1.9",
|
"tailwindcss": "^4.1.9",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.21.0",
|
||||||
"tw-animate-css": "1.3.3",
|
"tw-animate-css": "1.3.3",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"wait-on": "^8.0.1",
|
"wait-on": "^8.0.1",
|
||||||
|
|
|
||||||
BIN
public/android-chrome-192x192.png
Normal file
BIN
public/android-chrome-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
public/android-chrome-512x512.png
Normal file
BIN
public/android-chrome-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
BIN
public/apple-touch-icon.png
Normal file
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
BIN
public/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 478 B |
BIN
public/favicon-32x32.png
Normal file
BIN
public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
238
scripts/deploy-readiness.mjs
Normal file
238
scripts/deploy-readiness.mjs
Normal 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()
|
||||||
130
scripts/sync-manuals-to-convex.ts
Normal file
130
scripts/sync-manuals-to-convex.ts
Normal 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)
|
||||||
|
})
|
||||||
|
|
@ -22,16 +22,17 @@ import { existsSync } from 'fs';
|
||||||
|
|
||||||
const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID || 'bd6f76304a840ba11b75f9ced84264f4';
|
const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID || 'bd6f76304a840ba11b75f9ced84264f4';
|
||||||
const ENDPOINT = process.env.CLOUDFLARE_R2_ENDPOINT || `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`;
|
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 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;
|
const SECRET_ACCESS_KEY =
|
||||||
|
process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY || process.env.AWS_SECRET_KEY;
|
||||||
|
|
||||||
// Bucket names
|
// Bucket names
|
||||||
const MANUALS_BUCKET = process.env.R2_MANUALS_BUCKET || 'vending-vm-manuals';
|
const MANUALS_BUCKET = process.env.R2_MANUALS_BUCKET || 'vending-vm-manuals';
|
||||||
const THUMBNAILS_BUCKET = process.env.R2_THUMBNAILS_BUCKET || 'vending-vm-thumbnails';
|
const THUMBNAILS_BUCKET = process.env.R2_THUMBNAILS_BUCKET || 'vending-vm-thumbnails';
|
||||||
|
|
||||||
// Source directories (relative to project root)
|
// Source directories (relative to project root)
|
||||||
const MANUALS_SOURCE = join(process.cwd(), '..', 'manuals');
|
const MANUALS_SOURCE = join(process.cwd(), '..', 'manuals-data', 'manuals');
|
||||||
const THUMBNAILS_SOURCE = join(process.cwd(), '..', 'thumbnails');
|
const THUMBNAILS_SOURCE = join(process.cwd(), '..', 'manuals-data', 'thumbnails');
|
||||||
|
|
||||||
// Parse command line arguments
|
// Parse command line arguments
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
|
|
@ -261,5 +262,3 @@ main().catch(error => {
|
||||||
console.error('\n❌ Fatal error:', error);
|
console.error('\n❌ Fatal error:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue