diff --git a/app/(app)/dashboard/surveys/[id]/page.tsx b/app/(app)/dashboard/surveys/[id]/page.tsx
new file mode 100644
index 00000000..7f3ab65e
--- /dev/null
+++ b/app/(app)/dashboard/surveys/[id]/page.tsx
@@ -0,0 +1,144 @@
+import Link from "next/link";
+import { notFound, redirect } from "next/navigation";
+import { createClient } from "@/lib/supabase/server";
+import { serviceClient } from "@/lib/supabase/service";
+import { SurveyStatusButton } from "./status-button";
+import { SurveyInviteForm } from "./send-invite-form";
+
+export const metadata = { title: "Survey results · CrawlProof" };
+
+export default async function SurveyDetailPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) redirect("/login");
+ const svc = serviceClient();
+ const { data: survey } = await svc.from("surveys")
+ .select("id, organization_id, project_id, slug, title, question, answer_type, status, variants, created_at")
+ .eq("id", id)
+ .maybeSingle();
+ if (!survey) notFound();
+ const { data: owner } = await svc.from("organization_members")
+ .select("id")
+ .eq("organization_id", survey.organization_id)
+ .eq("user_id", user.id)
+ .eq("role", "owner")
+ .maybeSingle();
+ if (!owner) notFound();
+
+ const [{ data: project }, { data: inviteRows }, { data: responseRows }] = await Promise.all([
+ svc.from("projects").select("name, url").eq("id", survey.project_id).maybeSingle(),
+ svc.from("survey_invites").select("id, variant_key, email_consent, sms_consent, requested_channel, delivery_channel, delivery_status, delivery_error, created_at").eq("survey_id", id).order("created_at", { ascending: false }).limit(500),
+ svc.from("survey_responses").select("id, invite_id, variant_key, rating, answer_text, created_at").eq("survey_id", id).order("created_at", { ascending: false }).limit(500),
+ ]);
+ const invites = (inviteRows ?? []) as Array<{
+ id: string; variant_key: string;
+ email_consent: boolean; sms_consent: boolean; requested_channel: string; delivery_channel: string | null;
+ delivery_status: string; delivery_error: string | null; created_at: string;
+ }>;
+ const responses = (responseRows ?? []) as Array<{
+ id: string; invite_id: string; variant_key: string; rating: number | null;
+ answer_text: string | null; created_at: string;
+ }>;
+ const responseByInvite = new Map(responses.map((response) => [response.invite_id, response]));
+ const variants = (Array.isArray(survey.variants) ? survey.variants : []) as Array<{ key: string; subject: string; message: string }>;
+ const inviteUrl = `${process.env.NEXT_PUBLIC_SITE_URL ?? "https://crawlproof.com"}/surveys/${survey.slug}/invite`;
+
+ return (
+
+
+
← Surveys
+
+
+
+
{survey.title}
+ {survey.status}
+
+
{project?.name ?? "Site"} · {project?.url ?? ""}
+
{survey.question}
+
+
+
+
+
+
+
+
Opt-in link
+
Share this link where people can choose email, SMS, or both. Each channel requires a separate opt-in.
+
+ {inviteUrl}
+
+
+
+ Send one invitation
+ Use this when someone gives you their contact details and agrees to a survey invite. If SMS fails, email is used only when email consent is also checked.
+
+
+
+
+
+
+
Invitation A/B test
+
Stable recipient assignment · question stays the same between variants
+
+
{responses.length} response{responses.length === 1 ? "" : "s"} · {invites.length} invitation{invites.length === 1 ? "" : "s"}
+
+
+
+
+ Variant Invited Delivered Responses Response rate
+
+
+ {variants.map((variant) => {
+ const assigned = invites.filter((invite) => invite.variant_key === variant.key);
+ const delivered = assigned.filter((invite) => invite.delivery_status === "sent" || invite.delivery_status === "responded").length;
+ const answered = responses.filter((response) => response.variant_key === variant.key).length;
+ const rate = delivered ? `${Math.round((answered / delivered) * 100)}%` : "—";
+ return (
+
+ {variant.key} {variant.message}
+ {assigned.length}
+ {delivered}
+ {answered}
+ {rate}
+
+ );
+ })}
+
+
+
+
+
+
+ Responses Contact details are not shown in the response list.
+ {responses.length === 0 ? No responses yet. Share the opt-in link or send an invitation.
: (
+
+ {responses.map((response) => {
+ const invite = invites.find((row) => row.id === response.invite_id);
+ return
+
Variant {response.variant_key} {response.rating != null ? `${response.rating} / 5` : response.answer_text}
+
{new Date(response.created_at).toLocaleString()}
+ {invite?.delivery_channel &&
via {invite.delivery_channel} }
+
;
+ })}
+
+ )}
+
+
+
+
Invitation history
+ {invites.length === 0 ? No invitations sent yet.
: (
+
+ Recipient Variant Channel Status Sent
+ {invites.map((invite) =>
+ {invite.sms_consent && invite.email_consent ? "SMS + email fallback" : invite.sms_consent ? "SMS opt-in" : "Email opt-in"}
+ {invite.variant_key} {invite.delivery_channel ?? invite.requested_channel}
+ {invite.delivery_status} {invite.delivery_error && {invite.delivery_error}
}
+ {new Date(invite.created_at).toLocaleString()}
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/app/(app)/dashboard/surveys/[id]/send-invite-form.tsx b/app/(app)/dashboard/surveys/[id]/send-invite-form.tsx
new file mode 100644
index 00000000..4f5c9ff9
--- /dev/null
+++ b/app/(app)/dashboard/surveys/[id]/send-invite-form.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { sendSurveyInvitation } from "@/app/actions/surveys";
+
+export function SurveyInviteForm({ slug }: { slug: string }) {
+ const [pending, start] = useTransition();
+ const [email, setEmail] = useState("");
+ const [phone, setPhone] = useState("");
+ const [emailConsent, setEmailConsent] = useState(false);
+ const [smsConsent, setSmsConsent] = useState(false);
+ const [message, setMessage] = useState("");
+ const [error, setError] = useState(false);
+
+ function submit(event: React.FormEvent) {
+ event.preventDefault();
+ setMessage("");
+ start(async () => {
+ const result = await sendSurveyInvitation({ slug, email, phone, emailConsent, smsConsent });
+ setError(!result.ok);
+ if (!result.ok) { setMessage(result.error); return; }
+ if (result.channel === "none") return;
+ setMessage(`Invitation sent by ${result.channel}.`);
+ setEmail(""); setPhone(""); setEmailConsent(false); setSmsConsent(false);
+ });
+ }
+
+ return ;
+}
diff --git a/app/(app)/dashboard/surveys/[id]/status-button.tsx b/app/(app)/dashboard/surveys/[id]/status-button.tsx
new file mode 100644
index 00000000..38035c6e
--- /dev/null
+++ b/app/(app)/dashboard/surveys/[id]/status-button.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { deleteSurvey, setSurveyStatus } from "@/app/actions/surveys";
+
+export function SurveyStatusButton({ surveyId, status }: { surveyId: string; status: string }) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState("");
+ const next = status === "live" ? "paused" : "live";
+ return
+
startTransition(async () => {
+ setError("");
+ const result = await setSurveyStatus({ surveyId, status: next });
+ if (!result.ok) { setError(result.error); return; }
+ router.refresh();
+ })}>{pending ? "Saving…" : next === "paused" ? "Pause survey" : "Resume survey"}
+
{
+ if (!window.confirm("Delete this survey, its invitations and all responses? This cannot be undone.")) return;
+ startTransition(async () => {
+ setError("");
+ const result = await deleteSurvey({ surveyId });
+ if (!result.ok) { setError(result.error); return; }
+ router.push("/dashboard/surveys");
+ router.refresh();
+ });
+ }}>Delete
+ {error &&
{error}
}
+
;
+}
diff --git a/app/(app)/dashboard/surveys/new/form.tsx b/app/(app)/dashboard/surveys/new/form.tsx
new file mode 100644
index 00000000..1c65ee04
--- /dev/null
+++ b/app/(app)/dashboard/surveys/new/form.tsx
@@ -0,0 +1,102 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useState, useTransition } from "react";
+import { createSurvey } from "@/app/actions/surveys";
+
+export type SurveyProjectOption = { id: string; name: string; url: string; organization_id: string };
+
+export function NewSurveyForm({ projects }: { projects: SurveyProjectOption[] }) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState("");
+ const [projectId, setProjectId] = useState(projects[0]?.id ?? "");
+ const [title, setTitle] = useState("");
+ const [question, setQuestion] = useState("");
+ const [answerType, setAnswerType] = useState<"rating" | "text">("rating");
+ const [aSubject, setASubject] = useState("One quick question about your experience");
+ const [aMessage, setAMessage] = useState("Would you share quick feedback about your experience?");
+ const [bSubject, setBSubject] = useState("Help us improve");
+ const [bMessage, setBMessage] = useState("Could you spare a minute to answer one question?");
+
+ function submit(event: React.FormEvent) {
+ event.preventDefault();
+ setError("");
+ const project = projects.find((item) => item.id === projectId);
+ if (!project) { setError("Choose a site."); return; }
+ startTransition(async () => {
+ const result = await createSurvey({
+ organizationId: project.organization_id,
+ projectId,
+ title,
+ question,
+ answerType,
+ variants: [
+ { subject: aSubject, message: aMessage },
+ { subject: bSubject, message: bMessage },
+ ],
+ });
+ if (!result.ok) { setError(result.error); return; }
+ router.push(`/dashboard/surveys/${result.id}`);
+ router.refresh();
+ });
+ }
+
+ return (
+
+ );
+}
+
+function VariantFields({ label, subject, message, onSubject, onMessage }: {
+ label: string;
+ subject: string;
+ message: string;
+ onSubject: (value: string) => void;
+ onMessage: (value: string) => void;
+}) {
+ return (
+
+ {label}
+ Email subject
+ onSubject(event.target.value)} maxLength={120} className="input mt-1 block w-full" />
+
+ Invitation message
+ onMessage(event.target.value)} required maxLength={240} rows={3} className="input mt-1 block w-full" />
+
+
+ );
+}
diff --git a/app/(app)/dashboard/surveys/new/page.tsx b/app/(app)/dashboard/surveys/new/page.tsx
new file mode 100644
index 00000000..8f300f42
--- /dev/null
+++ b/app/(app)/dashboard/surveys/new/page.tsx
@@ -0,0 +1,38 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { createClient } from "@/lib/supabase/server";
+import { serviceClient } from "@/lib/supabase/service";
+import { listUserOrgs } from "@/lib/orgs";
+import { NewSurveyForm, type SurveyProjectOption } from "./form";
+
+export const metadata = { title: "New survey · CrawlProof" };
+
+export default async function NewSurveyPage() {
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) redirect("/login");
+
+ const owned = (await listUserOrgs(supabase, user.id)).filter((org) => org.role === "owner" && org.id);
+ const orgIds = owned.map((org) => org.id);
+ const { data: rows } = orgIds.length
+ ? await serviceClient().from("projects").select("id, name, url, organization_id")
+ .in("organization_id", orgIds).in("status", ["active", "paused"]).order("name")
+ : { data: [] };
+ const projects = (rows ?? []) as SurveyProjectOption[];
+
+ return (
+
+
+
← Surveys
+
Create a survey
+
One question, two invitation versions. Each workspace can use the projects already registered in its CrawlProof site list.
+
+ {projects.length ?
: (
+
+ Add a site to your workspace before creating a survey.
+ Add a site
+
+ )}
+
+ );
+}
diff --git a/app/(app)/dashboard/surveys/page.tsx b/app/(app)/dashboard/surveys/page.tsx
new file mode 100644
index 00000000..a43c7530
--- /dev/null
+++ b/app/(app)/dashboard/surveys/page.tsx
@@ -0,0 +1,87 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { createClient } from "@/lib/supabase/server";
+import { serviceClient } from "@/lib/supabase/service";
+import { listUserOrgs } from "@/lib/orgs";
+
+export const metadata = { title: "Surveys · CrawlProof" };
+
+export default async function SurveysPage() {
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) redirect("/login");
+
+ const orgs = await listUserOrgs(supabase, user.id);
+ const owned = orgs.filter((org) => org.role === "owner");
+ const orgIds = owned.map((org) => org.id).filter(Boolean);
+ const svc = serviceClient();
+ const { data: rows } = orgIds.length
+ ? await svc.from("surveys")
+ .select("id, organization_id, project_id, title, question, status, created_at")
+ .in("organization_id", orgIds)
+ .order("created_at", { ascending: false })
+ : { data: [] };
+ const surveys = (rows ?? []) as Array<{
+ id: string;
+ organization_id: string;
+ project_id: string;
+ title: string;
+ question: string;
+ status: string;
+ created_at: string;
+ }>;
+ const projectIds = [...new Set(surveys.map((survey) => survey.project_id))];
+ const { data: projectRows } = projectIds.length
+ ? await svc.from("projects").select("id, name, url").in("id", projectIds)
+ : { data: [] };
+ const projects = new Map(((projectRows ?? []) as Array<{ id: string; name: string; url: string }>).map((project) => [project.id, project]));
+
+ return (
+
+
+
+ {owned.length === 0 ? (
+
+ Workspace owner access needed
+ Surveys use the workspace sender settings and site directory, so only workspace owners can create and send them.
+
+ ) : surveys.length === 0 ? (
+
+ Start with one question
+ Create a survey for one of your sites, write two invitation versions, then share the opt-in link or send it to someone who agreed to participate.
+ Create your first survey
+
+ ) : (
+
+ {surveys.map((survey) => {
+ const project = projects.get(survey.project_id);
+ return (
+
+
+
+
{survey.title}
+
{project?.name ?? "Site"}
+
+
{survey.status}
+
+
{survey.question}
+
{project?.url ?? ""} · created {new Date(survey.created_at).toLocaleDateString()}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx
index e83f1286..78809160 100644
--- a/app/(app)/layout.tsx
+++ b/app/(app)/layout.tsx
@@ -37,6 +37,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
Dashboard
Analytics
+ Surveys
Alerts
Recent
New
diff --git a/app/actions/surveys.ts b/app/actions/surveys.ts
new file mode 100644
index 00000000..3ea6b089
--- /dev/null
+++ b/app/actions/surveys.ts
@@ -0,0 +1,430 @@
+"use server";
+
+import { createHmac, randomBytes } from "node:crypto";
+import { revalidatePath } from "next/cache";
+import { headers } from "next/headers";
+import { createClient } from "@/lib/supabase/server";
+import { serviceClient } from "@/lib/supabase/service";
+import { env } from "@/lib/env";
+import { missingOrgSchema } from "@/lib/orgs";
+import {
+ sendOutreachEmail,
+ sendOutreachSms,
+ type OutreachConfig,
+} from "@/lib/outreach";
+import { OUTREACH_CREDITS } from "@/lib/credits";
+
+type Result = { ok: true } & (T extends undefined ? {} : T);
+type Failure = { ok: false; error: string };
+type SurveyVariant = { key: string; subject: string; message: string };
+type SurveyRow = {
+ id: string;
+ organization_id: string;
+ project_id: string;
+ owner_id: string;
+ slug: string;
+ title: string;
+ question: string;
+ answer_type: "rating" | "text";
+ status: "draft" | "live" | "paused";
+ variants: SurveyVariant[];
+};
+
+const MAX_EMAIL = 320;
+const MAX_PHONE = 24;
+
+export async function createSurvey(input: {
+ organizationId: string;
+ projectId: string;
+ title: string;
+ question: string;
+ answerType: "rating" | "text";
+ variants: Array<{ subject: string; message: string }>;
+}): Promise | Failure> {
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Sign in to create a survey." };
+
+ const title = input.title.trim().slice(0, 100);
+ const question = input.question.trim().slice(0, 500);
+ if (!input.organizationId || !input.projectId) return { ok: false, error: "Choose a workspace and site." };
+ if (title.length < 3 || question.length < 3) return { ok: false, error: "Add a title and survey question." };
+ if (input.answerType !== "rating" && input.answerType !== "text") return { ok: false, error: "Choose a supported answer type." };
+
+ const variants: SurveyVariant[] = input.variants
+ .slice(0, 4)
+ .map((variant, index) => ({
+ key: String.fromCharCode(65 + index),
+ subject: variant.subject.trim().slice(0, 120),
+ message: variant.message.trim().slice(0, 240),
+ }))
+ .filter((variant) => variant.message.length > 0);
+ if (variants.length < 2) return { ok: false, error: "Add invitation copy for both A and B." };
+ const svc = serviceClient();
+ const { data: owner } = await svc
+ .from("organization_members")
+ .select("id")
+ .eq("organization_id", input.organizationId)
+ .eq("user_id", user.id)
+ .eq("role", "owner")
+ .maybeSingle();
+ if (!owner) return { ok: false, error: "Only a workspace owner can create surveys." };
+
+ const { data: project } = await svc
+ .from("projects")
+ .select("id, organization_id")
+ .eq("id", input.projectId)
+ .eq("organization_id", input.organizationId)
+ .maybeSingle();
+ if (!project) return { ok: false, error: "That site is not in this workspace." };
+
+ const slug = randomBytes(9).toString("hex");
+ const { data, error } = await svc
+ .from("surveys")
+ .insert({
+ organization_id: input.organizationId,
+ project_id: input.projectId,
+ owner_id: user.id,
+ slug,
+ title,
+ question,
+ answer_type: input.answerType,
+ status: "live",
+ variants,
+ })
+ .select("id")
+ .single();
+ if (error || !data) return { ok: false, error: error?.message ?? "Could not create survey." };
+
+ revalidatePath("/dashboard/surveys");
+ return { ok: true, id: data.id as string };
+}
+
+export async function setSurveyStatus(input: {
+ surveyId: string;
+ status: "live" | "paused";
+}): Promise {
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Sign in to manage this survey." };
+
+ const svc = serviceClient();
+ const { data: survey } = await svc
+ .from("surveys")
+ .select("id, organization_id")
+ .eq("id", input.surveyId)
+ .maybeSingle();
+ if (!survey) return { ok: false, error: "Survey not found." };
+ const { data: owner } = await svc
+ .from("organization_members")
+ .select("id")
+ .eq("organization_id", survey.organization_id)
+ .eq("user_id", user.id)
+ .eq("role", "owner")
+ .maybeSingle();
+ if (!owner) return { ok: false, error: "Only a workspace owner can manage surveys." };
+
+ const { error } = await svc.from("surveys").update({ status: input.status }).eq("id", input.surveyId);
+ if (error) return { ok: false, error: error.message };
+ revalidatePath(`/dashboard/surveys/${input.surveyId}`);
+ revalidatePath("/dashboard/surveys");
+ return { ok: true };
+}
+
+export async function deleteSurvey(input: { surveyId: string }): Promise {
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Sign in to manage this survey." };
+ const svc = serviceClient();
+ const { data: survey } = await svc.from("surveys").select("id, organization_id").eq("id", input.surveyId).maybeSingle();
+ if (!survey) return { ok: false, error: "Survey not found." };
+ const { data: owner } = await svc.from("organization_members").select("id")
+ .eq("organization_id", survey.organization_id).eq("user_id", user.id).eq("role", "owner").maybeSingle();
+ if (!owner) return { ok: false, error: "Only a workspace owner can delete surveys." };
+ const { error } = await svc.from("surveys").delete().eq("id", input.surveyId);
+ if (error) return { ok: false, error: error.message };
+ revalidatePath("/dashboard/surveys");
+ return { ok: true };
+}
+
+/** A visitor asks for one survey invitation and explicitly selects each channel. */
+export async function sendSurveyInvitation(input: {
+ slug: string;
+ email?: string;
+ phone?: string;
+ emailConsent: boolean;
+ smsConsent: boolean;
+ website?: string;
+}): Promise | Failure> {
+ // Hidden honeypot for low-effort bot submissions.
+ if (input.website?.trim()) return { ok: true, channel: "none" };
+
+ const email = normalizeEmail(input.email ?? "");
+ const phone = normalizePhone(input.phone ?? "");
+ const emailConsent = input.emailConsent === true && !!email;
+ const smsConsent = input.smsConsent === true && !!phone;
+ if (!emailConsent && !smsConsent) {
+ return { ok: false, error: "Enter a contact and check its consent box to receive the survey." };
+ }
+
+ const svc = serviceClient();
+ const { data: survey } = await svc.from("surveys")
+ .select("id, organization_id, project_id, owner_id, slug, title, question, answer_type, status, variants")
+ .eq("slug", input.slug)
+ .eq("status", "live")
+ .maybeSingle();
+ if (!survey) return { ok: false, error: "This survey is not accepting invitations right now." };
+ const typedSurvey = survey as SurveyRow;
+
+ const headerBag = await headers();
+ const forwarded = headerBag.get("x-forwarded-for")?.split(",")[0]?.trim();
+ const ip = forwarded || headerBag.get("x-real-ip") || "unknown";
+ const ipHash = createHmac("sha256", env.ipHashSalt || env.supabaseServiceRoleKey)
+ .update(ip)
+ .digest("hex");
+ const windowStart = new Date(Date.now() - 60 * 60 * 1000).toISOString();
+ const { count: recentFromIp, error: rateError } = await svc
+ .from("survey_invites")
+ .select("id", { count: "exact", head: true })
+ .eq("ip_hash", ipHash)
+ .gte("created_at", windowStart);
+ if (rateError) return { ok: false, error: "Invitations are temporarily unavailable. Please try again shortly." };
+ if (!rateError && (recentFromIp ?? 0) >= 8) {
+ return { ok: false, error: "Please wait before requesting another survey." };
+ }
+
+ const recipient = smsConsent && phone ? phone : emailConsent && email ? email : null;
+ if (!recipient) return { ok: false, error: "Add a valid email or phone for a channel you selected." };
+ const hash = surveyRecipientHash(recipient);
+ const { data: existing } = await svc.from("survey_invites")
+ .select("id, delivery_status")
+ .eq("survey_id", typedSurvey.id)
+ .eq("recipient_hash", hash)
+ .maybeSingle();
+ if (existing && existing.delivery_status !== "failed") {
+ return { ok: false, error: "This address or number already received this survey." };
+ }
+ if (existing) await svc.from("survey_invites").delete().eq("id", existing.id);
+
+ const variants = Array.isArray(typedSurvey.variants) ? typedSurvey.variants : [];
+ if (variants.length < 2) return { ok: false, error: "This survey is missing invitation variants." };
+ const assignment = createHmac("sha256", env.supabaseServiceRoleKey).update(`${typedSurvey.id}:${recipient}`).digest("hex");
+ const variant = variants[Number.parseInt(assignment.slice(0, 8), 16) % variants.length];
+ const token = randomBytes(24).toString("hex");
+ const { data: invite, error: insertError } = await svc
+ .from("survey_invites")
+ .insert({
+ survey_id: typedSurvey.id,
+ variant_key: variant.key,
+ recipient_hash: hash,
+ invite_token: token,
+ email: emailConsent ? email : null,
+ phone: smsConsent ? phone : null,
+ email_consent: emailConsent,
+ sms_consent: smsConsent,
+ consent_at: new Date().toISOString(),
+ consent_source: "survey_invite_form",
+ requested_channel: smsConsent ? "sms" : "email",
+ delivery_status: "sending",
+ ip_hash: ipHash,
+ })
+ .select("id")
+ .single();
+ if (insertError || !invite) {
+ return { ok: false, error: /duplicate|unique/i.test(insertError?.message ?? "")
+ ? "This address or number already received this survey."
+ : insertError?.message ?? "Could not save the invitation." };
+ }
+
+ const charged = await chargeSurveyCredit(svc, typedSurvey.owner_id);
+ if (!charged) {
+ await svc.from("survey_invites").update({
+ delivery_status: "failed",
+ delivery_error: "Not enough credits to send this invitation.",
+ email: null,
+ phone: null,
+ }).eq("id", invite.id);
+ return { ok: false, error: "Not enough credits to send this invitation. Buy credits in Billing." };
+ }
+
+ const { data: project } = await svc.from("projects").select("name").eq("id", typedSurvey.project_id).maybeSingle();
+ const senderName = project?.name?.trim() || "CrawlProof survey";
+ const responseUrl = `${env.siteUrl.replace(/\/$/, "")}/surveys/r/${token}`;
+ const smsMessage = `${senderName}: ${variant.message}\n${responseUrl}\nReply STOP to opt out or HELP for help.`;
+ const emailBody = `${senderName}: ${variant.message}\n\n${typedSurvey.question}\n\nRespond here: ${responseUrl}\n\nYou requested this one-time survey invitation.`;
+ let delivery: { sent: boolean; provider: string; providerMessageId?: string; error?: string } = {
+ sent: false,
+ provider: "unknown",
+ error: "Delivery was not attempted.",
+ };
+ let deliveredChannel: "email" | "sms" = "email";
+
+ try {
+ const outreachConfig = await loadSurveyOutreachConfig(svc, typedSurvey.organization_id, smsConsent ? "sms" : "email");
+ if (smsConsent && phone) {
+ deliveredChannel = "sms";
+ delivery = smsMessage.length <= 480
+ ? await sendOutreachSms({ to: phone, body: smsMessage, config: outreachConfig }).catch((error) => ({
+ sent: false,
+ provider: "sms",
+ error: error instanceof Error ? error.message : "SMS could not be sent.",
+ }))
+ : { sent: false, provider: "sms", error: "SMS invite is too long." };
+ if (!delivery.sent && emailConsent && email) {
+ const emailConfig = await loadSurveyOutreachConfig(svc, typedSurvey.organization_id, "email");
+ const fallback = await sendOutreachEmail({
+ to: email,
+ subject: `${senderName}: ${variant.subject || typedSurvey.title}`,
+ body: emailBody,
+ html: inviteHtml(`${senderName}: ${variant.message}`, typedSurvey.question, responseUrl),
+ config: emailConfig,
+ });
+ if (fallback.sent) {
+ delivery = fallback;
+ deliveredChannel = "email";
+ }
+ }
+ } else if (emailConsent && email) {
+ delivery = await sendOutreachEmail({
+ to: email,
+ subject: `${senderName}: ${variant.subject || typedSurvey.title}`,
+ body: emailBody,
+ html: inviteHtml(`${senderName}: ${variant.message}`, typedSurvey.question, responseUrl),
+ config: outreachConfig,
+ });
+ } else {
+ delivery = { sent: false, provider: "survey", error: "No consented delivery channel is available." };
+ }
+ } catch (error) {
+ delivery = {
+ sent: false,
+ provider: deliveredChannel,
+ error: error instanceof Error ? error.message : "The invitation could not be sent.",
+ };
+ }
+
+ if (!delivery.sent) await refundSurveyCredit(svc, typedSurvey.owner_id);
+
+ const { error: updateError } = await svc.from("survey_invites").update({
+ delivery_status: delivery.sent ? "sent" : "failed",
+ delivery_channel: delivery.sent ? deliveredChannel : null,
+ provider: delivery.provider,
+ provider_message_id: delivery.providerMessageId ?? null,
+ delivery_error: delivery.error ?? null,
+ email: null,
+ phone: null,
+ }).eq("id", invite.id);
+ if (updateError) console.error("survey invite delivery update failed", updateError.message);
+ if (!delivery.sent) {
+ return { ok: false, error: delivery.error ?? "We could not send the invitation. Check the workspace sender settings." };
+ }
+
+ return { ok: true, channel: deliveredChannel };
+}
+
+export async function submitSurveyResponse(input: {
+ token: string;
+ rating?: number;
+ answer?: string;
+}): Promise {
+ if (!/^[a-f0-9]{48}$/.test(input.token)) return { ok: false, error: "This survey link is invalid." };
+ const svc = serviceClient();
+ const { data: invite } = await svc.from("survey_invites")
+ .select("id, survey_id, variant_key, delivery_status")
+ .eq("invite_token", input.token)
+ .maybeSingle();
+ if (!invite || (invite.delivery_status !== "sent" && invite.delivery_status !== "responded")) {
+ return { ok: false, error: "This survey invitation is no longer available." };
+ }
+
+ const { data: survey } = await svc.from("surveys")
+ .select("answer_type")
+ .eq("id", invite.survey_id)
+ .maybeSingle();
+ if (!survey) return { ok: false, error: "Survey not found." };
+ const payload: Record = {
+ survey_id: invite.survey_id,
+ invite_id: invite.id,
+ variant_key: invite.variant_key,
+ };
+ if (survey.answer_type === "rating") {
+ const rating = Math.round(Number(input.rating));
+ if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
+ return { ok: false, error: "Choose a rating from 1 to 5." };
+ }
+ payload.rating = rating;
+ } else {
+ const answer = (input.answer ?? "").trim().slice(0, 4000);
+ if (!answer) return { ok: false, error: "Enter a response." };
+ payload.answer_text = answer;
+ }
+
+ const { error } = await svc.from("survey_responses").insert(payload);
+ if (error) {
+ if (/duplicate|unique/i.test(error.message)) return { ok: false, error: "This survey has already been submitted." };
+ return { ok: false, error: error.message };
+ }
+ await svc.from("survey_invites").update({ delivery_status: "responded" }).eq("id", invite.id);
+ revalidatePath(`/dashboard/surveys/${invite.survey_id}`);
+ return { ok: true };
+}
+
+async function loadSurveyOutreachConfig(
+ svc: ReturnType,
+ organizationId: string,
+ channel: "email" | "sms",
+): Promise {
+ const { data, error } = await svc.from("organization_outreach_configs")
+ .select("provider, from_email, from_phone, reply_to, smtp_host, smtp_port, smtp_secure, smtp_user, smtp_pass, api_key, account_sid, auth_token, enc_smtp_user, enc_smtp_pass, enc_api_key, enc_auth_token")
+ .eq("organization_id", organizationId)
+ .eq("channel", channel)
+ .eq("enabled", true)
+ .order("is_default", { ascending: false })
+ .order("created_at", { ascending: true })
+ .limit(1)
+ .maybeSingle();
+ if (error) {
+ if (missingOrgSchema(error)) return null;
+ throw new Error(error.message);
+ }
+ if (!data) return null;
+ const { data: org } = await svc.from("organizations").select("owner_id").eq("id", organizationId).maybeSingle();
+ return { ...(data as OutreachConfig), owner_id: (org as { owner_id?: string } | null)?.owner_id ?? null };
+}
+
+async function chargeSurveyCredit(svc: ReturnType, ownerId: string): Promise {
+ if (OUTREACH_CREDITS <= 0) return true;
+ const { data } = await svc.rpc("consume_credit", { p_owner: ownerId, p_count: OUTREACH_CREDITS });
+ return data === true;
+}
+
+async function refundSurveyCredit(svc: ReturnType, ownerId: string): Promise {
+ if (OUTREACH_CREDITS <= 0) return;
+ await svc.rpc("refund_credit", { p_owner: ownerId, p_count: OUTREACH_CREDITS });
+}
+
+function normalizeEmail(raw: string): string | null {
+ const value = raw.trim().toLowerCase();
+ if (!value || value.length > MAX_EMAIL || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return null;
+ return value;
+}
+
+function surveyRecipientHash(value: string): string {
+ return createHmac("sha256", env.supabaseServiceRoleKey).update(`survey-recipient:${value}`).digest("hex");
+}
+
+function normalizePhone(raw: string): string | null {
+ const value = raw.trim();
+ if (!value || value.length > MAX_PHONE) return null;
+ const normalized = `${value.startsWith("+") ? "+" : ""}${value.replace(/\D/g, "")}`;
+ const digits = normalized.replace(/\D/g, "");
+ return digits.length >= 8 && digits.length <= 15 ? normalized : null;
+}
+
+function escapeHtml(value: string): string {
+ return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" })[char]!);
+}
+
+function inviteHtml(message: string, question: string, url: string): string {
+ return `${escapeHtml(message)}
${escapeHtml(question)}
Share your feedback
You requested this one-time survey invitation.
`;
+}
diff --git a/app/surveys/[slug]/invite/form.tsx b/app/surveys/[slug]/invite/form.tsx
new file mode 100644
index 00000000..ecfbe638
--- /dev/null
+++ b/app/surveys/[slug]/invite/form.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { sendSurveyInvitation } from "@/app/actions/surveys";
+
+export function PublicInviteForm({ slug, siteName }: { slug: string; siteName: string }) {
+ const [pending, start] = useTransition();
+ const [email, setEmail] = useState("");
+ const [phone, setPhone] = useState("");
+ const [emailConsent, setEmailConsent] = useState(false);
+ const [smsConsent, setSmsConsent] = useState(false);
+ const [message, setMessage] = useState("");
+ const [failed, setFailed] = useState(false);
+
+ function submit(event: React.FormEvent) {
+ event.preventDefault();
+ setMessage("");
+ const form = new FormData(event.currentTarget);
+ const website = String(form.get("website") ?? "");
+ start(async () => {
+ const result = await sendSurveyInvitation({ slug, email, phone, emailConsent, smsConsent, website });
+ setFailed(!result.ok);
+ setMessage(result.ok
+ ? result.channel === "none" ? "Thanks." : `The survey invitation was sent by ${result.channel}. Check your inbox or messages.`
+ : result.error);
+ if (result.ok) { setEmail(""); setPhone(""); setEmailConsent(false); setSmsConsent(false); }
+ });
+ }
+
+ return
+ Email address
+ setEmail(event.target.value)} className="input mt-1 w-full" placeholder="you@example.com" />
+
+ Phone number
+ setPhone(event.target.value)} className="input mt-1 w-full" placeholder="+1 555 010 0123" />
+
+
+
+ {message && {message}
}
+ {pending ? "Sending invitation…" : "Send me the survey"}
+ ;
+}
diff --git a/app/surveys/[slug]/invite/page.tsx b/app/surveys/[slug]/invite/page.tsx
new file mode 100644
index 00000000..cb8bf2d9
--- /dev/null
+++ b/app/surveys/[slug]/invite/page.tsx
@@ -0,0 +1,32 @@
+import { notFound } from "next/navigation";
+import { serviceClient } from "@/lib/supabase/service";
+import { PublicInviteForm } from "./form";
+
+export const metadata = {
+ title: "Get a survey invitation · CrawlProof",
+ robots: { index: false, follow: false },
+};
+
+export default async function SurveyInvitePage({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params;
+ const svc = serviceClient();
+ const { data: survey } = await svc.from("surveys")
+ .select("title, question, answer_type, status, project_id")
+ .eq("slug", slug)
+ .maybeSingle();
+ if (!survey) notFound();
+ const { data: project } = await svc.from("projects").select("name").eq("id", survey.project_id).maybeSingle();
+ const active = survey.status === "live";
+
+ return (
+
+
+
CrawlProof Survey
+
{survey.title}
+
{survey.question}
+ {active ?
:
This survey is paused and is not accepting new invitations.
}
+
Your email or phone is used only for the survey invitation channel you select. SMS is sent only with your explicit permission. Email is used as a fallback only when you separately allow it.
+
+
+ );
+}
diff --git a/app/surveys/page.tsx b/app/surveys/page.tsx
new file mode 100644
index 00000000..446f42ab
--- /dev/null
+++ b/app/surveys/page.tsx
@@ -0,0 +1,27 @@
+import Link from "next/link";
+
+export const metadata = {
+ title: "Surveys · CrawlProof",
+ description: "Ask one focused question across your sites. Send survey invitations by email or SMS with consent, compare invitation copy, and review responses.",
+};
+
+export default function SurveysLandingPage() {
+ return (
+
+
+
CrawlProof Surveys
+
One useful question. Across every site you manage.
+
Create a short survey for a site in your workspace, send a consent-based email or text invitation, test two invitation versions, and see responses together.
+
+ Open surveys
+ Go to dashboard
+
+
+
+
Use your site directory Surveys are connected to the sites already grouped in a CrawlProof workspace.
+
Contact people on their terms Email and SMS have separate opt-ins. Email is a fallback only when the recipient approved email too.
+
Learn which invite works Recipients stay in one invitation variant, so response rates compare A and B fairly.
+
+
+ );
+}
diff --git a/app/surveys/r/[token]/form.tsx b/app/surveys/r/[token]/form.tsx
new file mode 100644
index 00000000..43e5c0bf
--- /dev/null
+++ b/app/surveys/r/[token]/form.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { submitSurveyResponse } from "@/app/actions/surveys";
+
+export function SurveyResponseForm({ token, answerType }: { token: string; answerType: string }) {
+ const [pending, start] = useTransition();
+ const [rating, setRating] = useState(0);
+ const [answer, setAnswer] = useState("");
+ const [error, setError] = useState("");
+ const [submitted, setSubmitted] = useState(false);
+
+ function submit(event: React.FormEvent) {
+ event.preventDefault();
+ setError("");
+ start(async () => {
+ const result = await submitSurveyResponse({ token, rating, answer });
+ if (!result.ok) { setError(result.error); return; }
+ setSubmitted(true);
+ });
+ }
+
+ if (submitted) return Thanks for sharing your feedback.
;
+
+ return
+ {answerType === "rating" ? (
+
+ Choose a rating from 1 (low) to 5 (high)
+
+ {[1, 2, 3, 4, 5].map((value) => setRating(value)} className={`h-12 w-12 rounded border font-semibold ${rating === value ? "border-[var(--color-accent)] bg-[var(--color-accent)] text-white" : "border-[var(--color-border)]"}`}>{value} )}
+
+
+ ) : (
+ Your response
+ setAnswer(event.target.value)} maxLength={4000} rows={5} className="input mt-1 w-full" required />
+
+ )}
+ {error && {error}
}
+ {pending ? "Submitting…" : "Submit response"}
+ ;
+}
diff --git a/app/surveys/r/[token]/page.tsx b/app/surveys/r/[token]/page.tsx
new file mode 100644
index 00000000..6f35c3e7
--- /dev/null
+++ b/app/surveys/r/[token]/page.tsx
@@ -0,0 +1,37 @@
+import { notFound } from "next/navigation";
+import { serviceClient } from "@/lib/supabase/service";
+import { SurveyResponseForm } from "./form";
+
+export const metadata = {
+ title: "Survey response · CrawlProof",
+ robots: { index: false, follow: false },
+};
+
+export default async function SurveyResponsePage({ params }: { params: Promise<{ token: string }> }) {
+ const { token } = await params;
+ if (!/^[a-f0-9]{48}$/.test(token)) notFound();
+ const svc = serviceClient();
+ const { data: invite } = await svc.from("survey_invites")
+ .select("id, survey_id, delivery_status")
+ .eq("invite_token", token)
+ .maybeSingle();
+ if (!invite || (invite.delivery_status !== "sent" && invite.delivery_status !== "responded")) notFound();
+ const [{ data: survey }, { data: response }] = await Promise.all([
+ svc.from("surveys").select("title, question, answer_type").eq("id", invite.survey_id).maybeSingle(),
+ svc.from("survey_responses").select("id").eq("invite_id", invite.id).maybeSingle(),
+ ]);
+ if (!survey) notFound();
+
+ return (
+
+
+
CrawlProof Survey
+
{survey.title}
+ {response ?
Thanks for sharing your feedback.
: <>
+
{survey.question}
+
+ >}
+
+
+ );
+}
diff --git a/supabase/migrations/20261030200000_surveys.sql b/supabase/migrations/20261030200000_surveys.sql
new file mode 100644
index 00000000..adce0fb9
--- /dev/null
+++ b/supabase/migrations/20261030200000_surveys.sql
@@ -0,0 +1,88 @@
+-- Surveys are workspace-owned and project-scoped. Public visitors only touch
+-- them through server routes; no anon table policies expose contact details.
+
+create table if not exists public.surveys (
+ id uuid primary key default gen_random_uuid(),
+ organization_id uuid not null references public.organizations(id) on delete cascade,
+ project_id uuid not null references public.projects(id) on delete cascade,
+ owner_id uuid not null references public.profiles(id) on delete cascade,
+ slug text not null unique,
+ title text not null,
+ question text not null,
+ answer_type text not null default 'rating'
+ check (answer_type in ('rating', 'text')),
+ status text not null default 'live'
+ check (status in ('draft', 'live', 'paused')),
+ -- Invitation copy variants are assigned deterministically per recipient.
+ variants jsonb not null default '[]'::jsonb
+ check (jsonb_typeof(variants) = 'array'),
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+create index if not exists surveys_org_created_idx
+ on public.surveys(organization_id, created_at desc);
+create index if not exists surveys_project_created_idx
+ on public.surveys(project_id, created_at desc);
+
+alter table public.surveys enable row level security;
+
+create table if not exists public.survey_invites (
+ id uuid primary key default gen_random_uuid(),
+ survey_id uuid not null references public.surveys(id) on delete cascade,
+ variant_key text not null,
+ recipient_hash text not null,
+ invite_token text not null unique default encode(gen_random_bytes(24), 'hex'),
+ email text,
+ phone text,
+ email_consent boolean not null default false,
+ sms_consent boolean not null default false,
+ consent_at timestamptz not null,
+ consent_source text not null default 'survey_form',
+ requested_channel text not null check (requested_channel in ('email', 'sms')),
+ delivery_channel text check (delivery_channel in ('email', 'sms')),
+ delivery_status text not null default 'sending'
+ check (delivery_status in ('sending', 'sent', 'failed', 'responded')),
+ provider text,
+ provider_message_id text,
+ delivery_error text,
+ ip_hash text,
+ created_at timestamptz not null default now(),
+ check (email_consent or sms_consent)
+);
+
+create unique index if not exists survey_invites_recipient_idx
+ on public.survey_invites(survey_id, recipient_hash);
+create index if not exists survey_invites_survey_created_idx
+ on public.survey_invites(survey_id, created_at desc);
+create index if not exists survey_invites_ip_created_idx
+ on public.survey_invites(ip_hash, created_at desc)
+ where ip_hash is not null;
+
+alter table public.survey_invites enable row level security;
+
+create table if not exists public.survey_responses (
+ id uuid primary key default gen_random_uuid(),
+ survey_id uuid not null references public.surveys(id) on delete cascade,
+ invite_id uuid not null unique references public.survey_invites(id) on delete cascade,
+ variant_key text not null,
+ rating smallint check (rating between 1 and 5),
+ answer_text text check (char_length(answer_text) <= 4000),
+ created_at timestamptz not null default now(),
+ check ((rating is not null) <> (answer_text is not null))
+);
+
+create index if not exists survey_responses_survey_created_idx
+ on public.survey_responses(survey_id, created_at desc);
+
+alter table public.survey_responses enable row level security;
+
+drop trigger if exists surveys_set_updated_at on public.surveys;
+create trigger surveys_set_updated_at
+ before update on public.surveys
+ for each row execute function public.lx_set_updated_at();
+
+comment on table public.surveys is
+ 'Project-scoped survey links, invitation variants and response collection.';
+comment on table public.survey_invites is
+ 'Consent-bearing one-time invitations. Contact values are cleared after each delivery attempt; keyed hashes remain for stable assignment and deduplication.';