Prompt Pack
This Prompt Pack provides a complete 10-phase step-by-step instruction sequence for Bolt.new to scaffold Aura, a production Tinder-style social matching app. Each prompt targets an isolated product phase—covering project foundation, database schema, Row Level Security, geolocation logic, matching, and monetization.
Aura runs on a modern serverless tech stack comprising React with Vite, Tailwind CSS, Supabase (PostgreSQL, PostGIS, Auth, Realtime, Storage), and Vercel for deployment.
Prompt 1
This prompt sets up the entire project foundation — the Supabase client, Sentry error tracking, and a router with placeholder screens for every page in the app. Once it's done, you'll be able to navigate between all the app's routes and see a working skeleton, though nothing is connected to data yet.
Paste everything below into your AI coding tool:
You are building Aura, a Tinder-style social matching app. This is the first prompt — no code exists yet. Scaffold the complete project foundation.
Stack: React with Vite, Tailwind CSS, React Router v6, TanStack Query v5, Supabase JS SDK, Sentry JS SDK.
Do not build: authentication logic, database schema, any real data fetching, or any UI beyond placeholder page headings.
Files to create
src/lib/supabase.ts
Export a single named constant supabaseClient — an initialized Supabase client instance constructed using import.meta.env.VITE_SUPABASE_URL and import.meta.env.VITE_SUPABASE_ANON_KEY. Every module in the project imports this single instance; no other module may initialize a second client.
In development mode (import.meta.env.DEV), log the Supabase project URL to the console on initialization so the developer can confirm the correct project is connected.
src/lib/sentry.ts
Export a function initSentry(): void that initializes the Sentry JS SDK using the DSN from import.meta.env.VITE_SENTRY_DSN. Initialize with:
sendDefaultPii: false
- A
beforeSend hook that removes any breadcrumb entries whose category is 'console' and whose message field contains content, and strips the body field from any request context attached to the event. The hook must not alter or drop the event itself — only scrub those specific fields.
Also export captureError(error: unknown, context?: Record<string, string>): void — a wrapper around the Sentry SDK's captureException call that attaches the context object as extra data when provided.
src/lib/errors.ts
Define the shared error vocabulary for the entire app. All modules throw or return these types; no module surfaces raw Supabase or PostgreSQL error text to the caller.
Export the following, all as named exports:
AppError — base class with fields code: string and message: string. Constructor accepts both.
ValidationError extends AppError
QuotaExhaustedError extends AppError
DuplicateActionError extends AppError
PermissionError extends AppError
AuthError extends AppError
NotFoundError extends AppError
ServiceError extends AppError
src/main.tsx
App entry point. Import and call initSentry() as the very first statement, before any React import or render call. Then wrap <App /> in both <QueryClientProvider> (with a new QueryClient instance) and <AuthProvider> (stub — imported from src/contexts/AuthContext.tsx, defined below). Mount to document.getElementById('root').
src/contexts/AuthContext.tsx
Stub only at this phase. Export AuthProvider as a component that renders its children unconditionally. Export useAuth as a hook that returns { session: null, loading: false }. Both will be replaced in Prompt 2.
src/components/ProtectedRoute.tsx
Stub only at this phase. Render <Outlet /> unconditionally. This will be replaced in Prompt 2.
src/App.tsx
Own the React Router v6 router and all route definitions. Define the following routes:
| Path |
Component |
Protected |
/ |
DiscoveryPage |
Yes |
/login |
LoginPage |
No |
/onboarding |
OnboardingPage |
Yes |
/matches |
MatchListPage |
Yes |
/chat/:matchId |
ChatPage |
Yes |
/profile |
ProfileEditorPage |
Yes |
/settings |
SettingsPage |
Yes |
Protected routes are wrapped in <ProtectedRoute> using React Router's layout route pattern. Unprotected routes render their page component directly.
Placeholder page components
Create one file per page. Each renders a single <h1> containing the page name and nothing else. Files:
src/pages/DiscoveryPage.tsx — heading: "Discovery"
src/pages/LoginPage.tsx — heading: "Login"
src/pages/OnboardingPage.tsx — heading: "Onboarding"
src/pages/MatchListPage.tsx — heading: "Matches"
src/pages/ChatPage.tsx — heading: "Chat"
src/pages/ProfileEditorPage.tsx — heading: "Profile Editor"
src/pages/SettingsPage.tsx — heading: "Settings"
Constraints:
supabaseClient must be exported from src/lib/supabase.ts as a named export, not a default export.
captureError must be exported from src/lib/sentry.ts as a named export.
- All
AppError subclasses must be individually named exports from src/lib/errors.ts.
initSentry() must be called before ReactDOM.createRoot or any equivalent mount call.
- Do not add any other routes, pages, or components beyond what is listed above.
Check before continuing (do this yourself, do not paste it):
- Navigate to each route (e.g.,
/, /login, /matches) and confirm the correct <h1> heading renders with no console errors.
- Open your browser's network tab and confirm requests are going to your Supabase project URL, not a placeholder.
- If something is wrong: tell Bolt "The app has a console error on startup — fix it without changing the file structure or adding new components."
Prompt 2
Run this after Prompt 1 is complete.
This prompt wires up real Google and Facebook sign-in, session management across the whole app, and the protected route guard. It also creates the database trigger that automatically creates a profile row the first time a user signs in. When this is done, clicking "Continue with Google" or "Continue with Facebook" will complete a real OAuth flow and land you inside the protected app.
Paste everything below into your AI coding tool:
You are building Aura, a Tinder-style social matching app. Prompt 1 is complete: the project skeleton exists with src/lib/supabase.ts (exports supabaseClient), src/lib/errors.ts (exports AppError and subclasses), src/lib/sentry.ts (exports captureError), src/contexts/AuthContext.tsx (stub), src/components/ProtectedRoute.tsx (stub), and placeholder pages for all routes. Now build authentication and session management.
Do not build: database schema, profile data fetching, onboarding UI, or any page beyond LoginPage.
Files to create or replace
src/lib/auth.ts
All auth operations in the app go through this module exclusively. Nothing else in the codebase calls supabaseClient.auth directly.
Export the following named functions:
signInWithGoogle(): Promise<void> — initiates the Supabase Google OAuth redirect. On any SDK error, throws AuthError with code: 'oauth_redirect_failed' and a human-readable message. Import AuthError from src/lib/errors.ts.
signInWithFacebook(): Promise<void> — same shape as signInWithGoogle, same error type, code: 'oauth_redirect_failed'.
signOut(): Promise<void> — signs out the current user and clears the local session. On SDK error, throws AuthError with code: 'signout_failed'.
getSession(): Promise<Session | null> — returns the current Supabase session or null. Re-export the Session type from the Supabase JS SDK so callers import it from here.
onAuthStateChange(callback: (session: Session | null) => void): () => void — registers an auth state change listener using the Supabase SDK. Returns an unsubscribe function. The callback receives the new session (or null on sign-out) whenever the auth state changes.
src/contexts/AuthContext.tsx (replaces Prompt 1 stub)
Provide session state to the entire app.
Context value shape: { session: Session | null; loading: boolean }.
AuthProvider component behavior:
- On mount, call
getSession() to get the initial session and set loading: false.
- Subscribe to
onAuthStateChange and update the stored session in state on every change.
- Unsubscribe on unmount.
- While the initial
getSession() call is in flight, loading is true.
Export useAuth(): { session: Session | null; loading: boolean } — a hook that reads the context. If called outside AuthProvider, throw an error with the message 'useAuth must be used within AuthProvider'.
src/components/ProtectedRoute.tsx (replaces Prompt 1 stub)
Read { session, loading } from useAuth().
- While
loading is true: render a centered full-screen spinner. Do not redirect or render children.
- When
loading is false and session is null: redirect to /login using React Router's <Navigate>.
- When
loading is false and session is not null: render <Outlet />.
src/pages/LoginPage.tsx (replaces placeholder)
Replace the placeholder heading with a real login screen. Render:
- A "Continue with Google" button that calls
signInWithGoogle() on click.
- A "Continue with Facebook" button that calls
signInWithFacebook() on click.
- If either call throws an
AuthError, render the error's message field as inline text below the buttons.
- No navigation logic — the OAuth redirect takes over on success.
supabase/migrations/0001_auth_trigger.sql
SQL migration that installs a PostgreSQL trigger on auth.users. On INSERT (new user), it calls a SECURITY DEFINER function named create_skeleton_profile().
create_skeleton_profile() must:
- Insert a row into
profiles with auth_uid set to the new user's ID and created_at set to now(). All other columns take their column defaults (null or the default value defined in Phase 3's schema migration).
- If the insert fails for any reason (including a duplicate key, which should not occur but is guarded), catch the exception and log it using
RAISE LOG without re-raising — the trigger must never block Supabase Auth from completing the sign-in.
- The
profiles table is created in a later migration. Use IF EXISTS guards or EXECUTE with dynamic SQL so this trigger function installs without error even before the profiles table exists. When the table does not exist, the function should log a notice and return without error.
This migration is stored in supabase/migrations/0001_auth_trigger.sql and applied via the Supabase CLI (supabase db push) from the developer's machine.
Constraints:
signInWithGoogle and signInWithFacebook must use the Supabase SDK's OAuth sign-in with redirect — not a popup.
- No component other than
AuthProvider may call onAuthStateChange or getSession.
ProtectedRoute must show a spinner (not a blank screen) while loading is true.
- Do not modify
src/lib/supabase.ts, src/lib/errors.ts, or src/lib/sentry.ts.
- Do not build profile fetching, onboarding routing logic, or post-login redirection beyond what React Router's existing route structure already provides.
Check before continuing (do this yourself, do not paste it):
- Click "Continue with Google" and confirm you are redirected to Google's OAuth screen, then returned to the app with a session.
- Click "Continue with Facebook" and confirm the same flow completes successfully.
- Visiting
/ without a session redirects to /login. Visiting / after signing in renders the "Discovery" placeholder heading.
- After calling sign-out (you can test this via Bolt's console), the session clears and the router redirects to
/login.
- If something is wrong: tell Bolt "The OAuth flow is not completing — check the Supabase redirect URL configuration and fix the auth module without changing other files."
Prompt 3
Run this after Prompt 2 is complete.
This prompt creates every database table, index, and access policy the app depends on. No application code changes — this is entirely SQL migration files. When it's done, the full database structure is in place and access rules are enforced at the database level, so no later prompt can accidentally read data it shouldn't.
Paste everything below into your AI coding tool:
You are building Aura, a Tinder-style social matching app. Prompts 1 and 2 are complete: the project skeleton and OAuth authentication are working. Now create all database tables, indexes, and Row Level Security policies. This prompt is entirely SQL migration files — do not modify any TypeScript files.
Do not build: stored procedures, application logic, or any UI changes.
Files to create
supabase/migrations/0002_schema.sql
Create the following tables in this exact order (to satisfy foreign key dependencies). Column names listed here are the canonical reference — all later stored procedures, RLS policies, and TypeScript types must match these exactly.
profiles
auth_uid uuid PRIMARY KEY REFERENCES auth.users(id)
display_name text
date_of_birth date
gender_identity text
sexual_orientation text
interest_tags text[]
bio text
location geography(Point, 4326)
photo_urls text[]
subscription_tier text NOT NULL DEFAULT 'free'
boost_expires_at timestamptz
suspended boolean NOT NULL DEFAULT false
created_at timestamptz NOT NULL DEFAULT now()
updated_at timestamptz NOT NULL DEFAULT now()
deleted_at timestamptz
swipe_quotas
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
user_id uuid NOT NULL REFERENCES profiles(auth_uid)
quota_date date NOT NULL
swipes_used integer NOT NULL DEFAULT 0
quota_limit integer NOT NULL
reset_at timestamptz NOT NULL
UNIQUE(user_id, quota_date)
swipes
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
swiper_id uuid NOT NULL REFERENCES profiles(auth_uid)
target_id uuid NOT NULL REFERENCES profiles(auth_uid)
direction text NOT NULL
super_like boolean NOT NULL DEFAULT false
created_at timestamptz NOT NULL DEFAULT now()
UNIQUE(swiper_id, target_id)
matches
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
participant_a_id uuid NOT NULL REFERENCES profiles(auth_uid)
participant_b_id uuid NOT NULL REFERENCES profiles(auth_uid)
active boolean NOT NULL DEFAULT true
created_at timestamptz NOT NULL DEFAULT now()
deleted_at timestamptz
conversations
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
match_id uuid NOT NULL UNIQUE REFERENCES matches(id)
created_at timestamptz NOT NULL DEFAULT now()
deleted_at timestamptz
messages
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
conversation_id uuid NOT NULL REFERENCES conversations(id)
sender_id uuid NOT NULL REFERENCES profiles(auth_uid)
content text NOT NULL
sent_at timestamptz NOT NULL DEFAULT now()
deleted boolean NOT NULL DEFAULT false
blocks
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
blocker_id uuid NOT NULL REFERENCES profiles(auth_uid)
blocked_id uuid NOT NULL REFERENCES profiles(auth_uid)
created_at timestamptz NOT NULL DEFAULT now()
UNIQUE(blocker_id, blocked_id)
reports
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
reporter_id uuid NOT NULL REFERENCES profiles(auth_uid)
entity_type text NOT NULL
entity_id uuid NOT NULL
reason text NOT NULL
detail text
created_at timestamptz NOT NULL DEFAULT now()
review_status text NOT NULL DEFAULT 'pending'
supabase/migrations/0003_indexes.sql
Create the following indexes. Use CREATE INDEX CONCURRENTLY for all B-tree indexes (non-blocking). The GIST index on profiles.location must not use CONCURRENTLY — PostGIS GIST indexes do not support concurrent creation.
- GIST index on
profiles(location).
- Composite B-tree index on
swipes(target_id, swiper_id) — this is the reverse lookup index for mutual match detection; the forward direction is already covered by the UNIQUE(swiper_id, target_id) constraint.
- Composite B-tree index on
matches(participant_a_id, participant_b_id).
- B-tree index on
messages(conversation_id).
Do not create redundant indexes for columns already covered by PRIMARY KEY or UNIQUE constraints.
supabase/migrations/0004_rls.sql
Enable RLS on every table: profiles, swipe_quotas, swipes, matches, conversations, messages, blocks, reports.
Create the following policies:
profiles
- SELECT policy for authenticated users:
deleted_at IS NULL AND suspended = false.
- UPDATE policy for authenticated users:
auth_uid = auth.uid() (own row only).
- No INSERT policy from client (the auth trigger handles it). No DELETE policy from client.
swipe_quotas — no client policies. Access is exclusively through SECURITY DEFINER procedures.
swipes — no client policies. Access is exclusively through SECURITY DEFINER procedures.
matches
- SELECT policy for authenticated users:
(auth.uid() = participant_a_id OR auth.uid() = participant_b_id) AND active = true AND deleted_at IS NULL.
- No client INSERT, UPDATE, or DELETE policies.
conversations
- SELECT policy for authenticated users: the conversation's
match_id must reference a match where auth.uid() IN (participant_a_id, participant_b_id), the match's active = true, and the conversation's deleted_at IS NULL. Implement this as a subquery join against matches.
- No client INSERT, UPDATE, or DELETE policies.
messages
- SELECT policy for authenticated users:
auth.uid() must be a participant in the linked conversation's match; the match must be active = true; the conversation must have deleted_at IS NULL; and the message's own deleted = false. Implement using a subquery join through conversations to matches.
- INSERT policy for authenticated users:
sender_id = auth.uid(), and the same active/non-deleted conditions on the linked conversation and match apply. Implement with the same subquery join pattern.
- No UPDATE or DELETE policies from client.
blocks
- SELECT policy for authenticated users:
blocker_id = auth.uid() OR blocked_id = auth.uid().
- No client INSERT policy (insert is via stored procedure).
reports
- INSERT policy for authenticated users:
reporter_id = auth.uid().
- No SELECT policy for authenticated users (admin-only via service-role key).
Constraints:
- Apply migrations in numeric order:
0002, 0003, 0004.
- The migration file for RLS (
0004) must call ALTER TABLE <table> ENABLE ROW LEVEL SECURITY before creating any policy on that table.
- Do not modify any TypeScript files.
- Do not create any stored procedures in this prompt — those come in later prompts.
Check before continuing (do this yourself, do not paste it):
- All three migration files apply to your dev Supabase project with no errors (run
supabase db push from your local machine or confirm via the Supabase SQL editor).
- In the Supabase dashboard → Database → Extensions, PostGIS is listed as enabled.
- In the Supabase dashboard → Database → Tables, all eight tables are present.
- If something is wrong: tell Bolt "Migration [filename] failed with an error — fix only that migration file without changing the others."
Prompt 4
Run this after Prompt 3 is complete.
This prompt builds the onboarding flow — the multi-step form new users complete after signing in for the first time, including the server-enforced age gate. It also creates the shared TypeScript types that every later prompt uses, and the stored procedures for creating and updating a user's location. When this is done, a new user can complete onboarding and have a real profile row in the database.
Paste everything below into your AI coding tool:
You are building Aura, a Tinder-style social matching app. Prompts 1–3 are complete: the project skeleton, OAuth authentication, and the full database schema with RLS are working. Now build the shared TypeScript types, the profile creation stored procedure, and the onboarding UI.
Do not build: photo upload, profile editing beyond initial creation, discovery, or chat.
Files to create or replace
src/lib/types.ts
Canonical TypeScript types for all database shapes. Every other module imports from here — never re-declares inline.
export type SubscriptionTier = 'free' | 'premium';
export type SwipeDirection = 'like' | 'pass' | 'super_like';
export type ReportReason = 'spam' | 'harassment' | 'underage' | 'inappropriate' | 'other';
export type ReviewStatus = 'pending' | 'reviewed' | 'actioned';
export interface Profile {
auth_uid: string;
display_name: string | null;
date_of_birth: string | null; // ISO date string 'YYYY-MM-DD'
gender_identity: string | null;
sexual_orientation: string | null;
interest_tags: string[] | null;
bio: string | null;
photo_urls: string[] | null;
subscription_tier: SubscriptionTier;
boost_expires_at: string | null; // ISO timestamp string
suspended: boolean;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
export interface Match {
id: string;
participant_a_id: string;
participant_b_id: string;
active: boolean;
created_at: string;
deleted_at: string | null;
}
export interface Message {
id: string;
conversation_id: string;
sender_id: string;
content: string;
sent_at: string;
deleted: boolean;
}
export interface Candidate {
profile_id: string;
display_name: string;
age: number;
distance_label: string;
interest_tags: string[];
photo_urls: string[];
boost_active: boolean;
}
export interface DiscoveryResult {
candidates: Candidate[];
quota_exhausted: boolean;
}
export interface SwipeResult {
match_created: boolean;
match_id: string | null;
quota_exhausted: boolean;
}
export interface ProcedureError {
code: 'UNDERAGE' | 'VALIDATION' | 'DUPLICATE' | 'QUOTA_EXHAUSTED' | 'NOT_FOUND' | 'PERMISSION' | 'INTERNAL';
message: string;
}
All stored procedures return a jsonb object with either a data field (success) or an error field of type ProcedureError (failure). This envelope is the universal contract between SQL and TypeScript that all later modules depend on.
supabase/migrations/0005_create_profile_proc.sql
Creates the SECURITY DEFINER stored procedure create_profile(p_auth_uid uuid, p_display_name text, p_date_of_birth date, p_gender_identity text, p_sexual_orientation text, p_interest_tags text[], p_bio text) returning jsonb.
Validation sequence — each check returns { "error": { "code": "...", "message": "..." } } on failure with no writes:
- Confirm
p_auth_uid = auth.uid(). If not, return { "error": { "code": "PERMISSION", "message": "Caller mismatch" } }.
- Calculate age as
DATE_PART('year', AGE(CURRENT_DATE, p_date_of_birth)). If age < 18, return { "error": { "code": "UNDERAGE", "message": "You must be 18 or older to use Aura" } }.
- Validate
p_display_name is non-null and between 1 and 50 characters. On failure, return { "error": { "code": "VALIDATION", "message": "Display name must be 1–50 characters" } }.
- Validate
p_bio is null or LENGTH(p_bio) <= 500. On failure, return { "error": { "code": "VALIDATION", "message": "Bio must be 500 characters or fewer" } }.
- Validate
p_gender_identity is non-null and non-empty. On failure, return { "error": { "code": "VALIDATION", "message": "Gender identity is required" } }.
- Validate
p_sexual_orientation is non-null and non-empty. On failure, return { "error": { "code": "VALIDATION", "message": "Sexual orientation is required" } }.
- Validate
p_interest_tags is null or array_length(p_interest_tags, 1) <= 10. On failure, return { "error": { "code": "VALIDATION", "message": "Maximum 10 interest tags allowed" } }.
On all validations passing: update the skeleton profile row where auth_uid = p_auth_uid, setting all supplied fields and updated_at = now(). Return { "data": { "success": true } }.
supabase/migrations/0006_update_location_proc.sql
Creates update_location(p_lat float8, p_lng float8) returning jsonb as a SECURITY DEFINER procedure.
Validate: p_lat is between -90 and 90 inclusive; p_lng is between -180 and 180 inclusive. On failure, return { "error": { "code": "VALIDATION", "message": "Invalid coordinates" } }.
On success: update profiles.location = ST_MakePoint(p_lng, p_lat)::geography where auth_uid = auth.uid(). Set updated_at = now(). Return { "data": { "success": true } }.
src/lib/profile.ts
Thin wrapper around profile stored procedures and queries. Import supabaseClient from src/lib/supabase.ts. Import error classes from src/lib/errors.ts. Import Profile and ProcedureError from src/lib/types.ts.
Error mapping contract (applies to all functions in this module and is the inherited standard for all later data modules): when the Supabase SDK returns an error (not a procedure-level ProcedureError, but an SDK-level transport or auth error), map it to the appropriate AppError subclass — AuthError for session errors, PermissionError for RLS rejections, ServiceError for all others — and throw it. When the RPC returns { error: ProcedureError }, map code to subclass: UNDERAGE → ValidationError, VALIDATION → ValidationError, DUPLICATE → DuplicateActionError, QUOTA_EXHAUSTED → QuotaExhaustedError, NOT_FOUND → NotFoundError, PERMISSION → PermissionError, INTERNAL → ServiceError. Preserve the procedure's message string in the thrown error.
Export the following named functions:
createProfile(params: { displayName: string; dateOfBirth: string; genderIdentity: string; sexualOrientation: string; interestTags: string[]; bio: string }): Promise<void> — calls the create_profile RPC with p_auth_uid set to the current session's user ID (retrieved from supabaseClient.auth.getSession()). Maps procedure and SDK errors as above. Returns void on success.
getMyProfile(): Promise<Profile> — selects the authenticated user's own profile row using supabaseClient.from('profiles').select('*').eq('auth_uid', <session user id>).single(). Throws NotFoundError if no row is returned (the auth trigger may have failed; calling code redirects to onboarding). Maps SDK errors as above.
updateProfileLocation(lat: number, lng: number): Promise<void> — calls the update_location RPC. Maps errors as above. Returns void on success.
src/pages/OnboardingPage.tsx (replaces placeholder)
Multi-step form rendered as a single page with internal step state. Steps in order:
Step 1 — Date of birth: Date input. On "Next," calculate age client-side. If calculated age < 18, show an inline message "You must be 18 or older to use Aura" and disable the Next button. This is a UX convenience only — the stored procedure enforces the real gate. Do not proceed to Step 2 if client-side age < 18.
Step 2 — Display name and bio: Text input for display name (max 50 characters, show character count), textarea for bio (max 500 characters, show character count).
Step 3 — Gender identity and sexual orientation: Two select/dropdown menus. Options:
- Gender identity: "Man", "Woman", "Non-binary", "Other"
- Sexual orientation: "Straight", "Gay", "Lesbian", "Bisexual", "Pansexual", "Other"
Both are required; Next is disabled until both are selected.
Step 4 — Interest tags: A multi-select UI showing these fixed tags: "Music", "Film", "Travel", "Fitness", "Food", "Art", "Gaming", "Reading", "Outdoors", "Tech". Maximum 10 selectable (selecting an 11th does nothing). Shows selected count.
Step 5 — Submit: A "Create Profile" button. On click, call createProfile with all collected values. While the call is in progress, show a loading state and disable the button. On ValidationError with code matching underage, render "You must be 18 or older to use Aura" with no retry path (the user cannot proceed). On other ValidationError, show the error message with a "Go back" link that returns to the relevant step. On ServiceError, show the message and a "Try again" button. On success, navigate to /profile.
Back buttons on steps 2–4 return to the previous step.
Constraints:
src/lib/types.ts must export every interface and type exactly as specified — field names, field types, and nullability must match exactly.
src/lib/profile.ts must follow the error mapping contract exactly; no raw Supabase error strings may reach calling code.
- Do not modify
src/lib/supabase.ts, src/lib/auth.ts, src/lib/errors.ts, src/lib/sentry.ts, or any migration files from Prompts 1–3.
- Do not build photo upload in this prompt — the profile editor in the next prompt handles that.
Check before continuing (do this yourself, do not paste it):
- Sign in, complete all five onboarding steps with a date of birth indicating age 18, and confirm the app navigates to
/profile (the placeholder) after submission.
- Repeat the flow with a date of birth indicating age 17 and confirm the rejection message appears and the form does not submit.
- In the Supabase dashboard → Table Editor →
profiles, confirm the row for your test user has display_name, date_of_birth, and other fields populated.
- If something is wrong: tell Bolt "The profile creation RPC is returning an error — show me the raw response from the RPC call without changing any other code."
Prompt 5
Run this after Prompt 4 is complete.
This prompt builds photo management and the full profile editor. Users can upload up to 6 photos directly to Supabase Storage, remove photos, and edit their display name, bio, and other fields. The 6-photo limit is enforced by the database, not the browser. When this is done, users have a working profile editor they can return to at any time.
Paste everything below into your AI coding tool:
You are building Aura, a Tinder-style social matching app. Prompts 1–4 are complete: the project skeleton, OAuth, database schema with RLS, shared types, and onboarding are all working. Now build photo upload, Storage integration, and the profile editor.
Do not build: discovery, swipe logic, chat, or matching.
Files to create or replace
supabase/migrations/0007_update_profile_proc.sql
Creates update_profile(p_display_name text, p_bio text, p_interest_tags text[], p_gender_identity text, p_sexual_orientation text, p_photo_urls text[]) returning jsonb as a SECURITY DEFINER procedure for the authenticated user's own profile.
Validation (each returns { "error": { "code": "VALIDATION", "message": "..." } } on failure, no writes):
p_display_name: non-null, 1–50 characters.
p_bio: null or LENGTH(p_bio) <= 500.
p_photo_urls: null or array_length(p_photo_urls, 1) <= 6. If > 6, return { "error": { "code": "VALIDATION", "message": "Maximum 6 photos allowed" } }.
p_interest_tags: null or array_length(p_interest_tags, 1) <= 10.
On success: update all supplied fields on the profile row where auth_uid = auth.uid(), set updated_at = now(), return { "data": { "success": true } }.
src/lib/storage.ts
All Storage operations go through this module. Import supabaseClient from src/lib/supabase.ts. Import error classes from src/lib/errors.ts. Apply the same error mapping contract established in src/lib/profile.ts.
The Storage bucket name is profile-photos. Photo paths follow the pattern {auth_uid}/{uuid}.{extension}, where auth_uid is the current session user's ID and extension is derived from the file's MIME type.
Export the following named functions:
uploadPhoto(file: File): Promise<string> — client-side gates (throw before any Storage call if violated):
- Allowed MIME types:
image/jpeg, image/png, image/webp. If the file's type is not one of these, throw ValidationError with code: 'VALIDATION' and message 'Only JPEG, PNG, and WebP images are supported'.
- Maximum file size: 10 MB (
10 * 1024 * 1024 bytes). If exceeded, throw ValidationError with message 'Image must be 10 MB or smaller'.
After gates pass: generate a UUID for the filename, construct the storage path, upload to the profile-photos bucket using the Supabase Storage SDK. On upload success, retrieve and return the public URL for the uploaded object. On Storage RLS rejection, throw PermissionError. On any other SDK error, throw ServiceError.
deletePhoto(publicUrl: string): Promise<void> — derive the storage path from the public URL by extracting the path segment after the bucket name. Delete the object using the Supabase Storage SDK. On SDK error, throw ServiceError. If the object is already gone, treat as success.
src/lib/profile.ts (add one export to the existing module)
Add the following named export without changing any existing exports:
updateProfile(params: { displayName: string; bio: string; interestTags: string[]; genderIdentity: string; sexualOrientation: string; photoUrls: string[] }): Promise<void> — calls the update_profile RPC. Maps procedure and SDK errors using the same error mapping contract already in this module. Returns void on success.
src/pages/ProfileEditorPage.tsx (replaces placeholder)
On mount, fetch the current user's profile using getMyProfile() wrapped in a TanStack Query useQuery. While loading, show a skeleton/spinner. On NotFoundError, redirect to /onboarding.
Render two sections:
Photos grid:
- Up to 6 photo slots displayed in a grid.
- Occupied slots: show the photo with a remove ("×") button overlaid. Clicking remove calls
deletePhoto(url) then updateProfile with the URL removed from the array. Show a per-slot loading state during the operation.
- Empty slots (up to the 6-photo maximum): show an upload button. Clicking opens a file picker. On file selection, call
uploadPhoto(file) then updateProfile with the new URL appended to the existing array. Show a per-slot loading state during the operation.
- Per-slot errors (from
ValidationError or ServiceError) are shown as text below that slot.
Edit fields:
- Display name (text input, max 50 characters, shows character count).
- Bio (textarea, max 500 characters, shows character count).
- Gender identity (select menu with the same options as onboarding).
- Sexual orientation (select menu with the same options as onboarding).
- Interest tags (multi-select with the same fixed tags as onboarding, max 10).
- "Save changes" button — calls
updateProfile with all current field values plus the current photo_urls array. Shows loading state during call. On ValidationError, shows the error message inline next to the relevant field. On ServiceError, shows the message at the top of the form with a retry option.
After a successful save, invalidate the getMyProfile query so the UI reflects the updated data.
Constraints:
uploadPhoto must validate MIME type and file size client-side before making any Storage SDK call.
deletePhoto must derive the Storage path from the public URL — do not store paths separately.
updateProfile in src/lib/profile.ts must be an addition to the existing module, not a replacement. All exports from Prompt 4 must remain unchanged.
- Do not modify any migration files from Prompts 1–4 or any files in
src/lib/ other than profile.ts and the new storage.ts.
- The
profile-photos Storage bucket must exist in Supabase before this prompt's code runs. Create it manually in the Supabase dashboard → Storage → New Bucket → name: profile-photos, set to Public.
Check before continuing (do this yourself, do not paste it):
- Upload a JPEG photo in the profile editor and confirm it appears in the photo grid and in the Supabase Storage browser under
profile-photos/{your-uid}/.
- Upload 6 photos and attempt to upload a 7th — confirm the stored procedure returns a validation error shown in the UI (not a browser crash).
- Attempt to upload a PDF file — confirm it is rejected client-side before any network request is made.
- Delete a photo and confirm it is removed from both the UI grid and the Supabase Storage browser.
- If something is wrong: tell Bolt "The photo upload is failing — log the raw Storage SDK response for debugging without changing the upload logic."
Prompt 6
Run this after Prompt 5 is complete.
This prompt builds the core product loop — the stored procedures that find nearby candidates and record swipes, the swipe deck UI, and the filter preferences screen. When this is done, signed-in users with complete profiles can swipe on real candidate profiles and see a paywall when their daily limit runs out. Match creation also works end-to-end, though the chat UI comes in the next prompt.
Paste everything below into your AI coding tool:
You are building Aura, a Tinder-style social matching app. Prompts 1–5 are complete: the project skeleton, OAuth, database schema, types, onboarding, and photo/profile editing are all working. Now build the candidate discovery stored procedure, the swipe stored procedure, the filter store, and the discovery and settings UIs.
Do not build: chat, the matches list screen, the Stripe payment flow, or the full paywall modal (create a stub component for the paywall that renders a placeholder message — it will be completed in Prompt 8).
Files to create or replace
supabase/migrations/0008_discovery_proc.sql
Creates discover_candidates(p_lat float8, p_lng float8, p_radius_km float8, p_age_min integer, p_age_max integer, p_gender_prefs text[], p_orientation_filter text, p_interest_tags text[], p_page_offset integer, p_page_size integer DEFAULT 20) as a SECURITY DEFINER function returning jsonb.
Algorithm — each step depends on the previous completing successfully:
Step 1 — Input validation. Validate:
p_lat between -90 and 90; p_lng between -180 and 180.
p_radius_km between 1 and 200.
p_age_min >= 18; p_age_max <= 100; p_age_max >= p_age_min.
p_page_size <= 50.
On any failure, return { "error": { "code": "VALIDATION", "message": "..." } }.
Step 2 — Quota check. Query swipe_quotas where user_id = auth.uid() AND quota_date = CURRENT_DATE. If a row exists and swipes_used >= quota_limit, return { "data": { "candidates": [], "quota_exhausted": true } }. If no row exists for today, the user has not yet swiped today — treat as not exhausted and continue.
Step 3 — Caller profile lookup. Retrieve caller_gender and caller_orientation from profiles where auth_uid = auth.uid().
Step 4 — Candidate query. Query profiles applying all of the following conditions in a single query:
ST_DWithin(location, ST_MakePoint(p_lng, p_lat)::geography, p_radius_km * 1000.0).
auth_uid != auth.uid().
deleted_at IS NULL AND suspended = false.
photo_urls IS NOT NULL AND array_length(photo_urls, 1) > 0.
- Age computed as
DATE_PART('year', AGE(CURRENT_DATE, date_of_birth)) is between p_age_min and p_age_max.
- If
p_gender_prefs is non-null and non-empty: gender_identity = ANY(p_gender_prefs).
- Orientation compatibility: exclude candidates whose
sexual_orientation indicates they are exclusively interested in a gender that does not match caller_gender. Implement this as an inline CASE expression or helper condition. The compatibility rule is: if the candidate's orientation value explicitly excludes the caller's gender (e.g., a candidate who is "Straight" and whose gender_identity is "Woman" would be excluded for a caller whose caller_gender is "Woman"), exclude that candidate. Where orientation is ambiguous or inclusive (e.g., "Bisexual", "Pansexual", "Other"), always include. Use the same gender and orientation string values defined in the onboarding step options.
- Anti-join against
swipes: NOT EXISTS (SELECT 1 FROM swipes WHERE swiper_id = auth.uid() AND target_id = profiles.auth_uid).
- Anti-join against
blocks: NOT EXISTS (SELECT 1 FROM blocks WHERE (blocker_id = auth.uid() AND blocked_id = profiles.auth_uid) OR (blocker_id = profiles.auth_uid AND blocked_id = auth.uid())).
- If
p_interest_tags is non-null and non-empty: profiles.interest_tags && p_interest_tags (array overlap operator).
Step 5 — Ordering. Order by: (boost_expires_at > now()) DESC NULLS LAST, then ST_Distance(location, ST_MakePoint(p_lng, p_lat)::geography) ASC.
Step 6 — Pagination. Apply LIMIT p_page_size OFFSET p_page_offset.
Step 7 — Result construction. For each result row, compute:
age: integer years from date_of_birth.
distance_label: if ST_Distance(location, ST_MakePoint(p_lng, p_lat)::geography) < 1000, use '< 1 km away'; otherwise use ROUND(ST_Distance(...) / 1000.0)::text || ' km away'.
boost_active: boolean, true if boost_expires_at > now().
- Include
auth_uid as profile_id, display_name, interest_tags, photo_urls.
- Do not include
date_of_birth, location, raw coordinates, or any other column not listed above.
Return { "data": { "candidates": [...], "quota_exhausted": false } }.
supabase/migrations/0009_swipe_proc.sql
Creates record_swipe(p_target_id uuid, p_direction text, p_super_like boolean DEFAULT false) returning jsonb as a SECURITY DEFINER function. Runs entirely within a single transaction (BEGIN/COMMIT).
Algorithm:
Step 1 — Validation. Validate p_direction IN ('like', 'pass', 'super_like'). Validate p_target_id != auth.uid(). On failure, return { "error": { "code": "VALIDATION", "message": "..." } }.
Step 2 — Target exists check. Confirm a profiles row exists for p_target_id with deleted_at IS NULL AND suspended = false. If not, return { "error": { "code": "NOT_FOUND", "message": "Profile not found" } }.
Step 3 — Quota lock and initialization. SELECT ... FOR UPDATE the swipe_quotas row for user_id = auth.uid() AND quota_date = CURRENT_DATE. If no row exists, insert one with: quota_limit set to 10 if the caller's subscription_tier = 'free', or 999999 if 'premium' (look up from profiles); swipes_used = 0; reset_at = (CURRENT_DATE + INTERVAL '1 day')::timestamptz. Then re-select with FOR UPDATE.
Step 4 — Quota enforcement. If swipes_used >= quota_limit, return { "data": { "match_created": false, "match_id": null, "quota_exhausted": true } }. Do not insert anything.
Step 5 — Increment quota. Update swipe_quotas set swipes_used = swipes_used + 1 for this row.
Step 6 — Record swipe. Insert into swipes (swiper_id, target_id, direction, super_like, created_at). On uniqueness constraint violation (duplicate key), catch the exception and return { "error": { "code": "DUPLICATE", "message": "Already swiped this profile" } }.
Step 7 — Mutual match check. If p_direction IN ('like', 'super_like'): query swipes for a row where swiper_id = p_target_id AND target_id = auth.uid() AND direction IN ('like', 'super_like'). If found:
- Insert into
matches: participant_a_id = p_target_id (the earlier swiper), participant_b_id = auth.uid().
- Insert into
conversations: match_id = <new match id>.
- Insert into
messages: conversation_id = <new conversation id>, sender_id = auth.uid(), content = 'You matched! Say hello 👋'.
- Return
{ "data": { "match_created": true, "match_id": "<new match uuid>", "quota_exhausted": false } }.
Step 8 — No match. Return { "data": { "match_created": false, "match_id": null, "quota_exhausted": false } }.
src/lib/discovery.ts
Import supabaseClient from src/lib/supabase.ts. Import types from src/lib/types.ts. Import error classes from src/lib/errors.ts. Apply the same error mapping contract from src/lib/profile.ts.
Export:
discoverCandidates(params: { lat: number; lng: number; radiusKm: number; ageMin: number; ageMax: number; genderPrefs: string[]; orientationFilter: string; interestTags: string[]; pageOffset: number }): Promise<DiscoveryResult> — calls the discover_candidates RPC. On procedure error, maps to AppError subclass. On success, returns DiscoveryResult.
recordSwipe(params: { targetId: string; direction: SwipeDirection; superLike?: boolean }): Promise<SwipeResult> — calls the record_swipe RPC. On ProcedureError.code === 'DUPLICATE', throws DuplicateActionError. On success (including quota_exhausted: true in the data), returns SwipeResult — the quota exhausted case is not an error, it is a valid result the calling component handles by showing the paywall.
src/lib/filters.ts
Manages discovery filter preferences stored in localStorage.
Export:
export interface FilterPreferences {
radiusKm: number;
ageMin: number;
ageMax: number;
genderPrefs: string[];
orientationFilter: string;
interestTags: string[];
}
getFilterPreferences(userId: string): FilterPreferences — reads from localStorage key aura_filters_{userId}. Returns defaults { radiusKm: 50, ageMin: 18, ageMax: 99, genderPrefs: [], orientationFilter: '', interestTags: [] } if no stored value is found or parsing fails.
setFilterPreferences(userId: string, prefs: FilterPreferences): void — serializes and writes to localStorage key aura_filters_{userId}.
src/components/PaywallModal.tsx
Stub only at this phase. Renders a placeholder message: "Premium required — payment coming soon." Accepts onDismiss: () => void as a prop. The dismiss button calls onDismiss. This stub will be replaced in Prompt 8.
src/pages/DiscoveryPage.tsx (replaces placeholder)
On mount:
- Request the browser's geolocation using
navigator.geolocation.getCurrentPosition. If denied or unavailable, show a prompt: "Enable location access to find people nearby" with no swipe deck. Do not store coordinates beyond the current component render cycle.
- Read filter preferences using
getFilterPreferences(session.user.id).
- Call
discoverCandidates using the current coordinates and filter preferences with pageOffset: 0.
Deck behavior:
- Render a stack of candidate cards showing: first photo, display name, age, distance label, interest tags.
- Swipe right (or tap a "Like" button) calls
recordSwipe with direction: 'like'.
- Swipe left (or tap a "Pass" button) calls
recordSwipe with direction: 'pass'.
- After each swipe, remove the card from the local deck. When the unviewed card count drops to 3, fetch the next page (
pageOffset incremented by the page size) and append results to the deck.
- On
DuplicateActionError, treat as a no-op (the card is removed without showing an error).
- If
SwipeResult.match_created is true, show an in-app modal: "It's a Match! 🎉" with a "Start chatting" button that navigates to /chat/{match_id} and a "Keep swiping" button that dismisses the modal.
- If
SwipeResult.quota_exhausted is true, render <PaywallModal onDismiss={() => {}} />.
- If
DiscoveryResult.quota_exhausted is true on the initial fetch, render <PaywallModal onDismiss={() => {}} /> immediately.
- Empty state (no candidates, quota not exhausted): "No one nearby — try expanding your distance or filters."
src/pages/SettingsPage.tsx (replaces placeholder)
Read filter preferences via getFilterPreferences(session.user.id). Render controls:
- Distance slider: 1–200 km, current value shown as label.
- Age range: two number inputs or a dual-handle slider, min 18, max 99.
- Gender preference: checkboxes for "Man", "Woman", "Non-binary", "Other". Multiple selectable.
- Interest tags: same multi-select as onboarding, max 10.
- Every change immediately calls
setFilterPreferences — no save button needed.
Constraints:
discover_candidates must never return date_of_birth, location, or raw coordinate values in the result.
record_swipe must run steps 3–8 within a single transaction.
recordSwipe in src/lib/discovery.ts must return SwipeResult (not throw) when quota_exhausted: true is in the result data — the component, not the library, decides to show the paywall.
- Do not modify any migration files from Prompts 1–5, or any file in
src/lib/ other than discovery.ts and the new filters.ts.
- Do not build the full Stripe paywall —
PaywallModal is a stub.
Check before continuing (do this yourself, do not paste it):
- With two test accounts that have both completed onboarding and uploaded at least one photo, confirm that Account A sees Account B as a candidate (and vice versa) when they are within the configured radius.
- Swipe right as Account A on Account B, then right as Account B on Account A — confirm exactly one match row and one conversation row appear in the Supabase Table Editor.
- As a free-tier user, perform 10 swipes and confirm the 11th swipe returns
quota_exhausted: true and the stub paywall modal appears (no additional swipe row is created).
- Confirm raw coordinates do not appear anywhere in the candidate cards or browser console from the discovery RPC response.
- If something is wrong: tell Bolt "The discovery or swipe RPC is returning an unexpected result — log the raw RPC response to the console for debugging without changing the procedure logic."
Prompt 7
Run this after Prompt 6 is complete.
This prompt builds the live social experience — the matches list, real-time chat, match notifications, and the unmatch and block actions. When this is done, matched users can send messages to each other that appear instantly in both browser windows, and either user can unmatch or block at any time.
Paste everything below into your AI coding tool:
You are building Aura, a Tinder-style social matching app. Prompts 1–6 are complete: skeleton, OAuth, schema, types, onboarding, photos, discovery, and swipe engine are all working. Now build the matches list, chat screen, Realtime subscriptions, and block/unmatch flows.
Do not build: Stripe integration, account deletion, or security headers.
Files to create or replace
supabase/migrations/0010_block_unmatch_procs.sql
Creates two SECURITY DEFINER stored procedures.
block_user(p_blocked_id uuid) returning jsonb. Runs in a single transaction:
- Validate
p_blocked_id != auth.uid(). On failure, return { "error": { "code": "VALIDATION", "message": "Cannot block yourself" } }.
- Insert into
blocks (blocker_id, blocked_id). On uniqueness constraint violation, catch and continue (already blocked — treat as no-op).
- Query
matches for any row where (participant_a_id = auth.uid() AND participant_b_id = p_blocked_id) OR (participant_a_id = p_blocked_id AND participant_b_id = auth.uid()) and active = true. If found: update matches.active = false, matches.deleted_at = now(); update conversations.deleted_at = now() for the linked match_id.
- Return
{ "data": { "success": true } }.
unmatch(p_match_id uuid) returning jsonb. Runs in a single transaction:
- Verify a match row exists for
p_match_id where auth.uid() IN (participant_a_id, participant_b_id). If not found or caller is not a participant, return { "error": { "code": "NOT_FOUND", "message": "Match not found" } }.
- Update
matches.active = false, matches.deleted_at = now() for id = p_match_id.
- Update
conversations.deleted_at = now() where match_id = p_match_id.
- Return
{ "data": { "success": true } }.
src/lib/matches.ts
Import supabaseClient from src/lib/supabase.ts. Import types from src/lib/types.ts. Import error classes from src/lib/errors.ts. Apply the same error mapping contract from src/lib/profile.ts.
Export:
getMatches(): Promise<Match[]> — selects all rows from matches where auth.uid() IN (participant_a_id, participant_b_id) and active = true and deleted_at IS NULL, ordered by created_at DESC. Returns Match[].
unmatch(matchId: string): Promise<void> — calls the unmatch RPC. Maps errors per contract.
blockUser(blockedId: string): Promise<void> — calls the block_user RPC. Maps errors per contract.
src/lib/messages.ts
Import supabaseClient from src/lib/supabase.ts. Import types from src/lib/types.ts. Import error classes from src/lib/errors.ts. Apply the same error mapping contract from src/lib/profile.ts.
Export:
getMessages(conversationId: string): Promise<Message[]> — selects all messages rows where conversation_id = conversationId and deleted = false, ordered by sent_at ASC. Throws PermissionError if RLS rejects. Returns Message[].
sendMessage(conversationId: string, content: string): Promise<Message> — validates: content.trim() is non-empty and content.length <= 2000 (client-side gate; throw ValidationError with message 'Message cannot be empty' or 'Message must be 2000 characters or fewer'). Inserts a row into messages with conversation_id, sender_id = auth.uid(), content = content.trim(). On RLS rejection, throws PermissionError with message 'Message not delivered — this match may no longer be active'. On other SDK error, throws ServiceError. Returns the inserted row as Message.
getConversationIdForMatch(matchId: string): Promise<string> — selects id from conversations where match_id = matchId and deleted_at IS NULL. Throws NotFoundError if no row is returned. Returns the conversation id string.
subscribeToMessages(conversationId: string, onMessage: (message: Message) => void): () => void — subscribes to Supabase Realtime Postgres Changes INSERT events on the messages table, filtered to conversation_id = conversationId. The subscription's error/reconnect handler must call getMessages(conversationId) to re-fetch from the last known sent_at and deliver any missed messages through onMessage before re-establishing the subscription. Returns an unsubscribe function that removes the channel.
subscribeToMatchNotifications(userId: string, onMatch: (match: Match) => void): () => void — subscribes to Supabase Realtime Postgres Changes INSERT events on the matches table, filtered so that new rows where participant_a_id = userId OR participant_b_id = userId trigger onMatch. Because Supabase Realtime filter expressions support a single equality condition per subscription, implement this as two separate channel subscriptions (one for participant_a_id, one for participant_b_id) and call onMatch from either. Return an unsubscribe function that removes both channels.
src/lib/reports.ts
Import supabaseClient from src/lib/supabase.ts. Import types from src/lib/types.ts. Import error classes from src/lib/errors.ts. Apply the same error mapping contract from src/lib/profile.ts.
Export:
reportEntity(params: { entityType: 'profile' | 'message'; entityId: string; reason: ReportReason; detail?: string }): Promise<void> — inserts a row into reports with reporter_id = auth.uid(), entity_type, entity_id, reason, detail. On RLS rejection, throws PermissionError. On other SDK error, throws ServiceError.
src/pages/MatchListPage.tsx (replaces placeholder)
Fetches matches via getMatches() using TanStack Query with refetchInterval: 30000. While loading, shows a spinner. Empty state: "No matches yet — keep swiping!"
For each match, display a card showing the other participant's display_name and first photo_url. To get the other participant's profile data, fetch it via a getMyProfile-style query scoped to the other participant's auth_uid (select from profiles where auth_uid matches whichever of participant_a_id / participant_b_id is not the current user). Tapping a match card navigates to /chat/{match.id}.
On mount, call subscribeToMatchNotifications(session.user.id, ...). When a new match arrives:
- Invalidate the
getMatches TanStack Query.
- Show a toast notification: "It's a Match! 🎉"
Unsubscribe from the Realtime channel on unmount.
src/pages/ChatPage.tsx (replaces placeholder)
The route provides matchId via useParams().
On mount:
- Call
getConversationIdForMatch(matchId). On NotFoundError, show "Conversation not found" and a back button to /matches.
- Call
getMessages(conversationId) to load history via TanStack Query.
- Call
subscribeToMessages(conversationId, ...). On each incoming message, append to the local message list. Track the subscription error state; when an error occurs, show a "Reconnecting…" banner.
Message list: renders each message, aligning the current user's messages to the right and the other participant's to the left. Scroll to the bottom on mount and on each new message.
Input bar at the bottom: text input and a "Send" button. On send, call sendMessage(conversationId, content). On ValidationError, show the error inline. On PermissionError, show "Message not delivered — this match may no longer be active." Clear the input on successful send.
Overflow menu (three-dot or similar) in the header containing:
- Unmatch: shows a confirmation dialog "Are you sure? This will end your match." Confirming calls
unmatch(matchId) then navigates to /matches. On error, shows the error message.
- Block: shows a confirmation dialog "Block this user? They will no longer appear in your matches or discovery." Confirming calls
blockUser(otherParticipantId) then navigates to /matches. On error, shows the error message.
- Report: opens a modal with a reason selector (options: "Spam", "Harassment", "Underage", "Inappropriate", "Other") and an optional detail text input. Submitting calls
reportEntity with entityType: 'profile' and the other participant's ID. Shows a success confirmation or error inline.
Unsubscribe from the messages Realtime channel on unmount.
Constraints:
subscribeToMessages must handle the reconnection gap-fill: on reconnect, re-fetch messages since the last known sent_at and deliver them through onMessage before re-subscribing.
subscribeToMatchNotifications must use two separate Realtime channel subscriptions to cover both participant columns.
sendMessage must never store raw content in component state before trimming.
- Do not modify any migration files from Prompts 1–6, or any files in
src/lib/ other than the new matches.ts, messages.ts, and reports.ts.
- Do not build Stripe, account deletion, or security headers.
Check before continuing (do this yourself, do not paste it):
- Open two browser windows signed in as two mutually matched users. Send a message as User A and confirm it appears in User B's chat window within 2 seconds without a page refresh.
- Unmatch as User A and confirm both users' match lists no longer show the match, and neither can open the conversation.
- Block User B as User A — confirm User B no longer appears in User A's discovery results (run a new discovery query and check).
- Submit a report and confirm a row appears in the
reports table in the Supabase dashboard with review_status = 'pending'.
- If something is wrong: tell Bolt "The Realtime subscription is not delivering messages — log the channel status and any errors without changing the subscription logic."
Prompt 8
Run this after Prompt 7 is complete.
This prompt wires up Stripe payments — the Edge Function that creates a checkout session, the webhook that upgrades a user's account after payment, and the real paywall modal that replaces the stub from Prompt 6. When this is done, a free-tier user who hits their swipe limit can pay to upgrade and immediately get unlimited swipes.
Paste everything below into your AI coding tool:
You are building Aura, a Tinder-style social matching app. Prompts 1–7 are complete: the full app is working including discovery, swiping, matching, and chat. Now build Stripe Checkout integration: the Edge Functions that create checkout sessions and handle payment webhooks, and the real paywall modal.
Do not build: account deletion, security headers, or any other feature.
Files to create or replace
supabase/functions/create-checkout-session/index.ts
Supabase Edge Function (Deno runtime). Handles authenticated POST requests from the client.
Processing sequence:
- Parse the request's
Authorization header to extract the bearer token. Use SUPABASE_SERVICE_ROLE_KEY (from environment) to initialize a Supabase admin client and verify the JWT. If invalid or missing, return a JSON response with HTTP 401: { "error": { "code": "PERMISSION", "message": "Unauthorized" } }.
- Extract the user's
auth_uid from the verified JWT claims.
- Using
STRIPE_SECRET_KEY (from environment), create a Stripe Checkout session with:
mode: 'subscription'
- A line item referencing the Aura Premium price. The price ID must be read from an environment variable named
STRIPE_PREMIUM_PRICE_ID — do not hardcode it.
client_reference_id set to the user's auth_uid.
success_url read from an environment variable named APP_URL, with ?session=success appended.
cancel_url read from APP_URL with ?session=cancelled appended.
- Return
{ "data": { "url": "<checkout session url>" } } with HTTP 200.
- On any Stripe API error, return
{ "error": { "code": "SERVICE_ERROR", "message": "Payment service unavailable — try again later" } } with HTTP 500.
Add STRIPE_PREMIUM_PRICE_ID and APP_URL to Supabase project secrets before deploying this function.
supabase/functions/stripe-webhook/index.ts
Supabase Edge Function (Deno runtime). Invoked by Stripe via HTTP POST. Must not require authentication headers — Stripe sends raw webhook payloads.
Processing sequence:
- Read the raw request body as text and read the
Stripe-Signature header.
- Verify the webhook signature using
STRIPE_WEBHOOK_SIGNING_SECRET (from environment). If verification fails, log the failure and return HTTP 400 with no body. Do not process the payload.
- Parse the verified event JSON.
- If the event type is not
checkout.session.completed, return HTTP 200 immediately with { "received": true }.
- Extract
client_reference_id from the session object — this is the user's auth_uid.
- Using
SUPABASE_SERVICE_ROLE_KEY, initialize a Supabase admin client. Update the profiles row where auth_uid = client_reference_id: set subscription_tier = 'premium' and updated_at = now().
- Upsert a
swipe_quotas row for user_id = client_reference_id and quota_date = CURRENT_DATE: set quota_limit = 999999. If a row already exists, update only quota_limit.
- Return HTTP 200 with
{ "received": true }.
- On any unhandled exception: log the error to standard output (visible in Supabase Edge Function logs) and return HTTP 500 so Stripe retries.
This function is idempotent: processing the same checkout.session.completed event twice sets the same values twice, with no side effects.
src/lib/stripe.ts
Import supabaseClient from src/lib/supabase.ts. Import error classes from src/lib/errors.ts. Apply the same error mapping contract from src/lib/profile.ts.
Export:
createCheckoutSession(): Promise<string> — retrieves the current session JWT using supabaseClient.auth.getSession(). Calls the create-checkout-session Edge Function via supabaseClient.functions.invoke(...), passing the JWT in the Authorization header. On success, returns the URL string from the response's data.url field. On any error (function invocation failure, non-2xx response, or missing URL in response), throws ServiceError with message 'Payment service unavailable — try again later'.
src/components/PaywallModal.tsx (replaces Prompt 6 stub)
Props: onDismiss: () => void.
Renders:
- A heading: "You've used all your free swipes today"
- Body text: "Upgrade to Premium for unlimited swipes, super likes, and more."
- The free tier daily limit: "Free plan: 10 swipes per day"
- An "Upgrade to Premium" button. On click: call
createCheckoutSession(). While the call is in progress, show a loading spinner on the button and disable it. On success, redirect the browser to the returned URL using window.location.href. On ServiceError, show the error message below the button.
- A "Maybe later" link that calls
onDismiss.
Constraints:
- The
STRIPE_PREMIUM_PRICE_ID and APP_URL values must come from Supabase Edge Function environment secrets — never hardcoded.
- The
stripe-webhook function must not require a Supabase JWT — it is called by Stripe, not the client.
- The
create-checkout-session function must validate the JWT before doing anything else.
createCheckoutSession() in src/lib/stripe.ts must pass the JWT to the Edge Function — the function cannot call auth.uid() without it.
- Do not modify any migration files, or any files in
src/lib/ other than the new stripe.ts.
Check before continuing (do this yourself, do not paste it):
- As a free-tier user, exhaust your daily swipes and confirm the real paywall modal appears (not the old stub) with the "Upgrade to Premium" button.
- Click "Upgrade to Premium" and confirm you are redirected to a Stripe-hosted checkout page.
- Complete a test payment using Stripe's test card number. In the Supabase dashboard → Table Editor →
profiles, confirm subscription_tier changed to 'premium' for your test user.
- In Stripe Dashboard → Developers → Webhooks, confirm the webhook event was delivered with a 200 response.
- If something is wrong: tell Bolt "The webhook is not updating the profile — log the full event payload and Supabase update response inside the Edge Function without changing the webhook logic."
Setup step — do this now, before testing: In Stripe Dashboard → Developers → Webhooks, add your Supabase Edge Function URL as a webhook endpoint. The URL is in your Supabase project → Edge Functions → stripe-webhook → URL. Set it to listen for the checkout.session.completed event. Copy the signing secret it generates and add it as STRIPE_WEBHOOK_SIGNING_SECRET in Supabase project secrets. Also add STRIPE_PREMIUM_PRICE_ID (from your Stripe product's price ID) and APP_URL (your Vercel production domain) to Supabase secrets.
Prompt 9
Run this after Prompt 8 is complete.
This prompt builds the account deletion flow — the Edge Function that permanently erases a user's personal data in compliance with GDPR, and the UI in the Settings screen that lets users request deletion. When this is done, a user can delete their account and all their personal data is removed from the system, while message records are retained (anonymized) for moderation purposes.
Paste everything below into your AI coding tool:
You are building Aura, a Tinder-style social matching app. Prompts 1–8 are complete: the full app including payments is working. Now build the GDPR account deletion Edge Function and the delete account UI in Settings.
Do not build: security headers, error boundaries, or any other feature.
Files to create or replace
supabase/functions/gdpr-erase/index.ts
Supabase Edge Function (Deno runtime). Handles authenticated DELETE requests from the client.
Processing sequence — each step is designed to be safely re-runnable on retry (idempotent):
Step 1 — Auth validation. Parse the Authorization header. Using SUPABASE_SERVICE_ROLE_KEY, verify the JWT and extract user_id. If invalid or missing, return HTTP 401.
Step 2 — Anonymize profile PII. Using the admin client, update the profiles row where auth_uid = user_id: set display_name = '[deleted]', date_of_birth = null, gender_identity = null, sexual_orientation = null, interest_tags = null, bio = null, location = null, photo_urls = null, deleted_at = now(). If the row is already anonymized (fields already null), the update is a no-op — treat as success.
Step 3 — Delete Storage photos. List all objects in the profile-photos bucket under path {user_id}/. For each object found, delete it. If the folder does not exist or is empty, proceed without error.
Step 4 — Anonymize message sender IDs. Using the admin client, update all rows in messages where sender_id = user_id, setting sender_id = '00000000-0000-0000-0000-000000000000'. This UUID does not reference any real profile row. On retry, the WHERE clause sender_id = user_id will match zero rows (already anonymized), so no rows are updated — this is correct behavior.
Step 5 — Delete Auth user. Call the Supabase Admin API to delete the user record for user_id. If the response indicates the user does not exist (already deleted — retry scenario), treat as success.
Step 6 — Return. On completing all steps without exception, return HTTP 200 with { "data": { "success": true } }. If any step throws, log the step name and error to standard output, then return HTTP 500 with { "error": { "code": "INTERNAL", "message": "Erasure incomplete — retry to continue" } }.
src/lib/account.ts
Import supabaseClient from src/lib/supabase.ts. Import signOut from src/lib/auth.ts. Import error classes from src/lib/errors.ts. Apply the same error mapping contract from src/lib/profile.ts.
Export:
deleteAccount(): Promise<void> — retrieves the current JWT from supabaseClient.auth.getSession(). Invokes the gdpr-erase Edge Function via supabaseClient.functions.invoke(...) with a DELETE method, passing the JWT in the Authorization header. On HTTP 200 response, calls signOut() and returns. On any error (invocation failure, non-200 response, or missing success field), throws ServiceError with the message from the response body if available, otherwise 'Account deletion failed — please try again'.
src/pages/SettingsPage.tsx (addition to Prompt 6's version)
Add a "Delete Account" section at the bottom of the existing settings content. Do not remove or replace any existing settings content.
Render:
- A heading: "Delete Account"
- Body text: "Permanently delete your account and all your personal data. This cannot be undone."
- A "Delete My Account" button styled to indicate a destructive action.
On click, show a confirmation dialog (modal or browser confirm — your choice) with the message: "This will permanently delete your account and all your data. This cannot be undone." Two options: "Cancel" (dismisses dialog, no action) and "Delete permanently" (confirms).
On confirmation: call deleteAccount(). While the call is in progress, show a loading state on the button and disable it. On success, the function already handles sign-out and the router redirects to /login. On ServiceError, show the error message and a "Try again" button.
Constraints:
- The
gdpr-erase function must validate the JWT before performing any data operation.
- Steps 2–5 must execute in the order specified — profile anonymization before Storage deletion before message anonymization before Auth user deletion.
- The anonymized
sender_id UUID '00000000-0000-0000-0000-000000000000' must be used exactly as written — this is the canonical anonymization sentinel value for the system.
deleteAccount() must call signOut() only after receiving a successful response — not before.
- Do not modify
src/pages/SettingsPage.tsx in any way that removes or changes existing filter preferences content.
- Do not modify any migration files or any
src/lib/ files other than the new account.ts.
Check before continuing (do this yourself, do not paste it):
- Complete the account deletion flow for a test user and confirm: their
profiles row has display_name = '[deleted]' and all PII fields are null; their Storage folder under profile-photos/{uid}/ is empty; their messages show sender_id = '00000000-0000-0000-0000-000000000000'; and their Supabase Auth user record is gone (Authentication → Users in the dashboard).
- Confirm that after deletion, signing in again with the same Google or Facebook account creates a new user with a fresh
auth_uid (a new profile row) rather than restoring the deleted one.
- Retry the deletion Edge Function for an already-deleted user ID and confirm it returns HTTP 200 (idempotent).
- If something is wrong: tell Bolt "The gdpr-erase function is failing at step [N] — log the step output and error without changing the erasure sequence."
Prompt 10
Run this after Prompt 9 is complete.
This is the final prompt. It adds the security headers Vercel needs to serve the app safely, wraps every screen in an error boundary so a crash in one part of the app doesn't take down the rest, and provides the RLS audit script you'll run manually before going live. When this is done, the app is ready for production launch.
Paste everything below into your AI coding tool:
You are building Aura, a Tinder-style social matching app. Prompts 1–9 are complete: the full app including payments and account deletion is working. Now add security headers, React error boundaries, and the RLS audit script. This is the final prompt.
Do not build: any new features or screens.
Files to create or replace
vercel.json
Configure Vercel deployment. Apply all headers to every route using the glob pattern that matches all paths.
Security headers — use these exact values:
Content-Security-Policy: default-src 'self'; script-src 'self'; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://js.stripe.com; img-src 'self' https://*.supabase.co data:; frame-src https://js.stripe.com; style-src 'self' 'unsafe-inline'
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(self)
SPA fallback: configure a rewrite rule so that all non-asset requests (requests that do not resolve to a file with an extension) are served index.html with HTTP 200. This is required for React Router client-side routing to work correctly on Vercel.
src/components/ErrorBoundary.tsx
React class component error boundary.
Props:
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
State: { hasError: boolean }. Initially { hasError: false }.
Behavior:
componentDidCatch: call captureError(error, { componentStack: info.componentStack ?? '' }) from src/lib/sentry.ts.
getDerivedStateFromError: return { hasError: true }.
- When
hasError is true: render fallback if provided; otherwise render a centered message "Something went wrong — reload the page" with a button labeled "Reload" that calls window.location.reload().
- When
hasError is false: render children.
Export ErrorBoundary as a named export.
src/App.tsx (addition)
Import ErrorBoundary from src/components/ErrorBoundary.tsx. Wrap each route's page component in <ErrorBoundary> so that a runtime error in one screen does not crash the router or any other screen.
The wrapping must be applied at the individual route level — not as a single wrapper around the entire router — so that navigating away from an errored screen clears the error boundary state.
Do not change the route definitions, path strings, or the ProtectedRoute structure from Prompt 2.
supabase/migrations/0011_rls_audit.sql
A SQL script for manual verification only — not a migration to be applied via supabase db push.
Begin the file with this comment block:
-- RLS audit script: run manually in the Supabase SQL editor.
-- Do NOT apply via supabase db push.
-- Purpose: verify that RLS is enabled and at least one policy exists on every application table.
The script should query pg_tables and pg_policies (or the Supabase-specific equivalents) to produce a result set showing, for each of the following tables, whether rowsecurity is enabled and how many policies exist:
profiles, swipe_quotas, swipes, matches, conversations, messages, blocks, reports.
The output should make it immediately obvious if any table has RLS disabled or zero policies.
Constraints:
- The
Content-Security-Policy value must match the string above exactly — do not add, remove, or reorder directives.
ErrorBoundary must be a class component — React error boundaries cannot be function components.
ErrorBoundary must call captureError from src/lib/sentry.ts — do not call the Sentry SDK directly from this component.
- Do not add
vercel.json to .gitignore — it must be committed to the repository.
0011_rls_audit.sql must not be applied as a migration — it is a read-only diagnostic script.
- Do not modify any existing
src/lib/ files, migration files from Prompts 1–9, or any page components.
Check before continuing (do this yourself, do not paste it):
- Deploy to Vercel (push to
main) and open the production URL. In your browser's developer tools → Network tab, click any request to the Aura domain and confirm the response headers include X-Frame-Options: DENY and X-Content-Type-Options: nosniff.
- Navigate directly to
/matches in the browser address bar (not via a link) and confirm the page loads correctly — this verifies the SPA fallback rewrite is working.
- Run
0011_rls_audit.sql in the Supabase SQL editor and confirm every table shows RLS enabled and at least one policy.
- If something is wrong with security headers: tell Bolt "The Vercel deployment is not returning the security headers — check vercel.json without changing any other files."
- If the SPA fallback is not working: tell Bolt "Direct navigation to /matches returns a 404 — fix the rewrite rule in vercel.json only."