From b63ca581f51cb3f5722614668894e0a2518b3abf Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 25 Mar 2026 15:46:26 -0600 Subject: [PATCH] Prepare Rocky Mountain for Coolify launch --- COOLIFY_LAUNCH.md | 38 + Dockerfile | 29 +- app/admin/layout.tsx | 14 +- app/api/contact/route.test.ts | 140 ++ app/api/contact/route.ts | 70 +- app/api/orders/route.ts | 14 +- app/api/products/admin/route.ts | 23 +- app/api/request-machine/route.ts | 103 +- app/api/stripe/test/route.ts | 13 +- app/layout.tsx | 73 +- app/sign-in/[[...sign-in]]/page.tsx | 19 +- components/forms/contact-form.tsx | 23 +- components/forms/request-machine-form.tsx | 25 +- lib/convex.ts | 76 + lib/email.ts | 93 ++ lib/ghl.ts | 58 + lib/seo-config.ts | 6 +- lib/server/admin-auth.ts | 32 + lib/server/contact-submission.ts | 608 +++++++ lib/webhook-client.ts | 135 +- middleware.ts | 7 +- package-lock.json | 1778 ++++++++++++++++++--- package.json | 9 +- 23 files changed, 2766 insertions(+), 620 deletions(-) create mode 100644 COOLIFY_LAUNCH.md create mode 100644 app/api/contact/route.test.ts create mode 100644 lib/convex.ts create mode 100644 lib/email.ts create mode 100644 lib/ghl.ts create mode 100644 lib/server/admin-auth.ts create mode 100644 lib/server/contact-submission.ts diff --git a/COOLIFY_LAUNCH.md b/COOLIFY_LAUNCH.md new file mode 100644 index 00000000..79bc3e8d --- /dev/null +++ b/COOLIFY_LAUNCH.md @@ -0,0 +1,38 @@ +# Rocky Mountain Coolify Launch + +## Staging +- Coolify project: `Rocky Mountain Vending` +- Environment UUID: `ew8k8og0gw48swck4ckk84kk` +- Forgejo repo: `https://git.abundancepartners.app/matt/Rocky_Mountain_Vending.git` +- Branch: `main` +- Build pack: `dockerfile` +- Dockerfile: `/Dockerfile` +- Port: `3001` +- Staging host: `rmv.rockymountainvending.com` +- DNS: `A rmv -> 85.239.237.247` (`DNS only`, `TTL Auto`) + +## Required App Env +- `NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com` +- `NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com` +- `CONVEX_URL` +- `CONVEX_DEPLOY_ON_BUILD=false` +- `CONVEX_SELF_HOSTED_URL` +- `CONVEX_SELF_HOSTED_ADMIN_KEY` +- `CONVEX_TENANT_SLUG=rocky_mountain_vending` +- `CONVEX_TENANT_NAME=Rocky Mountain Vending` +- `USESEND_API_KEY` +- `USESEND_BASE_URL` +- `USESEND_FROM_EMAIL` +- `CONTACT_FORM_TO_EMAIL` +- `GHL_API_TOKEN` +- `GHL_LOCATION_ID=YAoWLgNSid8oG44j9BjG` +- `ADMIN_UI_ENABLED=false` +- `ADMIN_API_TOKEN` +- `LIVEKIT_URL` +- `LIVEKIT_API_KEY` +- `LIVEKIT_API_SECRET` + +## Email Notes +- The app prefers Usesend when `USESEND_API_KEY` is present. +- If Usesend is not ready yet, the lead pipeline can fall back to AWS SES using `AWS_ACCESS_KEY`, `AWS_SECRET_KEY`, and `AWS_DEFAULT_REGION`. +- Keep all email secrets in Coolify-managed env vars. diff --git a/Dockerfile b/Dockerfile index 40760fdf..af37564f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,50 +1,25 @@ -# Multi-stage Dockerfile for Next.js frontend -# Stage 1: Install dependencies and build the application FROM node:20-alpine AS builder - -# Set working directory WORKDIR /app -# Copy package files COPY package.json package-lock.json ./ -COPY pnpm-lock.yaml ./ +RUN npm ci -# Install pnpm and dependencies -RUN npm install -g pnpm -RUN pnpm install --no-frozen-lockfile - -# Copy source code COPY . . +RUN npm run build -# Build the application -RUN pnpm build - -# Stage 2: Production image FROM node:20-alpine AS runner - -# Set environment variables ENV NODE_ENV=production - -# Create app directory WORKDIR /app -# Create non-root user RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs -# Copy built application from builder stage COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone/. ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static -# Switch to non-root user USER nextjs - -# Expose port EXPOSE 3001 - -# Set environment variable for port ENV PORT=3001 -# Start the application CMD ["node", "server.js"] diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index a570fff7..778830cd 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -1,16 +1,14 @@ -import { auth } from '@clerk/nextjs/server' -import { redirect } from 'next/navigation' +import { redirect } from "next/navigation"; +import { isAdminUiEnabled } from "@/lib/server/admin-auth"; export default async function AdminLayout({ children, }: { children: React.ReactNode }) { - const { userId } = await auth() - - if (!userId) { - redirect('/sign-in') + if (!isAdminUiEnabled()) { + redirect("/"); } - - return <>{children} + + return <>{children}; } diff --git a/app/api/contact/route.test.ts b/app/api/contact/route.test.ts new file mode 100644 index 00000000..0757cdf1 --- /dev/null +++ b/app/api/contact/route.test.ts @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + processLeadSubmission, + type ContactLeadPayload, + type RequestMachineLeadPayload, +} from "@/lib/server/contact-submission"; + +test("processLeadSubmission stores and syncs a contact lead", async () => { + const calls: string[] = []; + const payload: ContactLeadPayload = { + kind: "contact", + firstName: "John", + lastName: "Doe", + email: "john@example.com", + phone: "(435) 555-1212", + company: "ACME", + message: "Need vending help for our office.", + source: "website", + page: "/contact", + timestamp: "2026-03-25T00:00:00.000Z", + url: "https://rmv.example/contact", + }; + + const result = await processLeadSubmission(payload, "rmv.example", { + storageConfigured: true, + emailConfigured: true, + ghlConfigured: true, + tenantSlug: "rocky_mountain_vending", + tenantName: "Rocky Mountain Vending", + tenantDomains: ["rockymountainvending.com"], + ingest: async () => { + calls.push("ingest"); + return { + inserted: true, + leadId: "lead_123", + idempotencyKey: "abc", + tenantId: "tenant_123", + }; + }, + updateLeadStatus: async () => { + calls.push("update"); + return { ok: true }; + }, + sendEmail: async () => { + calls.push("email"); + return {}; + }, + createContact: async () => { + calls.push("ghl"); + return { contact: { id: "ghl_123" } }; + }, + logger: console, + }); + + assert.equal(result.status, 200); + assert.equal(result.body.success, true); + assert.deepEqual(result.body.deliveredVia, ["convex", "email", "ghl"]); + assert.equal(calls.filter((call) => call === "email").length, 2); + assert.ok(calls.includes("ingest")); + assert.ok(calls.includes("update")); + assert.ok(calls.includes("ghl")); +}); + +test("processLeadSubmission validates request-machine submissions", async () => { + const payload: RequestMachineLeadPayload = { + kind: "request-machine", + firstName: "Jane", + lastName: "Smith", + email: "jane@example.com", + phone: "4352339668", + company: "Warehouse Co", + employeeCount: "0", + machineType: "snack", + machineCount: "2", + marketingConsent: true, + termsAgreement: true, + }; + + const result = await processLeadSubmission(payload, "rmv.example", { + storageConfigured: false, + emailConfigured: false, + ghlConfigured: false, + tenantSlug: "rocky_mountain_vending", + tenantName: "Rocky Mountain Vending", + tenantDomains: [], + ingest: async () => { + throw new Error("should not run"); + }, + updateLeadStatus: async () => { + throw new Error("should not run"); + }, + sendEmail: async () => { + throw new Error("should not run"); + }, + createContact: async () => { + throw new Error("should not run"); + }, + logger: console, + }); + + assert.equal(result.status, 400); + assert.equal(result.body.success, false); + assert.match(result.body.error || "", /Invalid number of employees/); +}); + +test("processLeadSubmission returns deduped success when Convex already has the lead", async () => { + const payload: ContactLeadPayload = { + kind: "contact", + firstName: "John", + lastName: "Doe", + email: "john@example.com", + phone: "(435) 555-1212", + message: "Need vending help for our office.", + }; + + const result = await processLeadSubmission(payload, "rmv.example", { + storageConfigured: true, + emailConfigured: false, + ghlConfigured: false, + tenantSlug: "rocky_mountain_vending", + tenantName: "Rocky Mountain Vending", + tenantDomains: [], + ingest: async () => ({ + inserted: false, + leadId: "lead_123", + idempotencyKey: "abc", + tenantId: "tenant_123", + }), + updateLeadStatus: async () => ({ ok: true }), + sendEmail: async () => ({}), + createContact: async () => null, + logger: console, + }); + + assert.equal(result.status, 200); + assert.equal(result.body.success, true); + assert.equal(result.body.deduped, true); + assert.deepEqual(result.body.deliveredVia, ["convex"]); +}); diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index 8be2d0cd..7dc5a092 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -1,67 +1,5 @@ -import { NextRequest, NextResponse } from "next/server" -import { WebhookClient, ContactFormWebhookData } from "@/lib/webhook-client" +import { handleLeadRequest } from "@/lib/server/contact-submission"; -interface ContactFormData extends ContactFormWebhookData {} - -export async function POST(request: NextRequest) { - try { - const body = await request.json() - const formData: ContactFormData = body - - // Validate required fields - const requiredFields = ['firstName', 'lastName', 'email', 'phone', 'message'] - const missingFields = requiredFields.filter(field => !formData[field]) - - if (missingFields.length > 0) { - return NextResponse.json( - { error: `Missing required fields: ${missingFields.join(', ')}` }, - { status: 400 } - ) - } - - // Basic email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - if (!emailRegex.test(formData.email)) { - return NextResponse.json( - { error: 'Invalid email address' }, - { status: 400 } - ) - } - - // Basic phone validation (remove all non-digit characters) - const phoneDigits = formData.phone.replace(/\D/g, '') - if (phoneDigits.length < 10) { - return NextResponse.json( - { error: 'Invalid phone number' }, - { status: 400 } - ) - } - - // Submit to webhook - const webhookResult = await WebhookClient.submitContactForm(formData) - - if (!webhookResult.success) { - console.error('Contact webhook submission failed:', webhookResult.error) - return NextResponse.json( - { error: webhookResult.message }, - { status: 500 } - ) - } - - console.log('Successfully submitted contact form to GHL:', formData) - - // Return success response - return NextResponse.json({ - success: true, - message: webhookResult.message - }) - - } catch (error) { - console.error('Contact form submission error:', error) - - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ) - } -} \ No newline at end of file +export async function POST(request: Request) { + return handleLeadRequest(request); +} diff --git a/app/api/orders/route.ts b/app/api/orders/route.ts index bd311809..6b42149c 100644 --- a/app/api/orders/route.ts +++ b/app/api/orders/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { auth } from '@clerk/nextjs/server' +import { requireAdminToken } from '@/lib/server/admin-auth' // Order types interface OrderItem { @@ -42,10 +42,9 @@ function generateOrderId(): string { } export async function GET(request: NextRequest) { - // Check authentication - only authenticated users can view orders - const { userId } = await auth() - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const authError = requireAdminToken(request) + if (authError) { + return authError } try { @@ -95,6 +94,11 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { + const authError = requireAdminToken(request) + if (authError) { + return authError + } + try { const body = await request.json() const { items, customerEmail, paymentIntentId, stripeSessionId, shippingAddress } = body diff --git a/app/api/products/admin/route.ts b/app/api/products/admin/route.ts index e0cbd872..a40c435a 100644 --- a/app/api/products/admin/route.ts +++ b/app/api/products/admin/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' -import { auth } from '@clerk/nextjs/server' import { getStripeClient } from '@/lib/stripe/client' +import { requireAdminToken } from '@/lib/server/admin-auth' import { fetchAllProducts, fetchProductById, @@ -10,10 +10,9 @@ import { } from '@/lib/stripe/products' export async function GET(request: NextRequest) { - // Check authentication - const { userId } = await auth() - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const authError = requireAdminToken(request) + if (authError) { + return authError } try { @@ -71,10 +70,9 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { - // Check authentication - const { userId } = await auth() - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const authError = requireAdminToken(request) + if (authError) { + return authError } try { @@ -125,10 +123,9 @@ export async function POST(request: NextRequest) { // Handle PUT and PATCH for bulk operations export async function PUT(request: NextRequest) { - // Check authentication - const { userId } = await auth() - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const authError = requireAdminToken(request) + if (authError) { + return authError } try { diff --git a/app/api/request-machine/route.ts b/app/api/request-machine/route.ts index 4ec59037..11fbd106 100644 --- a/app/api/request-machine/route.ts +++ b/app/api/request-machine/route.ts @@ -1,100 +1,5 @@ -import { NextRequest, NextResponse } from "next/server" -import { WebhookClient, RequestMachineFormWebhookData } from "@/lib/webhook-client" +import { handleLeadRequest } from "@/lib/server/contact-submission"; -interface RequestMachineFormData extends RequestMachineFormWebhookData {} - -export async function POST(request: NextRequest) { - try { - const body = await request.json() - const formData: RequestMachineFormData = body - - // Validate required fields - const requiredFields = ['firstName', 'lastName', 'email', 'phone', 'company', 'employeeCount', 'machineType', 'machineCount'] - const missingFields = requiredFields.filter(field => !formData[field]) - - if (missingFields.length > 0) { - return NextResponse.json( - { error: `Missing required fields: ${missingFields.join(', ')}` }, - { status: 400 } - ) - } - - // Basic email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - if (!emailRegex.test(formData.email)) { - return NextResponse.json( - { error: 'Invalid email address' }, - { status: 400 } - ) - } - - // Basic phone validation (remove all non-digit characters) - const phoneDigits = formData.phone.replace(/\D/g, '') - if (phoneDigits.length < 10) { - return NextResponse.json( - { error: 'Invalid phone number' }, - { status: 400 } - ) - } - - // Validate employee count - const employeeCount = parseInt(formData.employeeCount) - if (isNaN(employeeCount) || employeeCount < 1 || employeeCount > 10000) { - return NextResponse.json( - { error: 'Invalid number of employees/people' }, - { status: 400 } - ) - } - - // Validate machine count - const machineCount = parseInt(formData.machineCount) - if (isNaN(machineCount) || machineCount < 1 || machineCount > 100) { - return NextResponse.json( - { error: 'Invalid number of machines' }, - { status: 400 } - ) - } - - // Validate consent and agreement - if (!formData.marketingConsent) { - return NextResponse.json( - { error: 'Marketing consent is required' }, - { status: 400 } - ) - } - - if (!formData.termsAgreement) { - return NextResponse.json( - { error: 'Terms agreement is required' }, - { status: 400 } - ) - } - - // Submit to webhook - const webhookResult = await WebhookClient.submitRequestMachineForm(formData) - - if (!webhookResult.success) { - console.error('Machine request webhook submission failed:', webhookResult.error) - return NextResponse.json( - { error: webhookResult.message }, - { status: 500 } - ) - } - - console.log('Successfully submitted machine request form to GHL:', formData) - - // Return success response - return NextResponse.json({ - success: true, - message: webhookResult.message - }) - - } catch (error) { - console.error('Machine request form submission error:', error) - - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ) - } -} \ No newline at end of file +export async function POST(request: Request) { + return handleLeadRequest(request, "request-machine"); +} diff --git a/app/api/stripe/test/route.ts b/app/api/stripe/test/route.ts index 6b3e8958..7302ddb0 100644 --- a/app/api/stripe/test/route.ts +++ b/app/api/stripe/test/route.ts @@ -1,12 +1,11 @@ -import { NextRequest, NextResponse } from 'next/server' -import { auth } from '@clerk/nextjs/server' +import { NextResponse } from 'next/server' import { getStripeClient } from '@/lib/stripe/client' +import { requireAdminToken } from '@/lib/server/admin-auth' -export async function POST() { - // Check authentication - only authenticated users can test Stripe - const { userId } = await auth() - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export async function POST(request: Request) { + const authError = requireAdminToken(request) + if (authError) { + return authError } try { diff --git a/app/layout.tsx b/app/layout.tsx index 9ef99aa6..07323b5c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,7 +3,6 @@ import type { Metadata } from "next" import { Inter, Geist_Mono } from "next/font/google" import Script from "next/script" import { Analytics } from "@vercel/analytics/next" -import { ClerkProvider } from "@clerk/nextjs" import { Header } from "@/components/header" import { Footer } from "@/components/footer" import { StructuredData } from "@/components/structured-data" @@ -114,42 +113,40 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - - - - {/* Resource hints for third-party domains */} - - - - - - - -
- {/* Skip to main content link for keyboard users */} - - Skip to main content - -
-
- {children} -
-
-
-
- {/* Third-party scripts - loaded after page becomes interactive to avoid blocking render */} - -