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 (
-
+ Admin sign-in is not configured in this deployment. Enable the admin + UI and wire an auth provider before using this area. +
+