Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
name: Manual Build Verification
name: Build Verification

on:
workflow_dispatch:
pull_request:
branches:
- main

permissions:
contents: read

concurrency:
group: manual-build-verification-${{ github.ref }}
group: build-verification-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
Expand Down Expand Up @@ -41,7 +44,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 24
cache: pnpm

- name: Install dependencies
Expand Down
55 changes: 55 additions & 0 deletions src/__tests__/contact-intake-gateway.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";

const routeSource = readFileSync("src/app/api/contact/route.ts", "utf8");
const gatewaySource = readFileSync("src/lib/supabase-gateway.ts", "utf8");
const functionSource = readFileSync(
"supabase/functions/gem-contact-gateway/index.ts",
"utf8",
);
const formSource = readFileSync("src/app/contact/ContactForm.tsx", "utf8");

describe("controlled launch contact intake", () => {
it("uses the Supabase gateway when direct database access is unavailable", () => {
expect(routeSource).toContain("shouldUseSupabaseGateway()");
expect(routeSource).toContain("submitContactGateway({");
expect(routeSource.indexOf("shouldUseSupabaseGateway()")).toBeLessThan(
routeSource.indexOf("db.supportBooking.create"),
);
expect(gatewaySource).toContain("submitContactGateway");
expect(gatewaySource).toContain('"gem-contact-gateway"');
});

it("keeps Prisma as the preferred direct-database path", () => {
expect(routeSource).toContain("db.supportBooking.create");
expect(routeSource).toContain('persistence: "prisma"');
});

it("does not report success when the gateway cannot persist the message", () => {
expect(gatewaySource).toContain("if (!response.ok)");
expect(gatewaySource).toContain("GATEWAY_UNAVAILABLE");
expect(routeSource).toContain("Your message could not be stored");
expect(routeSource).toContain("{ status: 503 }");
});

it("stores the enquiry before sending an optional notification", () => {
expect(functionSource.indexOf('.from("support_bookings").insert')).toBeLessThan(
functionSource.indexOf("await sendNotification"),
);
expect(functionSource).toContain("Notification delivery: pending");
expect(functionSource).toContain("notificationDelivery");
});

it("applies spam controls and records an audit event", () => {
expect(functionSource).toContain("MAX_SUBMISSIONS_PER_EMAIL_PER_HOUR");
expect(functionSource).toContain('"RATE_LIMITED"');
expect(functionSource).toContain('resource: "contact_message"');
expect(functionSource).toContain('source: "supabase_contact_gateway"');
});

it("retains the honeypot and accurate success wording", () => {
expect(routeSource).toContain("Honeypot submissions");
expect(formSource).toContain("accepted by our contact system");
expect(formSource).not.toContain("guaranteed response");
});
});
46 changes: 45 additions & 1 deletion src/app/api/contact/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { emitAuditLog } from "@/lib/audit";
import { getRequestContext, badRequest } from "@/lib/api/auth-helpers";
import { rateLimit, rateLimitedResponse } from "@/lib/api/rate-limit";
import { sendMail } from "@/lib/mail/send";
import {
shouldUseSupabaseGateway,
submitContactGateway,
} from "@/lib/supabase-gateway";

const schema = z.object({
name: z.string().trim().min(2, "Name must be at least 2 characters").max(120),
Expand Down Expand Up @@ -44,8 +48,47 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ ok: true });
}

const session = await getSession();
const normalizedSubject = subject?.trim() || `Contact from ${name}`;

// During the controlled launch, the canonical Vercel runtime may operate
// without a direct Prisma connection. Persist public enquiries through the
// Supabase gateway instead of claiming success without durable storage.
if (shouldUseSupabaseGateway()) {
try {
const submission = await submitContactGateway({
name,
email,
subject: normalizedSubject,
message,
website,
ipAddress,
userAgent,
});

return NextResponse.json({
ok: true,
submissionId: submission.submissionId,
ticketId: submission.ticketId,
persistence: submission.persistence,
});
} catch (error) {
console.error("[contact] Supabase intake gateway unavailable", {
code:
error && typeof error === "object" && "code" in error
? String(error.code)
: "unknown_error",
});
return NextResponse.json(
{
ok: false,
error: "Your message could not be stored. Please use the published support email.",
},
{ status: 503 },
);
}
}

const session = await getSession();
const baseNotes = [
`Website contact enquiry`,
`From: ${name} <${email}>`,
Expand Down Expand Up @@ -148,6 +191,7 @@ export async function POST(req: NextRequest) {
ok: true,
submissionId: submission.id,
ticketId,
persistence: "prisma",
});
} catch (error) {
console.error("[contact] failed to persist public enquiry", error);
Expand Down
29 changes: 29 additions & 0 deletions src/lib/supabase-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,3 +396,32 @@ export async function completePasswordRecoveryGateway(
},
);
}

export interface ContactGatewaySubmission {
name: string;
email: string;
subject: string;
message: string;
website?: string;
ipAddress?: string;
userAgent?: string;
}

export interface ContactGatewayResult {
ok: true;
accepted: true;
submissionId?: string;
ticketId: null;
persistence: "supabase_gateway";
notificationDelivery?: "sent" | "failed" | "not_configured";
suppressed?: boolean;
}

export async function submitContactGateway(
submission: ContactGatewaySubmission,
): Promise<ContactGatewayResult> {
return invokeGateway<ContactGatewayResult>("gem-contact-gateway", {
action: "submit",
...submission,
});
}
6 changes: 6 additions & 0 deletions supabase/functions/gem-contact-gateway/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"compilerOptions": {
"strict": true,
"lib": ["deno.ns", "dom"]
}
}
Loading
Loading