mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
feat(launch): waitlist page, raw-WebGPU hero, supabase + vercel infra
The July 14 launch surface, previously uncommitted: - /dashboard/launch raw-WebGPU particle "memory brain" hero (RawVestigeEngine, NeuralWordmark, dendrite sign) + DOM waitlist overlay with share/referral. - Supabase waitlist client + migrations + welcome edge function; legacy waitlist archived under supabase/legacy. - vercel.json deploy config, root-redirect env wiring, base-path config, graph-only route, Playwright launch verifiers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
24ef2d504f
commit
6837198328
27 changed files with 5249 additions and 747 deletions
|
|
@ -1,10 +1,16 @@
|
|||
# Supabase project config for the Vestige Pro waitlist capture.
|
||||
# Supabase project config for the Vestige LAUNCH waitlist (/dashboard/launch).
|
||||
#
|
||||
# Link to a real project with: supabase link --project-ref <your-ref>
|
||||
# Then deploy: supabase db push && supabase functions deploy waitlist-join
|
||||
# Apply the schema: supabase db push
|
||||
# Deploy the welcome email fn: supabase functions deploy waitlist-welcome --no-verify-jwt
|
||||
#
|
||||
# The launch page inserts directly with the anon key (insert-only RLS + RPCs);
|
||||
# see supabase/migrations/0002_launch_waitlist.sql and docs/launch/waitlist-setup.md.
|
||||
# The legacy service-role waitlist-join function is archived in supabase/legacy/.
|
||||
|
||||
project_id = "vestige-waitlist"
|
||||
|
||||
[functions.waitlist-join]
|
||||
# Public form posts here; no Supabase JWT required (we gate with the honeypot,
|
||||
# email validation, and the service-role insert inside the function).
|
||||
[functions.waitlist-welcome]
|
||||
# Invoked by a Database Webhook on INSERT into public.waitlist (no end-user JWT);
|
||||
# guard with the optional WAITLIST_WEBHOOK_SECRET header instead.
|
||||
verify_jwt = false
|
||||
|
|
|
|||
165
supabase/functions/waitlist-welcome/index.ts
Normal file
165
supabase/functions/waitlist-welcome/index.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// Vestige launch waitlist — welcome + SHARE email (Supabase Edge Function, Deno).
|
||||
//
|
||||
// Fired by a Supabase Database Webhook on INSERT into public.waitlist. It emails
|
||||
// the new signup a confirmation that contains THEIR personal referral link and a
|
||||
// one-tap "Share on X" button. That email is the launch-day multiplier: it
|
||||
// reaches people after they've closed the tab and nudges them to share, which is
|
||||
// what actually compounds a dev-launch waitlist.
|
||||
//
|
||||
// Secrets (set with `supabase secrets set`, NEVER committed):
|
||||
// RESEND_API_KEY — required to actually send (no key => no-op, 200 OK)
|
||||
// WAITLIST_FROM — sender, e.g. "Vestige <onboarding@resend.dev>"
|
||||
// (use a verified domain in production)
|
||||
// WAITLIST_PUBLIC_URL — the public launch page, e.g.
|
||||
// "https://samvallad33.github.io/vestige/dashboard/launch"
|
||||
// WAITLIST_WEBHOOK_SECRET — optional shared secret; if set, the webhook must
|
||||
// send it as the `x-webhook-secret` header.
|
||||
//
|
||||
// Deploy: supabase functions deploy waitlist-welcome --no-verify-jwt
|
||||
// Wire up: Supabase → Database → Webhooks → on INSERT of public.waitlist →
|
||||
// Supabase Edge Functions → waitlist-welcome.
|
||||
|
||||
const DEFAULT_FROM = "Vestige <onboarding@resend.dev>";
|
||||
const DEFAULT_PUBLIC_URL = "https://samvallad33.github.io/vestige/dashboard/launch";
|
||||
|
||||
interface WebhookPayload {
|
||||
type?: string; // 'INSERT'
|
||||
record?: {
|
||||
email?: string;
|
||||
referral_code?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function shareUrl(base: string, code: string): string {
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
return `${base}${sep}ref=${encodeURIComponent(code)}`;
|
||||
}
|
||||
|
||||
function buildEmail(link: string, xIntent: string) {
|
||||
const text =
|
||||
`You're on the Vestige waitlist. 🧠\n\n` +
|
||||
`Vestige is local-first memory for AI coding agents — rendered as a living ` +
|
||||
`WebGPU particle brain you can watch remember, forget, and reform. ` +
|
||||
`Launching July 14, 2026.\n\n` +
|
||||
`Want in earlier? Every friend who joins from your personal link moves you ` +
|
||||
`up the list:\n\n${link}\n\n` +
|
||||
`Share it on X: ${xIntent}\n\n` +
|
||||
`— Sam (building Vestige solo)`;
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;background:#02030a;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#eaf3ff;">
|
||||
<div style="max-width:520px;margin:0 auto;padding:40px 28px;">
|
||||
<div style="font-weight:800;letter-spacing:.34em;font-size:13px;color:#8fb6ff;margin-bottom:24px;">VESTIGE</div>
|
||||
<h1 style="font-size:22px;line-height:1.3;margin:0 0 14px;">You're on the waitlist. 🧠</h1>
|
||||
<p style="font-size:15px;line-height:1.6;color:#b9cdec;margin:0 0 18px;">
|
||||
Vestige is local-first memory for AI coding agents — rendered as a living
|
||||
WebGPU particle brain you can watch remember, forget, and reform.
|
||||
<strong style="color:#8fe9ff;">Launching July 14, 2026.</strong>
|
||||
</p>
|
||||
<p style="font-size:15px;line-height:1.6;color:#b9cdec;margin:0 0 10px;">
|
||||
Want in earlier? Every friend who joins from your personal link moves you up the list:
|
||||
</p>
|
||||
<div style="background:#0a1020;border:1px solid rgba(140,190,255,.28);border-radius:12px;padding:14px 16px;margin:0 0 20px;font-size:13px;color:#bfe0ff;word-break:break-all;">
|
||||
${link}
|
||||
</div>
|
||||
<a href="${xIntent}" style="display:inline-block;background:#f5f8ff;color:#0a0f1a;font-weight:700;font-size:15px;text-decoration:none;padding:12px 22px;border-radius:12px;">
|
||||
Share on X →
|
||||
</a>
|
||||
<p style="font-size:13px;line-height:1.6;color:#7d93b8;margin:28px 0 0;">
|
||||
— Sam, building Vestige solo. Reply any time.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return { text, html };
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
// optional shared-secret check
|
||||
const expected = Deno.env.get("WAITLIST_WEBHOOK_SECRET");
|
||||
if (expected && req.headers.get("x-webhook-secret") !== expected) {
|
||||
return new Response(JSON.stringify({ ok: false, error: "unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let payload: WebhookPayload;
|
||||
try {
|
||||
payload = await req.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ ok: false, error: "invalid_json" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const email = payload.record?.email?.trim();
|
||||
const code = payload.record?.referral_code?.trim();
|
||||
if (!email || !code) {
|
||||
// Nothing to send; ack so the webhook doesn't retry forever.
|
||||
return new Response(JSON.stringify({ ok: true, skipped: "no email/code" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const key = Deno.env.get("RESEND_API_KEY");
|
||||
if (!key) {
|
||||
// Email is optional infra — capture already succeeded. Ack with a note.
|
||||
return new Response(JSON.stringify({ ok: true, skipped: "no RESEND_API_KEY" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const from = Deno.env.get("WAITLIST_FROM") ?? DEFAULT_FROM;
|
||||
const base = Deno.env.get("WAITLIST_PUBLIC_URL") ?? DEFAULT_PUBLIC_URL;
|
||||
const link = shareUrl(base, code);
|
||||
const xText =
|
||||
"I just joined the Vestige waitlist — local-first memory for AI agents, " +
|
||||
"rendered as a live WebGPU particle brain. Launching July 14:";
|
||||
const xIntent =
|
||||
`https://twitter.com/intent/tweet?text=${encodeURIComponent(xText)}` +
|
||||
`&url=${encodeURIComponent(link)}`;
|
||||
const { text, html } = buildEmail(link, xIntent);
|
||||
|
||||
try {
|
||||
const res = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from,
|
||||
to: email,
|
||||
subject: "You're on the Vestige waitlist — here's your invite link",
|
||||
text,
|
||||
html,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
console.error("resend send failed:", res.status, detail);
|
||||
// Ack anyway: the signup is committed; we don't want infinite retries.
|
||||
return new Response(JSON.stringify({ ok: false, error: "send_failed" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("resend send threw:", err);
|
||||
return new Response(JSON.stringify({ ok: false, error: "send_threw" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ok: true, sent: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
37
supabase/legacy/0001_waitlist.sql
Normal file
37
supabase/legacy/0001_waitlist.sql
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
-- Vestige Pro waitlist capture.
|
||||
--
|
||||
-- One row per signup from the public /dashboard/waitlist form. The form POSTs
|
||||
-- through the `waitlist-join` Edge Function (service-role), NOT directly to this
|
||||
-- table, so Row-Level Security stays closed to anon clients while the function
|
||||
-- can insert. We keep the table minimal and queryable for launch-day export.
|
||||
|
||||
create table if not exists public.waitlist (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
email text not null,
|
||||
name text,
|
||||
plan text, -- 'solo' | 'team' (free-form, not enforced)
|
||||
priority text, -- 'sync' | 'team-memory' | 'postgres' | 'support'
|
||||
notes text,
|
||||
source text default 'vestige-pro-waitlist',
|
||||
user_agent text,
|
||||
-- Lowercased email for case-insensitive dedup. Generated so callers can't
|
||||
-- desync it from `email`.
|
||||
email_norm text generated always as (lower(trim(email))) stored,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- One signup per email address. ON CONFLICT in the function turns a re-submit
|
||||
-- into a friendly "already on the list" instead of a duplicate row or an error.
|
||||
create unique index if not exists waitlist_email_norm_key
|
||||
on public.waitlist (email_norm);
|
||||
|
||||
-- Newest-first export / dashboards.
|
||||
create index if not exists waitlist_created_at_idx
|
||||
on public.waitlist (created_at desc);
|
||||
|
||||
-- RLS ON, no anon policies: the anon/public key can do NOTHING here directly.
|
||||
-- Only the service-role key used inside the Edge Function bypasses RLS to insert.
|
||||
alter table public.waitlist enable row level security;
|
||||
|
||||
comment on table public.waitlist is
|
||||
'Vestige Pro launch waitlist. Inserted only via the waitlist-join Edge Function (service-role). RLS blocks all anon access.';
|
||||
21
supabase/legacy/README.md
Normal file
21
supabase/legacy/README.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Legacy waitlist backend (archived)
|
||||
|
||||
These files backed the **old** `/dashboard/waitlist` page (Canvas2D grid +
|
||||
support bot), which POSTed signups to the `waitlist-join` Edge Function using a
|
||||
service-role insert.
|
||||
|
||||
The **live launch page** is `/dashboard/launch`. It uses a different, simpler
|
||||
architecture: direct browser inserts with the public anon key + insert-only RLS,
|
||||
plus `security definer` RPCs for the referral loop. See:
|
||||
|
||||
- Schema + RPCs: `supabase/migrations/0002_launch_waitlist.sql`
|
||||
- Go-live runbook: `docs/launch/waitlist-setup.md`
|
||||
- Welcome/share email: `supabase/functions/waitlist-welcome/`
|
||||
|
||||
Both schemas declared a table literally named `waitlist` with **incompatible**
|
||||
shapes (this one is `uuid` PK with `name/plan/notes`; the launch one is `bigint`
|
||||
PK with `referral_code/referred_by`). They cannot coexist in the same database
|
||||
under that name. The launch schema is the source of truth as of 2026-06-26.
|
||||
|
||||
Kept for reference / in case the old page is ever revived (it would need its own
|
||||
table name). Nothing in the app references `waitlist-join` anymore.
|
||||
146
supabase/legacy/waitlist-join/index.ts
Normal file
146
supabase/legacy/waitlist-join/index.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// Vestige Pro waitlist capture — Supabase Edge Function (Deno).
|
||||
//
|
||||
// The public /dashboard/waitlist form POSTs JSON here. This function:
|
||||
// 1. Handles CORS preflight (the form is served from a different origin).
|
||||
// 2. Validates the email and rejects honeypot/bot submissions.
|
||||
// 3. Inserts one row per email into public.waitlist (dedup on re-submit).
|
||||
// 4. Optionally sends a confirmation email via Resend.
|
||||
//
|
||||
// Secrets (set with `supabase secrets set`, NEVER committed):
|
||||
// SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY — auto-injected by Supabase
|
||||
// RESEND_API_KEY — optional; if unset, skip email
|
||||
// WAITLIST_FROM_EMAIL — optional; defaults below
|
||||
// WAITLIST_ALLOWED_ORIGIN — optional CORS allowlist (comma-sep)
|
||||
|
||||
import { createClient } from "jsr:@supabase/supabase-js@2";
|
||||
|
||||
const DEFAULT_FROM = "Vestige <waitlist@vestige.dev>";
|
||||
|
||||
function corsHeaders(origin: string | null): HeadersInit {
|
||||
const allow = (Deno.env.get("WAITLIST_ALLOWED_ORIGIN") ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
// If an allowlist is configured and the origin isn't on it, fall back to the
|
||||
// first allowed origin (so browsers see a definite, non-wildcard value).
|
||||
const allowedOrigin =
|
||||
allow.length === 0 ? "*" : (origin && allow.includes(origin) ? origin : allow[0]);
|
||||
return {
|
||||
"Access-Control-Allow-Origin": allowedOrigin,
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "content-type",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
interface WaitlistPayload {
|
||||
name?: string;
|
||||
email?: string;
|
||||
plan?: string;
|
||||
priority?: string;
|
||||
notes?: string;
|
||||
source?: string;
|
||||
companySite?: string; // honeypot — must be empty
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
async function sendConfirmation(email: string, name: string) {
|
||||
const key = Deno.env.get("RESEND_API_KEY");
|
||||
if (!key) return; // email is optional; capture still succeeds without it
|
||||
const from = Deno.env.get("WAITLIST_FROM_EMAIL") ?? DEFAULT_FROM;
|
||||
const greeting = name ? `Hi ${name},` : "Hi,";
|
||||
try {
|
||||
await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${key}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from,
|
||||
to: email,
|
||||
subject: "You're on the Vestige Pro list",
|
||||
text:
|
||||
`${greeting}\n\nYou're on the Vestige Pro early-access list. ` +
|
||||
`We'll email you before launch with your invite.\n\n— The Vestige team`,
|
||||
}),
|
||||
});
|
||||
} catch (_err) {
|
||||
// Never fail the signup because the email provider hiccuped.
|
||||
}
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const origin = req.headers.get("origin");
|
||||
const headers = corsHeaders(origin);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers });
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
return new Response(JSON.stringify({ ok: false, error: "method_not_allowed" }), {
|
||||
status: 405,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
let body: WaitlistPayload;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ ok: false, error: "invalid_json" }), {
|
||||
status: 400,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
// Honeypot: real users never fill this hidden field. Pretend success so bots
|
||||
// get no signal, but write nothing.
|
||||
if (body.companySite && body.companySite.trim()) {
|
||||
return new Response(JSON.stringify({ ok: true, deduped: false }), { status: 200, headers });
|
||||
}
|
||||
|
||||
const email = (body.email ?? "").trim();
|
||||
if (!EMAIL_RE.test(email)) {
|
||||
return new Response(JSON.stringify({ ok: false, error: "invalid_email" }), {
|
||||
status: 400,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
);
|
||||
|
||||
// Insert; on email conflict, treat as already-joined (idempotent, friendly).
|
||||
const row = {
|
||||
email,
|
||||
name: (body.name ?? "").trim() || null,
|
||||
plan: (body.plan ?? "").trim() || null,
|
||||
priority: (body.priority ?? "").trim() || null,
|
||||
notes: (body.notes ?? "").trim() || null,
|
||||
source: (body.source ?? "vestige-pro-waitlist").trim(),
|
||||
user_agent: req.headers.get("user-agent")?.slice(0, 400) ?? null,
|
||||
};
|
||||
|
||||
const { error } = await supabase.from("waitlist").insert(row);
|
||||
|
||||
if (error) {
|
||||
// 23505 = unique_violation on email_norm → already on the list.
|
||||
if (error.code === "23505") {
|
||||
return new Response(JSON.stringify({ ok: true, deduped: true }), { status: 200, headers });
|
||||
}
|
||||
console.error("waitlist insert failed:", error);
|
||||
return new Response(JSON.stringify({ ok: false, error: "storage_failed" }), {
|
||||
status: 500,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
// Fire-and-forget confirmation; the signup is already committed.
|
||||
await sendConfirmation(email, row.name ?? "");
|
||||
|
||||
return new Response(JSON.stringify({ ok: true, deduped: false }), { status: 200, headers });
|
||||
});
|
||||
122
supabase/migrations/0002_launch_waitlist.sql
Normal file
122
supabase/migrations/0002_launch_waitlist.sql
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
-- Vestige LAUNCH waitlist (/dashboard/launch) — table + referral loop.
|
||||
--
|
||||
-- The browser inserts directly with the public anon key. The table is
|
||||
-- INSERT-ONLY to anon via RLS, and every read (own referral code, referral
|
||||
-- count, total count) goes through a `security definer` RPC that returns a
|
||||
-- single value, so the anon key never gets SELECT on the table.
|
||||
--
|
||||
-- This is the source of truth for the launch page. The older service-role
|
||||
-- Edge Function schema lives in supabase/legacy/ and is retired.
|
||||
--
|
||||
-- Apply with: supabase db push (or paste into the SQL editor — see
|
||||
-- docs/launch/waitlist-setup.md, which mirrors this file exactly).
|
||||
|
||||
create table if not exists public.waitlist (
|
||||
id bigint generated always as identity primary key,
|
||||
email text not null,
|
||||
source text,
|
||||
referrer text, -- document.referrer (analytics only)
|
||||
referral_code text not null, -- THIS user's own share code
|
||||
referred_by text, -- the code that brought them in
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create unique index if not exists waitlist_email_key
|
||||
on public.waitlist (lower(email));
|
||||
|
||||
create unique index if not exists waitlist_referral_code_key
|
||||
on public.waitlist (referral_code);
|
||||
create index if not exists waitlist_referred_by_idx
|
||||
on public.waitlist (referred_by);
|
||||
|
||||
alter table public.waitlist enable row level security;
|
||||
|
||||
drop policy if exists "anon can insert" on public.waitlist;
|
||||
create policy "anon can insert"
|
||||
on public.waitlist
|
||||
for insert
|
||||
to anon
|
||||
with check (true);
|
||||
|
||||
create or replace function public.gen_referral_code()
|
||||
returns text
|
||||
language plpgsql
|
||||
as $$
|
||||
declare
|
||||
alphabet constant text := 'abcdefghjkmnpqrstuvwxyz23456789';
|
||||
code text;
|
||||
i int;
|
||||
begin
|
||||
loop
|
||||
code := '';
|
||||
for i in 1..7 loop
|
||||
code := code || substr(alphabet, 1 + floor(random() * length(alphabet))::int, 1);
|
||||
end loop;
|
||||
exit when not exists (select 1 from public.waitlist where referral_code = code);
|
||||
end loop;
|
||||
return code;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.join_waitlist(
|
||||
p_email text,
|
||||
p_referred_by text default null,
|
||||
p_referrer text default null
|
||||
)
|
||||
returns table (referral_code text, referrals int, duplicate boolean)
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_email text := lower(trim(p_email));
|
||||
v_code text;
|
||||
v_ref text := nullif(trim(coalesce(p_referred_by, '')), '');
|
||||
v_dup boolean := false;
|
||||
begin
|
||||
if v_email !~ '^[^@\s]+@[^@\s]+\.[^@\s]+$' then
|
||||
raise exception 'invalid email';
|
||||
end if;
|
||||
|
||||
select w.referral_code into v_code
|
||||
from public.waitlist w
|
||||
where lower(w.email) = v_email
|
||||
limit 1;
|
||||
|
||||
if v_code is null then
|
||||
v_code := public.gen_referral_code();
|
||||
insert into public.waitlist (email, source, referrer, referral_code, referred_by)
|
||||
values (v_email, 'vestige-launch', p_referrer, v_code,
|
||||
case when v_ref = v_code then null else v_ref end);
|
||||
else
|
||||
v_dup := true;
|
||||
end if;
|
||||
|
||||
return query
|
||||
select v_code,
|
||||
(select count(*)::int from public.waitlist where referred_by = v_code),
|
||||
v_dup;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.referral_count(p_code text)
|
||||
returns integer
|
||||
language sql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
select count(*)::int from public.waitlist where referred_by = p_code;
|
||||
$$;
|
||||
|
||||
create or replace function public.waitlist_count()
|
||||
returns integer
|
||||
language sql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
select count(*)::int from public.waitlist;
|
||||
$$;
|
||||
|
||||
grant execute on function public.join_waitlist(text, text, text) to anon;
|
||||
grant execute on function public.referral_count(text) to anon;
|
||||
grant execute on function public.waitlist_count() to anon;
|
||||
Loading…
Add table
Add a link
Reference in a new issue