"I want to build an app like Tinder. People should be able to create a profile, swipe left or right on others, and message each other if they both match."
Aura: Social Matching App
Architecture Design Document
Architecture Design Document
TL;DR
"The takeaway is that Aura is a production Supabase and Bolt.new architecture blueprint generated by Zalcro. It uses PostgreSQL PostGIS for server-side geolocation filtering and Row Level Security policies to enforce matching logic and swipe quotas, ensuring the frontend never computes spatial data or bypasses access rules."
Aura is a Tinder-style social matching app where users swipe on profiles, chat only after a mutual match, and discover people nearby using server-side location filtering. It's built entirely on Supabase and deployed to Vercel, keeping the infrastructure fully managed so a solo developer can run it without a dedicated ops team. The two most important architectural choices are: all geolocation math runs inside PostgreSQL via PostGIS (never on the client), and all access rules are enforced through Supabase Row Level Security so the frontend can never read data it shouldn't.
Executive Summary
Aura is an MVP-stage B2C matching app built on a Supabase-first backend with a React/Vite/Tailwind frontend generated via Bolt.new and hosted on Vercel. The architecture is deliberately thin: Supabase provides PostgreSQL (with PostGIS), Auth, Storage, and Realtime as a single managed platform, eliminating the need for a separate API server, message broker, or caching layer at this stage.
Core architectural approach: The frontend is a stateless single-page application. All business logic — matching, geolocation filtering, swipe quota enforcement, and moderation gates — runs inside PostgreSQL as stored procedures and Row Level Security (RLS) policies. The client calls Supabase's auto-generated REST or SDK interface; it never performs geolocation math, match eligibility checks, or quota calculations itself. This is the primary anti-hallucination guardrail for Bolt.new: the frontend's job is to render UI and relay user intent to the database.
Key tradeoffs:
- RLS as the security layer means all policy logic lives in SQL. This is powerful but requires careful authoring; a misconfigured policy is invisible at the application layer.
- Supabase Realtime is used for chat delivery. At 500–2,000 concurrent sessions it is within Supabase's managed capacity on a Pro plan, but it is a shared-infrastructure dependency with no automatic failover.
- Soft-delete on unmatch retains conversation rows for moderation while hiding them from both users — this is a data lifecycle choice, not a product feature.
- Freemium quota enforcement lives entirely in the database (a stored procedure checks and decrements a daily swipe counter), so the client cannot bypass it by calling the API directly.
System Overview
graph TD
subgraph client["Client Layer"]
SPA["React SPA\n(Vite + Tailwind)"]
RQ["TanStack Query\n(Server State)"]
SPA --> RQ
end
subgraph auth["Auth"]
SAUTH["Supabase Auth\n(Google + Facebook OAuth)"]
end
subgraph db["Database Layer"]
PG["PostgreSQL 15\n(Supabase Managed)"]
PGIS["PostGIS 3.4\nSpatial Queries"]
RLS["Row Level Security\nPolicies"]
PG --> PGIS
PG --> RLS
end
subgraph procs["Stored Procedures"]
DISC["Discovery Procedure\n(Candidate Queue)"]
SWIPE["Swipe Procedure\n(Quota + Match)"]
BLOCK["Block / Report\nProcedures"]
DISC --> PGIS
SWIPE --> PG
BLOCK --> PG
end
subgraph realtime["Realtime"]
RT["Supabase Realtime\n(Chat + Match Events)"]
end
subgraph storage["Storage"]
STORE["Supabase Storage\n(Profile Photos)"]
end
subgraph edge["Edge Functions"]
STRIPE_EF["Stripe Webhook\nHandler"]
GDPR_EF["GDPR Erasure\nHandler"]
end
subgraph external["External Services"]
GOOGLE["Google OAuth"]
FACEBOOK["Facebook OAuth"]
STRIPE["Stripe Checkout"]
SENTRY["Sentry\n(Error Tracking)"]
end
subgraph hosting["Hosting"]
VERCEL["Vercel CDN\n(Static Deploy)"]
end
SPA -->|"OAuth Redirect"| SAUTH
SAUTH -->|"JWT Session"| SPA
SAUTH --> GOOGLE
SAUTH --> FACEBOOK
RQ -->|"Calls Discovery"| DISC
RQ -->|"Calls Swipe"| SWIPE
RQ -->|"Calls Block/Report"| BLOCK
SPA -->|"Photo Upload"| STORE
SPA -->|"Subscribe"| RT
RT -->|"Broadcast Changes"| PG
STRIPE -->|"Webhook"| STRIPE_EF
STRIPE_EF -->|"Update Tier"| PG
GDPR_EF -->|"Anonymize + Delete"| PG
GDPR_EF -->|"Delete Photos"| STORE
SPA --> SENTRY
VERCEL --> SPA
The system has four logical layers:
1. Client (React SPA on Vercel) The Bolt.new-generated frontend handles all rendering, routing, form state, and user interaction. It communicates with Supabase exclusively through the Supabase JavaScript SDK. It never computes distances, match eligibility, or quota status — it only reads results the database has already computed and sends user actions to stored procedures.
2. Supabase Auth
Handles Google OAuth and Facebook OAuth flows. On successful authentication, Supabase issues a JWT that the client attaches to every subsequent request. RLS policies read the auth.uid() value from that JWT to scope every query to the authenticated user.
3. Supabase PostgreSQL + PostGIS
The single source of truth. Contains all user profiles, match state, swipe history, chat messages, moderation records, and quota counters. PostGIS is installed as an extension and provides the ST_DWithin and ST_MakePoint functions used in the candidate discovery query. Stored procedures enforce swipe quota, mutual match creation, and soft-delete on unmatch. RLS policies enforce data isolation.
4. Supabase Storage Hosts user-uploaded profile photos (up to 6 per user). Access is controlled by Storage RLS policies: a user can read any public profile photo but can only write to their own folder. No server-side image processing occurs at MVP.
5. Supabase Realtime Powers the live chat channel. When a message is inserted into the messages table, Supabase Realtime broadcasts the change to subscribed clients. Both sides of a match subscribe to a channel scoped to their conversation ID. Realtime also broadcasts match notifications when a mutual match row is created.
Data flow for the core swipe loop:
- User opens the discovery screen → client calls the candidate discovery stored procedure, passing the user's current location coordinates and active filters.
- The procedure queries profiles using PostGIS radius filtering, applies age/gender/orientation/interest filters, excludes already-swiped profiles, and returns a ranked page of candidates. No coordinates are returned to the client — only the computed distance label (e.g., "3 km away").
- User swipes right (like) or left (pass) → client calls the swipe stored procedure with the target user ID and direction.
- The swipe procedure checks the daily quota, records the swipe, and checks for a mutual like. If mutual, it creates a match row and inserts a system message into the conversation.
- Supabase Realtime delivers the match notification to both clients.
- Inside the match, users exchange text messages → client inserts a message row. Realtime delivers it to the other subscriber. RLS ensures only match participants can read or write to that conversation.
Architectural Constraints Summary
PostGIS as a non-negotiable: The constraint to run geolocation server-side was explicitly chosen to prevent Bolt.new from generating client-side Haversine math. This drove the decision to encapsulate the entire discovery query inside a PostgreSQL stored procedure. The client passes raw latitude/longitude coordinates; the procedure does all spatial work and returns only display-safe results.
Supabase-only backend: No separate API server, no Redis, no message queue. This constraint directly determines the deployment topology (client + Supabase + Vercel only) and means all business logic must be expressible as SQL, RLS policies, or Supabase Edge Functions for the rare cases SQL is insufficient.
Single-mode profile isolation: Each user has exactly one profile and operates in one mode. RLS policies do not need to handle mode-switching or multi-profile scenarios, which keeps policy logic simple.
COPPA / GDPR / CCPA compliance: Age gating at signup is a hard constraint — users under 18 must be blocked. GDPR right to erasure and CCPA opt-out add data lifecycle requirements: a delete-account flow must hard-delete PII and anonymize retained moderation records. These compliance requirements drove the soft-delete design (retain messages for moderation, hide from users) and the explicit data retention policy described in Data Architecture.
Freemium paywall: The hard paywall on swipe exhaustion means quota enforcement must be server-side and non-bypassable. Storing and decrementing the quota counter in PostgreSQL, inside a stored procedure with RLS preventing direct writes, satisfies this constraint without a separate payment or entitlement service at MVP.
Scale ceiling and recovery posture: 500–2,000 concurrent chat sessions fits within Supabase Pro's Realtime capacity. The 99.9% uptime target is met by Supabase's own SLA on managed infrastructure; the team's recovery posture (best-effort manual within a few hours) is the operational response to any incident beyond Supabase's control. No automated failover is engineered.
Solo developer / small team: Every infrastructure decision favors managed services over self-hosted. Observability is Supabase dashboard + Sentry for frontend; no custom metrics pipeline.
Component Architecture
1. React SPA (Bolt.new / Vite)
Responsibility: Render all UI screens, manage local component state, handle OAuth redirect flows, and relay user actions to Supabase. The SPA is the only component the end user directly interacts with.
Key screens and their data contracts:
- Onboarding / Age Gate: Collects date of birth before profile creation. Calculates age client-side for display only; the stored procedure that creates the profile re-validates age server-side and rejects profiles for users under 18.
- Discovery (Swipe Deck): Calls the candidate discovery stored procedure. Renders the returned candidate cards with photos from Supabase Storage public URLs. Sends swipe actions to the swipe stored procedure.
- Match Notification: Subscribes to a Realtime channel scoped to the authenticated user. Displays an in-app modal when a match row is broadcast.
- Chat: Subscribes to a Realtime channel scoped to the conversation ID. Renders message history fetched on mount. Inserts new messages via the Supabase SDK (RLS enforces participant-only access).
- Profile Editor: Uploads photos directly to Supabase Storage using the SDK's upload operation. Updates profile fields via a stored procedure (not direct table writes, to enforce business rules like photo count limits).
- Settings / Filters: Writes filter preferences to a local profile preferences row. These are read by the discovery stored procedure on the next call.
- Paywall Modal: Rendered client-side when the discovery stored procedure returns a quota-exhausted signal. No quota logic runs in the client — the signal is a flag in the procedure's response.
State management: React Query (TanStack Query v5) for server state (candidate queue, match list, message history). React Context for auth session state. No Redux or global state store.
Routing: React Router v6. Protected routes check for a valid Supabase session; unauthenticated users are redirected to the login screen.
2. Supabase Auth
Responsibility: Issue and validate JWTs for Google OAuth and Facebook OAuth. Manage session refresh. Trigger a database hook on first sign-in to create the user's profile row.
OAuth flow: The client initiates the OAuth redirect via the Supabase SDK. Supabase Auth handles the provider callback, exchanges the code for tokens, and redirects the client back to the app with a session. The client never handles provider tokens directly.
Post-signup hook: On the first successful sign-in for a new user, a Supabase Auth hook (database trigger on auth.users insert) fires a stored procedure that creates a skeleton profile row. The onboarding flow then populates the remaining required fields (age, gender, orientation, photos).
Session handling: The Supabase SDK handles JWT refresh automatically. The client reads auth.uid() from the session; it is also embedded in every JWT and read by RLS policies server-side.
3. Discovery Stored Procedure
Responsibility: Produce a paginated, filtered, distance-ranked list of candidate profiles for a given user. This is the most complex piece of server-side logic in the system.
Algorithm:
- Accept inputs: calling user's latitude and longitude, desired radius in kilometers, age range (min/max), gender preference array, orientation filter, interest/tag array (optional), page cursor (offset), page size (default 20).
- Retrieve the calling user's own profile to get their gender and orientation (used for reciprocal filter logic).
- Query the profiles table WHERE:
ST_DWithin(profile.location, ST_MakePoint(input_lng, input_lat)::geography, radius_meters)— PostGIS radius filter.- Age is within the requested range (computed from stored date of birth).
- Gender matches the calling user's preference.
- Orientation is compatible (mutual visibility rules: a user who is not interested in the calling user's gender is excluded).
- The profile has not already been swiped by the calling user (anti-join against the swipes table).
- The profile is not blocked by or blocking the calling user (anti-join against the blocks table).
- The profile is active (not soft-deleted, not suspended).
- At least one photo exists.
- Order results by
ST_Distanceascending (closest first), then by a secondary boost score (users with active boosts are promoted — boost status is a flag on the profile row with an expiry timestamp). - Return a result set with: candidate profile ID, display name, age, distance label (formatted string, e.g., "2 km away"), interest tags, photo URLs (from Supabase Storage), and a boolean indicating whether the quota is exhausted.
Quota check: Before returning results, the procedure checks the calling user's daily swipe quota. If exhausted, it returns an empty candidate list plus the quota-exhausted flag. The quota counter is stored in a separate swipe_quotas table with a daily reset timestamp.
4. Swipe Stored Procedure
Responsibility: Record a swipe action, enforce quota, and create a match if mutual.
Algorithm:
- Accept inputs: calling user ID (from
auth.uid()), target user ID, direction (like or pass), optional super-like flag. - Check and decrement the daily quota counter. If exhausted, return a quota-exhausted error — do not record the swipe.
- Insert a row into the swipes table (caller, target, direction, timestamp, super-like flag).
- If direction is "like" or "super-like": query the swipes table for a reciprocal like from the target user to the calling user. If found, create a match row (participant A, participant B, created timestamp, active status). Also insert a system-generated opening message row into the messages table for that conversation.
- If a match is created, return a match-created signal with the new match ID. The client uses this to trigger the match notification UI.
Atomicity: Steps 2–5 run inside a single database transaction. If any step fails (e.g., duplicate swipe due to a race condition), the transaction rolls back and the client receives an error signal.
5. Chat / Messaging
Responsibility: Store and deliver text messages between matched users.
Storage: Messages are rows in the messages table, linked to a conversation row (which is linked to a match row). Each message has a sender ID, conversation ID, content text, sent timestamp, and a soft-delete flag.
Delivery: Supabase Realtime listens to INSERT events on the messages table, filtered by conversation ID. Both participants subscribe to the channel for their conversation on screen mount. Messages arrive via the Realtime subscription; the client appends them to the local message list managed by React Query.
Access control: RLS on the messages table allows read and insert only where auth.uid() is one of the two participants in the linked conversation, and the conversation's match is in active status. A soft-deleted (unmatched) conversation can be read by neither participant.
Unmatch behavior: Calling the unmatch stored procedure sets the match row to inactive and sets a deleted_at timestamp on the conversation row. This hides the conversation from both users (RLS filters it out) while retaining the rows for moderation review.
6. Report / Block System
Responsibility: Allow users to flag profiles or messages for moderation, and prevent blocked users from appearing in discovery or messaging.
Block: Inserting a block record (blocker, blocked, timestamp) is handled by a stored procedure. The block is bi-directional in effect: the discovery procedure excludes both directions, and RLS on the matches and messages tables prevents re-contact. The block procedure also soft-deletes any active match between the two users.
Report: Inserting a report record (reporter, reported entity type, reported entity ID, reason, timestamp) is a direct table insert via RLS-gated policy (any authenticated user can create a report against any other user or message ID). Reports are reviewed manually via the Supabase dashboard at MVP. No automated moderation pipeline is built at this stage.
Suspension: An admin (authenticated with a service-role key outside the client app) can set a suspended flag on a profile. The discovery procedure and RLS policies exclude suspended profiles from all queries.
7. Supabase Storage (Photo Management)
Responsibility: Store and serve up to 6 profile photos per user.
Organization: Photos are stored in a bucket with a per-user folder path keyed by the user's auth UID. The Supabase Storage RLS policy allows any authenticated user to read from any folder (photos are public within the app) but restricts writes to the owner's own folder.
Upload flow: The client uploads photos directly to Supabase Storage using the SDK. On successful upload, the returned public URL is written to the profile's photo array (stored as an ordered array column in the profiles table). The profile update stored procedure validates that the array length does not exceed 6.
No server-side processing: Images are stored and served as-is. No resizing, format conversion, or NSFW scanning at MVP. This is a known risk (documented in Risks and Mitigations).
8. Supabase Edge Functions (Minimal Use)
Responsibility: Handle the small number of operations that cannot be expressed purely in SQL or RLS. At MVP this is limited to:
- Payment webhook handler: Receives a webhook from the payment provider (Stripe), verifies the signature, and updates the user's subscription tier and quota limit in the database using the service-role key.
- GDPR erasure handler: Accepts an authenticated deletion request, hard-deletes PII fields from the profile, anonymizes message sender IDs in retained moderation records, and deletes the Supabase Auth user record.
Edge Functions run on Deno (Supabase's runtime). They are invoked via HTTPS — the payment webhook is called by Stripe; the GDPR handler is called by the client after user confirmation.
Data Architecture
Core Tables
| Entity | Attribute | Type / Constraints | Business Rule |
|---|---|---|---|
profiles |
auth_uid, location, photo_urls, subscription_tier |
Foreign Key, PostGIS geography(Point), Array (max 6), Enum |
Stores one row per user. Soft-delete for GDPR erasure flow. |
swipe_quotas |
user_id, swipes_used, quota_limit |
Foreign Key, Integer, Integer | One row per user per day. Managed transactionally by swipe stored procedure. |
swipes |
swiper_id, target_id, direction, super_like |
Foreign Key, Foreign Key, Enum, Boolean | Append-only log of user intent. Indexed for mutual-match lookup. No deletes. |
matches |
participant_a_id, participant_b_id, active |
Foreign Key, Foreign Key, Boolean | One row per mutual match. Soft-deleted on unmatch. |
conversations |
match_id, deleted_timestamp |
Foreign Key, Timestamp | 1:1 relationship with matches. Exists for future group-chat. Soft-deleted on unmatch. |
messages |
conversation_id, sender_id, content_text |
Foreign Key, Foreign Key, Text | Sender ID must match auth.uid(). Soft-deleted on unmatch. |
blocks |
blocker_id, blocked_id |
Foreign Key, Foreign Key | Queried by discovery procedure and RLS to exclude blocked users. |
reports |
reported_entity_type, reported_entity_id, reason |
Enum, UUID, Enum | Reviewed manually at MVP. |
Location Data
Profile location is stored as a PostGIS geography(Point, 4326) column (WGS84 coordinates). The client sends the user's current latitude and longitude when calling the discovery procedure; the procedure uses ST_DWithin for radius filtering and ST_Distance for ordering. Raw coordinates are never returned to the client. A spatial index (GIST) on the location column is required for query performance.
Data Lifecycle and Retention
Active data: All profile, match, and message data is live and RLS-accessible to participants.
Unmatched conversations: The conversation and its messages are soft-deleted (deleted_at set, active flag cleared). RLS hides them from both participants. Retained indefinitely for moderation review at MVP; a future retention policy (e.g., 90 days post-unmatch) can be added via a scheduled Supabase cron job.
Blocked users: Block records are retained indefinitely. The blocked user's profile remains in the database but is excluded from discovery and messaging.
GDPR erasure: On a verified erasure request, the GDPR Edge Function: hard-deletes the profile row's PII fields (name, bio, date of birth, location, photos — replaced with null/anonymized values), deletes photos from Supabase Storage, removes the Supabase Auth user (which invalidates all sessions), and sets an anonymized sender ID on retained message rows. The messages themselves are retained for moderation; the link to a real person is severed.
GDPR portability: A data export Edge Function (future scope, but architecturally planned) queries all tables for the user's auth UID and returns a JSON bundle. At MVP, this is a manual Supabase dashboard export.
CCPA opt-out: Aura does not sell user data. The privacy policy states this. No technical opt-out mechanism is required beyond the erasure flow, which GDPR compliance already provides.
Indexes
profiles.location: GIST spatial index (required for PostGIS performance).profiles.auth_uid: unique B-tree index.swipes.(swiper_id, target_id): composite unique index (prevents duplicate swipes).swipes.(target_id, swiper_id): composite index (mutual match lookup).matches.(participant_a_id, participant_b_id): composite index.messages.conversation_id: B-tree index (message history fetch).blocks.(blocker_id, blocked_id): composite index.
Integration Architecture
Google OAuth
Method: Supabase Auth built-in Google OAuth provider. The client calls the Supabase SDK's OAuth sign-in operation with the Google provider. Supabase handles the full OAuth 2.0 PKCE flow, token exchange, and session issuance.
Auth: Google client ID and secret are stored in Supabase Auth configuration (not in client code). The client never sees the Google access token.
Failure behavior: If Google's OAuth endpoint is unavailable, the redirect fails and the user lands on an error screen with a retry prompt. Facebook Auth remains available as an alternative. Supabase Auth does not retry OAuth flows automatically.
Facebook OAuth
Method: Identical to Google — Supabase Auth built-in Facebook provider. Facebook App ID and secret stored in Supabase Auth configuration.
Failure behavior: Same as Google OAuth — isolated failure; the other provider remains available.
Supabase Realtime
Method: The Supabase SDK's channel subscription API. The client subscribes to a Postgres Changes channel filtered to INSERT events on the messages table for a specific conversation ID, and to INSERT events on the matches table for the authenticated user's ID.
Auth: The subscription is authenticated by the user's JWT. Supabase Realtime validates the JWT and applies RLS before broadcasting changes. A user cannot subscribe to a channel they don't have RLS access to read.
Failure behavior: If the Realtime WebSocket connection drops, the Supabase SDK attempts automatic reconnection with exponential backoff. During the disconnected window, the user does not receive live messages. On reconnect, the client re-fetches the message history from the last known message timestamp to fill the gap. The UI shows a "reconnecting" indicator during the outage.
Scale consideration: At 2,000 concurrent chat sessions (4,000 WebSocket connections — two per conversation), this is within Supabase Pro's Realtime capacity. If the app scales beyond this, Realtime channel limits on the Pro plan (8,000 concurrent connections as of current plan specs) provide headroom.
Stripe (Payment / Freemium Paywall)
Method: Stripe Checkout (hosted payment page). The client redirects to a Stripe-hosted checkout URL generated by a Supabase Edge Function. On successful payment, Stripe calls the Edge Function's webhook endpoint.
Auth: The Edge Function uses the Stripe secret key (stored as a Supabase secret, not in client code) to create checkout sessions. Webhook payloads are verified using the Stripe webhook signing secret before processing.
Failure behavior: If Stripe is unavailable, the checkout redirect fails and the user sees a payment error screen. The database is not updated. The user remains on the free tier. If the webhook is not received (network failure), Stripe retries the webhook for up to 72 hours. The Edge Function is idempotent — duplicate webhook delivery updates the same row to the same value without side effects.
Quota update: On confirmed payment, the webhook handler updates the user's subscription tier and quota limit on the profiles table using the service-role key (bypasses RLS safely in a server-side context).
Sentry (Frontend Error Tracking)
Method: Sentry JavaScript SDK initialized in the React app's entry point. Captures unhandled exceptions, React error boundaries, and manual captureException calls at critical operations (swipe procedure failure, photo upload failure, auth error).
Auth: Sentry DSN is a public key — safe to include in client-side code. No user PII is sent to Sentry; Sentry is configured with sendDefaultPii: false and a beforeSend hook that strips any message content from error payloads.
Failure behavior: If Sentry's ingest endpoint is unavailable, errors are silently dropped. This does not affect app functionality.
Security Architecture
Row Level Security (RLS)
RLS is enabled on every table. No table is accessible without a policy. Key policies:
| Entity | Attribute | Type / Constraints | Business Rule |
|---|---|---|---|
profiles |
auth.uid() |
SELECT, UPDATE |
Any authenticated user can read active/non-suspended profiles. Write own row. |
swipes |
N/A | None (Direct) | No direct client access. Managed via SECURITY DEFINER procedures. |
matches |
participant_a_id, participant_b_id |
SELECT |
Read if participant A or B. No client writes. |
conversations / messages |
match_id, active |
SELECT, INSERT |
Read/insert if active match participant and not soft-deleted. |
blocks |
blocker_id |
SELECT |
Read own records. Inserts via stored procedure. |
reports |
reporter_id |
INSERT |
Any authenticated user can insert. Admin-only reads. |
swipe_quotas |
N/A | None (Direct) | No direct client access. Managed via SECURITY DEFINER procedures. |
Input Validation
- Client-side: Form inputs (age, name, bio) are validated in the React UI for immediate user feedback. This is convenience validation only — it is not a security boundary.
- Server-side (stored procedures): Every stored procedure re-validates its inputs: age ≥ 18 check, text field length limits, enum values for gender/orientation/direction, photo array length ≤ 6, coordinate range validation (lat: -90 to 90, lng: -180 to 180). Invalid inputs cause the procedure to return an error signal without performing any writes.
- Storage uploads: File type is validated client-side (JPEG/PNG/WebP only) and enforced by a Supabase Storage MIME type policy. Maximum file size is 10 MB per photo, enforced by the Storage policy.
Secrets Management
- All secrets (Google OAuth client secret, Facebook App secret, Stripe secret key, Stripe webhook signing secret) are stored as Supabase project secrets and Vercel environment variables. They are never embedded in client-side code.
- The Supabase
anonkey is public (it is safe to expose; RLS is the enforcement layer). Theservice_rolekey is used only in Edge Functions and is never sent to the client. - Sentry DSN is a public ingest key — safe in client code.
Age Gating (COPPA)
The onboarding stored procedure rejects profile creation for users whose date of birth indicates they are under 18 at the time of registration. The client collects the date of birth and sends it to the procedure; the procedure calculates the age using the database's current timestamp (not trusting any client-supplied age value). There is no alternative verification method at MVP — self-reported date of birth with server-side enforcement is the gate.
Content Moderation
At MVP, moderation is manual: reports are reviewed via the Supabase dashboard. Admins can set the suspended flag on a profile, which immediately excludes it from discovery and blocks all messaging (RLS and the discovery procedure both check this flag). No automated scanning of message content or photos at MVP.
Web Security Headers
Deployed on Vercel with the following headers configured in vercel.json:
Content-Security-Policy: restricts script sources to self and the Supabase domain.X-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: disables camera, microphone (not used at MVP).
CORS
Supabase's API accepts requests from the configured allowed origins list (the Vercel production domain and localhost for development). The Supabase anon key scopes requests to the project — requests with an invalid key are rejected.
HTTPS
Enforced by both Vercel (frontend) and Supabase (backend). All traffic is TLS 1.2+. No HTTP fallback.
Error Handling Policy
Errors returned from stored procedures use a structured signal (a status field in the return row) rather than PostgreSQL exception messages. This ensures that raw database error text is never surfaced to the client. The client maps procedure status codes to user-friendly messages. Sentry captures procedure failures as structured events, not raw SQL errors.
Deployment Architecture
Hosting
- Frontend: Vercel. The React/Vite app is deployed as a static site with server-side rendering disabled. Vercel's CDN serves the bundle globally.
- Backend: Supabase cloud (Pro plan). PostgreSQL, Auth, Storage, Realtime, and Edge Functions are all managed by Supabase. No self-hosted Supabase components.
Environments
- Development: Local Vite dev server (
localhost:5173) connected to a Supabase development project. Supabase CLI used for local schema migrations and stored procedure development. Google and Facebook OAuth configured with localhost redirect URIs in their respective developer consoles. - Production: Vercel production deployment connected to the Supabase production project. Environment variables set in Vercel's project settings (Supabase URL, anon key) and Supabase secrets (OAuth secrets, Stripe keys).
No staging environment is built at MVP — the cost and complexity are not justified for a solo/small team. Schema changes are tested in the development Supabase project before being applied to production via Supabase migrations.
CI/CD
- Frontend: Vercel's GitHub integration. Pushing to the
mainbranch triggers an automatic production deploy. Pull request branches get preview deployments automatically. - Database migrations: Managed with the Supabase CLI. The developer runs
supabase db pushfrom a local machine (or CI runner if the team scales) to apply migrations to the production Supabase project. Migrations are stored in the repo undersupabase/migrations/. No automated migration-on-deploy pipeline at MVP — migrations are applied manually before or after a frontend deploy.
Scaling
- Frontend: Vercel's CDN scales automatically. No configuration needed.
- Realtime: Supabase Pro supports up to 8,000 concurrent Realtime connections. At 2,000 concurrent chat sessions (4,000 connections), there is comfortable headroom. If the app grows beyond this, upgrading to Supabase Enterprise or implementing connection multiplexing (one subscription per user rather than per conversation) is the next step.
- Database: Supabase Pro provides a dedicated PostgreSQL instance. The discovery stored procedure is the most query-intensive operation. The PostGIS GIST spatial index on the location column and the composite indexes on the swipes table are the primary performance levers. Connection pooling via Supabase's built-in PgBouncer (in transaction mode) handles burst concurrency.
- Storage: Supabase Storage scales automatically on the Pro plan. No configuration needed for MVP volume.
Observability
- Supabase dashboard: Query performance, database logs, Auth logs, Storage logs, Realtime connection counts, Edge Function invocation logs. Primary operational view for the solo developer.
- Sentry: Frontend error tracking. Alerts configured for error rate spikes on the swipe, auth, and chat flows.
- Vercel dashboard: Deploy logs, function logs (if any), CDN metrics.
- No custom metrics pipeline, alerting infrastructure, or APM at MVP.
Technology Stack
| Technology | Version | Role | Why Chosen |
|---|---|---|---|
| React | 18.3 | UI framework | Bolt.new's default; large ecosystem, component model suits swipe-deck UI |
| Vite | 5.4 | Build tool | Bolt.new's default; fast HMR for development |
| Tailwind CSS | 3.4 | Styling | Bolt.new's default; utility-first suits rapid UI iteration |
| React Router | 6.26 | Client-side routing | De facto standard for React SPAs; v6 data router API handles protected routes cleanly |
| TanStack Query | 5.56 | Server state management | Handles caching, background refetch, and pagination of Supabase query results without a custom store |
| Supabase JS SDK | 2.45 | Supabase client | Official SDK; handles auth session, RLS-gated queries, Storage uploads, Realtime subscriptions |
| Supabase Auth | Managed | Authentication | Built-in OAuth for Google and Facebook; JWT issuance; integrates directly with RLS |
| Supabase PostgreSQL | 15 (Managed) | Primary database | Single source of truth; PostGIS extension available; RLS engine |
| PostGIS | 3.4 | Geolocation matching | Server-side spatial queries via ST_DWithin and ST_Distance; prevents client-side math |
| Supabase Realtime | Managed | Live chat delivery | Postgres Changes subscriptions; built into Supabase; no separate message broker |
| Supabase Storage | Managed | Photo hosting | Built into Supabase; Storage RLS for access control; no CDN configuration needed |
| Supabase Edge Functions | Managed (Deno) | Webhook handling, GDPR erasure | Required for operations needing service-role key outside the client (Stripe webhook, erasure) |
| Stripe | API v1 / Checkout | Payment processing | Hosted checkout reduces PCI scope; webhook pattern fits the serverless model |
| Sentry | 8.x (JS SDK) | Frontend error tracking | Lightweight; integrates with React error boundaries; no infrastructure to manage |
| Vercel | Managed | Frontend hosting | Zero-config for Vite/React; GitHub integration for CI/CD; global CDN |
Key Architectural Decisions
ADR-001: All geolocation logic runs in PostgreSQL via PostGIS, never on the client. Decided to encapsulate the entire candidate discovery query — including radius filtering, distance calculation, and result ordering — inside a PostgreSQL stored procedure using PostGIS functions. The alternative was client-side Haversine math (common in AI-generated code). Client-side math was rejected because it is a known Bolt.new hallucination pattern, it exposes raw user coordinates to the client, and it cannot be enforced by RLS. PostGIS runs inside the trusted database boundary, returns only display-safe distance labels, and produces correct results regardless of what the client sends.
ADR-002: All business logic lives in PostgreSQL stored procedures, not in the React client.
Swipe quota enforcement, mutual match detection, unmatch side effects, and block side effects are all implemented as SECURITY DEFINER stored procedures. The alternative was implementing this logic in the React app and calling the Supabase REST API directly. Client-side logic was rejected because it is bypassable (a user could call the API directly), it is the primary source of Bolt.new hallucinations on business rules, and it cannot be atomically enforced. Stored procedures run in the database transaction context, are not accessible to the client for direct manipulation, and enforce atomicity naturally.
ADR-003: RLS is the sole data access enforcement layer. Every table has RLS enabled, and no data is accessible without a matching policy. The alternative was a traditional API server that enforces access control in application code. An API server was rejected because it would require deployment, scaling, and operational overhead incompatible with the solo developer constraint. RLS runs inside PostgreSQL and enforces policies on every query regardless of how the query is issued, making it a stronger guarantee than application-layer checks.
ADR-004: Soft-delete on unmatch, retaining messages for moderation. When two users unmatch, the conversation and its messages are hidden from both (via soft-delete flag and RLS) but not physically deleted. The alternative was hard-delete on unmatch. Hard-delete was rejected because it would eliminate the evidentiary record needed for moderation and abuse investigation. The compliance requirement (GDPR) is satisfied separately: the erasure flow anonymizes the sender ID on retained messages, severing the link to a real person while keeping the content for safety review.
ADR-005: No staging environment at MVP. Schema changes are developed and tested in a Supabase development project and applied to production manually. The alternative was a full dev/staging/production pipeline with automated migration on deploy. A staging environment was rejected as disproportionate to a solo developer / small team operating at MVP scale. The risk (a bad migration hitting production) is mitigated by testing all migrations in the development project and by Supabase's point-in-time recovery on the Pro plan.
ADR-006: Supabase Edge Functions for Stripe webhook and GDPR erasure only. Edge Functions are used exclusively for two operations that require the service-role key in a server-side context. All other logic lives in SQL. The alternative was using Edge Functions more broadly as a general API layer. Broad Edge Function use was rejected because it adds cold-start latency, Deno-specific development friction, and deployment complexity for a solo developer. SQL stored procedures are simpler to write, test, and debug in the Supabase dashboard.
ADR-007: Freemium quota enforced entirely server-side.
The daily swipe counter is stored in the swipe_quotas table and managed exclusively by the swipe stored procedure with SECURITY DEFINER. The client has no write access to this table. The alternative was a client-side counter with server validation. Client-side enforcement was rejected because any user could bypass it by calling the Supabase REST API directly. The hard paywall on swipe exhaustion is a monetization requirement — it must be non-bypassable.
Error Handling & Failure Modes
Discovery Stored Procedure Failures
Invalid coordinates: If the client sends coordinates outside valid ranges (lat > 90, lng > 180, etc.), the procedure's input validation rejects the call and returns a validation-error signal. The client shows a location error prompt and prompts the user to re-enable location permissions. No database writes occur.
Empty result set: If no candidates match the filters (valid and common), the procedure returns an empty list with a no-candidates signal. The client shows an empty state ("No one nearby — try expanding your distance or filters"). This is not an error — it is a normal outcome.
Quota exhausted: The procedure returns the quota-exhausted signal. The client renders the paywall modal. The user cannot swipe until the quota resets (midnight UTC) or they upgrade.
PostGIS index unavailable: If the spatial index is missing or corrupted, the query still returns correct results but degrades to a sequential scan (very slow at scale). This is detectable via Supabase dashboard slow-query logs. Mitigation: index creation is part of the migration scripts and verified post-deploy.
Swipe Stored Procedure Failures
Duplicate swipe (race condition): The composite unique index on (swiper_id, target_id) prevents duplicate rows. If the client calls the procedure twice quickly (e.g., network retry), the second call hits the uniqueness constraint, the transaction rolls back, and the procedure returns a duplicate-swipe signal. The client treats this as a no-op (the swipe was already recorded).
Transaction failure mid-match: If the match row is created but the system message insert fails, the transaction rolls back entirely. The match is not persisted. The client receives a generic failure signal and can retry. No partial match state is left in the database.
Quota table row missing: If the daily quota row for a user does not exist (e.g., first swipe of the day), the procedure creates it in the same transaction before decrementing. This is an expected initialization path, not a failure.
Realtime / Chat Failures
WebSocket disconnection: The Supabase SDK reconnects automatically with exponential backoff (default: up to 10 retries over ~30 seconds). During the gap, the client shows a "Reconnecting…" banner. On reconnect, the client re-queries the messages table for rows newer than the last received message timestamp, filling any gap. Messages sent during the disconnected window are delivered on reconnection.
Message insert failure: If the RLS check fails (e.g., the match was deactivated between screen load and send), the insert is rejected and the client shows a "Message not delivered — this match may no longer be active" error. The message is not retried automatically.
Realtime broadcast delay: Supabase Realtime is eventually consistent — there is no guaranteed delivery SLA. At MVP volume, broadcast delays are expected to be under 1 second. If a message is not received within 5 seconds of insert (detectable by comparing the sent timestamp to the Realtime delivery timestamp), the client can poll the messages table as a fallback. This fallback is not built at MVP — it is noted as a future resilience improvement.
Auth Failures
OAuth provider unavailable (Google or Facebook): The auth redirect fails. The user sees an error screen with a retry button. The other provider is still available. Supabase Auth does not retry OAuth flows.
JWT expiry during session: The Supabase SDK refreshes JWTs automatically before expiry. If the refresh fails (network issue), the SDK marks the session as invalid and emits an auth state change event. The React app listens for this event and redirects the user to the login screen.
New user hook failure: If the trigger that creates the skeleton profile row fails on first sign-in, the user has a valid Supabase Auth session but no profile. The app detects the missing profile on the first authenticated request and redirects to onboarding. The hook failure is logged to Supabase's function logs and triggers a Sentry alert.
Storage / Photo Upload Failures
Upload failure: If the Supabase Storage upload fails (network error, file size exceeded, unsupported MIME type), the SDK returns an error. The client shows a per-photo error state ("Upload failed — try again") and does not update the profile photo array. The user can retry.
Storage RLS rejection: If the client attempts to upload to another user's folder (should not happen in normal flow, but possible via client manipulation), the Storage RLS policy rejects the request. The client receives a permission-error signal. This is logged in Sentry as an anomaly.
Photo URL broken: If a photo URL in the profile array points to a deleted or inaccessible Storage object, the client renders a placeholder image. The broken URL persists in the database until the user removes the photo from their profile. No automatic cleanup at MVP.
Stripe / Payment Failures
Checkout session creation failure: If the Edge Function fails to create a Stripe checkout session, the client receives an error and shows "Payment unavailable — try again later." The user remains on the free tier.
Webhook delivery failure: Stripe retries failed webhooks for 72 hours. The Edge Function is idempotent. If the webhook is never delivered, the user's tier remains free. The user can contact support for manual resolution. This failure mode is detectable via the Stripe dashboard's webhook delivery log.
Webhook signature verification failure: The Edge Function rejects the payload and returns a 400 (bad request) response to Stripe, which triggers a retry. If retries consistently fail, it indicates a misconfigured signing secret — detectable in Stripe's dashboard and Supabase's Edge Function logs.
GDPR Erasure Failures
Partial erasure: The erasure Edge Function runs sequentially: anonymize profile PII → delete Storage photos → delete Auth user → anonymize message sender IDs. If it fails midway (e.g., Storage delete fails), the Edge Function logs the failure and returns an error to the client. The user is advised to retry. On retry, already-completed steps are idempotent (anonymizing already-null fields or deleting already-deleted files returns success). Full atomicity is not achievable across Supabase services — the Edge Function is designed to be safely re-runnable.
Pre-Launch Verification
- PostGIS: Verify the
ST_DWithinquery returns correct results with known test coordinates before launch. RunEXPLAIN ANALYZEto confirm the spatial index is being used. - RLS policies: Test each policy using Supabase's RLS policy tester with different
auth.uid()values. Verify that a user cannot read another user's messages, swipe data, or reports. - Stripe webhook: Use the Stripe CLI's webhook forwarding (
stripe listen) to simulate payment events against the local Edge Function before pointing to the production endpoint. - OAuth: Complete the full Google and Facebook OAuth flows in the development environment (with localhost redirect URIs) and in a Vercel preview deployment before go-live.
- Quota enforcement: Write a test that calls the swipe procedure more times than the daily limit and confirm the quota-exhausted signal is returned without additional swipes being recorded.
- Age gate: Submit a date of birth indicating age 17 and confirm the profile creation procedure returns a rejection. Submit age 18 and confirm success.
Risks and Mitigations
Risk 1: RLS policy misconfiguration exposes private data. Component: PostgreSQL RLS policies on messages, matches, and swipes tables. Impact: A user could read another user's messages or discover who swiped them — a serious privacy breach that would damage trust and potentially violate GDPR. Mitigation: Every RLS policy is authored with a matching test in the Supabase RLS policy tester using at least three user contexts (owner, stranger, blocked user). Policies are reviewed in code review before any migration is applied to production. The Supabase dashboard's "Run as user" feature is used to manually verify access before launch.
Risk 2: Supabase Realtime connection limits under viral growth. Component: Supabase Realtime, concurrent chat sessions. Impact: If the app unexpectedly scales beyond 8,000 concurrent WebSocket connections (Supabase Pro limit), new connections are refused and users cannot receive live messages. Mitigation: Monitor the Realtime connection count in the Supabase dashboard. At 70% of the limit, open a Supabase Enterprise conversation. Architecturally, the fallback is to switch from per-conversation subscriptions to per-user subscriptions (halving the connection count) — this change is isolated to the client's subscription setup and does not require a backend change.
Risk 3: No server-side photo moderation allows NSFW content at scale. Component: Supabase Storage, profile photos. Impact: Inappropriate or illegal image content can be uploaded and displayed to other users before manual review catches it. At MVP scale with manual moderation, the window of exposure could be hours. Mitigation: The report system gives users an immediate tool to flag content. Reported profiles are reviewed within a best-effort window. Post-MVP, integrate an automated image moderation API (e.g., AWS Rekognition or Google Cloud Vision) called from a Storage trigger or upload Edge Function. The Storage architecture (direct upload to a per-user folder) is compatible with adding a post-upload scan step without restructuring.
Risk 4: Self-reported date of birth is the only age gate. Component: Profile creation stored procedure, COPPA compliance. Impact: A user under 18 can bypass the age gate by entering a false date of birth. If discovered, this is a COPPA violation and reputational risk. Mitigation: The server-side age check ensures the gate cannot be bypassed at the technical layer — a user must actively lie. For MVP, this is the accepted posture. Post-MVP, consider integrating an age verification service (e.g., Veriff or Yoti) for markets where COPPA/GDPR-K enforcement is high. Document the self-reported gate approach in the privacy policy.
Risk 5: Manual database migration process creates a production outage window.
Component: Supabase migrations, deployment process.
Impact: A migration that alters a column type, drops an index, or restructures a table used by the discovery procedure can lock the table and cause downtime. With no staging environment, the first real test is on production data.
Mitigation: Follow additive-only migration practices: add new columns with defaults (never rename or drop immediately), add indexes with CREATE INDEX CONCURRENTLY (non-blocking), and deploy new stored procedure versions alongside old ones before switching the client. Keep a Supabase point-in-time recovery restore point before any non-trivial migration.
Implementation Plan
Implementation Plan
Environment Setup
What you'll need
Before any code gets written, you need a handful of accounts set up and configured correctly. Getting these right upfront means the coding agent has everything it needs to wire the app together without hitting dead ends mid-build.
Accounts to create
- Supabase — supabase.com — This is the backbone of the whole app: it handles your database, user authentication, file storage, live messaging, and the serverless functions that process payments and deletion requests. You'll create two projects here — one for development and one for production.
- Vercel — vercel.com — Hosts the frontend and connects directly to your GitHub repo so every push to
maindeploys automatically. - Stripe — stripe.com — Handles premium subscription payments. You'll use it in test mode during development and flip it to live mode before launch.
- Google Cloud Console — console.cloud.google.com — Needed to set up "Sign in with Google" for your users.
- Facebook for Developers — developers.facebook.com — Needed to set up "Continue with Facebook" for your users.
- Sentry — sentry.io — Catches and reports frontend errors so you know when something breaks in production. Create one project of type "React."
Before you start
Supabase — both projects (repeat these steps for dev and prod):
- Enable the PostGIS extension: go to your Supabase project → Database → Extensions → search for "postgis" → enable it.
- Enable Google as an Auth provider: Authentication → Providers → Google → toggle on. You'll paste in the Client ID and Secret once you have them from Google.
- Enable Facebook as an Auth provider: Authentication → Providers → Facebook → toggle on. You'll paste in the App ID and Secret once you have them from Facebook.
- Set your site URL: Authentication → URL Configuration → Site URL. For dev, set this to
http://localhost:5173. For prod, set it to your Vercel production domain once you have it. - Add redirect URLs: in that same URL Configuration panel, add
http://localhost:5173/**for dev and your Vercel domain with/**for prod. - Enable Realtime on the messages and matches tables: Database → Replication → enable for both tables.
Google Cloud Console:
- Create a new project (or use an existing one).
- Go to APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID.
- Set the application type to "Web application."
- Add
https://<your-supabase-project-ref>.supabase.co/auth/v1/callbackas an authorized redirect URI. Do this for both your dev and prod Supabase projects.
Facebook for Developers:
- Create a new app (choose "Consumer" type).
- Add the Facebook Login product to the app.
- Under Facebook Login → Settings, add
https://<your-supabase-project-ref>.supabase.co/auth/v1/callbackas a valid OAuth redirect URI. Do this for both dev and prod Supabase projects. - Make sure the app is set to Live mode before production launch (it starts in Development mode, which restricts which users can log in).
Stripe:
- Create a product called "Aura Premium" with a monthly recurring price.
- In Stripe Dashboard → Developers → Webhooks, add an endpoint pointing to your Supabase production Edge Function URL for the payment webhook (you'll know this URL after Phase 7). For now, note the webhook signing secret — you'll need it.
- Note your test publishable key, test secret key, and webhook signing secret for dev. You'll swap these for live keys at launch.
Vercel:
- Connect your GitHub repository to a new Vercel project.
- Set the framework preset to Vite.
- Don't set environment variables yet — do that in Phase 1 once you have all the values.
Environment variables
In your local .env file and Vercel project settings:
VITE_SUPABASE_URL— Supabase project dashboard → Settings → API → Project URLVITE_SUPABASE_ANON_KEY— Supabase project dashboard → Settings → API → Project API Keys →anonpublicVITE_SENTRY_DSN— Sentry project → Settings → Client Keys (DSN)VITE_STRIPE_PUBLISHABLE_KEY— Stripe Dashboard → Developers → API keys → Publishable key
In Supabase project secrets (Dashboard → Settings → Edge Functions → Secrets), for both dev and prod:
STRIPE_SECRET_KEY— Stripe Dashboard → Developers → API keys → Secret keySTRIPE_WEBHOOK_SIGNING_SECRET— Stripe Dashboard → Developers → Webhooks → your endpoint → Signing secretSUPABASE_SERVICE_ROLE_KEY— Supabase project dashboard → Settings → API → Project API Keys →service_role(keep this secret — never put it in client code)
Phases
Phase 1: Project Scaffold and Supabase Client Foundation
This phase creates the runnable project skeleton — Vite/React/Tailwind wired up, the Supabase client initialized and exported from a single module, Sentry initialized at the app entry point, and routing in place with a placeholder for every screen the ADD specifies. Every later phase imports from the modules defined here, so the contracts established now are the permanent foundation.
Contracts
src/lib/supabase.ts
Exports a single initialized Supabase client instance.
supabaseClient— a named export of the initialized client, constructed usingVITE_SUPABASE_URLandVITE_SUPABASE_ANON_KEYfrom environment variables. Every module that needs to talk to Supabase imports this single instance — nothing else in the codebase initializes a second client.
src/lib/sentry.ts
Exports the Sentry initialization call. Imported once in src/main.tsx before anything else renders.
- Initializes Sentry with the DSN from
VITE_SENTRY_DSN, setssendDefaultPii: false, and registers abeforeSendhook that removes anymessage-typed breadcrumb content and strips thebodyfield from any request context before the event is sent. - Exports
captureError(error: unknown, context?: Record<string, string>): void— wrapsSentry.captureException, used by all later phases at critical failure points.
src/lib/errors.ts
Defines the shared error vocabulary for the whole app. All modules throw or return these types; no module surfaces raw Supabase or PostgreSQL error text to the caller.
AppError— base class with fieldscode: stringandmessage: string.- Subclasses (all extend
AppError):ValidationError— input failed server-side validation.QuotaExhaustedError— daily swipe quota is exhausted.DuplicateActionError— action was already recorded (duplicate swipe).PermissionError— RLS or Storage policy rejected the request.AuthError— session is invalid or expired.NotFoundError— expected row does not exist.ServiceError— transient or unknown failure from Supabase or a third-party service.
src/main.tsx
App entry point. Imports and calls the Sentry initializer first. Mounts <App /> wrapped in QueryClientProvider (TanStack Query) and AuthProvider (defined in Phase 2). No routing logic lives here.
src/App.tsx
Owns the React Router v6 router and all route definitions. Routes are:
| 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 render <ProtectedRoute> (defined in Phase 2) as a wrapper. Unprotected routes render directly. All page components are placeholder files at this phase — they render their route name as an <h1> and nothing else.
src/components/ProtectedRoute.tsx
Stub only at this phase — renders its children unconditionally. Phase 2 replaces this with real session-checking logic.
Deliverables:
npm run devstarts the local server with no console errors.- Navigating to each route renders the correct placeholder page title.
supabaseClientimport resolves and logs the project URL in dev mode.- Sentry is initialized; a test
captureErrorcall in dev mode appears in the Sentry dashboard.
Phase 2: Authentication and Session Management
This phase wires up Google and Facebook OAuth through Supabase Auth, establishes the AuthContext that every protected route depends on, and implements the ProtectedRoute guard. It also creates the database trigger that creates a skeleton profile row on first sign-in.
Contracts
src/lib/auth.ts
All auth operations go through this module. Nothing in the app calls supabaseClient.auth directly — it always goes through here.
signInWithGoogle(): Promise<void>— initiates the OAuth redirect. On failure, throwsAuthErrorwithcode: 'oauth_redirect_failed'.signInWithFacebook(): Promise<void>— same shape assignInWithGoogle.signOut(): Promise<void>— signs out and clears the local session. On failure, throwsAuthErrorwithcode: 'signout_failed'.getSession(): Promise<Session | null>— returns the current session or null.Sessionis re-exported from the Supabase JS SDK's type surface as-is.onAuthStateChange(callback: (session: Session | null) => void): () => void— registers a listener and returns an unsubscribe function.
src/contexts/AuthContext.tsx
Provides session state to the whole app.
- Context value shape:
{ session: Session | null; loading: boolean }. AuthProvidercomponent: subscribes toonAuthStateChangeon mount, stores the result in state, setsloading: falseonce the initial session check resolves. Cleans up the subscription on unmount.useAuth(): { session: Session | null; loading: boolean }— hook that reads the context. Throws if called outsideAuthProvider.
src/components/ProtectedRoute.tsx (replaces Phase 1 stub)
- Reads
{ session, loading }fromuseAuth(). - While
loadingis true, renders a centered full-screen spinner. - If
sessionis null, redirects to/login. - Otherwise renders
<Outlet />.
supabase/migrations/0001_auth_trigger.sql
A migration file that installs a PostgreSQL trigger on auth.users. On INSERT (new user created), it calls a SECURITY DEFINER function create_skeleton_profile() that inserts a row into profiles with: auth_uid set to the new user's ID, created_at set to now, and all other fields set to null or their column defaults. If the insert fails (e.g., duplicate, which should not happen but is guarded), the function logs the error to Supabase's function log and returns without raising — the trigger must not block Auth from completing.
src/pages/LoginPage.tsx
Renders two buttons: "Continue with Google" (calls signInWithGoogle) and "Continue with Facebook" (calls signInWithFacebook). On error, renders the AppError.message inline. No navigation logic — the OAuth redirect takes over.
Deliverables:
- Clicking "Continue with Google" initiates a redirect to Google's OAuth screen and returns the user to the app with a valid session.
- Clicking "Continue with Facebook" does the same via Facebook.
- A first-time sign-in creates a row in the
profilestable. - Visiting
/without a session redirects to/login. - Visiting
/with a valid session renders the discovery placeholder. signOut()clears the session and the router redirects back to/login.
Phase 3: Database Schema and RLS Policies
This phase creates every table, index, and RLS policy the ADD specifies. Nothing is built on application-layer assumptions — if a policy is wrong here, every subsequent phase that relies on it will be wrong too. This phase is pure SQL migration files; no application code changes.
Contracts
supabase/migrations/0002_schema.sql
Creates the following tables with the exact column names all later phases reference. Column names listed here are the source of truth — stored procedures, RLS policies, and TypeScript types in later phases 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
Creates every index the ADD specifies:
GISTindex onprofiles.location.- Unique B-tree index on
profiles.auth_uid(already the PK, so this is implicit — verify and skip if redundant). - Composite index on
swipes(swiper_id, target_id)(already covered by the UNIQUE constraint — no additional index needed). - Composite index on
swipes(target_id, swiper_id). - Composite index on
matches(participant_a_id, participant_b_id). - B-tree index on
messages(conversation_id). - Composite index on
blocks(blocker_id, blocked_id)(already covered by UNIQUE — verify).
All non-spatial indexes use CREATE INDEX CONCURRENTLY to remain non-blocking in future production migrations.
supabase/migrations/0004_rls.sql
Enables RLS on every table and creates the following policies. Policy names are the canonical string that Supabase stores — other phases reference these by the table behavior, not the policy name string.
profiles
- Read: authenticated users can select rows where
deleted_at IS NULLandsuspended = false. - Write: authenticated users can update only the row where
auth_uid = auth.uid(). No direct insert from client (the auth trigger handles it). No direct delete.
swipes — no client read or write policies. All access is via SECURITY DEFINER procedures.
matches
- Read: authenticated users can select rows where
auth.uid() IN (participant_a_id, participant_b_id)andactive = trueanddeleted_at IS NULL. - No client write policies.
conversations
- Read: authenticated users can select rows where the linked
match_idreferences a match they participate in,active = true, anddeleted_at IS NULL(joined viamatches). - No client write policies.
messages
- Read: authenticated users can select rows where
auth.uid()is a participant in the linked conversation's match, the match is active, the conversation is not soft-deleted, anddeleted = false. - Insert: authenticated users can insert rows where
sender_id = auth.uid()and the same active/non-deleted conversation conditions hold.
blocks
- Read: authenticated users can select rows where
blocker_id = auth.uid()orblocked_id = auth.uid(). - No client write (insert is via stored procedure).
reports
- Insert: any authenticated user can insert a row where
reporter_id = auth.uid(). - No client read (admin only via service-role key).
swipe_quotas — no client read or write policies.
Deliverables:
- All migrations apply cleanly to the dev Supabase project with no errors.
- The Supabase dashboard shows the PostGIS GIST index on
profiles.location. - Querying
messagesas User A returns zero rows from User B's conversations. - Querying
reportsas any authenticated user returns zero rows (insert succeeds; read is denied). - Direct insert into
swipesas an authenticated user is rejected by RLS.
Phase 4: Onboarding and Profile Creation
This phase builds the onboarding flow: the age gate check, the multi-step form that populates the skeleton profile created by the auth trigger, and the create_profile stored procedure that validates and persists the completed profile. It also creates the TypeScript types derived from the database schema so all later phases share a single type definition.
Contracts
src/lib/types.ts
Canonical TypeScript types for all database shapes. Every other module imports from here — never re-declares inline.
type SubscriptionTier = 'free' | 'premium';
type SwipeDirection = 'like' | 'pass' | 'super_like';
type ReportReason = 'spam' | 'harassment' | 'underage' | 'inappropriate' | 'other';
type ReviewStatus = 'pending' | 'reviewed' | 'actioned';
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;
}
interface Match {
id: string;
participant_a_id: string;
participant_b_id: string;
active: boolean;
created_at: string;
deleted_at: string | null;
}
interface Message {
id: string;
conversation_id: string;
sender_id: string;
content: string;
sent_at: string;
deleted: boolean;
}
interface Candidate {
profile_id: string;
display_name: string;
age: number;
distance_label: string;
interest_tags: string[];
photo_urls: string[];
boost_active: boolean;
}
interface DiscoveryResult {
candidates: Candidate[];
quota_exhausted: boolean;
}
interface SwipeResult {
match_created: boolean;
match_id: string | null;
quota_exhausted: boolean;
}
interface ProcedureError {
code: 'UNDERAGE' | 'VALIDATION' | 'DUPLICATE' | 'QUOTA_EXHAUSTED' | 'NOT_FOUND' | 'PERMISSION' | 'INTERNAL';
message: string;
}
All stored procedures return a JSON 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.
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).
Validation sequence (each check returns ProcedureError on failure, no writes occur):
- Confirm
p_auth_uid = auth.uid()— rejects mismatched callers. - Calculate age from
p_date_of_birthusingCURRENT_DATE. If age < 18, return{ error: { code: 'UNDERAGE', message: '...' } }. - Validate
p_display_nameis non-null and 1–50 characters. - Validate
p_biois null or ≤ 500 characters. - Validate
p_gender_identityandp_sexual_orientationare non-null and non-empty. - Validate
p_interest_tagshas at most 10 items.
On success: updates the skeleton profile row (created by the auth trigger) with all supplied fields and sets updated_at = now(). Returns { data: { success: true } }.
src/lib/profile.ts
Thin wrapper around the profile stored procedure and profile queries. Error handling: on any Supabase SDK error, map to the appropriate AppError subclass from src/lib/errors.ts (same error handling contract for all later modules calling Supabase — defined once here, inherited by reference in subsequent phases).
createProfile(params: { displayName: string; dateOfBirth: string; genderIdentity: string; sexualOrientation: string; interestTags: string[]; bio: string }): Promise<void>— calls thecreate_profileRPC. OnProcedureError.code === 'UNDERAGE', throwsValidationError. On other procedure errors, maps to the matchingAppErrorsubclass. On success, returns void.getMyProfile(): Promise<Profile>— selects the authenticated user's own profile row. ThrowsNotFoundErrorif no row exists (indicating the auth trigger failed — this state routes the user back to onboarding).updateProfileLocation(lat: number, lng: number): Promise<void>— calls a stored procedureupdate_location(p_lat float8, p_lng float8)(defined in the same migration as a simpleSECURITY DEFINERupdater for thelocationcolumn only). Validates coordinate ranges in the procedure.
supabase/migrations/0006_update_location_proc.sql
Creates update_location(p_lat float8, p_lng float8) as a SECURITY DEFINER procedure. Validates lat ∈ [-90, 90] and lng ∈ [-180, 180]; returns ProcedureError on out-of-range values. On success, updates profiles.location = ST_MakePoint(p_lng, p_lat)::geography for auth_uid = auth.uid() and returns { data: { success: true } }.
src/pages/OnboardingPage.tsx
Multi-step form (steps rendered in sequence, not separate routes):
- Date of birth — date picker. Client calculates display age only. "Next" is disabled if calculated age < 18 (UX convenience; server enforces the real gate).
- Display name + bio — text inputs with character count.
- Gender identity + sexual orientation — select menus.
- Interest tags — multi-select, max 10.
- Submit — calls
createProfile. OnValidationErrorwith underage message, renders an inline rejection and offers no retry path. On other errors, shows the error message and a retry button.
On success, navigates to /profile to prompt photo upload.
Deliverables:
- Submitting the onboarding form with a date of birth indicating age 17 returns a rejection and does not update the profile row.
- Submitting with age 18 updates the profile row and navigates to
/profile. getMyProfile()returns the populated profile immediately after onboarding completes.updateProfileLocationstores a valid PostGIS point;EXPLAIN ANALYZEon aST_DWithinquery against that point shows the GIST index being used.
Phase 5: Photo Upload and Profile Editor
This phase builds photo management — uploading to Supabase Storage, updating the photo_urls array on the profile, and the profile editor UI where users manage their photos and edit their display fields. The 6-photo limit is enforced by a stored procedure, not the client.
Contracts
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[]) as a SECURITY DEFINER procedure for the authenticated user's own profile.
Validation:
p_display_name: 1–50 characters.p_bio: null or ≤ 500 characters.p_photo_urls: array length ≤ 6. If > 6, returns{ error: { code: 'VALIDATION', message: 'Maximum 6 photos allowed' } }.p_interest_tags: ≤ 10 items.
On success: updates all supplied fields on the profile row, sets updated_at = now(), returns { data: { success: true } }.
src/lib/storage.ts
All Storage operations go through this module.
uploadPhoto(file: File): Promise<string>— uploads to the path{auth_uid}/{uuid}.{ext}in theprofile-photosbucket. The auth UID comes from the active session (retrieved fromsupabaseClient.auth.getSession()). Returns the public URL of the uploaded file on success. ThrowsValidationErrorif the file's MIME type is notimage/jpeg,image/png, orimage/webp, or if the file size exceeds 10 MB (client-side gate; Storage policy is the enforcing layer). ThrowsPermissionErrorif the Storage RLS policy rejects the upload. ThrowsServiceErroron transient failure. Same error handling contract assrc/lib/profile.ts.deletePhoto(publicUrl: string): Promise<void>— derives the storage path from the public URL and removes the object. ThrowsServiceErroron failure.
src/lib/profile.ts (additions to Phase 4 module)
updateProfile(params: { displayName: string; bio: string; interestTags: string[]; genderIdentity: string; sexualOrientation: string; photoUrls: string[] }): Promise<void>— calls theupdate_profileRPC. Maps procedure errors toAppErrorsubclasses.
src/pages/ProfileEditorPage.tsx
Renders the user's current profile (fetched via getMyProfile() with TanStack Query). Sections:
- Photos grid — up to 6 slots. Empty slots show an upload button. Occupied slots show the photo with a delete icon. Upload triggers
uploadPhotothenupdateProfilewith the new URL appended. Delete triggersdeletePhotothenupdateProfilewith the URL removed. Both show per-slot loading and error states. - Display name, bio, interest tags, gender, orientation — editable fields. "Save changes" calls
updateProfilewith the full current photo URL array plus updated text fields. - Error states show the
AppError.messageinline next to the relevant field or slot.
Deliverables:
- Uploading 6 photos succeeds; attempting a 7th is rejected by the stored procedure with a validation error shown to the user.
- Uploading a file with a disallowed MIME type is rejected client-side before the Storage call is made.
- Deleting a photo removes it from Storage and removes its URL from the profile's
photo_urlsarray in the database. - The profile editor loads the user's current data on mount and reflects saves immediately.
Phase 6: Discovery and Swipe Engine
This phase is the core product loop — the stored procedures for candidate discovery and swiping, the swipe deck UI, and the filter/preferences store. The discovery procedure is the most complex SQL in the system; the contracts here are exact about what it receives and returns.
Contracts
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 (in order — each step depends on the previous):
- Validate inputs: coordinates in range,
p_radius_kmbetween 1 and 200,p_age_min≥ 18,p_age_max≤ 100 and ≥p_age_min,p_page_size≤ 50. On failure, return{ error: { code: 'VALIDATION', message: '...' } }. - Check the calling user's quota in
swipe_quotasfor today. Ifswipes_used >= quota_limit, return{ data: { candidates: [], quota_exhausted: true } }. - Retrieve the calling user's
auth_uidascaller_idfromauth.uid(). Retrievecaller_genderandcaller_orientationfromprofileswhereauth_uid = caller_id. - Query
profileswith all of the following conditions applied together:ST_DWithin(location, ST_MakePoint(p_lng, p_lat)::geography, p_radius_km * 1000).auth_uid != caller_id.deleted_at IS NULLandsuspended = false.photo_urls IS NOT NULLandarray_length(photo_urls, 1) > 0.- Age (calculated from
date_of_birth) betweenp_age_minandp_age_max. gender_identity = ANY(p_gender_prefs)ifp_gender_prefsis non-empty.- Orientation compatibility: the candidate's
sexual_orientationmust be compatible with the caller's gender. (A candidate who is exclusively interested in a gender that does not includecaller_genderis excluded. The exact compatibility mapping — e.g., "straight woman is excluded for a caller who is male if the woman's orientation excludes males" — is implemented as an inline CASE or a helper function within the procedure.) - Anti-join against
swipes: no row exists inswipeswhereswiper_id = caller_id AND target_id = profiles.auth_uid. - Anti-join against
blocks: no row exists inblockswhere(blocker_id = caller_id AND blocked_id = profiles.auth_uid) OR (blocker_id = profiles.auth_uid AND blocked_id = caller_id). - If
p_interest_tagsis non-empty:profiles.interest_tags && p_interest_tags(array overlap, not exact match).
- Order results:
boost_expires_at > now()descending (boosted profiles first), thenST_Distance(location, ST_MakePoint(p_lng, p_lat)::geography)ascending. - Apply
LIMIT p_page_size OFFSET p_page_offset. - For each result row, compute:
ageas integer years fromdate_of_birth,distance_labelas a formatted string ('< 1 km away'if distance < 1000 m,'X km away'for distances ≥ 1 km rounded to nearest integer). Do not include raw coordinates,date_of_birth, orlocationin the output. - Return
{ data: { candidates: [...], quota_exhausted: false } }. Each candidate object matches theCandidateinterface insrc/lib/types.ts.
supabase/migrations/0009_swipe_proc.sql
Creates record_swipe(p_target_id uuid, p_direction text, p_super_like boolean DEFAULT false) as a SECURITY DEFINER function returning jsonb. Runs entirely within a single transaction.
Algorithm (in order):
- Validate
p_directionis one of'like','pass','super_like'. Validatep_target_id != auth.uid(). On failure, return{ error: { code: 'VALIDATION', message: '...' } }. - Check that the target profile exists, is not suspended, and is not soft-deleted. If not found, return
{ error: { code: 'NOT_FOUND', message: '...' } }. - Lock and read the
swipe_quotasrow foruser_id = auth.uid()and today's date usingSELECT ... FOR UPDATE. If no row exists, insert one withquota_limitcopied fromprofiles.subscription_tier(free = 10 swipes/day, premium = unlimited — represented as a very large integer, e.g., 999999),swipes_used = 0,quota_date = CURRENT_DATE,reset_at = (CURRENT_DATE + 1)::timestamptz. - If
swipes_used >= quota_limit, return{ data: { match_created: false, match_id: null, quota_exhausted: true } }without inserting anything. - Increment
swipes_usedby 1. - Insert into
swipes (swiper_id, target_id, direction, super_like, created_at). On uniqueness constraint violation (duplicate swipe), return{ error: { code: 'DUPLICATE', message: '...' } }. - If
p_directionis'like'or'super_like': queryswipesfor a row whereswiper_id = p_target_id AND target_id = auth.uid() AND direction IN ('like', 'super_like'). If found: a. Insert intomatches (participant_a_id, participant_b_id)where A is the earlier swiper (the target, who liked first) and B is the caller. b. Insert intoconversations (match_id)for the new match. c. Insert intomessages (conversation_id, sender_id, content)a system message:sender_idisauth.uid(), content is'You matched! Say hello 👋'. d. Return{ data: { match_created: true, match_id: '<new match uuid>', quota_exhausted: false } }. - If no match, return
{ data: { match_created: false, match_id: null, quota_exhausted: false } }.
src/lib/discovery.ts
discoverCandidates(params: { lat: number; lng: number; radiusKm: number; ageMin: number; ageMax: number; genderPrefs: string[]; orientationFilter: string; interestTags: string[]; pageOffset: number }): Promise<DiscoveryResult>— calls thediscover_candidatesRPC. Maps procedure errors toAppErrorsubclasses. ReturnsDiscoveryResulton success.recordSwipe(params: { targetId: string; direction: SwipeDirection; superLike?: boolean }): Promise<SwipeResult>— calls therecord_swipeRPC. OnDUPLICATEcode, throwsDuplicateActionError. OnQUOTA_EXHAUSTEDin the result data (not an error code), returns the result withquota_exhausted: true— the caller handles the paywall display. Maps other procedure errors toAppErrorsubclasses.
src/lib/filters.ts
Manages the user's discovery filter preferences. Preferences are stored in localStorage (keyed by auth UID) so they survive page refreshes without a database round-trip. At MVP these are not persisted to the database.
FilterPreferencesinterface:{ radiusKm: number; ageMin: number; ageMax: number; genderPrefs: string[]; orientationFilter: string; interestTags: string[] }.getFilterPreferences(userId: string): FilterPreferences— reads fromlocalStorage, returns defaults (radiusKm: 50, ageMin: 18, ageMax: 99, genderPrefs: [], orientationFilter: '', interestTags: []) if no stored value found.setFilterPreferences(userId: string, prefs: FilterPreferences): void— writes tolocalStorage.
src/pages/DiscoveryPage.tsx
Renders the swipe deck using TanStack Query to fetch candidates via discoverCandidates. Deck behavior:
- Fetches the next page when the remaining unviewed card count drops to 3.
- On swipe right or left, calls
recordSwipeand removes the card from the local deck. - If
SwipeResult.match_createdis true, shows an in-app "It's a Match!" modal with a button to open the chat (navigates to/chat/:matchId). - If
SwipeResult.quota_exhaustedis true, renders the Paywall Modal (a separate component stub — Stripe checkout is wired in Phase 8). - If
DiscoveryResult.quota_exhaustedis true on initial load, renders the Paywall Modal immediately. - Empty state (no candidates and quota not exhausted): renders "No one nearby — try expanding your distance or filters."
- Location permission: on mount, the page requests the browser's geolocation. If denied, shows a prompt to enable location access. Location coordinates are passed directly to
discoverCandidates— they are never stored in component state beyond the current session.
src/pages/SettingsPage.tsx
Renders filter preference controls (distance slider, age range, gender preference checkboxes, interest tag selector). Reads from and writes to getFilterPreferences/setFilterPreferences. Changes take effect on the next discoverCandidates call. No save button — preferences are written to localStorage on every change.
Deliverables:
discover_candidatescalled with valid coordinates returns candidates matching all filter conditions. No raw coordinates appear in the result.discover_candidatescalled with coordinates outside valid ranges returns aVALIDATIONerror.record_swipecalled more times than the daily quota returnsquota_exhausted: trueand does not insert additional swipe rows.record_swipecalled twice for the same target returns aDUPLICATEerror.- A mutual like creates exactly one match row, one conversation row, and one system message row in a single transaction.
- The swipe deck renders candidate cards and removes each on swipe. The paywall modal appears when quota is exhausted.
Phase 7: Matches, Chat, and Realtime
This phase builds the live chat experience — the matches list, the conversation screen, Realtime subscriptions for message delivery and match notifications, and the unmatch and block flows.
Contracts
supabase/migrations/0010_block_unmatch_procs.sql
block_user(p_blocked_id uuid) — SECURITY DEFINER, runs in a single transaction:
- Validate
p_blocked_id != auth.uid(). - Insert into
blocks (blocker_id, blocked_id). On uniqueness violation, treat as no-op and continue. - Find any active match between
auth.uid()andp_blocked_id. If found, setmatches.active = falseandmatches.deleted_at = now(), and setconversations.deleted_at = now()for the linked conversation. - Return
{ data: { success: true } }.
unmatch(p_match_id uuid) — SECURITY DEFINER, runs in a single transaction:
- Verify the match exists and
auth.uid()is a participant. If not, return{ error: { code: 'NOT_FOUND', message: '...' } }. - Set
matches.active = falseandmatches.deleted_at = now(). - Set
conversations.deleted_at = now()for the linked conversation. - Return
{ data: { success: true } }.
src/lib/matches.ts
getMatches(): Promise<Match[]>— selects all active match rows whereauth.uid()is a participant, ordered bycreated_atdescending. Returns an array ofMatchobjects.unmatch(matchId: string): Promise<void>— calls theunmatchRPC. Maps procedure errors toAppErrorsubclasses.blockUser(blockedId: string): Promise<void>— calls theblock_userRPC. Maps procedure errors toAppErrorsubclasses. Same error handling contract assrc/lib/profile.ts.
src/lib/messages.ts
getMessages(conversationId: string): Promise<Message[]>— selects all non-deleted messages for the conversation ordered bysent_atascending. ThrowsPermissionErrorif RLS rejects (match is no longer active or user is not a participant).sendMessage(conversationId: string, content: string): Promise<Message>— inserts a message row withsender_id = auth.uid(),contenttrimmed and validated as non-empty (≤ 2000 characters — client-side gate; no stored procedure wraps this insert, RLS is the enforcing layer). ThrowsValidationErrorif content is empty or over the limit. ThrowsPermissionErrorif RLS rejects. Returns the inserted row.getConversationIdForMatch(matchId: string): Promise<string>— selects theconversations.idfor the givenmatch_id. ThrowsNotFoundErrorif not found.subscribeToMessages(conversationId: string, onMessage: (message: Message) => void): () => void— subscribes to Realtime INSERT events onmessagesfiltered toconversation_id = conversationId. Returns an unsubscribe function. Reconnection and gap-filling behavior: the subscription'sonErrorcallback callsgetMessagesto re-fetch from the last knownsent_attimestamp, then re-subscribes.subscribeToMatchNotifications(onMatch: (match: Match) => void): () => void— subscribes to Realtime INSERT events onmatchesfiltered toparticipant_a_id = auth.uid() OR participant_b_id = auth.uid(). Returns an unsubscribe function.
src/lib/reports.ts
reportEntity(params: { entityType: 'profile' | 'message'; entityId: string; reason: ReportReason; detail?: string }): Promise<void>— inserts a report row wherereporter_id = auth.uid(). ThrowsPermissionErrorif RLS rejects. Same error handling contract assrc/lib/profile.ts.
src/pages/MatchListPage.tsx
Renders the list of active matches fetched via getMatches() using TanStack Query with a 30-second background refetch interval. Each match card shows the other participant's display name and first photo. Tapping a match navigates to /chat/:matchId. subscribeToMatchNotifications is called on mount; on receiving a new match, the query is invalidated to refresh the list and a "It's a Match!" toast is shown.
src/pages/ChatPage.tsx
On mount: resolves the conversationId via getConversationIdForMatch(matchId), then fetches message history via getMessages(conversationId). Subscribes to subscribeToMessages and appends incoming messages to the local list. Shows a "Reconnecting…" banner when the Realtime subscription is in an error state. Renders an input bar; "Send" calls sendMessage. Shows an overflow menu with "Unmatch" (calls unmatch, then navigates to /matches) and "Report" (opens a modal that calls reportEntity) and "Block" (calls blockUser, then navigates to /matches).
Deliverables:
- Two users who have mutually liked each other see each other's match card on the Matches screen.
- Messages sent by one participant appear in the other's chat in real time (tested in two browser tabs).
- After unmatching, both participants' RLS-gated queries return no rows for that conversation.
- After blocking, the blocked user does not appear in the blocker's discovery results.
subscribeToMatchNotificationsfires the callback when a new match row is inserted.
Phase 8: Stripe Integration and Freemium Paywall
This phase wires up Stripe Checkout for premium upgrades, the payment webhook Edge Function that updates the user's tier in the database, and the paywall modal UI. The quota enforcement itself was built in Phase 6 — this phase adds only the payment path that removes the quota gate.
Contracts
supabase/functions/stripe-webhook/index.ts
Supabase Edge Function (Deno runtime). Invoked by Stripe via HTTP POST.
Processing sequence:
- Read the raw request body and the
Stripe-Signatureheader. - Verify the webhook signature using
STRIPE_WEBHOOK_SIGNING_SECRET. If verification fails, return an HTTP 400 response and log the failure. Do not process the payload. - Parse the event. If the event type is not
checkout.session.completed, return an HTTP 200 response immediately (acknowledge without processing — Stripe expects 2xx for unhandled event types). - Extract the
client_reference_idfrom the session object — this is the user'sauth_uid, set when creating the checkout session in step 5 of this phase. - Using
SUPABASE_SERVICE_ROLE_KEY, update theprofilesrow whereauth_uid = client_reference_id: setsubscription_tier = 'premium'and updateupdated_at = now(). Also upsert aswipe_quotasrow for today settingquota_limit = 999999. - Return HTTP 200. The function is idempotent — if the same event fires twice, setting the same tier value twice is a no-op.
On any unhandled exception: log the error (Supabase Edge Function logs) and return HTTP 500 so Stripe retries.
src/lib/stripe.ts
createCheckoutSession(): Promise<string>— calls a Supabase Edge Functioncreate-checkout-session(defined below) to get a Stripe Checkout URL. Returns the URL string. ThrowsServiceErroron failure. Same error handling contract assrc/lib/profile.ts.
supabase/functions/create-checkout-session/index.ts
Supabase Edge Function. Invoked by the authenticated client.
Processing sequence:
- Validate the request has a valid Supabase JWT. Extract
auth.uid()from the JWT claims. If invalid, return HTTP 401. - Using
STRIPE_SECRET_KEY, create a Stripe Checkout session with:mode: 'subscription', the Aura Premium price ID (hardcoded from the Stripe dashboard product created in Environment Setup),client_reference_idset to the user'sauth_uid,success_urlandcancel_urlpointing back to the app's production domain. - Return
{ data: { url: '<checkout session url>' } }as JSON with HTTP 200. On Stripe API failure, return{ error: { code: 'SERVICE_ERROR', message: '...' } }with HTTP 500.
src/components/PaywallModal.tsx
Rendered by DiscoveryPage when quota_exhausted is true. Shows the current daily swipe limit, a description of Premium benefits, and an "Upgrade to Premium" button. Clicking the button calls createCheckoutSession() and redirects the browser to the returned URL. Shows a loading spinner while the session is being created. Shows a ServiceError message if the call fails. Props: onDismiss: () => void.
Deliverables:
- Clicking "Upgrade to Premium" in the paywall modal redirects to a Stripe-hosted checkout page.
- Completing a test payment in Stripe's test mode triggers the webhook, which sets
subscription_tier = 'premium'on the user's profile row (verified in the Supabase dashboard). - After upgrading, the user's
swipe_quotas.quota_limitreflects the premium quota. - A duplicate webhook delivery does not change the profile row to an incorrect state.
- A webhook with an invalid signature is rejected without updating the database.
Phase 9: GDPR Erasure and Account Deletion
This phase implements the user-facing account deletion flow and the Edge Function that executes the erasure sequence the ADD specifies: anonymize profile PII, delete Storage photos, delete the Auth user, and anonymize retained message sender IDs.
Contracts
supabase/functions/gdpr-erase/index.ts
Supabase Edge Function. Invoked by the authenticated client with a DELETE request.
Processing sequence (each step is designed to be safely re-runnable on retry):
- Validate the request has a valid Supabase JWT. Extract
user_id = auth.uid(). If invalid, return HTTP 401. - Anonymize profile PII: Using
SUPABASE_SERVICE_ROLE_KEY, update theprofilesrow whereauth_uid = user_id: setdisplay_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(). Safe to re-run: already-null fields remain null. - Delete Storage photos: List all objects in the
profile-photosbucket under the path{user_id}/. Delete each object. If the folder is already empty, this step succeeds immediately. - Anonymize message sender IDs: Update
messageswheresender_id = user_id, settingsender_idto a fixed anonymized UUID ('00000000-0000-0000-0000-000000000000'). This UUID does not reference any real profile. Safe to re-run: already-anonymized rows satisfy the WHERE clause but are not updated again (the conditionsender_id = user_idwon't match after the first run). - Delete Auth user: Call the Supabase Admin API to delete the user by
user_id. This invalidates all sessions. If the user is already deleted (retry scenario), treat the 404 response as success. - Return
{ data: { success: true } }with HTTP 200. On any step failure, log the failure and return{ error: { code: 'INTERNAL', message: 'Erasure incomplete — retry to continue' } }with HTTP 500.
src/lib/account.ts
deleteAccount(): Promise<void>— calls thegdpr-eraseEdge Function. On success, callssignOut()then navigates to/login. On failure, throwsServiceErrorwith the message from the function response. Same error handling contract assrc/lib/profile.ts.
src/pages/SettingsPage.tsx (addition to Phase 6's stub)
Adds a "Delete Account" section at the bottom. Clicking shows a confirmation dialog ("This will permanently delete your account and all your data. This cannot be undone.") with a confirm button. Confirming calls deleteAccount(). Shows a loading state during the call. On ServiceError, shows the error message and a "Try again" button.
Deliverables:
- Completing the deletion flow removes the user's PII fields from the
profilesrow and deletes their Storage photos (verified in the Supabase Storage browser). - Message rows the user sent are retained but have
sender_id = '00000000-0000-0000-0000-000000000000'. - After deletion, the user's Supabase Auth account is gone and they cannot sign back in with the same provider account (until they re-register, which creates a new
auth_uid). - Retrying a partially failed erasure completes successfully.
- The Edge Function rejects unauthenticated requests with HTTP 401.
Phase 10: Security Headers, Error Boundaries, and Pre-Launch Hardening
This phase applies the web security headers the ADD specifies, adds React error boundaries for graceful failure handling, validates all RLS policies against the ADD's access matrix, and runs the pre-launch verification checklist the ADD defines.
Contracts
vercel.json
Configures Vercel deployment with security headers applied to all routes (/(.*) glob). Exact header 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:DENYX-Content-Type-Options:nosniffReferrer-Policy:strict-origin-when-cross-originPermissions-Policy:camera=(), microphone=(), geolocation=(self)
Also sets SPA fallback: all non-asset routes return index.html with HTTP 200 (required for React Router client-side routing to work on Vercel).
src/components/ErrorBoundary.tsx
React class component error boundary. Props: children: ReactNode; fallback?: ReactNode. On error, calls captureError from src/lib/sentry.ts and renders either fallback or a default "Something went wrong — reload the page" message with a reload button.
src/App.tsx (addition)
Wraps each route's page component in <ErrorBoundary> so a crash in one screen does not take down the whole app.
supabase/migrations/0011_rls_audit.sql
Not a schema change — a SQL script (saved under supabase/migrations/ for reproducibility) that queries pg_policies and asserts every table defined in Phase 3 has at least one policy, and that rowsecurity is enabled on each table. Intended to be run as a manual verification step, not applied as a migration. Add a comment at the top: -- RLS audit script: run manually, do not apply via supabase db push.
Pre-launch verification steps (manual, not automated at MVP)
The following are human-executed checks against the dev Supabase project before applying to production. These are not deliverables of the coding agent — they are the developer's checklist:
- Run
EXPLAIN ANALYZEondiscover_candidateswith known test coordinates and confirm the GIST index appears in the plan. - Use Supabase's "Run as user" feature to confirm User A cannot read User B's messages.
- Use the Stripe CLI to fire a test
checkout.session.completedevent against the Edge Function and confirm the profile row is updated. - Complete the full Google and Facebook OAuth flows from a Vercel preview deployment URL.
- Call
record_swipe11 times as a free-tier user and confirm the 11th call returnsquota_exhausted: truewith no swipe recorded. - Submit date of birth for age 17 in onboarding and confirm rejection.
Deliverables:
- The Vercel production deployment returns all six security headers on every response (verified with a browser dev tools network inspection or a header-checking tool).
- Crashing the
DiscoveryPage(e.g., by returning malformed data from the RPC stub) renders the error boundary fallback without crashing the rest of the app. - The RLS audit script returns no tables with RLS disabled.
- The app loads, authenticates, discovers candidates, sends a swipe, opens chat, and sends a message with no console errors in production.
Prompt Pack
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
beforeSendhook that removes any breadcrumb entries whose category is'console'and whose message field contains content, and strips thebodyfield 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 fieldscode: stringandmessage: string. Constructor accepts both.ValidationError extends AppErrorQuotaExhaustedError extends AppErrorDuplicateActionError extends AppErrorPermissionError extends AppErrorAuthError extends AppErrorNotFoundError extends AppErrorServiceError 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:
supabaseClientmust be exported fromsrc/lib/supabase.tsas a named export, not a default export.captureErrormust be exported fromsrc/lib/sentry.tsas a named export.- All
AppErrorsubclasses must be individually named exports fromsrc/lib/errors.ts. initSentry()must be called beforeReactDOM.createRootor 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, throwsAuthErrorwithcode: 'oauth_redirect_failed'and a human-readable message. ImportAuthErrorfromsrc/lib/errors.ts.signInWithFacebook(): Promise<void>— same shape assignInWithGoogle, same error type,code: 'oauth_redirect_failed'.signOut(): Promise<void>— signs out the current user and clears the local session. On SDK error, throwsAuthErrorwithcode: 'signout_failed'.getSession(): Promise<Session | null>— returns the current Supabase session or null. Re-export theSessiontype 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 setloading: false. - Subscribe to
onAuthStateChangeand update the stored session in state on every change. - Unsubscribe on unmount.
- While the initial
getSession()call is in flight,loadingistrue.
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
loadingistrue: render a centered full-screen spinner. Do not redirect or render children. - When
loadingisfalseandsessionisnull: redirect to/loginusing React Router's<Navigate>. - When
loadingisfalseandsessionis 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'smessagefield 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
profileswithauth_uidset to the new user's ID andcreated_atset tonow(). 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 LOGwithout re-raising — the trigger must never block Supabase Auth from completing the sign-in. - The
profilestable is created in a later migration. UseIF EXISTSguards orEXECUTEwith dynamic SQL so this trigger function installs without error even before theprofilestable 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:
signInWithGoogleandsignInWithFacebookmust use the Supabase SDK's OAuth sign-in with redirect — not a popup.- No component other than
AuthProvidermay callonAuthStateChangeorgetSession. ProtectedRoutemust show a spinner (not a blank screen) whileloadingis true.- Do not modify
src/lib/supabase.ts,src/lib/errors.ts, orsrc/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 theUNIQUE(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_idmust reference a match whereauth.uid() IN (participant_a_id, participant_b_id), the match'sactive = true, and the conversation'sdeleted_at IS NULL. Implement this as a subquery join againstmatches. - 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 beactive = true; the conversation must havedeleted_at IS NULL; and the message's owndeleted = false. Implement using a subquery join throughconversationstomatches. - 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 callALTER TABLE <table> ENABLE ROW LEVEL SECURITYbefore 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 pushfrom 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_nameis non-null and between 1 and 50 characters. On failure, return{ "error": { "code": "VALIDATION", "message": "Display name must be 1–50 characters" } }. - Validate
p_biois null orLENGTH(p_bio) <= 500. On failure, return{ "error": { "code": "VALIDATION", "message": "Bio must be 500 characters or fewer" } }. - Validate
p_gender_identityis non-null and non-empty. On failure, return{ "error": { "code": "VALIDATION", "message": "Gender identity is required" } }. - Validate
p_sexual_orientationis non-null and non-empty. On failure, return{ "error": { "code": "VALIDATION", "message": "Sexual orientation is required" } }. - Validate
p_interest_tagsis null orarray_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 thecreate_profileRPC withp_auth_uidset to the current session's user ID (retrieved fromsupabaseClient.auth.getSession()). Maps procedure and SDK errors as above. Returns void on success.getMyProfile(): Promise<Profile>— selects the authenticated user's own profile row usingsupabaseClient.from('profiles').select('*').eq('auth_uid', <session user id>).single(). ThrowsNotFoundErrorif 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 theupdate_locationRPC. 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.tsmust export every interface and type exactly as specified — field names, field types, and nullability must match exactly.src/lib/profile.tsmust 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 hasdisplay_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 orLENGTH(p_bio) <= 500.p_photo_urls: null orarray_length(p_photo_urls, 1) <= 6. If > 6, return{ "error": { "code": "VALIDATION", "message": "Maximum 6 photos allowed" } }.p_interest_tags: null orarray_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'stypeis not one of these, throwValidationErrorwithcode: 'VALIDATION'and message'Only JPEG, PNG, and WebP images are supported'. - Maximum file size: 10 MB (
10 * 1024 * 1024bytes). If exceeded, throwValidationErrorwith 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-photosbucket using the Supabase Storage SDK. On upload success, retrieve and return the public URL for the uploaded object. On Storage RLS rejection, throwPermissionError. On any other SDK error, throwServiceError.- Allowed MIME types:
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, throwServiceError. 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 theupdate_profileRPC. 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)thenupdateProfilewith 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)thenupdateProfilewith the new URL appended to the existing array. Show a per-slot loading state during the operation. - Per-slot errors (from
ValidationErrororServiceError) 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
updateProfilewith all current field values plus the currentphoto_urlsarray. Shows loading state during call. OnValidationError, shows the error message inline next to the relevant field. OnServiceError, 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:
uploadPhotomust validate MIME type and file size client-side before making any Storage SDK call.deletePhotomust derive the Storage path from the public URL — do not store paths separately.updateProfileinsrc/lib/profile.tsmust 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 thanprofile.tsand the newstorage.ts. - The
profile-photosStorage 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_latbetween -90 and 90;p_lngbetween -180 and 180.p_radius_kmbetween 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 betweenp_age_minandp_age_max. - If
p_gender_prefsis non-null and non-empty:gender_identity = ANY(p_gender_prefs). - Orientation compatibility: exclude candidates whose
sexual_orientationindicates they are exclusively interested in a gender that does not matchcaller_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 whosegender_identityis "Woman" would be excluded for a caller whosecaller_genderis "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_tagsis 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 fromdate_of_birth.distance_label: ifST_Distance(location, ST_MakePoint(p_lng, p_lat)::geography) < 1000, use'< 1 km away'; otherwise useROUND(ST_Distance(...) / 1000.0)::text || ' km away'.boost_active: boolean, true ifboost_expires_at > now().- Include
auth_uidasprofile_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 thediscover_candidatesRPC. On procedure error, maps toAppErrorsubclass. On success, returnsDiscoveryResult.recordSwipe(params: { targetId: string; direction: SwipeDirection; superLike?: boolean }): Promise<SwipeResult>— calls therecord_swipeRPC. OnProcedureError.code === 'DUPLICATE', throwsDuplicateActionError. On success (includingquota_exhausted: truein the data), returnsSwipeResult— 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 fromlocalStoragekeyaura_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 tolocalStoragekeyaura_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
discoverCandidatesusing the current coordinates and filter preferences withpageOffset: 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
recordSwipewithdirection: 'like'. - Swipe left (or tap a "Pass" button) calls
recordSwipewithdirection: 'pass'. - After each swipe, remove the card from the local deck. When the unviewed card count drops to 3, fetch the next page (
pageOffsetincremented 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_createdis 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_exhaustedis true, render<PaywallModal onDismiss={() => {}} />. - If
DiscoveryResult.quota_exhaustedis 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_candidatesmust never returndate_of_birth,location, or raw coordinate values in the result.record_swipemust run steps 3–8 within a single transaction.recordSwipeinsrc/lib/discovery.tsmust returnSwipeResult(not throw) whenquota_exhausted: trueis 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 thandiscovery.tsand the newfilters.ts. - Do not build the full Stripe paywall —
PaywallModalis 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: trueand 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
matchesfor 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())andactive = true. If found: updatematches.active = false,matches.deleted_at = now(); updateconversations.deleted_at = now()for the linkedmatch_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_idwhereauth.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()forid = p_match_id. - Update
conversations.deleted_at = now()wherematch_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 frommatcheswhereauth.uid() IN (participant_a_id, participant_b_id)andactive = trueanddeleted_at IS NULL, ordered bycreated_at DESC. ReturnsMatch[].unmatch(matchId: string): Promise<void>— calls theunmatchRPC. Maps errors per contract.blockUser(blockedId: string): Promise<void>— calls theblock_userRPC. 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 allmessagesrows whereconversation_id = conversationIdanddeleted = false, ordered bysent_at ASC. ThrowsPermissionErrorif RLS rejects. ReturnsMessage[].sendMessage(conversationId: string, content: string): Promise<Message>— validates:content.trim()is non-empty andcontent.length <= 2000(client-side gate; throwValidationErrorwith message'Message cannot be empty'or'Message must be 2000 characters or fewer'). Inserts a row intomessageswithconversation_id,sender_id = auth.uid(),content = content.trim(). On RLS rejection, throwsPermissionErrorwith message'Message not delivered — this match may no longer be active'. On other SDK error, throwsServiceError. Returns the inserted row asMessage.getConversationIdForMatch(matchId: string): Promise<string>— selectsidfromconversationswherematch_id = matchIdanddeleted_at IS NULL. ThrowsNotFoundErrorif no row is returned. Returns the conversationidstring.subscribeToMessages(conversationId: string, onMessage: (message: Message) => void): () => void— subscribes to Supabase Realtime Postgres Changes INSERT events on themessagestable, filtered toconversation_id = conversationId. The subscription's error/reconnect handler must callgetMessages(conversationId)to re-fetch from the last knownsent_atand deliver any missed messages throughonMessagebefore 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 thematchestable, filtered so that new rows whereparticipant_a_id = userId OR participant_b_id = userIdtriggeronMatch. Because Supabase Realtime filter expressions support a single equality condition per subscription, implement this as two separate channel subscriptions (one forparticipant_a_id, one forparticipant_b_id) and callonMatchfrom 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 intoreportswithreporter_id = auth.uid(),entity_type,entity_id,reason,detail. On RLS rejection, throwsPermissionError. On other SDK error, throwsServiceError.
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
getMatchesTanStack 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). OnNotFoundError, 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
reportEntitywithentityType: 'profile'and the other participant's ID. Shows a success confirmation or error inline.
Unsubscribe from the messages Realtime channel on unmount.
Constraints:
subscribeToMessagesmust handle the reconnection gap-fill: on reconnect, re-fetch messages since the last knownsent_atand deliver them throughonMessagebefore re-subscribing.subscribeToMatchNotificationsmust use two separate Realtime channel subscriptions to cover both participant columns.sendMessagemust 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 newmatches.ts,messages.ts, andreports.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
reportstable in the Supabase dashboard withreview_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
Authorizationheader to extract the bearer token. UseSUPABASE_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_uidfrom 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_idset to the user'sauth_uid.success_urlread from an environment variable namedAPP_URL, with?session=successappended.cancel_urlread fromAPP_URLwith?session=cancelledappended.
- 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-Signatureheader. - 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_idfrom the session object — this is the user'sauth_uid. - Using
SUPABASE_SERVICE_ROLE_KEY, initialize a Supabase admin client. Update theprofilesrow whereauth_uid = client_reference_id: setsubscription_tier = 'premium'andupdated_at = now(). - Upsert a
swipe_quotasrow foruser_id = client_reference_idandquota_date = CURRENT_DATE: setquota_limit = 999999. If a row already exists, update onlyquota_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 usingsupabaseClient.auth.getSession(). Calls thecreate-checkout-sessionEdge Function viasupabaseClient.functions.invoke(...), passing the JWT in the Authorization header. On success, returns the URL string from the response'sdata.urlfield. On any error (function invocation failure, non-2xx response, or missing URL in response), throwsServiceErrorwith 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 usingwindow.location.href. OnServiceError, show the error message below the button. - A "Maybe later" link that calls
onDismiss.
Constraints:
- The
STRIPE_PREMIUM_PRICE_IDandAPP_URLvalues must come from Supabase Edge Function environment secrets — never hardcoded. - The
stripe-webhookfunction must not require a Supabase JWT — it is called by Stripe, not the client. - The
create-checkout-sessionfunction must validate the JWT before doing anything else. createCheckoutSession()insrc/lib/stripe.tsmust pass the JWT to the Edge Function — the function cannot callauth.uid()without it.- Do not modify any migration files, or any files in
src/lib/other than the newstripe.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, confirmsubscription_tierchanged 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 thecheckout.session.completedevent. Copy the signing secret it generates and add it asSTRIPE_WEBHOOK_SIGNING_SECRETin Supabase project secrets. Also addSTRIPE_PREMIUM_PRICE_ID(from your Stripe product's price ID) andAPP_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 fromsupabaseClient.auth.getSession(). Invokes thegdpr-eraseEdge Function viasupabaseClient.functions.invoke(...)with a DELETE method, passing the JWT in the Authorization header. On HTTP 200 response, callssignOut()and returns. On any error (invocation failure, non-200 response, or missing success field), throwsServiceErrorwith themessagefrom 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-erasefunction 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_idUUID'00000000-0000-0000-0000-000000000000'must be used exactly as written — this is the canonical anonymization sentinel value for the system. deleteAccount()must callsignOut()only after receiving a successful response — not before.- Do not modify
src/pages/SettingsPage.tsxin any way that removes or changes existing filter preferences content. - Do not modify any migration files or any
src/lib/files other than the newaccount.ts.
Check before continuing (do this yourself, do not paste it):
- Complete the account deletion flow for a test user and confirm: their
profilesrow hasdisplay_name = '[deleted]'and all PII fields are null; their Storage folder underprofile-photos/{uid}/is empty; their messages showsender_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:DENYX-Content-Type-Options:nosniffReferrer-Policy:strict-origin-when-cross-originPermissions-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: callcaptureError(error, { componentStack: info.componentStack ?? '' })fromsrc/lib/sentry.ts.getDerivedStateFromError: return{ hasError: true }.- When
hasErroris true: renderfallbackif provided; otherwise render a centered message "Something went wrong — reload the page" with a button labeled "Reload" that callswindow.location.reload(). - When
hasErroris false: renderchildren.
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-Policyvalue must match the string above exactly — do not add, remove, or reorder directives. ErrorBoundarymust be a class component — React error boundaries cannot be function components.ErrorBoundarymust callcaptureErrorfromsrc/lib/sentry.ts— do not call the Sentry SDK directly from this component.- Do not add
vercel.jsonto.gitignore— it must be committed to the repository. 0011_rls_audit.sqlmust 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 includeX-Frame-Options: DENYandX-Content-Type-Options: nosniff. - Navigate directly to
/matchesin 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.sqlin 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."
Guide
Before You Build
The Before You Build guide details the prerequisite service accounts (Vercel, Stripe, Sentry), OAuth provider setups (Google, Facebook), and Supabase database extension configurations (PostGIS) required prior to executing Prompt 1 of the Aura Bolt Prompt Pack generated by Zalcro.
How to Use This Prompt Pack
This pack is a sequence of prompts you paste one at a time into Bolt. Each prompt builds on the last — run them in order, confirm the result is working before moving to the next, and never skip ahead. You don't need to understand the technical instructions inside each prompt; they're written for Bolt, not for you.
This pack contains 10 prompts:
- Prompt 1 — Project skeleton: Supabase client, Sentry error tracking, routing, and placeholder screens
- Prompt 2 — Google and Facebook OAuth, session management, and the database trigger that creates a user profile on first sign-in
- Prompt 3 — All database tables, indexes, and Row Level Security policies
- Prompt 4 — Onboarding flow, age gate, and profile creation stored procedure
- Prompt 5 — Photo upload and profile editor
- Prompt 6 — Candidate discovery, swipe engine, and filter preferences
- Prompt 7 — Matches list, live chat, and block/unmatch flows
- Prompt 8 — Stripe payment integration and premium paywall
- Prompt 9 — GDPR account deletion flow
- Prompt 10 — Security headers, error boundaries, and pre-launch hardening
How Bolt Works — What to Expect
Bolt processes your entire prompt at once and generates code immediately. It does not ask clarifying questions — it acts on what you paste. Because of this:
- Paste the full prompt body, every time. Bolt has no memory between prompts. Each prompt includes the project context it needs.
- Be explicit about scope. Each prompt tells Bolt exactly what not to build yet. If Bolt builds something not in the prompt, paste a follow-up: "Remove [feature] — it will be added in a later step."
- If Bolt goes off-script, redirect it with: "Revert to my last working state and try again, building only what the prompt specifies."
- Connect a Git repo before you start. Bolt can sync to GitHub. Do this first so you can roll back to any working state if a prompt breaks something. Use the Git panel in Bolt to commit after each prompt passes its verification check.
- Confirm each prompt is working before pasting the next one. The verification block at the end of each prompt tells you exactly what to check.
Platform Configuration — Complete Before Prompt 1
Work through every step below before you run Prompt 1. Some steps reference values from specific Supabase, Google, Stripe, and Sentry screens — the instructions tell you exactly where to find each one.
1. Create Your Accounts
Sign up for accounts on all of these services if you don't already have them:
- Supabase at supabase.com — create two projects: one named for development, one for production
- Vercel at vercel.com — connect your GitHub repository and create a new project with the Vite framework preset
- Stripe at stripe.com — create a product called "Aura Premium" with a monthly recurring price
- Google Cloud Console at console.cloud.google.com
- Facebook for Developers at developers.facebook.com — create a Consumer-type app and add the Facebook Login product
- Sentry at sentry.io — create a new project of type "React"
2. Configure Supabase — Do This for Both Dev and Prod Projects
In each Supabase project, complete the following:
- Enable PostGIS: Database → Extensions → search "postgis" → enable it
- Enable Google Auth: Authentication → Providers → Google → toggle on (leave the Client ID and Secret blank for now — you'll fill these in after the next step)
- Enable Facebook Auth: Authentication → Providers → Facebook → toggle on (same — fill in after getting your App ID and Secret from Facebook)
- Set Site URL: Authentication → URL Configuration → Site URL →
http://localhost:5173for dev, your Vercel production domain for prod - Add Redirect URLs: In the same URL Configuration panel, add
http://localhost:5173/**for dev andhttps://your-vercel-domain.vercel.app/**for prod - Enable Realtime on two tables: Database → Replication → toggle on for both
messagesandmatches
3. Configure Google OAuth
In the Google Cloud Console:
- Go to APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID
- Set the application type to "Web application"
- Under "Authorized redirect URIs," add:
https://<your-supabase-project-ref>.supabase.co/auth/v1/callback— do this for both your dev and prod Supabase project refs - Copy the Client ID and Client Secret and paste them into Supabase → Authentication → Providers → Google for each project
4. Configure Facebook OAuth
In the Facebook for Developers dashboard:
- Go to your app → Facebook Login → Settings
- Under "Valid OAuth Redirect URIs," add:
https://<your-supabase-project-ref>.supabase.co/auth/v1/callback— for both dev and prod Supabase project refs - Copy the App ID and App Secret and paste them into Supabase → Authentication → Providers → Facebook for each project
- Ensure the app is in Development mode now; switch it to Live mode before production launch
5. Configure Stripe
- Note your test publishable key and test secret key from Stripe Dashboard → Developers → API keys
- You will add the webhook endpoint URL in the Prompt 8 verification block, once the Edge Function URL exists
6. Set Environment Variables in Bolt
In Bolt's environment/secrets panel, add the following before running Prompt 1. All four are needed from the start:
| Variable | Where to find it |
|---|---|
VITE_SUPABASE_URL |
Supabase project dashboard → Settings → API → Project URL |
VITE_SUPABASE_ANON_KEY |
Supabase project dashboard → Settings → API → Project API Keys → anon public |
VITE_SENTRY_DSN |
Sentry → your React project → Settings → Client Keys (DSN) |
VITE_STRIPE_PUBLISHABLE_KEY |
Stripe Dashboard → Developers → API keys → Publishable key |
Also add the following in Supabase project settings → Settings → Edge Functions → Secrets, for both dev and prod:
| Variable | Where to find it |
|---|---|
STRIPE_SECRET_KEY |
Stripe Dashboard → Developers → API keys → Secret key |
STRIPE_WEBHOOK_SIGNING_SECRET |
Stripe Dashboard → Developers → Webhooks → your endpoint → Signing secret (set this after Prompt 8 creates the endpoint) |
SUPABASE_SERVICE_ROLE_KEY |
Supabase project dashboard → Settings → API → Project API Keys → service_role — never put this in client code |