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
|
||||
}
|
||||
|
||||
function readRequiredEnv(name: string) {
|
||||
function getOptionalEnv(name: string) {
|
||||
const value = process.env[name]
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`Missing required site chat environment variable: ${name}`)
|
||||
}
|
||||
|
||||
return value
|
||||
return typeof value === "string" && value.trim() ? value.trim() : ""
|
||||
}
|
||||
|
||||
function getClientIp(request: NextRequest) {
|
||||
|
|
@ -259,7 +254,22 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
consumeChatRequest({ ip, requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS, sessionId })
|
||||
|
||||
const xaiApiKey = readRequiredEnv("XAI_API_KEY")
|
||||
const xaiApiKey = getOptionalEnv("XAI_API_KEY")
|
||||
if (!xaiApiKey) {
|
||||
console.warn("[site-chat] missing XAI_API_KEY", {
|
||||
pathname,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Jessica is temporarily unavailable right now. Please call us or use the contact form.",
|
||||
sessionId,
|
||||
},
|
||||
{ status: 503, headers: responseHeaders },
|
||||
)
|
||||
}
|
||||
|
||||
const completionResponse = await fetch("https://api.x.ai/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -344,9 +354,16 @@ export async function POST(request: NextRequest) {
|
|||
} catch (error) {
|
||||
console.error("[site-chat] request failed", error)
|
||||
|
||||
const safeError =
|
||||
error instanceof Error && error.message.startsWith("Missing required site chat environment variable:")
|
||||
? "Jessica is temporarily unavailable right now. Please call us or use the contact form."
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "Chat failed unexpectedly."
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "Chat failed unexpectedly.",
|
||||
error: safeError,
|
||||
},
|
||||
{ status: 500, headers: responseHeaders },
|
||||
)
|
||||
|
|
|
|||
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: {
|
||||
icon: [
|
||||
{
|
||||
url: "/icon-light-32x32.png",
|
||||
media: "(prefers-color-scheme: light)",
|
||||
url: "/favicon-16x16.png",
|
||||
sizes: "16x16",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/icon-dark-32x32.png",
|
||||
media: "(prefers-color-scheme: dark)",
|
||||
},
|
||||
{
|
||||
url: "/icon.svg",
|
||||
type: "image/svg+xml",
|
||||
url: "/favicon-32x32.png",
|
||||
sizes: "32x32",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
apple: "/apple-icon.png",
|
||||
shortcut: "/favicon.ico",
|
||||
apple: "/apple-touch-icon.png",
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ type SmsConsentFieldsProps = {
|
|||
idPrefix: string
|
||||
marketingChecked: boolean
|
||||
marketingError?: string
|
||||
mode?: "chat" | "forms"
|
||||
onMarketingChange: (checked: boolean) => void
|
||||
onServiceChange: (checked: boolean) => void
|
||||
serviceChecked: boolean
|
||||
|
|
@ -44,6 +45,7 @@ export function SmsConsentFields({
|
|||
idPrefix,
|
||||
marketingChecked,
|
||||
marketingError,
|
||||
mode = "forms",
|
||||
onMarketingChange,
|
||||
onServiceChange,
|
||||
serviceChecked,
|
||||
|
|
@ -70,6 +72,8 @@ export function SmsConsentFields({
|
|||
</div>
|
||||
{serviceError ? <p className="text-xs text-destructive">{serviceError}</p> : null}
|
||||
|
||||
{mode === "forms" ? (
|
||||
<>
|
||||
<div className="flex items-start gap-3 rounded-2xl border border-border/60 bg-background/90 px-4 py-3">
|
||||
<Checkbox
|
||||
id={`${idPrefix}-marketing-consent`}
|
||||
|
|
@ -88,6 +92,8 @@ export function SmsConsentFields({
|
|||
</Label>
|
||||
</div>
|
||||
{marketingError ? <p className="text-xs text-destructive">{marketingError}</p> : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ type ChatApiResponse = {
|
|||
limits?: ChatLimitStatus
|
||||
}
|
||||
|
||||
const CHAT_UNAVAILABLE_MESSAGE = "Jessica is temporarily unavailable right now. Please call or use the contact form."
|
||||
|
||||
const SESSION_STORAGE_KEY = "rmv-site-chat-session"
|
||||
const PROFILE_STORAGE_KEY = "rmv-site-chat-profile"
|
||||
const PANEL_MAX_HEIGHT = "min(40rem, calc(100vh - 7rem))"
|
||||
|
|
@ -348,16 +350,16 @@ export function SiteChatWidget() {
|
|||
}
|
||||
|
||||
if (!response.ok || !data.reply) {
|
||||
throw new Error(data.error || "Jessica could not reply right now.")
|
||||
throw new Error(data.error || CHAT_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
|
||||
setMessages((current) => [...current, createMessage("assistant", data.reply || "")].slice(-12))
|
||||
} catch (chatError) {
|
||||
const message = chatError instanceof Error ? chatError.message : "Jessica could not reply right now."
|
||||
const message = chatError instanceof Error ? chatError.message : CHAT_UNAVAILABLE_MESSAGE
|
||||
setError(message)
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createMessage("assistant", "I'm having trouble right now. Please try again or call us."),
|
||||
createMessage("assistant", "I'm temporarily unavailable right now. Please call us or use the contact form."),
|
||||
].slice(-12))
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
|
|
@ -457,6 +459,7 @@ export function SiteChatWidget() {
|
|||
<div className="mt-4">
|
||||
<SmsConsentFields
|
||||
idPrefix="site-chat"
|
||||
mode="chat"
|
||||
serviceChecked={profileDraft.serviceTextConsent}
|
||||
marketingChecked={profileDraft.marketingTextConsent}
|
||||
onServiceChange={(checked) =>
|
||||
|
|
@ -467,14 +470,7 @@ export function SiteChatWidget() {
|
|||
consentSourcePage: pathname || "/",
|
||||
}))
|
||||
}
|
||||
onMarketingChange={(checked) =>
|
||||
setProfileDraft((current) => ({
|
||||
...current,
|
||||
marketingTextConsent: checked,
|
||||
consentVersion: SMS_CONSENT_VERSION,
|
||||
consentSourcePage: pathname || "/",
|
||||
}))
|
||||
}
|
||||
onMarketingChange={() => undefined}
|
||||
serviceError={profileError && !profileDraft.serviceTextConsent ? profileError : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
4
convex/_generated/api.ts
Normal file
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
|
||||
* Supports both local static files and external hosting (e.g., Cloudflare)
|
||||
|
|
@ -26,6 +30,10 @@ export interface ManualGroup {
|
|||
* - If not set: Uses local static path "/manuals/"
|
||||
*/
|
||||
export function getManualUrl(manual: Manual): string {
|
||||
if (isAbsoluteUrl(manual.path)) {
|
||||
return manual.path
|
||||
}
|
||||
|
||||
// Handle both absolute and relative paths
|
||||
// If path is absolute (contains /manuals/), extract relative portion
|
||||
// Otherwise, assume it's already relative
|
||||
|
|
@ -83,6 +91,10 @@ export function getThumbnailUrl(manual: Manual): string | null {
|
|||
return null
|
||||
}
|
||||
|
||||
if (isAbsoluteUrl(manual.thumbnailUrl)) {
|
||||
return manual.thumbnailUrl
|
||||
}
|
||||
|
||||
// Handle both absolute and relative paths
|
||||
let relativePath: string
|
||||
if (manual.thumbnailUrl.includes('/thumbnails/')) {
|
||||
|
|
@ -119,4 +131,3 @@ export function getThumbnailUrl(manual: Manual): string | null {
|
|||
// Use local static path for GHL static hosting
|
||||
return `/thumbnails/${encodedPath}`
|
||||
}
|
||||
|
||||
|
|
|
|||
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",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"deploy:staging:env": "node scripts/deploy-readiness.mjs",
|
||||
"deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build",
|
||||
"deploy:staging:smoke": "node scripts/staging-smoke.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"manuals:sync:convex": "tsx scripts/sync-manuals-to-convex.ts",
|
||||
"manuals:sync:convex:dry": "tsx scripts/sync-manuals-to-convex.ts --dry-run",
|
||||
"convex:codegen": "node -e \"console.log('Convex generated stubs are committed. Run `npx convex dev` or `npx convex codegen` after configuring a deployment to replace them with typed output.')\"",
|
||||
"dev": "next dev",
|
||||
"lint": "eslint .",
|
||||
"voice-agent:dev": "tsx livekit-agent/worker.ts dev",
|
||||
"voice-agent:start": "tsx livekit-agent/worker.ts start",
|
||||
"voice-assistant:test:local": "node scripts/voice-assistant-regression.mjs --base-url http://127.0.0.1:3000 --label local",
|
||||
"voice-assistant:test:local:voice": "node scripts/voice-assistant-regression.mjs --base-url http://127.0.0.1:3000 --fake-media --label local-voice",
|
||||
"voice-assistant:test:live": "node scripts/voice-assistant-regression.mjs --base-url https://rockymountainvending.com --fake-media --label live",
|
||||
"start": "next start",
|
||||
"test": "tsx --test app/api/contact/route.test.ts",
|
||||
"lighthouse:dev": "node scripts/lighthouse-test.js --dev",
|
||||
"lighthouse:build": "node scripts/lighthouse-test.js",
|
||||
"lighthouse:ci": "lighthouse-ci autorun",
|
||||
|
|
@ -24,9 +35,12 @@
|
|||
"seo:interactive": "node scripts/seo-internal-link-tool.js interactive"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sesv2": "^3.888.0",
|
||||
"@aws-sdk/client-s3": "^3.0.0",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@livekit/agents": "^1.2.1",
|
||||
"@livekit/agents-plugin-xai": "^1.2.1",
|
||||
"@livekit/components-react": "^2.9.20",
|
||||
"@livekit/components-styles": "^1.2.0",
|
||||
"@radix-ui/react-accordion": "1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "1.1.4",
|
||||
"@radix-ui/react-aspect-ratio": "1.1.1",
|
||||
|
|
@ -55,14 +69,20 @@
|
|||
"@radix-ui/react-toggle-group": "1.1.1",
|
||||
"@radix-ui/react-tooltip": "1.1.6",
|
||||
"@stripe/stripe-js": "^4.0.0",
|
||||
"@supabase/ssr": "^0.8.0",
|
||||
"@supabase/supabase-js": "^2.95.3",
|
||||
"@vercel/analytics": "latest",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"convex": "^1.34.0",
|
||||
"date-fns": "4.1.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"embla-carousel-react": "8.5.1",
|
||||
"input-otp": "1.4.1",
|
||||
"livekit-client": "^2.18.0",
|
||||
"livekit-server-sdk": "^2.15.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "16.0.0",
|
||||
"next-themes": "^0.4.6",
|
||||
|
|
@ -76,7 +96,6 @@
|
|||
"stripe": "^17.0.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"usesend-js": "^1.6.3",
|
||||
"vaul": "^0.9.9",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
|
|
@ -88,9 +107,10 @@
|
|||
"@types/react-dom": "^19",
|
||||
"chrome-launcher": "^1.1.0",
|
||||
"lighthouse": "^12.0.0",
|
||||
"playwright": "^1.58.2",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^4.1.9",
|
||||
"tsx": "^4.20.6",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "1.3.3",
|
||||
"typescript": "^5",
|
||||
"wait-on": "^8.0.1",
|
||||
|
|
|
|||
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 ENDPOINT = process.env.CLOUDFLARE_R2_ENDPOINT || `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`;
|
||||
const ACCESS_KEY_ID = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID;
|
||||
const SECRET_ACCESS_KEY = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY;
|
||||
const ACCESS_KEY_ID = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY;
|
||||
const SECRET_ACCESS_KEY =
|
||||
process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY || process.env.AWS_SECRET_KEY;
|
||||
|
||||
// Bucket names
|
||||
const MANUALS_BUCKET = process.env.R2_MANUALS_BUCKET || 'vending-vm-manuals';
|
||||
const THUMBNAILS_BUCKET = process.env.R2_THUMBNAILS_BUCKET || 'vending-vm-thumbnails';
|
||||
|
||||
// Source directories (relative to project root)
|
||||
const MANUALS_SOURCE = join(process.cwd(), '..', 'manuals');
|
||||
const THUMBNAILS_SOURCE = join(process.cwd(), '..', 'thumbnails');
|
||||
const MANUALS_SOURCE = join(process.cwd(), '..', 'manuals-data', 'manuals');
|
||||
const THUMBNAILS_SOURCE = join(process.cwd(), '..', 'manuals-data', 'thumbnails');
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
|
|
@ -261,5 +262,3 @@ main().catch(error => {
|
|||
console.error('\n❌ Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue